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 password:

<?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 impersonate.

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 inside.

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 wife.

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.