EFF CTF Write-Up
Thu, Jul 28, 2016The EFF hosted a Little Brother-themed HOPE XI CTF last weekend and I came in second place. Here’s a walkthrough of how each challenge can be solved.
Social Network (100)
The first one is a fairly straightforward example of horizontal privilege escalation. When you view a message received, you have a link to both your message and a user profile of the sender:
https://level0x0.eff-ctf.org/user/M1k3y/message/1
https://level0x0.eff-ctf.org/profile/usdhs
Substitution of the sender’s username in the first link reveals the first
message received by the usdhs
user, which contains the flag.
Headache (100)
For the next challenge, you’re presented with a snippet of brainfuck.
>[-]+>[-]<[->+<]>>[-]+<[[-<+>]>>[-]+++++++++++++++++++++++++++++++++++++++++
+++++++++++++++++++++++++++++++++++++.[-]+++++++++++++++++++++++++++++++++++
++++++++++++++++++++++++++++++++++++++++++++.[-]++++++++++++++++++++++++++++
++++++++++++++++++++++++++++++++++++++++++++++++++++.[-]++++++++++++++++++++
+++++++++++++++++++++++++++++++++++++++++++++++++.[-]+++++++.[-]++++++++++.<
-<]>[[-]>[-]++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
+++++++++++++++++++++++++++++++++++++++++++++.++++++++++++.-----------------
---------.+++.++++++++++++++++.-----------------.++++++++.+++++.------------
---.+++++++++.+++++++++++++.---.++.-.---------------------------------------
------------------------------------------------------------------.<]>>>>>>>
If you try to run this in a brainfuck interpreter, it just prints out the string “NOPE”, which is not the flag. I figured the flag was either written in memory but not to stdout or located inside a block of dead code.
At this point, it helps to convert the brainfuck blob to slightly-more-readable C. Here’s one in haskell: https://github.com/pablojorge/brainfuck/
I’ll omit the output here, but you end up with something involving two huge
outer while(*p)
loops. I tried comenting out the while
control statment of
each individually such that the loop’s contents were guaranteed to run exactly
once. The second one ended up printing the flag.
Tweeting Twerps (200)
The twitter clone challenge requires you to perform SQL injection. If you view a tweet, an integer uid
parameter is url-encoded to identify the specific tweet.
Setting a uid
of zero or a non-integer results in a vague SQL error, which indicates it might be SQL injectable:
[matt@teselecta EFFCTF]$ curl 'https://level0x1.eff-ctf.org/tweets?uid=0'
Your SQL is bad and you should feel bad
Another tweet also hinted that the flag was hidden in a private message, presumably stored inthe same DB:
fedman Yo @fascistcop I sent you the secret password for the meeting tonight
The application seemed to be somewhat picky as to what values it would accept – it was not possible, e.g. to execute multiple statements in a single URL parameter.
UNION queries are one way to get around this. I successfully obtained a list of tables bu running:
https://level0x1.eff-ctf.org/tweets?uid=1%20union%20all%20select%201,table_name%20from%20information_schema.tables
Tables are appended to the bottom of the list and reveal the existance of a “messages” table, which contains the data we’re interested in. Columns can similarly be obtained by grabbing the column_name
from information_schema.columns
. A UNION query which selects body
from messages
reveals the flag.
A Mole in the Ranks (200)
Level 0x4 is fairly straightforward and just requires you to generate a PGP key matching a few parameters with a hash collision. The EasyVerify service uses some sort of modulo-7 caluation for fingerprinting, so it’s easy enough to solve this through brute force.
I first tried to script this through GPG unattended key generation but couldn’t get it to choose the correct key file for some reason. I resorted to a web-based key generator and got a fingerprint collision on the second attempt. From there you just need to import the private key into your GPG keyring and then decrypt the provided message.
Fileception (200)
The anarchism.jpg
file contains data hidden through steganography. The steghide
utility recovered the contents quickly when presented with an empty passphrase.
[matt@teselecta EFFCTF]$ steghide extract -sf anarchism.jpg -p ''
wrote extracted data to "flag.txt".
DNS-Vault (300)
DNSvault is a fun reversing challenge. You’re given a binary which reveals a list of passwords once presented with a valid password. If you provide an incorrect password, the program terminates.
[matt@teselecta EFFCTF]$ ./DNSvault
_____ _ __ __ _ _
| __ \ | | \ \ / / | | |
| |__) |_ _ ___ _____ _____ _ __ __| | \ \ / /_ _ _ _| | |_
| ___/ _` / __/ __\ \ /\ / / _ \| '__/ _` | \ \/ / _` | | | | | __|
| | | (_| \__ \__ \\ V V / (_) | | | (_| | \ / (_| | |_| | | |_
|_| \__,_|___/___/ \_/\_/ \___/|_| \__,_| \/ \__,_|\__,_|_|\__|
A project of the Department of National Security
Authorized use only!
Please enter your password: foobar
ERROR: Incorrect Password
At this point I ran strings
on the binary to determine if there was any
obvious password stored in plaintext, and there wasn’t.
I next used radare2 to disassemble the program and get a sense of how it works.
At 0x00400924
there’s a memcmp instruction whose result determines whether or
not the list of passwords is displayed. I next tried using
bless to change the jne
immediately following
to a je
so that passwords in the vault would only be printed if the user
password is incorrect.
This gives obviously incorrect values:
[matt@teselecta EFFCTF]$ ./DNSvault-nojne
_____ _ __ __ _ _
| __ \ | | \ \ / / | | |
| |__) |_ _ ___ _____ _____ _ __ __| | \ \ / /_ _ _ _| | |_
| ___/ _` / __/ __\ \ /\ / / _ \| '__/ _` | \ \/ / _` | | | | | __|
| | | (_| \__ \__ \\ V V / (_) | | | (_| | \ / (_| | |_| | | |_
|_| \__,_|___/___/ \_/\_/ \___/|_| \__,_| \/ \__,_|\__,_|_|\__|
A project of the Department of National Security
Authorized use only!
Please enter your password: foobar
1 Entry:
+----------------+---------------------+
| Username | Password |
+----------------+---------------------+
| the flag | l~i:JQ8 |
+----------------+---------------------+
So there must be decryption dependent on the password. I revisited the memcmp
and insepcted the value of rdi
and rsi
. In the original binary, passwords
would be printed if the values in these registers are the same.
I used this in my ~/.gdbinit
file:
file /home/matt/EFFCTF/DNSvault
break *0x400924
set disassembly-flavor intel
layout asm
layout regs
run
Since the registers contain memory addresses rather than literal values, you must deference them. Here’s the output of *rsi and *rdi respectively:
(gdb) x/5w 0x7fffffffe690
0x7fffffffe690: 0x3a373611 0x6236272c 0x62272a36 0x2c232e12
0x7fffffffe6a0: 0x00633627
(gdb) x/5w 0x7fffffffe6f0
0x7fffffffe6f0: 0x202d2d24 0x42483023 0x42424242 0x42424242
0x7fffffffe700: 0x0042bd42
Note that I chose a 5-word (20 byte) output because memcmp()
takes three
operands - the source and destination addresses and the size of the buffer. At
0x400919
, you can see the buffer size.
Neither of these registers corresponds to the ASCII of my password, but *rdi
shows a suspicious pattern. I next tried a known plaintext attack. The contents
of *rsi
stayed constant, but *rdi
varied with input, so you can infer that
*rsi
is the expected value and *rdi
is some function of user input.
User input | *rdi |
---|---|
A | 0x42424803 0x42424242 0x42424242 0x42424242 0x0042bd42 |
AA | 0x42480303 0x42424242 0x42424242 0x42424242 0x0042bd42 |
B | 0x42424800 0x42424242 0x42424242 0x42424242 0x0042bd42 |
\0 | 0x42424248 0x42424242 0x42424242 0x42424242 0x0042bd42 |
See the pattern yet?
Each ASCII character maps to one byte (’\0’->0x48, ‘A’->0x03, ‘B’->0x00)
Position in the string has no effect on the char-to-byte mapping
0x42 is the default content of the buffer
Functionally, this operates like
ECB with an
8-bit block size. The implication of this is you can brute force all 256 input
characters and determine the mapping of plaintext-to-codeword. You could script
this or run the program ceil(127/20)
times through gdb to get the mapping. I
chose the latter approach.
Once you determine the plaintext byte corresponding to every code byte in
*rsi
, you just need to assemble a string and enter that as the password.
DNSvault then decrypts the flag and prints to stdout.
Sikrit Missij (300)
The page for level7 just gives you this:
Here is today’s message: vJCSkpqRnJrfkI+ajZ6LlpCR35KelJrfi5CLnpPfm5qMi42QhtHfhJmTnpjF392Sv5SaoIvPi8uT\r\noJvMjIuNz4bdgv~~
This looks like base64, with two exceptions:
\r\n
occurs in the middle of the stringThe string ends with
~~
, which are not valid characters in base64.
I first attempted a Caesar cipher on the ASCII character space to convert ~~
to ==
. Note that the base64 spec
dictates that the equals character be used for padding if the final quantum is
one or two bytes (3-byte quantums are normal). This would have moved most
base64 characters to non-printable ASCII, so I took another approach.
I referred to the base64 RFC to see how non-base64 characters are handled. Section 3.1 makes reference to line breaks being expected for MIME encoding at the 76-character mark. Indeed, this is the case:
>>> orig = 'vJCSkpqRnJrfkI+ajZ6LlpCR35KelJrfi5CLnpPfm5qMi42QhtHfhJmTnpjF392Sv5SaoIvPi8uT\r\noJvMjIuNz4bdgv~~'
>>> len(orig.split('\r\n')[0])
76
I then removed it from the string
>>> unmime = orig.replace('\r\n', '')
Grepping the RFC for the ~
character, it says:
An alternative alphabet has been suggested that would use “~” as the 63rd character. Since the “~” character has special meaning in some file system environments, the encoding described in this section is recommended instead.
I attempted to make this substitution and got the following:
>>> base64.b64decode(unmime.replace('~','/'))
b'\xbc\x90\x92\x92\x9a\x91\x9c\x9a\xdf\x90\x8f\x9a\x8d\x9e\x8b\x96\x90\x91\xdf\x92\x9e\x94\x9a\xdf\x8b\x90\x8b\x9e\x93\xdf\x9b\x9a\x8c\x8b\x8d\x90\x86\xd1\xdf\x84\x99\x93\x9e\x98\xc5\xdf\xdd\x92\xbf\x94\x9a\xa0\x8b\xcf\x8b\xcb\x93\xa0\x9b\xcc\x8c\x8b\x8d\xcf\x86\xdd\x82\xff\xff'
This is finally valid base64, but the output is neither ASCII nor UTF8. The careful observer will note that each byte has the high bit set, while ASCII uses the lower 7 bits. I next tried inverting everything:
>>> b = b''
>>> for f in base64.b64decode(unmime.replace('~','/')):
... b = b + bytes([~f & 0xff])
...
>>> b
b'Commence operation make total destroy. {flag: "m@ke_t0t4l_d3str0y"}\x00\x00'
Success! My substitution of ~
to /
may have been incorrect since there’s
two trailing null terminators, but the flag was revealed so it ultimately
didn’t matter.
Never Roll Your Own Crypto (400)
The LOLCrypt challenge gives you a block of data encrypted with an unknown algorithm and a form to encrypt your own plain texts.
X-net agents have uncovered an encrypted message from the Department of National Security high command. It appears to be using a new cypher called LOLCrypt.
We have also found the tool that DNS agents use to generate LOLCrypt encrypted texts, maybe you can use this in your cracking efforts.
LOLCrypt CipherText:
8 59 58 46 28 19 40 64 42 36 52 20 42 12 47 39 99 66 6 52 34 29 39 34 9 40 67 38 4 5 39 106 30 61 29 28 46 1 105 64 31 17 14 78 37 21 92 73 42 31 33 34 27 36 47 44 8 17 64 67 24 53 41 23 39 13 86 16 27 26 49 37 21 54 74 74 40 32 52 19 51 54 14 39 29 6 55 50 27 28 2 83 35 6 67 35 19 36 53 43 42 40 7 52 34 70 45 9 22 114 2 2 50 37 26 41 19 55 86 63 26 52 20 52 30 30 51 29 75 2 77 43 12 88 96 27 56 10 46 44 59 28 41 16 32 62 39 8 34 41 33 33 9 36 4 105 5 46 59 46 13 53 12 61 37 33 41 49 35 30 46 46 24 58 18 58 58 47 29 5 58 4 25 52 38 48 23 45 20 61 8 52 22 7 42 63 55 22 76 7 8 19 17 96 36 60 4 39 78 36 17 27 36 37 36 34 36 42 57 12 17 39 63 35 14 35 83 28 46 28 22 55 58 43 41 2 21 57 40 23 31 33 39 37 54 42 9 49 4 30 61 45
I tried a few known plaintexts and noticed they all end up with a constant 4 numbers per character of input:
Plain text | LOLCrypt cipher text |
---|---|
A | 84 53 22 31 |
B | 6 51 68 64 |
a | 8 70 39 41 |
b | 40 3 60 54 |
aa | 11 79 19 49 84 27 2 45 |
(empty) | (empty) |
No obvious pattern here. But I eventually noticed when I submitted the same plain text multiple times, the cipher text differed:
Plain text | LOLCrypt cipher text |
---|---|
a | 44 6 57 51 |
a | 5 57 31 65 |
a | 8 70 39 41 |
a | 12 61 55 30 |
a | 57 56 31 14 |
a | 1 99 2 88 |
So some entropy was being added which explains the excessive block size and
lack of repetition. If you sum the digits of a block of cipher text derived
from plain text a
, you get a constant 158
. The same pattern holds true for
all ASCII characters I tested. Alphanumeric characters also have a sequential
pattern so you don’t need to test all printable characters.
Plain text sequence | sum(cipher block) sequence |
---|---|
a..z | 158..133 |
A..Z | 190..165 |
0..9 | 207..198 |
You can then just sum all blocks to get something which can be manually decoded fairly quickly:
>>> blocksums = ''
>>> i = 0
>>> while i < len(ciphertext.split()):
... blocksums = blocksums + str(int(ciphertext.split()[i]) + int(ciphertext.split()[i+1]) + int(ciphertext.split()[i+2]) + int(ciphertext.split()[i+3])) + ' '
... i = i + 4
...
>>> blocksums
'171 151 150 140 223 136 154 154 148 216 140 223 140 154 156 141 154 139 223 143 158 140 140 143 151 141 158 140 154 223 150 140 197 223 156 144 141 141 154 156 139 160 157 158 139 139 154 141 134 160 140 139 158 143 147 154 160 151 144 141 140 154 140 '
Stego-saurus-rex (400)
This is another steganography challenge. I started by running file
and strings
to try to figure out what it was:
[matt@teselecta EFFCTF]$ file stego2
stego2: data
Strings reveals it’s either a PDF or contains a PDF:
/PieceInfo << /MarkedPDF << /LastModified (D:20060611201827)>> >>
but it’s missing the PDF magic number, so nothing is willing to open it:
[matt@teselecta EFFCTF]$ xxd stego2 | head
00000000: 0d25 e2e3 cfd3 0d0a 3834 2030 206f 626a .%......84 0 obj
00000010: 0d3c 3c20 0d2f 4c69 6e65 6172 697a 6564 .<< ./Linearized
00000020: 2031 200d 2f4f 2038 3720 0d2f 4820 5b20 1 ./O 87 ./H [
00000030: 3134 3839 2032 3538 205d 200d 2f4c 2036 1489 258 ] ./L 6
00000040: 3339 3031 200d 2f45 2035 3333 3032 200d 3901 ./E 53302 .
00000050: 2f4e 2031 200d 2f54 2036 3231 3033 200d /N 1 ./T 62103 .
00000060: 3e3e 200d 656e 646f 626a 0d20 2020 2020 >> .endobj.
00000070: 2020 2020 2020 2020 2020 2020 2020 2020
00000080: 2020 2020 2020 2020 2020 2020 2020 2020
00000090: 2020 2020 2020 2020 2020 2020 2020 2020
I opened it in bless
and added the %PDF
(25 50 44 46
) magic number I
found on wikipedia to
the start of the file. Evince is now willing to open it, and we can tell that
it’s the Hacker Manifesto. This doesn’t
yet help us, since evince doesn’t know about anything embedded inside it.
Pouring through the xxd output again, we see a reference to an image file:
[matt@teselecta EFFCTF]$ xxd stego2 | tail
0003d330: f621 0300 2400 1800 0000 0000 0000 0000 .!..$...........
0003d340: b481 0000 0000 7363 7269 7074 5f6b 6974 ......script_kit
0003d350: 7479 5f62 795f 7061 756c 6a73 3735 2d64 ty_by_pauljs75-d
0003d360: 336c 3079 6c61 2e6a 7067 5554 0500 03f5 3l0yla.jpgUT....
0003d370: f28f 5775 780b 0001 04e8 0300 0004 e803 ..Wux...........
0003d380: 0000 504b 0506 0000 0000 0100 0100 6a00 ..PK..........j.
0003d390: 0000 83d9 0200 0000 7400 6800 6500 6900 ........t.h.e.i.
0003d3a0: 6e00 7400 6500 7200 6e00 6500 7400 6900 n.t.e.r.n.e.t.i.
0003d3b0: 7300 6d00 6100 6400 6500 6f00 6600 6300 s.m.a.d.e.o.f.c.
0003d3c0: 6100 7400 7300 0a a.t.s..
If you run strings
again and grep for metadata, you can see it’s embedded using the XMP standard:
<?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d' bytes='1039'?><rdf:RDF xmlns:rdf='https://www.w3.org/1999/02/22-rdf-syn
tax-ns#' xmlns:iX='https://ns.adobe.com/iX/1.0/'><rdf:Description about='' xmlns='https://ns.adobe.com/pdf/1.3/' xmlns:pd
f='https://ns.adobe.com/pdf/1.3/' pdf:CreationDate='2006-06-11T17:18:20Z' pdf:ModDate='2006-06-11T17:18:20Z' pdf:Produce
r='Acrobat Distiller 5.0 (Windows)' pdf:Author='user' pdf:Creator='Acrobat PDFMaker 5.0 for Word' pdf:Title='The Hacker
Manifesto'/>
I assumed there were binary objects embedded in the XMP metadata, since the standard supports that. However, the various tools I tried (pdfinfo, exiftool, exiv2…) were unable to extract it. Binwalk found an embedded zip file:
[matt@teselecta EFFCTF]$ binwalk -e stego2
DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------
61051 0xEE7B Unix path: /www.w3.org/1999/02/22-rdf-syntax-ns#' xmlns:iX='https://ns.adobe.com/iX/1.0/'><rdf:Description about='' xmlns='https://ns.adobe.c
61848 0xF198 Unix path: /purl.org/dc/elements/1.1/' xmlns:dc='https://purl.org/dc/elements/1.1/' dc:creator='user' dc:title='The Hacker Manifesto'/>
63893 0xF995 Zip archive data, at least v2.0 to extract, compressed size: 186661, uncompressed size: 205302, name: script_kitty_by_pauljs75-d3l0yla.jpg
250754 0x3D382 End of Zip archive
And inside it we see the file referenced earlier at the end of the hexdump of stego2
:
[matt@teselecta _stego2.extracted]$ file F995.zip
F995.zip: Zip archive data, at least v2.0 to extract
Something else is definitely hidden here since there’s no flag yet. I tried steghide as used in the previous challenge and was able to extract a flag.py file with an empty password:
[matt@teselecta _stego2.extracted]$ steghide extract -sf script_kitty_by_pauljs75-d3l0yla.jpg -p ''
wrote extracted data to "flag.py".
[matt@teselecta _stego2.extracted]$ cat flag.py
# Here is the encrypted flag, but where is the key?
flag = ['0x20', '0x0', '0x0', '0x49', '0x8', '0x18', '0x4', '0x15', '0x4e', '0xc', '0x7', '0x53', '0x53', '0x28', '0xf', '0x7', '0x17', '0x16', '0x16', '0x17', '0x3e', '0x15', '0x1f', '0x18', '0x37', '0x11', '0x1', '0xb', '0x2b', '0x11', '0x1a', '0x7', '0xb', '0x13', '0x1a', '0x52']
print "".join(map(lambda b: chr(int(b, 16)), flag))
There was a suspicious-looking string at the end of the stego2
binary, so I
tried using that as a key. The ciphertext was 36 bytes long, which doesn’t
match the block size of any common algorithm. I ended up trying just XORing the
encrypted blob with the ASCII of theinternetismadeofcats
(repeating as
necessary), and the flag was revealed.
Company Memo (400)
Visiting the company memo server, you’re given the message:
ERROR: You are not using the official E-Corp browser. Your supervisor has been notified.
If you check the HTML source, you see:
<!-- Admin note: if there is ever a new version of the E-Corp browser out just update the config file with the new hash -->
One can infer that it’s doing user agent checking and you need to spoof whatever is considered the company browser. I tried guessing common config file names and ended up discovering https://eff-ctf.org/config.json
after spending way too much time trying to spoof other HTTP headers. Here’s the contents:
{
"user_agent_hash": "1856668417"
}
Hashes are normally viewed in hex, so I did a simple base conversion:
>>> hex(1856668417)
'0x6eaa8301'
Since no one in their right mind would design a 32-bit hash function, I hazarded a guess that this was CRC32. CRC is designed to detect errors in transmission, not prevent intentional collisions.
I found a CRC32 reversing library on Github and gave it a shot:
[matt@teselecta crc32]$ python2 crc32.py reverse 0x6eaa8301
4 bytes: {0xca, 0xd1, 0x28, 0x91}
verification checksum: 0x6eaa8301 (OK)
alternative: 25gS2k (OK)
alternative: 2xJn_c (OK)
alternative: 5aMPuH (OK)
alternative: FaxzgL (OK)
alternative: PIUgJe (OK)
alternative: QhJF80 (OK)
alternative: UlWG9S (OK)
alternative: nYLl89 (OK)
alternative: oxSMJl (OK)
alternative: qZh3km (OK)
Sure enough, that works:
[matt@teselecta crc32]$ curl -A '25gS2k' 'https://eff-ctf.org/memo' | grep secret
<p> Due to heightened security measures in place we will all be using the secret code: <b>EFF!S0ci3ty</b> to verify each other's identities</p>