HKCERT CTF 2024 Quals Writeup (IV): The Reverse Challenges
I also wrote some reverse challenges this year: Void, Cyp.ress and Bashed!. We will cover them all in the last part of the blog post.
Void⌗
Challenge Summary⌗
I made a simple webpage that checks whether the flag is correct… Wait, where are the flag-checking functions?
We are given a static webpage (source code here), where it asks us for the flag:
The goal is to provide a legit flag.
Solution⌗
There is a bunch of whitespaces inside the with
block. They are not the regular whitespace (U+0020), but the Hangul filler (ㅤ
, U+3164). They are replaced with _
below for visibility:
with (ㅤ`` ) {
_______
____
_______
________________
_______
_______________
[snipped]
}
The below snippet written by @aemkei is why the invisible code block works:
function \u3164(){return f="",p=[]
,new Proxy({},{has:(t,n)=>(p.push(
n.length-1),2==p.length&&(p[0]||p[
1]||eval(f),f+=String.fromCharCode
(p[0]<<4|p[1]),p=[]),!0)})}//aem1k
To generate one byte, the function reads two lines from the “code block” and let there be $s+1$ and $t+1$ whitespaces on the two lines. It will append the character with ASCII value $16s + t$ to p
. After p
is ready, it will be evaluated by the eval
function. Thus we can inject the functions in this way.
We can extract the with
block and parse it manually to retrieve the actual code:
const flag = document.getElementById('flag');
flag.focus();
handleKeyPress = event => event.key === 'Enter' && check();
function check() {
if (flag.value === 'hkcert24{j4v4scr1p7_1s_n0w_alm0s7_y3t_4n0th3r_wh173sp4c3_pr09r4mm1n9_l4ngu4g3}') {
flag.disabled = true;
flag.classList.add('correct');
} else {
flag.classList.add('wrong');
setTimeout(() => flag.classList.remove('wrong'), 500);
}
}

