Recherche sécurité · Ruby

Un nom de fichier, deux injections de commandes dans spatial_features.

Je regardais cette gem pour un projet personnel quand un appel à system m'a fait remonter le chemin d'import d'un shapefile. Un nom choisi dans une archive ZIP suffisait à exécuter une commande sur le serveur, avant même que le fichier soit validé.

Divulgation coordonnée. La vulnérabilité a été signalée en privé, corrigée avant publication, puis publiée le 19 août 2026. Les versions <= 3.11.1 sont vulnérables. La version corrigée est la 3.11.2.

Je regardais la gem pour un projet perso

Je n'étais pas en train d'auditer spatial_features. Je voulais l'utiliser dans un projet perso et je lisais son code d'import de shapefiles. Dans Importers::Shapefile#project_to_4326, cet appel m'a fait tiquer :

system("ogr2ogr -s_srs '#{proj4}' -t_srs EPSG:4326 '#{output_path}' '#{file_path}'")

Un system avec une commande construite dans une chaîne mérite toujours qu'on regarde d'où viennent ses arguments. Ici, il y en avait trois : la projection, le chemin de sortie et le chemin du shapefile. Je suis parti de file_path.

Le nom du fichier finissait dans le shell

Lors de l'extraction, la gem reprenait tel quel le nom stocké dans le ZIP :

output_filename = entry.name
path = "#{tmpdir}/#{output_filename}"

Le chemin ainsi obtenu arrivait ensuite dans une autre commande, lancée avec les backticks Ruby :

`gdalsrsinfo "#{file_path}" -o proj4`

À première vue, le chemin est protégé par des guillemets. En réalité, ces guillemets font partie de la commande shell. Un nom de fichier contenant son propre guillemet peut en sortir. J'ai donc préparé une entrée ZIP sur ce modèle :

x";touch /tmp/sf_pwned;".shp

Le fichier n'avait même pas besoin d'être un shapefile valide :

evil_name = %Q{x";touch /tmp/sf_pwned;".shp}

Zip::OutputStream.open("upload.zip") do |zip|
  zip.put_next_entry(evil_name)
  zip.write("not-a-real-shapefile")
end

importers = SpatialFeatures::Importers::Shapefile.create_all("upload.zip")
importers.first.features rescue nil

File.exist?("/tmp/sf_pwned") # => true

La commande partait avant que le format du fichier soit validé. J'ai reproduit le comportement avec la gem publiée, dans un conteneur propre sous Ruby 3.3 avec GDAL.

Le fichier .prj ouvrait un second chemin

Une fois le premier PoC confirmé, j'ai repris les autres arguments de system. proj4 venait du fichier .prj contenu dans l'archive. La valeur était placée entre quotes simples dans la commande ogr2ogr. Là encore, une quote dans le fichier suffisait à fermer l'argument et à ajouter une commande.

Le maintainer a aussi trouvé une injection SQL

En relisant mon signalement, le maintainer a repéré un troisième problème. La même projection était injectée directement dans une requête PostGIS : ST_Transform(ST_GeomFromText('#{wkt}'), '#{proj4}', 4326). Une quote dans proj4 permettait de sortir du littéral SQL. Celui-là, je ne l'avais pas vu dans mon premier rapport.

Qui pouvait réellement exploiter la faille ?

spatial_features ne décide pas qui a le droit d'envoyer un shapefile. C'est l'application qui utilise la gem qui le décide. Le score CVSS 8.8 part donc du principe que l'upload nécessite un compte.

  • Upload authentifié : un utilisateur connecté peut exécuter des commandes avec les droits du processus applicatif et des requêtes avec ceux de l'utilisateur PostgreSQL.
  • Upload public : la même faille devient une exécution de code à distance sans authentification.

Le code vulnérable était là depuis 2016. Toutes les versions jusqu'à la 3.11.1 étaient concernées.

Ne plus construire de commande

Le correctif est arrivé environ une semaine après mon signalement. Pour ogr2ogr, le maintainer a remplacé la chaîne complète par des arguments séparés :

system(
  'ogr2ogr',
  '-s_srs', proj4,
  '-t_srs', 'EPSG:4326',
  output_path,
  file_path
)

Ruby n'a plus besoin de lancer un shell, donc les quotes et les points-virgules du nom de fichier ne sont plus interprétés. gdalsrsinfo passe maintenant par Open3.capture2, les valeurs SQL par connection.quote, et l'extraction refuse les chemins qui sortent du répertoire temporaire ainsi que les liens symboliques. Le commit ajoute aussi les tests de non-régression.

Le dépôt n'avait pas de canal privé

Les GitHub Security Advisories n'étaient pas activées sur le dépôt. Ouvrir une issue publique avec le PoC était hors de question, alors j'ai retrouvé l'adresse du maintainer et je lui ai écrit.

Il m'a répondu rapidement, a activé l'advisory et préparé le correctif. De mon côté, j'ai fourni le PoC puis testé la gem publiée. La version 3.11.2 est sortie le 16 août 2026. L'avis de sécurité a été publié trois jours plus tard.

Si vous utilisez spatial_features

Passez au minimum en 3.11.2. L'application ne peut pas vraiment filtrer ces valeurs avant l'import : c'est la gem elle-même qui ouvre le ZIP et lance les commandes.

Ce qui m'a mis sur la piste n'était pas un scanner ou une technique compliquée. C'était juste un appel à system, suivi d'une question assez banale : d'où vient chaque argument ?

Une application Ruby à tester ?

Je teste manuellement les applications web, les API et leurs dépendances. Vous échangez avec la même personne du cadrage au contre-test.