arrow_backward Back to blog

Stripe Capture the Flag 2.0 – Post Mortem

Mobomo is home to a Stripe CTF 2.0 security challenge winner.

Stripe’s second Capture the Flag ended nearly two weeks ago. If
you haven’t heard of it, the CTF is a security challenge in which
contestants progress by analyzing (web-based, in this iteration)
systems and exploiting vulnerabilities to gain access to a secret
token which serves as the password to the next level.

The levels in 2.0 tested a practical understanding of at least:

  • SQL injection
  • Code/command injection
  • XSS/CSRF attacks
  • Cryptographic weaknesses
  • Side-channel attacks

I had the pleasure and privilege of completing the challenge. It was a
really great exercise in real-world web application security.

The Challenge

The source of each challenge is available online now, so you can
review each level in detail if you’re curious.

Each contestant had their own isolated instance of each level, and
corresponding users and directories in the Linux virtual machines
hosting the challenge.

Several of the challenges involved exploiting the full range of the
underlying OS, user accounts, background services, and internal
network of the CTF servers.

Level 0

Level 0 was basically a low bar to see if you have any place at
all in the contest.

A simple web form serves up “secrets” to authorized users. Despite the
use of a parameterized query, which avoids the most common form of
SQL injection (unescaped quotes), this code is still vulnerable:

var query = 'SELECT * FROM secrets WHERE key LIKE ? || ".%"'; db.all(query, namespace, function(err, secrets) { if (err) throw err; renderPage(res, {namespace: namespace, secrets: secrets}); }); 

Can you spot it? By passing a wildcard (%)” to the server, it goes
straight into the LIKE condition, returning all of the records in
the table.

The Takeaway

Don’t just count on parameterized SQL queries and proper quote
escaping. You must also sanitize user data in a SQL LIKE condition.

Basically, do not trust user data.

Level 1

The code for level 1 uses a dangerous PHP function
which is used as a shortcut for extracting each HTTP parameter. This
is similar to Rails’ “mass assignment” problem, except that it allows
for an attacker to overwrite variables in the running program!

By simply sending a filename parameter in a request, the
previously-assigned value of $filename is overwritten when extract
is called.

An attacker can then read any file on the system readable by the PHP
process, most obviously the secret combination or password file.

The Takeaway

Do not pass user data (i.e. PHP’s $_GET, or Rails’ params) to any
routine that modifies the program’s executing environment.

Basically, do not trust user data.

Level 2

Another level, another bad PHP script: level 2 demonstrates an
indirect vulnerability that is very similar to level 1.

Instead of relying on any particular vulnerability in the execution of
the script itself, the weakness lies in trusting uploaded content. For
whatever reason, the code grants executable privileges to uploaded
files. This opens the system up to command injection attacks.

The simple solution here is to upload a PHP file that prints the

<?PHP echo file_get_contents("../password.txt"); ?> 

You can do lots of interesting things when you can execute any PHP
script you want, like running system commands (ls, etc.), and this
proves to be key in several later levels.

The Takeaway

You must be careful to only grant the minimum permissions absolutely
necessary to user content in the filesystem.

Basically, do not trust user data.

Level 3

Finally, a classic unescaped SQL injection! I was surprised that it took
this long to make an appearance.

The program in level 3 makes the mistake of not escaping quotes in
parameters to a SQL query. If the code were to do the password hash
comparison in SQL, then we could simply pass:

' OR '1' = '1 

And we would get back a user record.

However, since the hash comparison is done after the fact, we need to
return an actual valid hash and salt for the user we are trying to

The trick here is to tack a row onto the result set. Since we’re
already in a WHERE clause, the obvious choice is to add a UNION to
the query.

password = foo username = ' UNION SELECT '1', 'c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2', 'bar' -- 

The hash here is simply obtained by firing up a Python REPL:

$ python >>> import hashlib >>> hashlib.sha256("foobar").hexdigest() 'c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2' 

The user ID for the user with the level 4 password is randomly
shuffled when the system is initialized, so it must be ascertained
through trial and error.

The Takeaway

Always, always, always escape parameters to your database
queries. Many data access libraries do this for you through
parameterized queries.

All ad-hoc SQL queries in the applications we develop are
parameterized through data access libraries.

Basically, do not trust user data.

Level 4

Now things get interesting.

This level actually has another (automated) user sitting
out there, logging in and using the web app, whom is the real target
of our attack.

All we need to do is get karma_fountain to send us some karma, and
his password will sent along with it (by design… this is a strange
web app).

The system allows you to register any number of user accounts, and
while usernames must match /^w+$/, there are no restrictions
whatsoever on passwords.

Couple that with the fact that passwords are displayed, completely
as-is, when sending karma, and we have a quick route to injecting
arbitrary JavaScript into the page. This is a basic
Cross-Site Scripting, or XSS attack.

All we have to do here is sign up with a password that contains a
malicious JavaScript payload, and send a message to our friend
karma_fountain. The next time he loads the page, he will see our
“password” and his browser will do the work for us.

A suitably devious password for a user attacker might be:

<script>$("input[@name='to']").val("attacker"); $("input[@name='amount']").val("9001"); $("form").submit();</script> 

After logging in and sending a little friendly karma to
karma_fountain, the only thing left to do is wait for our karma to
come pouring in, along with the password.

The Takeaway

User data is often displayed in web apps. This is unavoidable. And
many fields only make sense as completely arbitrary text. The only
thing to do is to be sure to correctly escape it so that it cannot be
interpreted as a script by a viewer’s browser.

