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 to console.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 because f 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.

⚠️ Warning! Do this only if you trust the author, or work in an isolated environment. Running unknown code on your own machine is dangerous.

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
😕 Why isn’t 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:

digraph { graph [bgcolor="transparent"] node [color="#ffe4e1", fontcolor="#ffe4e1", fillcolor="#33333c", style="filled"] edge [color="#ffe4e1", fontcolor="#ffe4e1"] node[shape=box] n1 -&gt; n2 n2 -&gt; n3 n3 -&gt; n4 n4 -&gt; n5 n1[label=&#34;Generate nonce with three leading null bytes in its hash&#34;, margin=&#34;0.2,0&#34;] n2[label=&#34;Request \&#34;something\&#34; from the remote challenge server&#34;, margin=&#34;0.2,0&#34;] n3[label=&#34;Derive a key and an IV from the nonce&#34;, margin=&#34;0.2,0&#34;] n4[label=&#34;Encrypt the message using AES-CFB&#34;, margin=&#34;0.2,0&#34;] n5[label=&#34;Print 🙅 when the ciphertext doesn&#39;t match with the HTTP response&#34;, margin=&#34;0.2,0&#34;] }

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 character x,
  • 🌝(x) returns the number x 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.

🙏 Apologies! Sorry if you are using a 32-bit operating system - I haven’t considered it enough to support that. However, please consider getting a 64-bit one.

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}
🧀 Cheese! There are much more solves than I anticipated. Turns out that there is an unintended solution – @desp mentioned that he englished through. On the other hand, @Aali cheesed in a way by checking whether 0 <= x3 < 16, since this is the only way to get the hash check passing.