How I Captured the Flags in Tripwire VERT's Cyber Security Contest

NOTE: This post originally appeared on Tripwire's blog, The State of Security, in two parts. It is reposted here in full with minor modifications.

Introduction

Tripwire's Vulnerability and Exposure Research Team (VERT) set up a three-and-a-half day Capture the Flag (CTF) contest, where over 130 people from the academic community (mostly students, but more generally, people "affiliated with an educational institution") competed in exploiting two vulnerable web applications.

The idea of a CTF is that there are hidden "flags" (which can be links, passwords, comments, etc.) that are not accessible directly through the application, but which can be discovered through "hacking." CTFs — unlike wargames which I have played in the past — are competitive, and as a result involve rewards.

Come the day of the competition, I had already set up Burp, so that I could intercept and alter HTTP(S) requests and responses, and eagerly awaited for the email with the details to come. This is the story of how I completed the challenges and was placed second in the competition.

Level 1

The link provided to us was https://ctf.secur3.us, which had an invalid certificate, but could also be accessed over HTTP (Note that this link, and likely all other CTF links, might no longer work when you are reading this but I kept them intact for this write-up.) The link was an application that contained a "guestbook," where people could leave and view messages.

The form (shown below) and the guestbook entries (not shown) had three fields: name , handle , and comment .

Getting Started

The two things to test for immediately are Cross-Site Scripting (XSS) and SQL injection. The former doesn't work, but leaving single quotes in the three fields results in an error page redirecting to the guestbook. If you are quick enough to stop the redirection, a suspicious value appears in the source code:

<img src=/images/fail.jpg ALT=SU5TRVJUIElOVE8gZmVlZGJhY2sobmFtZSwgZW1haWwsIGNvbW1lbnQsIGRhdGV0aW1lLCBTSUQpIFZBTFVFUygnXCcnLCAnJycsICdcJycsICcxNS0wMy0yOCAwOTo0MjoxOScsICdic3NpaWx1NjBvOWJiNGFzZHB1MTM1ZmtnMScp.jpg height=0 width=0>

Though there are no equal signs at the end, the ALT value (without the .jpg extension) is the Base64 encoding of the following string, which represents the SQL query that was executed and failed:

