Skip to content ↓ | Skip to navigation ↓

In the first installment of this blog post, I took you through how I completed level 1 of Tripwire Vulnerability and Exposure Research (VERTs) Capture the Flag contest. Now, I’ll show you how I finished level 2 and successfully completed the challenge.

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:


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:


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


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­wrote them for this writeup)!

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, if you have any comments, feel free to contact me.


iliasAbout the Author: Ilias Giechaskiel is currently a PhD student in Cyber Security at the University of Oxford. He holds a Master’s in Advanced Computer Science (Distinction) from the University of Cambridge, and a Bachelor’s in Mathematics (Summa Cum Laude) from Princeton University. Ilias has interned in the Windows Security team at Microsoft and the Product Abuse Security team at Dropbox after a financial internship at Bloomberg. His website can be found at

Editor’s Note: The opinions expressed in this guest author article are solely those of the contributor, and do not necessarily reflect those of Tripwire, Inc.