Alternative solutions. There are some simpler solutions provided by the contestants, which I found interesting:
- @wyli downloaded the source code and changed
eval
toconsole.log
. The source code is then logged into the console. - @jas typed
alert(f)
into the console and the source code pops up. This is becausef
is the variable that stores the code inside the function\u3164
.
Cyp.ress⌗
Challenge Summary⌗
You will get sser.pyc when you reverse the title. Now reverse it back for me.
Attachment: cypress_5788c6411dc79b08e280746a07306538.zip
We are given a .pyc
file.
Solution⌗
We can throw the .pyc
file to the online decompilers. Most of the existing decompilers do not support Python 3.12. While tool.lu is accepting Python 3.12, it fails to decompile the entire binary file:
#!/usr/bin/env python
# visit https://tool.lu/pyc/ for more information
# Version: Python 3.12
import os
import requests
from Crypto.Cipher import AES
import hashlib
# WARNING: Decompyle incomplete
We are hinted, however, that it uses the os
, requests
, Crypto
and hashlib
packages. We can override these packages to guess the program’s logic.
To do this, we will create multiple files along with the .pyc
file:
├── sser.cpython-312.pyc
├── Crypto
│ ├── __init__.py
│ └── Cipher.py
├── hashlib.py
├── os.py
└── requests.py
This is a sample transcript by running the program with python3.12 sser.cpython-312.pyc
:
python3.12 sser.cpython-312.pyc
Traceback (most recent call last):
File "sser.py", line -1, in <module>
ImportError: cannot import name 'AES' from 'Crypto.Cipher' (/[omitted]/Crypto/Cipher.py)
We will create a placeholder class in Crypto/Cipher.py
:
class AES:
def __init__(self, *args, **kwargs):
print(f'AES({args}, {kwargs})')
Re-running the program, we are asked to provide hashlib.sha256
, as shown below. Let’s update hashlib.py
in a similar fashion.
python3.12 sser.cpython-312.pyc
'What is the flag?> '
hkcert24{test}
Traceback (most recent call last):
File "sser.py", line -1, in <module>
File "sser.py", line 9, in get_nonce
AttributeError: module 'hashlib' has no attribute 'sha256'
Now we have the class implemented. Let’s re-run the binary:
python3.12 sser.cpython-312.pyc
'What is the flag?> '
hkcert24{test}
hashlib.sha256((b'pow/yB8T$D@\x05\x89\\\xc7\xa2n\x99\x03\xbd',), {})
Traceback (most recent call last):
File "sser.py", line -1, in <module>
File "sser.py", line 9, in get_nonce
AttributeError: 'sha256' object has no attribute 'digest'
Turns out they are using the .digest
method of the sha256
class. Let’s add that as well:
class sha256:
def __init__(self, *args, **kwargs):
print(f'hashlib.sha256({args}, {kwargs})')
def digest(self):
return b'0'*256
However, the program does not seem to terminate when we re-run the binary. Let’s update hashlib.py
to
import os
class sha256:
def __init__(self, *args, **kwargs):
res = os.urandom(32)
print(f'hashlib.sha256({args}, {kwargs}) := {res}')
self.res = res
def digest(self):
return self.res
os.py
taken effect? Turns out that we are using a different importer when importing os
– it does not use the SourceFileLoader
but the FrozenImporter
. This hints that os.py
might not be overridden.
Now the program finally terminates:
python3.12 sser.cpython-312.pyc
'What is the flag?> '
hkcert24{test}
hashlib.sha256((b'pow/D\xa5\xbb\x85u\xb2~~\xb7\xae\xb2/\x97\xf4\xc9\x02',), {}) := b'\x9b\xfcR\xd0\xea\\L<\x0c"\xb6u"\xdc\x85\xbc@\x90\xf1\x06&\xf5\xe5\xb7\xd9+6_\xd5[\xbe$'
[...omitted...]
hashlib.sha256((b'pow/\x8f?j\xbc\xc7\xbd\x85\xfb\x88\x90\xee,\xd7~\xba\x05',), {}) := b'Q\xc4\xf6\xf9\x9c\xe7\x91_\x99 <%i\xa8\x8c\xfb\xad\x9c\xdf\xe7>L\x19\xa7.\x8c\x1eN\n\xcc\xae-'
hashlib.sha256((b'pow/\x94Py\xed\xeb}\xce6q\xacx\xfbw.\xe4\xd2',), {}) := b'\x00\x00\x00\x92A\x85\xcfl\x88\x16\x81L\x1b\x10Y\xa9kP\xd2DT\x1f\xc7<\x0b2:\x96v^\xbb\x9e'
Traceback (most recent call last):
File "sser.py", line -1, in <module>
AttributeError: module 'requests' has no attribute 'post'
Running the program multiple times and collect the last line:
hashlib.sha256((b'pow/\xf9\x1a\xdf\xb9@u\xcf*W\x9a\x9d\x94F\xcbg\xe3',), {}) := b'\x00\x00\x00\x99\xa7-\xd0\x04\x89\x1e^\xf6\xf2O2\x1d^\x11/\x9esg\nk\x1a\xda\x12\xadL\xe7\xda\x13'
hashlib.sha256((b'pow/zg\xe6\x8c\xeea\x8aw\x8bT\x93\x96>\x97\xd0\x0c',), {}) := b'\x00\x00\x00E\xa9\xaa\xa3~=\xe0\x1fv\xd2\xc9Z\xfdW\xa1\x89#\xe7\xcdq\xc2\xdb\x06\x0c\xbc<\xbc\xedc'
The first three bytes of the digest are always $\texttt{000000}$. We will update hashlib.py
to always return three leading null bytes. Also, we will create requests.py
and define as well self.text
:
class post:
def __init__(self, *args, **kwargs):
print(f'post({args}, {kwargs})')
self.text = ''
After that, we got AttributeError: type object 'AES' has no attribute 'new'
. Let’s work on some changes on Crypto/Cipher.py
.
# Updated Crypto/Cipher.py
class AES:
MODE_CFB = 1337
@classmethod
def new(self, *args, **kwargs):
class cipher:
def encrypt(self, *args, **kwargs):
print(f'cipher.encrypt({args}, {kwargs})')
return b'ciphertext'
print(f'AES.new({args}, {kwargs})')
return cipher()
Now we have a full transcript that doesn’t fail:
python3.12 sser.cpython-312.pyc
'What is the flag?> '
hkcert24{test}
hashlib.sha256((b'pow/\xb6n<\xba\xcb\x89\xf4\\\x0b\xb6G\x9d\xea\x98\xd3M',), {}) := b'\x00\x00\x00\x1b\xfayjX\xf6\x7f-\xb5i\x1a\x07\xb4\x96@\x979\x8a8\x9c\xde=NV|\xd5i>5'
post(('https://c12-cypress.hkcert24.pwnable.hk/',), {'json': {'nonce': 'b66e3cbacb89f45c0bb6479dea98d34d'}})
hashlib.sha256((b'key/\xb6n<\xba\xcb\x89\xf4\\\x0b\xb6G\x9d\xea\x98\xd3M',), {}) := b'\x00\x00\x00\xdfa\xcaf\x82%\xf5\xd6p*O(\x99\x9d\xe9/6\x82JT\x05\xf5#\x028\x07\x01\xc9\xe5'
hashlib.sha256((b'iv/\xb6n<\xba\xcb\x89\xf4\\\x0b\xb6G\x9d\xea\x98\xd3M',), {}) := b'\x00\x00\x00,\xfe\x18IS\xbafZ\x80|&S\xc1\xc4/\x86\x96\xed\xb5\x9f\xed\xee|,\x1f\xaa\x80\xa1\xb0'
AES.new((b'\x00\x00\x00\xdfa\xcaf\x82%\xf5\xd6p*O(\x99', 1337, b'\x00\x00\x00,\xfe\x18IS\xbafZ\x80|&S\xc1'), {})
cipher.encrypt((b'hkcert24{test}',), {})
🙅
Additionally, when sending a POST request to the remote endpoint with a proper nonce (i.e., its hash has leading null bytes), it will return a hexstring. Maybe this is the ciphertext that we want to compare? We can have a rough guess on the flow:
We can reverse the process – by generating a proper nonce, we can ask the server for the encrypted flag. Since we have the key and the IV, we are able to decrypt the flag on our own:
hkcert24{y0u_c4n_h00k_func710ns_t0_35c4p3_fr0m_r3v3r5e_3n9e3r1n9}
Zoomer’s solution⌗
Turns out ChatGPT is able to rebuild 80% of the code – below is the reconstructed code with GPT-4o
. We will provide a diff to the actual source code (the red lines are the incorrect ones):
import os
import requests
from Crypto.Cipher import AES
import hashlib
def get_nonce():
while True:
nonce = os.urandom(16)
h = hashlib.sha256(b'pow/' + nonce).digest()
- if h[0] < 3:
+ if h[:3] == b'\0\0\0':
return nonce
flag = input("What is the flag?> ")
nonce = get_nonce()
r = requests.post(
"https://c12-cypress.hkcert24.pwnable.hk/",
json={
- "key": hashlib.sha256(flag.encode()).digest().hex(),
- "iv": hashlib.sha256(nonce).digest().hex(),
+ "nonce": nonce.hex(),
},
)
- c0 = bytes.fromhex(r.text[:32])
- c1 = bytes.fromhex(r.text[32:])
+ c0 = bytes.fromhex(r.text)
cipher = AES.new(
- hashlib.sha256(flag.encode()).digest(),
+ hashlib.sha256(b"key/" + nonce").digest()[:16],
AES.MODE_CFB,
- hashlib.sha256(nonce).digest()
+ hashlib.sha256(b"iv/" + nonce").digest()[:16]
)
- decrypted = cipher.decrypt(c1)
+ c1 = cipher.encrypt(flag)
- if decrypted == b"🙆🙅":
- print("Correct!")
- else:
- print("Wrong!")
+ print('🙆🙅'[c0 != c1])
Bashed!⌗
Challenge Summary⌗
The program has only one meaningful function, and the function is less than 500 bytes long. What’s hard understanding it?
./out.sh ***FLAG*** ❤️
Attachment: bashed_89d984b177c984086cf3a5db1cb30adf.zip
The contestants are given ❤️.sh
, which has a lot of emojis. The below is a snippet of the bash file:
#!/bin/bash
FLAG=$1
GALF=1
if ! [[ "$FLAG" =~ ^[0-9A-Za-z_{}]{87}$ ]]; then echo 💔; exit 0; fi
function 🌚() { echo $(printf "%d" "'$1"); }
function 🌝() { echo $(printf "%x" "$1"); }
function 🍋() {
u=$(echo -n ${FLAG:$(($(🌚 $1)-$(🌚 👂))):$(($(🌚 $2)-$(🌚 👂)))} | sha1sum);
if [[ ${u:1:1} == $(🌝 $(($(🌚 $3)-$(🌚 👂)))) ]];
then wget https://c22-bashed.hkcert24.pwnable.hk/$4.sh -O $(basename $0) >/dev/null 2>&1; GALF=$((GALF*$(🌚 $6)));
else wget https://c22-bashed.hkcert24.pwnable.hk/$5.sh -O $(basename $0) >/dev/null 2>&1; GALF=$((GALF*$(🌚 $7)));
fi;
}
function 👂() { 🍋 $1 $5 $2 $3 $7 $6 $4; }
function 👃() { 🍋 $5 $6 $4 $7 $3 $2 $1; }
function 👄() { 🍋 $5 $6 $2 $4 $3 $1 $7; }
# ...
👚 👣 👺 👅 👳 👱 👄 👂
👢 👄 💅 👂 👣 👲 👅 👋
👥 👈 💐 💁 👄 👞 👒 👧 \
👄 💇 👥 💁 👒 💀 👅 👂 \
👞 👆 💅 👂 💄 👅 👽 💓
# ...
if [[ "$GALF" -ne 0 ]]; then echo ❤️; else echo 💔; fi
The goal is to execute ./out.sh **FLAG**
and make it output ❤️
.
Solution⌗
The engine: Self-updating script files⌗
Suppose that you have a file called foo.sh
with the below content:
#!/bin/bash
wget https://mystiz.hk/fun -O $(basename $0) >/dev/null 2>&1
echo :)
Guess what will be printed? You can try it if you are interested. In short, I don’t know. It depends on what is hosted on https://mystiz.hk/fun
.
Assume that below is the content on the /fun
endpoint:
#!/bin/bash
wget https://mystiz.hk/fun -O $(basename $0) >/dev/null 2>&1
echo :(
In that case, running ./foo.sh
in your terminal would print :(
.
The idea, in short, is that Bash keeps track of the “pointer” of the script - after each command, it proceeds to read the next command and executes it.
The first command is wget ...
, and the next instruction will be at the beginning of echo :)
. When the command is executed, the file is overwritten. It then reads the next command, which is overwritten to echo :(
.
This is the heart of the challenge, and let’s proceed with reverse engineering the given script.
Understanding the functions in the challenge file⌗
function 🌚() { echo $(printf "%d" "'$1"); }
function 🌝() { echo $(printf "%x" "$1"); }
function 🍋() {
u=$(echo -n ${FLAG:$(($(🌚 $1)-$(🌚 👂))):$(($(🌚 $2)-$(🌚 👂)))} | sha1sum);
if [[ ${u:1:1} == $(🌝 $(($(🌚 $3)-$(🌚 👂)))) ]];
then wget https://c22-bashed.hkcert24.pwnable.hk/$4.sh -O $(basename $0) >/dev/null 2>&1; GALF=$((GALF*$(🌚 $6)));
else wget https://c22-bashed.hkcert24.pwnable.hk/$5.sh -O $(basename $0) >/dev/null 2>&1; GALF=$((GALF*$(🌚 $7)));
fi;
}
function 👂() { 🍋 $1 $5 $2 $3 $7 $6 $4; }
function 👃() { 🍋 $5 $6 $4 $7 $3 $2 $1; }
# ...
There are a number of functions in the Bash file, namely, 🌚
, 🌝
, 🍋
, 👂
and so on.
We can see that
🌚(x)
returns the ASCII value of the characterx
,🌝(x)
returns the numberx
in hex, and👂
,👃
, … are calling🍋
with the parameters shuffled.
🍋
is the most complicated function. There are seven parameters in the function (we will use x1
up to x7
), and it performs the following procedures:
def 🍋(x1, x2, x3, x4, x5, x6, x7):
X = ord('👂') # 128066
u = hashlib.sha1(FLAG[x1-X:(x1-X)+(x2-X)]).hexdigest()
if u[1:2] == format('x', (x3-X)):
download_and_replace(f'https://c22-bashed.hkcert24.pwnable.hk/{x4}.sh')
GALF *= ord(x6)
else:
download_and_replace(f'https://c22-bashed.hkcert24.pwnable.hk/{x5}.sh')
GALF *= ord(x7)
Here download_and_replace
is a function that downloads and replaces the current Bash file.
Finally, GALF
starts at 1
and the goal is to make GALF != 0
after a series of “emojis”:
👚 👣 👺 👅 👳 👱 👄 👂
👢 👄 💅 👂 👣 👲 👅 👋
👥 👈 💐 💁 👄 👞 👒 👧 \
👄 💇 👥 💁 👒 💀 👅 👂 \
👞 👆 💅 👂 💄 👅 👽 💓
👋 👈 💌 👢 👱 👂 👛 👅
# ...
Since the numbers are 64 bits in Bash on a 64-bit OS, we will eventually get a $0$ if we have multiplied 64 $2$’s. Therefore, we must find a path that multiplies less than 64 $2$’s to keep GALF
nonzero.
Call sequence of the functions⌗
As an example, the third instruction is
👥 👈 💐 💁 👄 👞 👒 👧 \
👄 💇 👥 💁 👒 💀 👅 👂 \
👞 👆 💅 👂 💄 👅 👽 💓
This is equivalent to the instruction 🍋 👞 👄 👈 👧 👒 💁 💐
because of how 👥
is defined:
function 👥() { 🍋 $5 $4 $1 $7 $6 $3 $2; }
The eighth parameter onwards is dropped. Also, the below shows the ASCII values of the parameters:
- 👞 (ASCII value: $128094 = 128066 + 28$)
- 👄 (ASCII value: $128068 = 128066 + 2$)
- 👈 (ASCII value: $128072 = 128066 + 6$)
- 👧 (ASCII value: $128103 = 128066 + 37$)
- 👒 (ASCII value: $128082 = 128066 + 16$)
- 💁 (ASCII value: $128129 = 128066 + 63$)
- 💐 (ASCII value: $128144 = 128066 + 78$)
Therefore the actual logic for this line would be:
# 👥 👈 💐 💁 👄 👞 👒 👧
# --> 🍋 👞 👄 👈 👧 👒 💁 💐
u = hashlib.sha1(FLAG[28:30]).hexdigest() # 👞 = 128066 + 28, 👄 = 128066 + 2
if u[1:2] == '6': # 👈 = 128066 + 6
download_and_replace(f'https://c22-bashed.hkcert24.pwnable.hk/👧.sh')
GALF *= 128129 # 💁 = 128129
else:
download_and_replace(f'https://c22-bashed.hkcert24.pwnable.hk/👒.sh')
GALF *= 128144 # 💐 = 128144
If u[1:2] == '6'
, it will proceed and call the next instruction from 👧.sh
:
👋 👈 💌 👢 👱 👂 👛 👅 \
👶 💉 👯 💍 👃 💅 👗 👂 \
👌 💏 👃 👟 💔 💌 👤 👵 \
👋 👵 👚 👣 💈 👈 💃 👃
Otherwise, it would call the below instruction from 👒.sh
:
👋 👈 💌 👢 👱 👂 👛 👅
We can see that the next instruction depends on the file downloaded. There is only one path that makes GALF
nonzero, and these are the conditions:
$$ \begin{aligned} f(\text{flag}_{1} \ \| \ \text{flag}_{2}) &= \texttt{0a}_{16} \\ f(\text{flag}_{2} \ \| \ \text{flag}_{3}) &= \texttt{17}_{16} \\ \vdots & \\ f(\text{flag}_{2} \ \| \ \text{flag}_{3}) &= \texttt{17}_{16} \end{aligned} $$
Here $f(x)$ is the second hexadecimal character of $\texttt{SHA1}(x)$. There is a unique solution for this, too:
hkcert24{s33m1n9ly_b3g19n_b4sh_scr1p7s_c0u1d_b3_d4n93r0us_wh3n_7h3y_4r3_s3lf_m0d1fy1n9}
0 <= x3 < 16
, since this is the only way to get the hash check passing.