INSERT INTO feedback(name, email, comment, datetime, SID) VALUES('\'', ''', '\'', '15-03-28 09:42:19', 'bssiilu60o9bb4asdpu135fkg1')

The problem is indicated in red: the handle parameter (corresponding to email in the table) is not escaped! Indeed, setting handle to be

' + (SELECT VERSION()) + '

works... but only partially.

The problem is that MySQL string concatenation requires an explicit function call, and addition (with a + sign) treats strings as numbers, thus returning only part of the version ( 5.6 ) in the handle entry in the guestbook. We are, however, lucky: the comment column (which contains the feedback) appears after the email column, so we can ignore the rest of the existing query by commenting it out, and thus re-write it entirely with

', (SELECT VERSION()), '15-03-28 09:42:19', 'bssiilu60o9bb4asdpu135fkg1')#

The full query becomes as follows, with the original part of the query in blue, the inserted value in red, and the commented out part in strikethrough grey:

INSERT INTO feedback(name, email, comment, datetime, SID) VALUES('', '', (SELECT VERSION()), '15-03-28 09:42:19', 'bssiilu60o9bb4asdpu135fkg1')#', '', '15-03-28 09:53:25', 'bssiilu60o9bb4asdpu135fkg1')

Note here that doing the usual double dash ( -- ) for comments does not work, and that the session id ( SID ) needs to be the same as before (and the same as PHPSESSID ), so that the injection can be viewed in the guestbook:

Success! Running similar queries with DATABASE() , USER() , @@DATADIR , and @@HOSTNAME gives us the values ctf , [email protected] , /var/lib/mysql , and ctf respectively, which are not all strictly needed, but good to know just in case.

Recovering Table and Column Names

The next step is getting the table names:

tableX', (SELECT table_name FROM information_schema.tables WHERE table_schema='ctf' LIMIT 1 OFFSET X), '15-03-28 09:42:19', 'bssiilu60o9bb4asdpu135fkg1')#

Altering the value of X (starting at 0) gives us the following tables: accounts , feedback , and items . Similarly for the column names:

YcolumnX', (SELECT column_name FROM information_schema.columns WHERE table_schema='ctf' AND table_name='Y' LIMIT 1 OFFSET X), '15-03-28 09:42:19', 'bssiilu60o9bb4asdpu135fkg1')#

The accounts columns are id , account_id , account_pin , lastname , firstname , and balance , while for items they are id , name , price , brief , and private .

The Endgame

A reasonable assumption to make is that the flag is in the items table for an entry that has the private column set:

private', (SELECT private FROM items WHERE private IS NOT NULL), '15-03-28 09:42:19', 'bssiilu60o9bb4asdpu135fkg1')#

As seen above, the value we get is:

dWdnYyUzTiUyUyUyU3FwMjIuZnJwaGUzLmhmJTJTcHVueTIlMlNlcnR2ZmdyZS51Z3p5

which can be Base64 decoded to:

uggc%3N%2S%2Sqp22.frphe3.hf%2Spuny2%2Sertvfgre.ugzy

which after a ROT-13 becomes:

http%3A%2F%2Fdc22.secur3.us%2Fchal2%2Fregister.html

to be URL decoded as:

http://dc22.secur3.us/chal2/register.html

This was the first flag as confirmed by a DM and a public shout-out!

Red Herrings and Easter Eggs

Of course, not everything was this linear and on the right track. For one, there were some weird comments in the guestbook source. As an example, the word "test" was represented as t<!--e-->e<!--t-->s<!--set-->t . This was possibly to make XSS and automated injections harder, but it turned out to be irrelevant at the end.

An injection was also possible using the PHPSESSID variable. An empty value or a value with quotes revealed the format of yet another table:

INSERT INTO guestbook(message,handle,contact,SID) VALUES('1','2','3','');

Once more, this table did not matter, and from the investigation above it looks like it doesn't even exist! This was quite problematic when I was initially testing the injection, because I couldn't use either the guestbook or the feedback tables.

Finally, for completeness, the other columns for the flag were Flag , $59.10 , and This is not the flag you are looking for. (Or is it?) . The other rows contained a lot of references to the TV series Lost. Off to the next part!

Level 2

Going to the link above results in a registration page (pictured below) which requires a username, a password, as well as a "display name" and an "address." These extra fields (and the overall setup) suggest that the page itself is part of the challenge and can be used as often as necessary (this was confirmed much later on Twitter).

After successful registration, we are redirected to login with our credentials, and then either record a note or search for it. It is worth mentioning that these three pages all redirect to different ones upon completion of the action, to login.php, insert.php, and searchaction.php respectively.

Basic Reconnaissance

Monitoring the HTTP requests and responses, we notice that successful registrations return a header named X-VERT-Header , with a value like:

SU5TRVJUIElOVE8gcmVnaXN0cmF0aW9ucyh1c2VybmFtZSwgcGFzc3dvcmQsIGRuYW1lLGFkZHIscmVnaXN0cmF0aW9uX3RpbWUpIHZhbHVlcygnMicsJyowJywnJywnJywnMTUtMDMtMjcgMDU6NDU6MzAnKQ==

Decoding it as Base64, we get information about the database such as the table name and its columns:

INSERT INTO registrations(username, password, dname,addr,registration_time) values('2','*0','','','15-03-27 05:45:30')

Upon login, the following cookies are set: ZFT , VCK , isAdmin , Username and the usual PHPSESSID . isAdmin is always set to False , while Username is a Base64 encoding of the username used. ZFT and VCK are also Base64 encoded, but are not ASCII text (more on that below).

Playing around with the registration page, everything seems well-escaped, and the password is always encrypted — usually with descrypt but sometimes with salted MD5 , and sometimes (for instance, above) it is not shown at all. Changing the isAdmin and Username cookies makes no difference, while changing ZFT or VCK results in an error saying This is why we can't have nice stuff. , even when these values are correct but belong to a different account we have registered with. This strongly suggests that they are tied to our session (through PHPSESSID ), meaning that we cannot take over an account directly just by altering these parameters.

Understanding the Cookies

Although I tried plenty of things at this stage, none of them worked (see below), so I decided to focus on understanding the two elusive cookies. Playing with the registration and login, it becomes clear that ZFT solely depends on the display name and that VCK only depends on the username in the same fashion. In other words, ZFT=Base64(f(dname)) and VCK=Base64(f(username)) , where f is some function that creates binary data.

My first step to understand how the function works was to create accounts with usernames admif to admit changing only the last letter in order to see the pattern and predict the VCK value for admin . By looking at the hex values and doing some manual pattern matching, I determined that the encoded value would be eJxLTMnNzAMABgECCg== . Then, I promptly realized that all my work was completely unnecessary because (a) I could not set the values explicitly due to the PHPSESSID dependency and (b) I could have immediately learned the value by setting the display name as admin instead!

My time was not entirely wasted, however, because I determined that all encodings started with the magic values 0x78 0x9c . After some research, I discovered that this f was zlib compression! The way to encode a string s in Python (after importing zlib and base64 ) is s.encode('zlib').encode('base64') , while decoding it is the reverse: s.decode('base64').decode('zlib') .

This was the point where I spent some time on what turned out to be an Easter Egg, but more on that later.

A Helpful Hint

At this stage, I was stuck, but shortly thereafter, a helpful hint arrived (with the snippet reposted here for when the pastebin expires). The idea is that after ZFT gets decoded, it is checked against the stored display name, but not for equality. Instead, the decoded value needs to start and end with dname :

if not(dnfc.startswith(dname) and dnfc.endswith(dname))

The problem is thus clear: a ZFT corresponding to aaa will pass the check against a display name of aa , and more generally for dname=X and any Y , (the encoding of) XYX will be accepted for ZFT . Trying it in practice in various places worked immediately for a "normal" value of Y , but as soon as I tried a quote character for Y , the application indicated an error when trying to insert a note: OOPS: SOMETIMES STUFF JUST GOES WORNG! [sic]. This indicated the presence of an SQL injection vulnerability and warranted further probing.

Blind SQL Injection

Having picked insert.php as the target, altering ZFT can give us three possible outcomes: an error saying This is why we can't have nice stuff. , meaning that there is a problem with our encoding/setup of the variable, OOPS: SOMETIMES STUFF JUST GOES WORNG! , meaning that the SQL query failed, and a correct insertion. Because we never get back the value of our queries, our injection will necessarily be blind. The high-level idea is as follows: suppose you have a query that returns a single character, say the letter v , and you want to recover this value. Because you cannot directly see the output, you have to infer it by making the database behave differently in case of a correct and a wrong answer. A viable solution is to make the database sleep for a few seconds for the correct answer and return immediately for the wrong one. In pseudocode,

IF(answer='v', SLEEP(5), 0)

would cause the database to sleep for 5 seconds, while the same query with 'w' would return immediately.

As a result, by iterating over all possible characters, you can determine the right value, and if the result of the query has more than one characters, you simply need to iterate over all of them as well. This takes the complexity down from exponential to linear in the length of the answer (and also linear in the fixed alphabet size). I tested my idea by creating an account with user and display names of 33 and verified that the insertion took 5 seconds to complete for a ZFK value (before encoding) of:

33' + IF((SELECT MID(VERSION(), 1, 1))='5', SLEEP(5), 0) + '33

There were some technicalities I faced at this stage: first, I decided to look at the ASCII value of characters instead of just alphanumerical characters because it was easier to code and would allow binary search as an optimization (which I didn't end up implementing). This turned out to be very fortunate because one of the flags contains spaces and other special (yet still printable) characters! The second was an off-by-one error I had in my encoding (due to a stray newline character) that sometimes resulted in an incorrect value for the ZFT cookie. This was the reason why I ended up straying away from my original approach of error-based blind SQL injection, which can speed things up quite a bit, especially if you go down the binary search route.

Combining the ideas here with queries similar to Level 1, I determined that the database name was ctf2 , and that there was another table called notes whose columns were username , dname , and note . It was time to capture the remaining 2 flags!

The Flags

By looking at the password field of the registrations table, I easily recovered the hashed password of the admin user to be papAq5PwY/QQM (and the address as @CraigTweets). I excitedly cracked the password to be password (weird! I thought I had tried to log in with those credentials before...), DM'd my flag, and logged in to discover... nothing! No notes or anything exciting.

Turns out, I had the wrong account! Oops. Anyways, a couple of new Twitter hints arrived just in time, and suggested that the two remaining flags relate to the flag account. One is the password, and one is a note:

Using the same methodology, I quickly recovered the hash for flag to be 48C8d9EKdcTNU . I could not immediately crack the password (even with dictionaries that are tens of megabytes compressed), so I checked in via DM while leaving John the Ripper to try and brute force it, since it was just descrypt , and thus not impossible. At the same time, I had a very clear plan for recovering the note: the query essentially amounted to:

SELECT note FROM notes WHERE username='flag'

Unfortunately this led to SQL errors... In a moment of inspiration, I decided to name the table and explicitly access columns, which worked, because the error must have been due to the common column username between the two tables accessed ( registrations and notes ). The second flag was revealed: The flag is '4 8 15 16 23 42'. Tell this sequence to a contest official to claim a prize if you qualify!

In the meantime, I was told that the hash mentioned above was sufficient for the purposes of the third flag, so I was technically done! I still wanted to crack it though, and pivoted from a brute force approach to trying easy formats first (e.g. digits, lowercase, etc.), and hoping that it would work. With the first try, the password came back as 48151623 . Yet another Lost reference!

The Gotchas

Though I have evidence to the contrary, it felt like some things changed during the course of the CTF, but it is more likely that I was hitting some code checkpoints. As an example, I could not reproduce the encryption behavior, and some error comments appeared at one point and were Base64 encodings of 30 character hex strings with 3 random letters at the end (which remained fixed before and after the encoding).

I had also quickly discovered an XSS vulnerability in searchaction.php, where you could store a note such as <script>alert(123)</script> and if you searched for it (and only then) it would pop the alert . It also escaped quotes (which you can get around using backticks or regular expressions), but this did not lead anywhere and neither did a different attempt for session hijacking, which I tried since PHPSESSID remained the same even when you logged in with different accounts. Overall, most things seemed very well-escaped, and my attempts for exploits through null byte or CRLF injections failed.

Moreover, I had tried truncation vulnerabilities, where to access account X you create account X<lots of spaces>a , but I couldn't get that working. The reason for that was that I didn't put enough spaces: a hint that came soon after I submitted the flags revealed that the username column was 1024 characters long, while I was only trying up to about 300 characters!

In addition, there were a couple of cookies ( Username and isAdmin ) and one field ( Address ) that truly didn't matter, as I thankfully quickly realized. Moreover, going to the root page of the challenge presents a very appealing directory listing:

Of course, clicking on it just pops a javascript alert saying /not_that_ez/ and the flag.php file does not exist (the file size should have probably given it away). On a similar note, neither level had a robots.txt file, but if they did, it would probably be /that_ez/ .

Finally, as for the Easter egg, it was a persistent comment in the search results:

<!--AUoAtf94nB3I0QmAMAwFwFXeBILoJrpAo49GsC00CdLtFe/zdr0MxqPTUWiWMvEkg/QWWR3eMFpABlyJm+7s2JDq+UeNIl8s07y++McaDA==-->

After Base64 decoding, you are left again with binary data. But the first few characters look familiar: 0x1 0x4a 0x00 0xb5 0xff 0x78 0x9c . Indeed, skipping the first 5 bytes and then decompressing as above reveals a message which is never to be used again: This secret message was brought to you by the letter S and the number 3.14 (no, this unfortunately did not represent a hidden directory or file called Spy ).

Conclusion

Overall, I had a tremendously fun time taking part in the competition, and the prizes were just extra motivation to work hard: I spent about 4 hours on the first part, and about 22 hours on the second part, finishing around midnight GMT Saturday to Sunday (I needed to sleep and eat in the remaining hours, while any discrepancies in query times are due to the fact that I re-ran them for this write-up)!

I have put a cleaned-up version of my SQL injection code on Github in case someone wants to study it or give it a go. Though permitted by the contest rules, I believe that automated scanning tools, such as sqlmap and DirBuster, are a bad idea because (1) they are overkill, (2) they negatively impact the performance for the others (which happened in Level 1 as we were informed), (3) they are not necessary, sufficient, or (most importantly) educational, and (4) they often don't work. Though Burp has tremendous capabilities, I only used it to intercept and alter queries, but what tools you use is really a matter of personal style and preference: use what you know best.

In closing, I would really like to thank Tripwire VERT in general and Craig Young in particular for setting the CTF up and being very helpful, yet not too revealing in his hints (as a side-note, you should definitely check-in with your discoveries every now and then, even if they are not complete flags). And as always, if you have any comments, feel free to contact me.