How I Captured the Flags in Tripwire VERT's Cyber Security Contest
Posted 2015/04/16. Last updated 2015/04/16.
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.
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:
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
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
', (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
@@HOSTNAME gives us the values
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:
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')#
accounts columns are
balance, while for
items they are
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:
which can be Base64 decoded to:
which after a ROT-13 becomes:
to be URL decoded as:
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
Finally, for completeness, the other columns for the flag were
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!
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.
Monitoring the HTTP requests and responses, we notice that successful registrations return a header named
X-VERT-Header, with a value like:
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:
Username and the usual
isAdmin is always set to
Username is a Base64 encoding of the username used.
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
Username cookies makes no difference, while changing
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,
f is some function that creates binary data.
My first step to understand how the function works was to create accounts with usernames
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
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
s.encode('zlib').encode('base64'), while decoding it is the reverse:
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
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
note. It was time to capture the remaining 2 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 (
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!
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 (
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:
/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
Finally, as for the Easter egg, it was a persistent comment in the search results:
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
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.