We make use of libraries that escape output by default, so that
outputting raw text is an extra step that must be done deliberately
and only with a valid reason.

In case you haven’t noticed a pattern yet, this basically boils down
to: do not trust user data.

Level 5

The system in level 5 introduces an authentication scheme that
uses an “auth pingback” URL to call an arbitrary endpoint inside the
CTF network to authenticate a user.

The authenticating server calls a user-supplied pingback URL to
validate a user, looking for a string matching the pattern

The obvious vector for this attack was to use the level 2 server to
host a malicious PHP script that authenticates.

However, the page only reveals the level 6 password when you are
authenticated on a “level05” server. Fortunately the authentication
script echoes the pingback response regardless of source.

This means we can use the level05 server itself as the pingback, with
a nested authorization request to the level02 server tucked away

The Takeaway

Stick with tried-and-true authentication schemes, and don’t get
clever. If you feel you need this kind of feature, go with OAuth 1.0.

Although, any networked system is only as secure as the systems it
relies on, and in this case the authentication server trusted a
compromised server inside its own network.

Level 6

The solution to level 6 was basically an escalated version of the
attack in level 4. HTML tags are not properly escaped when user data
is printed out as JSON in the page, and so by posting a malicious
message we can jump out of the JSON and into our own <script> tag and get to work.

We can post message for other users to see, so when they load the page
they execute our malicious script. We want to construct a script that
will cause any user who loads the page to post their own password. We
can view the logged-in user’s password on their profile page.

Our payload script is just going to leverage jQuery to request the
profile page, parse out the user’s password, and post it as a message
using the message posting API.

The payload wrapper is essentially:

</script><script> ... </script> // 

The only tricky part is that quotes are not allowed in messages, so
it’s necessary to encode our script without using any literal
strings. Obviously some strings are necessary to do anything
interesting with the victim’s session. The JavaScript function
fromCharCode() can take a sequence of integers and return a string,
though, so you can turn any code into this form easily, and then pass
it to eval().

The Takeaway

As always, sanitize your output to prevent JavaScript injections. Do I
really have to say “don’t trust user data” again?

Level 7

The underlying vulerability in level 7 was new to me, and after
much Googling, it became apparent that I was looking for a
hash length extension attack. I won’t go too much into this,
because the link explains it better than I can.

In a nutshell:

If you know a mesage and the result of:

H(secret + message) 

… then you can calculate:


… where H’ is a hand-tweaked hash function, such that the result
is equal to:

H(secret + message + padding + bad_message) 

This works because hash functions are state machines that operate in
blocks, and the digest of a message is really just the final value of
the registers in the state machine for the last block calculated.

This digest gives you a starting point for extending the message and
calculating a valid hash without knowing the secret.

The Takeaway

DO NOT, under any circumstances, unless you are truly an expert,
try to roll your own cryptography. You will likely get it wrong,
perhaps in subtle and hard-to-understand ways.

We rely on open, tested, trusted cryptography here, and fight the
temptation to throw home-made cryptography at a problem.

Level 8

This was by far the most challenging level, because of the rather
oblique nature of the attack.

The target in level 8 is a password validation system, consisting
of one master server and a chain of “chunk” nodes. No single node
knows the entire password. The master server takes password validation
requests, breaks the supplied password into chunks of 3 characters
(trigrams) and sends the first chunk to the first chunk node. If the
first chunk is valid, then it continues through the other nodes in the
same fashion.

If any chunk fails, a user-supplied “web hook” URL is called with a
simple success/failure message. Nothing in the content of this
response is useful for anything more than a simple brute force attack.

While brute force is an option, it would require about
1012/2 attempts, and there is likely not enough time in the
world for that when the server can only handle a few per second.

However, by analyzing other attributes of the response, we can actual
glean enough information to start narrowing things down.

Specifically, the callback URL receives a connection from an
auto-generated port. And, since the master server only connects to a
subsequent chunk server on success, the typical difference in port
numbers is larger when more chunks are correct.

To begin solving this problem, the level 2 server (allowing arbitrary
PHP files to be posted) had to be exploited to allow me to run a
custom server to act as the web hook. By uploading a PHP script which
copied my SSH public key to the correct location, I could then connect
to the level 2 server and run any code I wanted.

I came up with several “solutions” that worked on a local copy of the
system but failed in the real world, when the algorithm ran up against
the “jitter” introduced by all of the other contestants hitting the
live servers.

Finally, after clearing my head and stepping through the logic,
slowly, again, a solution emerged. It was not
entirely natural to come up with, because programming tasks rarely
depend on “fuzzy” data such as this, where you can only guess at first
and then later eliminate false positives.

The key was to start testing values (001, 002, 003, etc.) for a single
chunk, “push” it (save it and start testing the next chunk) when the
port difference jumped by 1, and “pop” it (throw the saved value away
and start counting where you left off) if the port difference dipped
down (indicating a false positive). This cut the runtime to a mere 14
minutes for my final attempt.

The Takeaway

Nothing is secure. Everything is vulnerable. Hide yo kids, hide yo

Just kidding.

This level was really eye-opening, in that it demonstrated how a
seemingly inconsequential bit of noise (auto-generated port numbers)
coupled with pattern detection and oblique logic can reveal secrets.

Be careful in what you may reveal to attackers, because so much more
can be deduced than what is readily apparent.


New Project Request