🎥LACTF 2026
Solution to most challenges
crypto
lazy-bigrams
Description
We are given attachments/chall.py and a ciphertext attachments/ct.txt.
chall.py does:
pt = phonetic_mapping(phonetic_mapping(flag))ct = encryption(pt)
phonetic_mapping() replaces each allowed character with its NATO-style word (plus words for _{}0-9), and if the resulting mapped string length is odd it appends a single padding letter "X".
encryption() removes non-letters, groups the plaintext into disjoint 2-letter blocks (bigrams), and substitutes each plaintext bigram via a random permutation of all 26^2 possible bigrams. The ciphertext is emitted as 2-letter bigrams.
Flag format is lactf{...} and is all lowercase.
Solution
Model this as a substitution cipher over the set of ciphertext bigrams that appear.
Let each distinct ciphertext bigram be a “symbol”. Each symbol maps injectively to a plaintext bigram in AA..ZZ (0..675). Expanding those plaintext bigrams yields the full plaintext letter stream s2.
Key observation: s2 is (almost always) a pure concatenation of NATO phonetic words for letters A-Z, because it is the output of the second phonetic_mapping() (the only possible exception is a single trailing padding letter X, which is appended to make the length even).
Constraints used:
Injective mapping: ciphertext symbol -> plaintext bigram (all-different).
Known prefix crib: because the flag starts with
lactf{, the start ofs2 = phonetic_mapping(phonetic_mapping("lactf{"))is fully known, which fixes many symbol->bigram assignments immediately.Regular-language constraint:
s2must be accepted by a DFA for “concatenation of NATO words” (or that plus a final paddingX). This is enforced with OR-Tools CP-SATAddAutomaton.
Once s2 is recovered, decode:
s2->s1by tokenizing NATO words back into letters.s1->flagby tokenizing the fullPHONETIC_MAPwords back into characters.
All code (solver + decoding) is below:
Running it prints the flag: lactf{n0t_r34lly_4_b1gr4m_su8st1tu7ion_bu7_1_w1ll_tak3_1t_f0r_n0w}
misdirection
Description
A snake game web app backed by NTRUSign cryptographic signatures. The /grow endpoint increments a counter if you provide a valid NTRUSign signature for the current count, but limits growth to current_count < 4. The /flag endpoint requires current_count >= 14. The server uses gunicorn with gthread (1 worker, 80 threads) and has no locking around the check-and-increment logic.
Solution
The "misdirection" is NTRUSign itself — you don't need to break the cryptography. The vulnerability is a race condition in the /grow endpoint's TOCTOU (time-of-check-time-of-use) pattern:
Multiple threads can pass the current_count < 4 check before any thread increments. Once past the check, each thread independently increments the counter regardless of its new value.
Key trick — cache busting: The server caches signatures by string. Cached lookups are instant (no race window). To force the slow NTRU.Verifying() code path (which takes ~100ms due to O(N^2) polynomial multiplication), we modify each signature string to be unique while parsing identically. Adding leading zeros to the nonce r (e.g., ==0 → ==00, ==000, etc.) produces different cache keys but int("00") == int("0") == 0.
Simultaneous delivery: To maximize threads passing the check before any increments, we use Python multiprocessing with a Barrier: each subprocess pre-establishes its TCP+TLS connection, waits at the barrier until all are connected, then all send their HTTP request simultaneously.
With 80 concurrent requests, enough threads (14+) enter the slow verification path simultaneously and all increment the counter past the limit. Then we call /flag.
Flag: lactf{d0nt_b3_n0nc00p3r4t1v3_w1th_my_s3rv3r}
not-so-lazy-trigrams
Description
Finally got the energy to write a trigram substitution cipher. Surely three shuffles are better than one!
Files: ct.txt, chall.py
Solution
Analysis of the cipher:
The challenge implements a "trigram substitution cipher" using three independent alphabet shuffles (shufflei, shufflej, shufflek). The key insight is that despite appearing to operate on trigrams (3-letter blocks), the cipher actually decomposes into three independent monoalphabetic substitution ciphers based on character position mod 3:
Position 0, 3, 6, ... → substituted by
shuffleiPosition 1, 4, 7, ... → substituted by
shufflejPosition 2, 5, 8, ... → substituted by
shufflek
This is because sub_trigrams[a*676 + b*26 + c] = chr(shufflei[a]) + chr(shufflej[b]) + chr(shufflek[c]), meaning each character in a trigram is substituted independently.
The formatter function removes spaces from the output but preserves all other punctuation from the original plaintext.
Cracking approach:
The ciphertext ends with a visible flag structure:
zjlel{heqmz_dgk_tevr_tk_vnnds_c_imcqaeyde_ug_byndu_e_jjaogy_rqqnisoqe_cwtnamd}We know
zjlel→lactf, which gives us initial mappings across all three ciphers.From the flag word length pattern
[5, 3, 4, 2, 5, 1, 9, 2, 5, 1, 6, 9, 7]and the challenge theme ("not so lazy"), we hypothesize the flag content is:still_too_lazy_to_write_a_plaintext_so_heres_a_random_wikipedia_articleVerifying this hypothesis against the ciphertext shows perfect consistency across all three cipher mappings — no contradictions.
Partial decryption of the main text confirms it's a Wikipedia article about circular polarization, validating the hypothesis.
Flag: lactf{still_too_lazy_to_write_a_plaintext_so_heres_a_random_wikipedia_article}
Solver code:
sisyphus
Description
A garbled circuit challenge implementing Yao's garbled circuits with the free XOR optimization. The circuit computes AND(0, your_choice), which always outputs 0 regardless of input. To get the flag, you must provide the output wire's one label key — a value that should be unreachable through normal evaluation.
Solution
The circuit uses the half-gates / point-and-permute technique where one garbled table entry (at pointer position (0,0)) is implicit (derived via decrypt_zeros), and the other three entries are stored explicitly.
In the free XOR scheme, every wire has one.key = zero.key ⊕ Δ for a global secret Δ. Normal evaluation only yields wc.zero (since AND(0, x) = 0). To get wc.one.key = wc.zero.key ⊕ Δ, we need to recover Δ.
The vulnerability: For an AND gate, three of the four truth table rows encrypt wc.zero and one encrypts wc.one. The encrypted key at position (i,j) is E(la.key) ⊕ E(lb.key) ⊕ lc.key, where E(k) = AES_k(iv‖0). Due to the algebraic structure of free XOR (where paired labels differ by Δ in key-space), XORing all three explicit table entries causes all the AES terms to cancel:
This holds regardless of the random pointer bit assignment. With Δ recovered, we evaluate normally to get c0 = wc.zero.key, then compute c1 = c0 ⊕ Δ.
Flag: lactf{m4yb3_h3_w4s_h4ppy_aft3r_4all}
six seven
Description
RSA encryption where primes p and q are 256-digit numbers composed only of digits 6 and 7, with the last digit always being 7. We're given n = p*q and c = pow(m, 65537, n) and need to decrypt the flag.
Solution
Since every digit of p and q is either 6 or 7, we can recover p digit-by-digit from the least significant digit (LSB) upward using the constraint that n = p * q.
Key insight: If we know p mod 10^k, we can compute q mod 10^k = n * p^(-1) mod 10^k (since p ends in 7, it's always invertible mod powers of 10). We then check whether the k-th digit of q is in {6, 7}. If not, that candidate is pruned.
At each step we try extending p's next digit with both 6 and 7 (2 choices), but only ~2/10 of candidates survive the digit check on q. The branching factor of 2 * 0.2 = 0.4 means false candidates die off exponentially, leaving only 1-4 candidates throughout the entire search.
After recovering all 256 digits of p, we verify n % p == 0, compute phi = (p-1)(q-1), find d = e^(-1) mod phi, and decrypt m = c^d mod n.
Flag: lactf{wh4t_67s_15_blud_f4ct0r1ng_15_blud_31nst31n}
six seven again
Description
LA CTF will take place on Feburary 6 and Feburary 7, 2026.
nc chall.lac.tf 31181
RSA challenge where one prime p is generated with a highly structured form: 67 digits of '6', followed by 67 digits each randomly '6' or '7', followed by 67 digits of '7' (201 decimal digits total). The other prime q is a standard 670-bit prime.
Solution
The prime p has the form:
where base = 6 * (10^201 - 10^67)/9 + 7 * (10^67 - 1)/9 is fully known (the contribution from the fixed 6s and 7s, plus the minimum contribution of 6 from each middle digit), and x = sum(b_i * 10^i for i in 0..66) with each b_i ∈ {0,1} represents the unknown bits (whether each middle digit is 6 or 7).
The key insight is that x < (10^67 - 1)/9 ≈ 10^66, which is roughly 219 bits. Since p ≈ 10^200 (668 bits) and q ≈ 670 bits, N ≈ 1338 bits. Coppersmith's method can find small roots of a polynomial modulo an unknown factor of N when the root is smaller than N^(β²) where β ≈ 0.5. Here N^0.25 ≈ 2^334, and our unknown x ≈ 2^219 < 2^334, so Coppersmith's method applies directly.
We construct the monic polynomial f(x) = x + base * (10^67)^{-1} mod N and use SageMath's small_roots() to recover x, then factor N = p * q and decrypt.
Flag: lactf{n_h4s_1337_b1ts_b3c4us3_667+670=1337}
slow-gold
Description
The server (EMP-ZK arithmetic) commits to two secret length-10 vectors vec1, vec2 over F_p where p = 2^61-1, and proves in zero-knowledge that they are a permutation by checking: prod_i (vec1[i] + X) == prod_i (vec2[i] + X) for verifier-chosen X.
After the proof, the verifier must submit the 10 elements of vec1 (order doesn’t matter) to get the flag.
Solution
Bug 1: Broken Batched Multiplication Check Only Checks One Gate
In attachments/dist/emp-zk/emp-zk/emp-zk-arith/ostriple.h, the challenge modified the coefficient generation for the multiplication-gate batch check:
This should have been task_n, but with 1 only chi[0] is derived from the seed and the rest of the check is effectively not covered.
Worse, the loop bounds are broken:
Because i is uint32_t, starting at start + task_n - 1 combined with the < start + task_n condition makes the loop execute exactly once: it “checks” only the last multiplication gate in that batch.
So, per connection, the verifier learns data about exactly one multiplication gate.
Bug 2: Verifier MAC Key delta Can Be Forced to 0
EMP-ZK’s arithmetic backend uses an information-theoretic MAC: mac = key + delta * value (mod p), where delta is sampled by the verifier.
Nothing prevents choosing delta = 0. With delta=0, mac == key and the broken one-gate check becomes a linear relation between the (unknown) gate inputs instead of a quadratic.
We patch the verifier to set delta=0 and to record the one checked multiplication gate’s transcript (seed, V, ka, kb, kc) plus delta (sanity).
What The One-Gate Leak Gives
Let the checked multiplication gate have secret inputs a, b, output c = a*b. The verifier’s per-wire keys are ka, kb, kc and the prover sends V.
From the check derivation, with delta=0:
kb*a + ka*b = (V/seed) + kc (mod p)
In this circuit, the checked gate is the final multiplication gate for vec2:
a = g(X) = prod_{i=0..8} (vec2[i] + X)
b = last + X where last = vec2[9]
So each connection at chosen X yields:
kb*g(X) + ka*(last + X) = rhs (mod p) where rhs = (V/seed) + kc.
Solve With One 10x10 Linear System (10 Connections)
Write g(X) as a monic degree-9 polynomial:
g(X) = c0 + c1*X + ... + c8*X^8 + X^9
Rearrange the leaked equation into a linear equation in the 10 unknowns (c0..c8, last):
sum_{j=0..8} (kb*X^j)*c_j + ka*last = rhs - ka*X - kb*X^9
Collect this for 10 distinct X values (we used X=0..9), solve the resulting 10x10 system over F_p with Gaussian elimination to recover:
last = vec2[9]the coefficients
c0..c8ofg(X)
Factor To Recover The Other 9 Elements
g(X) = prod_{i=0..8} (vec2[i] + X) so its roots are X = -vec2[i] for i=0..8.
Factor g(X) over F_p to get these linear factors, recover vec2[i] = -root (mod p), and then submit the 10-element multiset {vec2[0..9]} as the guess for vec1.
This works because vec1 is a permutation of vec2.
Notes On Connectivity
EMP NetIO uses inet_addr() and does not resolve DNS hostnames. Use an IP (for LACTF it was 34.169.138.235) via --host or SLOW_GOLD_HOST.
Final Flag
lactf{1_h0p3_y0u_l1v3_th1s_0ne_t0_th3_fullest}
Code
Below is all code used for the solve (patches + solver).
1) Leak Struct (new)
File: attachments/dist/emp-zk/emp-zk/emp-zk-arith/leak.h
2) Define Global (new)
File: attachments/dist/emp-zk/emp-zk/emp-zk-arith/emp-zk-arith.cpp
3) Patch EMP-ZK: Force delta=0 and Capture One-Gate Transcript
File: attachments/dist/emp-zk/emp-zk/emp-zk-arith/ostriple.h
4) Patched Client: JSON Transcript Dump + Non-interactive Flag Fetch
File: attachments/dist/emp-zk/test/arith/client.cpp
5) Solver Script
File: solve.py
smol cats
Description
My cat walked across my keyboard and made this RSA implementation, encrypting the location of the treats they stole from me! However, they already got fed twice today, and are already overweight and needs to lose some weight, so I cannot let them eat more treats. Can you defeat my cat's encryption so I can find their secret stash of treats and keep my cat from overeating?
nc chall.lac.tf 31224
Solution
Connecting to the server presents an RSA challenge: given n, e=65537, and c, decrypt the ciphertext to recover the plaintext number of treats. The values change each connection.
The key insight is in the description: "my paws are small, so I used tiny primes." The modulus n is ~200 bits (60 digits), composed of two ~100-bit primes. This is far too small for secure RSA and can be factored quickly using ECM (Elliptic Curve Method) or other factoring algorithms.
Once n is factored into p * q, standard RSA decryption recovers the plaintext: compute phi = (p-1)(q-1), then d = e^(-1) mod phi, and m = c^d mod n.
Flag: lactf{sm0l_pr1m3s_4r3_n0t_s3cur3}
spreading-secrets
Description
The server uses Shamir Secret Sharing over a 512-bit prime field, but it generates the polynomial coefficients from an RNG seeded with the secret itself. Only one share is revealed: (x, y) = (1, f(1)), plus the modulus p.
Solution
In proper Shamir, threshold shares are needed because the non-constant coefficients are uniform random and independent of the secret.
Here, coefficients are deterministic functions of the secret:
c0 = sc1 = g(s)c2 = g(g(s)) = g^2(s)...
c9 = g^9(s)
where g(z) = a z^3 + b z^2 + c z + d (mod p) is the RNG transition.
With only the share at x=1:
f(1) = sum_{i=0..9} c_i = s + g(s) + g^2(s) + ... + g^9(s) = y.
So s is a root of the univariate polynomial over GF(p):
h(x) = x + g(x) + g^2(x) + ... + g^9(x) - y.
Since deg(g)=3, deg(g^9)=3^9=19683, so h has degree 19683. We build h by iterated composition in the polynomial ring GF(p)[x].
To extract roots without fully factoring h, use the finite-field identity that the product of all distinct linear factors of h divides x^p - x. Thus:
gcd(h(x), x^p - x) is the squarefree product of linear factors of h.
Compute x^p mod h(x) by binary exponentiation (repeated squaring with polynomial modular reduction), then take the GCD and read its roots. There are two roots; the correct one decodes to a flag string.
Flag: lactf{d0nt_d3r1v3_th3_wh0l3_p0lyn0m14l_fr0m_th3_s3cr3t_t00!!!}
the-clock
Description
Don't run out of time
A Diffie-Hellman key exchange is performed on the "clock group" — points (x, y) satisfying x² + y² ≡ 1 (mod p) with the group law (x1*y2 + y1*x2, y1*y2 - x1*x2). The prime p is omitted from the source. Alice and Bob exchange public keys, derive a shared secret, and use it to AES-ECB encrypt the flag. We're given both public keys and the ciphertext.
Solution
Step 1: Recover p. Each point satisfies x² + y² ≡ 1 (mod p), so p divides (x² + y² - 1) for every known point. Taking the GCD of these values across the base point and both public keys yields p = 13767529254441196841515381394007440393432406281042568706344277693298736356611.
Step 2: Identify the group structure. The clock group operation is equivalent to multiplication of elements z = y + ix in F_{p²} restricted to norm-1 elements. This group has order p+1 when -1 is a quadratic non-residue mod p (which it is here). The order p+1 factors completely into small (~16-bit) primes: 4 × 39623 × 41849 × 42773 × 46511 × 47951 × 50587 × 50741 × 51971 × 54983 × 55511 × 56377 × 58733 × 61843 × 63391 × 63839 × 64489.
Step 3: Pohlig-Hellman attack. Since the group order is entirely smooth, the discrete log problem decomposes via Pohlig-Hellman into tiny subgroup DLPs, each solvable with baby-step giant-step in O(√q) time where q ≤ 64489. CRT combines the partial results to recover Alice's full secret key.
Step 4: Decrypt. Compute the shared secret using Alice's secret and Bob's public key, derive the AES key via MD5, and decrypt.
Flag: lactf{t1m3_c0m3s_f4r_u_4all}
ttyspin
Description
The challenge is a terminal Tetris clone over SSH. You can export/import a save state. Import is protected by a checksum:
The flag is printed only if the in-memory board equals a fixed winning_board.
Solution
There are two key observations.
Observation 1: you cannot reach the winning board by playing. Each placed tetromino adds 4 blocks and each cleared line removes 10 blocks, so the board's non-zero cell count stays even. The provided winning_board has 19 blocks (odd), so the only viable path is to import a crafted save whose decoded board equals winning_board.
Observation 2: SHA-256 length extension works because glue padding can go in username. The MAC is SHA256(SECRET || message) (not HMAC), and len(SECRET) == 40 is known. The imported save_bytes must be valid UTF-8 because Board.start() does save.decode().split("|"), but username is never decoded (it's raw bytes from sys.stdin.buffer.readline()), so it can contain arbitrary bytes, including 0x80 and NULs. That lets us do a classic length extension: get a checksum for SECRET||m, then forge a checksum for SECRET||m||glue_padding||ext, by placing m||glue_padding in username and ext in the imported save (still valid UTF-8).
Practical detail: the code hashes (SECRET + username + save_bytes).strip(). If we export with an empty username and an empty board, the save string ends with many spaces and .strip() truncates it, leaving a very short m (ending right after the | before the board). That keeps username = m || glue_padding under the 32-byte username limit.
One more practical constraint: the game only shows the export screen after you have a non-zero score. So you need to score while keeping the board empty; the easiest way is to play until you get a perfect clear / full clear (clear lines so the board returns to all-spaces) and then export immediately.
Below is a complete solver that:
builds a valid save whose board equals
winning_board(and appends a 1-byte sentinel so.strip()won’t trim trailing spaces),performs SHA-256 length extension from an exported checksum where username was empty,
logs in via SSH (Paramiko) and imports the forged save to print the flag.
Flag: lactf{T3rM1n4L_g4mE5_R_a_Pa1N_2e075ab9ae6ae098}
misc
CTFaaS - CTFs as a Service!
Description
We are given access to a “CTF Challenge Deployer” web UI on a provisioned VM. It accepts a Docker image tarball and exposes a chosen container port via a NodePort. The challenge claims a “Secret in the cluster” contains the company keys (flag), and that sandboxing/RBAC prevents malicious actions.
Solution
The core issue is that uploaded images run as pods inside the Kubernetes cluster with a ServiceAccount token. That token can impersonate the deployer’s ServiceAccount (ctf-deployer-sa). As ctf-deployer-sa, we can create pods in default and mount a hostPath to /, which lets us read host files. On k3s, /etc/rancher/k3s/k3s.yaml is a kubeconfig containing a client certificate+key that has high privileges. Using those credentials against the apiserver, we can directly read the secret in the hidden namespace and recover the flag.
Steps (commands shown for the instance VM IP 35.219.138.219):
Deploy a probe container so we can read the in-pod ServiceAccount token and talk to the apiserver from inside the cluster.
Probe image code (Dockerfile + server):
Deploy it via the web UI (or with curl), then note the resulting NodePort URL (example: http://35.219.138.219:32483/). From that URL, read the in-pod token:
Confirm the interesting permission:
ctf-appcan impersonatectf-deployer-sa:
Use that to create a pod as
ctf-deployer-sawith ahostPathmount of/and read the host’s k3s kubeconfig (/etc/rancher/k3s/k3s.yaml). This file containsclient-certificate-dataandclient-key-data.
Extract the client cert/key from
k3s.yamland use them to access the hidden namespace and read the secret:
This outputs the flag:
cat_bomb
Description
Given an image, determine the location shown to recover the flag.
Solution
Reverse image search the provided image and follow the results until you can identify the exact spot. Then view it manually in Google Maps to confirm the location.
The reverse image search result points to Fushimi Inari Taisha shrine in Kyoto, Japan.
Flag: lactf{34.9681588,135.7772502}
endians
Description
I was reading about Unicode character encodings until one day, my flag turned into Japanese! Does little-endian mean the little byte's at the end or that the characters start with the little byte?
Files: chall.txt, gen.py
Solution
The provided attachments/gen.py shows the flag was turned into "Japanese" by encoding/decoding with opposite UTF-16 endianness:
In attachments/chall.txt, each displayed CJK character is actually a single Unicode code point whose bytes look like 0xXX 0x00 (ASCII byte as the high byte, 0x00 as the low byte). That happens when UTF-16-LE bytes like 6c 00 (for 'l') are mis-decoded as UTF-16-BE, producing U+6C00 (氀).
So the forward transform is:
encode("utf-16-le")thendecode("utf-16-be")
To reverse it, do the opposite:
encode("utf-16-be")thendecode("utf-16-le")
Flag: lactf{1_sur3_h0pe_th1s_d0es_n0t_g3t_l0st_1n_translati0n!}
error-correction
Description
We are given chall.png, a scrambled QR code (version 7, 45x45 modules, no border). The challenge script (attachments/chall.py) shows it was made by:
Generating a QR for the flag (
segno.make(..., mode='byte', error='L', boost_error=False, version=7)).Splitting the 45x45 module image into a 5x5 grid of 9x9 chunks (25 total).
Randomly shuffling the chunks and reassembling into a scrambled QR image.
Goal: recover the original chunk permutation and decode the QR to get the flag.
Solution
Phase 1 (already reflected in progress.md) uses QR function patterns (finders, timing, alignments, version info) to place 13 of 25 chunks uniquely.
The remaining 12 chunks lie entirely in the data area, so structural matching alone is insufficient.
Key trick to finish:
For a fixed QR configuration (v7, EC=L, mask pattern), and a fixed payload length
nbytes, many of the placed codewords are invariant across different payload contents of that same length (they correspond to padding-only regions and their Reed-Solomon EC).We can discover these invariant codewords empirically by generating a few random payloads of length
nbyteswithsegno, extracting the raw (unmasked) placed codewords from the generated matrices, and taking the positions that are identical across all samples.Those invariant codewords imply exact expected module colors at many matrix coordinates. That creates strong per-position constraints, which lets us solve the remaining chunk permutation via backtracking.
The only unknown is the payload length, so we iterate
nbytesuntil reconstruction yields a decodablelactf{...}.
Running the solver prints the decoded flag and writes the reconstructed QR to /tmp/qr_solve_solved.png.
Solution code:
flag irl
Description
A video of a 3D printer printing a text nameplate is provided. The flag is the text being printed, which must be recovered by tracking the printer's motion.
Solution
The key insight is that on the top layers of a 3D-printed text nameplate, the print head only visits positions where the raised letters exist. By tracking the head's X position (physical X axis) and the bed's X position in the video frame (which maps to the physical Y axis due to the side-on camera angle), we can reconstruct a 2D map of the printed text.
Step 1: Position tracking (pre-existing)
The 1080p video (video1080p.mp4, 60fps, 29168 frames) had already been processed with template-matching trackers to produce:
pos_1080.npy— head/nozzle (X, Y) pixel position per framebed_pos_1080.npy— bed reference point (X, Y, confidence) per frame
The head X tracks the physical X axis (head moves left/right). The bed X tracks the physical Y axis (bed moves forward/backward, appearing as horizontal motion from the camera's oblique angle).
Step 2: Identify the text-printing region
During base layers (frames 0–~26000), the head sweeps the full width uniformly (rectangular infill). During the top/text layers (frames ~26100–28350), the head only visits positions where letters exist, producing variable-width sweeps. After ~28400 the head parks at home position.
Step 3: Reconstruct the 2D print path
Plot head X vs bed X for the text-layer frames, filtering out fast travel moves (speed > 2 px/frame) to keep only slow printing moves. The resulting 2D histogram reveals the letter shapes.
The resulting heatmap shows three rows of text. At this resolution the font renders f like P, g/e like G, and } like 3, but the text is readable:
Prepending the lactf{ prefix (from Row 1, which is faintest due to fewer data points at the start of the text layer):
Flag
grammar
Description
Inspired by CS 131 Programming Languages, I decided to make a context-free grammar in EBNF for my flag! But it looks like some squirrels have eaten away at the parse tree...
Provided files: grammar-notes.txt (EBNF grammar + notes about the tree) and tree.png (parse tree with opaque terminal boxes).
Solution
The challenge provides an EBNF grammar that generates flags and a parse tree image where the terminal characters (boxes at the bottom) are blacked out. The goal is to reconstruct the flag by reading the nonterminal chain depths from the tree.
Grammar analysis:
The grammar produces flags of the form lactf{word1_word2_...} where each word is composed of fragments. Each fragment is one of 5 types:
cd(consonant + digit) - 2 charactersvc(vowel + consonant) - 2 charactersvd(vowel + digit) - 2 charactersc(consonant) - 1 characterd(digit) - 1 character
Each character type chains through numbered nonterminals, so the chain depth determines the specific character:
Consonants: depth 1=f, 2=g, 3=p, 4=t, 5=r
Vowels: depth 1=e, 2=o, 3=u
Digits: depth 1=0, 2=1, 3=4, 4=5
Tree analysis:
The notes state colored circles represent fragment types with sequence ABACDE BC EAEA (3 words). The image is 1920x1080 with 28 terminal boxes at the bottom.
Step 1: Determine which fragment types are 1-char vs 2-char by checking if colored circle x-positions align with 1 or 2 terminal boxes:
For 2-char fragments, the colored circle sits at the midpoint of its two terminal boxes
Spatial analysis confirmed: A, D = 1-char; B, C, E = 2-char
This gives 6+9+1+4+1+6+1 = 28 terminal boxes, matching the image.
Step 2: Count black circle depths above each terminal box. The tree has 5 rows of black circles between the colored circles (y400) and terminal boxes (y1000). Circles stack from the bottom up: depth-1 chains have 1 circle at the bottom row, depth-5 chains fill all 5 rows.
Step 3: Determine fragment type assignments using depth constraints:
B has a left branch with depth 5: only
cdallows con(5)=r on the left (vowels max at depth 3) → B = cdE has a right branch with depth 5: only
vcallows con(5)=r on the right → E = vcBy elimination → C = vd
A = c (consonant), D = d (digit) chosen because it produces readable text
Step 4: Decode each fragment:
A1
3
c
p
B1
L:5, R:1
cd
r0
A2
1
c
f
C1
L:1, R:4
vd
e5
D1
4
d
5
E1
L:2, R:5
vc
or
B2
L:3, R:3
cd
p4
C2
L:3, R:2
vd
u1
E2
L:1, R:2
vc
eg
A3
2
c
g
E3
L:1, R:5
vc
er
A4
4
c
t
Result: pr0fe55or _ p4u1 _ eggert = "professor paul eggert" (the UCLA professor who teaches CS 131).
Flag: lactf{pr0fe55or_p4u1_eggert}
literally-1984
Description
We are given a Python 3.14 “pyjail”:
Input is length-limited (
< 67) and must be printable ASCII.Blacklisted characters: space,
_,.,\\,",',{},#,=.Our input is wrapped into
eval(f"print({inp})")inside a subinterpreter with an audit hook that kills the process after more than 3 audit events.
Goal: get the real flag (the container includes an execute-only printflag binary).
Solution
Key observation: concurrent.interpreters.Interpreter.call() pickles/unpickles arguments and return values across interpreters. The audit hook is only installed in the subinterpreter, not in the main interpreter.
So we:
Break out of the
print(<inp>)wrapper by starting our input with)or(, making the overall evaluated expression:print() or (<our expression>)
Modify a picklable object (
exit, a_sitebuiltins.Quitterinstance) to override its__reduce_ex__method (without typing underscores, usingdir(0)[41]which is the string"__reduce_ex__").Return that
exitobject, forcing the subinterpreter to pickle it.During unpickling in the main interpreter, our custom reduction runs.
Instead of trying to directly reach os.system under the tight 66-character limit, we make unpickling call breakpoint(), which drops into pdb. Even though the jail only reads one line for inp, the TCP stream can include additional lines; pdb will read them from stdin next. We pre-send:
!import os;os.system('/app/printflag')(note: pwn.red/jail chroots to/srv, so the binary is at/app/printflag)cto continue execution and let the process exit cleanly
One-shot exploit input (first line):
Example run with netcat (sends pdb commands after the payload):
Automated solver (no external deps): solve.py
not-just-a-hobby
Description
"It's not just a hobby!!!" - A single Verilog file v.v is provided.
Solution
The challenge provides a Verilog VGA module with 7-bit inputs (input [6:0] x, input [6:0] y) but comparisons against values that exceed the 7-bit range (0-127). The key insight is understanding Verilog bit-width semantics:
7'd588: A 7-bit decimal literal — 588 gets truncated to588 % 128 = 76. This comparison can match.588(no width prefix): A 32-bit literal. Sincexis only 7 bits (0-127),x == 588never matches.
A pixel coordinate is only "active" (drawn black) when both the x and y comparisons are satisfiable with 7-bit inputs. This means:
Values with
7'dprefix: truncate viavalue % 128, always reachableBare values ≤ 127: directly reachable
Bare values > 127: unreachable, the pixel comparison is dead code
Applying this filter and rendering the valid pixels on a 128x128 canvas reveals an image of the "Graphic Design Is My Passion" meme rendered in leet speak, with a stick figure holding an LACTF flag and a small creature.
The text reads across four lines: lactf{graph1c_d3sign_ / is_My_ / PA55i0N!!1!}
The leet speak substitutions are: i→1 (graphic), e→3 (design), S→5 (PASSION), O→0 (PASSION).
Flag: lactf{graph1c_d3sign_is_My_PA55i0N!!1!}
Solver script:
pwn
ScrabASM
Description
Scrabble for ASM! A pwn challenge where the program generates 14 random byte "tiles", allows swapping individual tiles (replaced with the next rand() & 0xFF value), and then copies the 14-byte hand to an RWX page at 0x13370000 and executes it as shellcode. The key constraints: only 14 bytes of shellcode, swaps produce random values you can't see, and srand(time(NULL)) seeds the PRNG.
Solution
Approach: Brute-force the PRNG seed from the displayed initial hand, predict all future rand() values, then use a greedy algorithm to construct a 14-byte read stager that loads full shellcode as a second stage.
Stager shellcode (14 bytes): Calls read(0, 0x1337000e, 255) to read stage 2 shellcode from stdin directly after the stager. After syscall returns, execution falls through to offset 0x0e where stage 2 was written.
Greedy tile assignment: Rather than processing tiles sequentially (each tile swapped until correct, ~3000 swaps), each rand() value is checked against ALL unfinished tiles. If it matches any tile's target byte, that tile is assigned. Otherwise the value is wasted on any unfinished tile. This reduces swaps from ~3000 to ~800, critical for staying within the server timeout.
Flag: lactf{gg_y0u_sp3ll3d_sh3llc0d3}
adventure
Description
Text-adventure pwnable.
Remote: nc chall.lac.tf 31337
Solution
The game is a 16x16 grid with 8 items. Grabbing the Flag triggers a password prompt.
1) Bug: stack overflow in check_flag_password
In attachments/chall.c:
char password[0020];where0020is octal = 16 bytesfgets(password, 0x20, stdin);reads up to 31 bytes + NUL
So we can overwrite saved rbp and the return address.
2) Leak PIE base via board layout
init_board() places each item using a byte of the runtime address of main:
For item index
i, it usesbytes[i](byteiof the little-endianmainpointer)x = high_nibble(bytes[i]),y = low_nibble(bytes[i])If the cell collides, it linearly probes forward.
By walking the whole grid (serpentine), we record each item’s final (x,y). We then invert the placement algorithm to recover main and compute:
pie_base = main_addr - 0x1adf
Collision probing can create ambiguities; the exploit resolves them by enforcing that (main_addr - 0x1adf) & 0xfff == 0 (PIE base must be page-aligned).
3) Turn the overflow into a .bss write primitive
We return into the middle of check_flag_password right before its fgets call (FGETS_SETUP), with a controlled rbp.
Setting rbp = pie_base + 0x4030 makes the buffer pointer used by that fgets (rbp - 0x10) equal pie_base + 0x4020, which is the global last_item pointer. That fgets becomes a 31-byte write into the writable .bss page.
4) Leak libc via last_item printing
print_inventory() prints last_item with %-6s. If we overwrite last_item = &GOT[puts], the inventory line outputs the raw little-endian bytes of the resolved libc puts pointer (until the first NUL), giving a libc leak and therefore libc_base.
5) ROP chain and pivots
The binary has no pop rdi; ret, so the exploit uses:
The global
historyarray as a tiny ROP stack (each command can store 6 bytes of an address).A double
leave; retpivot to start executing fromhistory.Two more redirected
fgetscalls to write a minimal libc ROP chain into high.bss.
Final libc chain calls system("/bin/sh"), then the exploit sends cat /app/flag.txt.
6) Flag path in jail
The Dockerfile copies the rootfs to /srv and then runs under pwn.red/jail, which typically chroots into /srv. That makes /srv/app/flag.txt in the image visible as /app/flag.txt to the running program and spawned shell.
Exploit
ourukla (pwn, 308 pts, 24 solves)
Description
A student management system ("ourUKLA v0.1.7") with add/get/remove operations. Source provided. Binary is amd64 with Partial RELRO, no canary, NX, PIE. Ships with glibc 2.41.
Solution
The bug is an uninitialized sinfo pointer in add_student(). When malloc(sizeof(struct student)) returns a recycled (non-top) chunk, the student struct's sinfo field contains stale heap data instead of being zeroed. The code only NULLs sinfo when the allocation came from the top chunk:
If the student is added without filling info (add_empty), the stale sinfo pointer persists. When get_student_info() later dereferences it, it reads from whatever the pointer happens to point at.
Struct layout:
The exploit has four phases, each leveraging a double-split primitive: plant a controlled value into an unsorted chunk's metadata via one student's sinfo->major write, then split the unsorted chunk 9 more times so the 10th split's student struct picks up the planted value as its sinfo.
Phase 1 - Libc leak: Fill tcache bins for 0x20/0x100/0x110, then free a student pair to create a 0x210 unsorted chunk. Drain tcache[0x20], then add_empty pulls from the fastbin. The recycled student struct has a stale sinfo pointing into the unsorted chunk, whose fd contains a libc arena pointer. get_student_info prints sinfo->name = unsorted fd = libc leak.
Phase 2 - Stack leak: Use the double-split primitive to plant __environ - 0x18 as a fake sinfo pointer. When get_student_info prints sinfo->attributes (at sinfo+0x18), it reads *(__environ) which is a stack address.
Phase 3 - PIE leak: Same technique targeting a stack return address at __environ - 0x30 to leak PIE base.
Phase 4 - Stack ROP: The key insight is that fill_student_info writes to sinfo->major (at sinfo+0x20) via read(). By planting sinfo = __environ - 0x160, the major write lands at __environ - 0x140, which is exactly add_student's return address on the stack. The offset must be chosen carefully: sinfo+0x10 (the name pointer write) must NOT collide with fill_student_info's own sinfo local variable at __environ - 0x190. With sinfo = env-0x160:
sinfo+0x10 (name)
env-0x150
add_student saved rbx (harmless)
sinfo+0x18 (attrs)
env-0x148
add_student saved rbp (harmless)
sinfo+0x20 (major)
env-0x140
add_student return addr -> ROP chain
The ROP chain is pop rdi; ret -> "/bin/sh" -> ret (alignment) -> system.
No stack canary means the overwrite goes undetected. When add_student returns, it jumps into the ROP chain and spawns a shell.
Flag: lactf{w0w_y0u_s0lv3d_m3_heap_heap_hurray}
tcademy
Description
Menu-based note app with 2 slots. create allocates malloc(size) (0 to 0xf8) and then reads user data, read prints the note with puts, delete frees.
Bug in the read length: For size == 8 it reads 1 byte, otherwise it reads size - 8 bytes. For size < 8 this underflows an unsigned short and becomes a huge read, giving a forward heap overflow from the allocated chunk.
Goal: get code execution on glibc 2.35 with PIE, RELRO, NX, canary, and safe-linking.
Solution
Libc leak (unsorted bin fd) using 1-byte clobber +
putsAllocate a small chunk and a 0x110 chunk.
Free the small chunk, then reallocate it with
size=0and overflow into the 0x110 chunk header to fake its size as0x421(unsorted bin sized) and place fake next chunk headers to satisfyfreechecks.Free that “large” chunk into the unsorted bin.
Allocate a
size=8chunk from it: the program only reads 1 byte, so it overwrites only the low byte of the stale unsortedfdpointer.puts()then leaks the remaining bytes.Reconstruct the leaked libc page and compute
libc_baseusing the fixed relation (Ubuntu glibc 2.35-0ubuntu3.8):fd_page - libc_base == 0x21b000.
Heap leak (safe-linking) from two adjacent 0x20 chunks
Free two adjacent 0x20 chunks into tcache.
Reallocate both with
size=8so only 1 byte is written, preserving most of the safe-linkedfdvalues in the chunk user data.Leak both values via
puts(), brute-force the clobbered low bytes, and solve the safe-linking equations.Multiple solutions can exist within the same heap page; in this challenge the first user allocation is consistently at offset
0x2a0in its heap page (after thetcache_perthread_structchunk), so we select the candidate with that page offset.
Tcache poisoning into
_IO_2_1_stderr_From the earlier unsorted remainder, allocate two 0x110 chunks
VandW, free them soVis at the head oftcache[0x110].Allocate the adjacent 0x20 chunk with
size=0and overflow into freedV’s tcachenextpointer, overwriting it with a safe-linked pointer to_IO_2_1_stderr_in libc.Next
malloc(0xf8)returnsV(we use it for attacker-controlled_IO_wide_data), and the followingmalloc(0xf8)returns a chunk overlappingstderr, letting us overwrite theFILEobject.
FSOP on exit (wide stream path)
Overwrite
stderrwith a fakeFILE:Place the command string at the start so
system(fp)uses it.Set
_mode=1andvtable=_IO_wfile_jumpsto take the wide-stream flush path.Point
_wide_datato our heapwide_datachunk.Set
_lockto a safe writable address that does not clobberwide_data->write_ptr/write_base.
Craft
_IO_wide_dataso flush sees pending output (write_ptr > write_base) and forces buffer allocation (buf_base == NULL), reaching_IO_wdoallocbufand then a function pointer in the wide vtable.Place a fake wide vtable pointer inside
wide_datasuch that the vtable slot at+0x68issystem.Trigger
exit, which flushes_IO_list_all, invoking the chain and executingsystem("echo;cat /app/flag.txt").
Exploit code (used for remote solve and local validation):
this-is-how-you-pwn-the-time-war
Description
The binary prints a 4-digit lock code generated by rand()%16, then lets you “turn” two dials by choosing indices and values. The indices are short and there is no bounds checking, so the program performs two out-of-bounds 16-bit writes on its stack frame.
Remote: nc chall.lac.tf 31313
Solution
Bug: run() has short code[4] at rbp-0xc and writes code[ind]=val twice. That’s an arbitrary 2-byte write at rbp-0xc + 2*ind.
Useful stack targets (halfword indices from &code[0]):
10: low16 ofrun()return address18/19: low32 ofmain()return address (into libc)0x9a:*(u16*)rbxat gadget time (needed for the one-gadget constraint)
Libc low32 from RNG: init() does srand(clock_gettime) where clock_gettime comes from the GOT. srand() truncates to 32 bits, so the seed is clock_gettime_addr & 0xffffffff. The printed dial values are rand()%16, so two consecutive dial lines (8 outputs) uniquely identify the 32-bit seed for glibc 2.36 rand(). From that: libc_base_lo32 = (seed - clock_gettime_offset) & 0xffffffff.
This solve script uses attachments/seed_finder + attachments/rand_helper (built against the challenge glibc) to deduce the seed.
Getting enough writes: main() calls run() once. To get multiple run() invocations, overwrite run()’s return low16 (index 10) to return to main+0x132a (just before the call run). PIE makes the low16 depend on a 4-bit nibble of the PIE base; brute that nibble (16).
Important: every time main executes call run, it re-pushes the return address, so the loop overwrite must be applied again each run() iteration.
Hijack into libc: after seed recovery, compute the one-gadget low32 one_lo32 = (libc_base_lo32 + 0x4c139) & 0xffffffff and overwrite main’s return low32 (indices 18/19). Upper 32 bits stay correct from the original libc return address.
One-gadget constraint: ensure rbx == NULL || *(u16*)rbx == 0 by writing 0 at index 0x9a.
Write schedule (4 runs total once the correct PIE nibble is used):
Run 1: set
run()return to loop.Run 2: set
run()return to loop, and zero*(u16*)rbx.Run 3: set
run()return to loop, and writemainreturn low16.Run 4: restore
run()return to normal (main+0x1334) and writemainreturn hi16, somainreturns into the one-gadget and spawns a shell.
In the jail, the flag is at /app/flag.txt (pwn.red/jail typically chroots /srv to /).
Exploit Code (solve.py)
tic-tac-no
Description
Tic-tac-toe is a draw when played perfectly. Can you be more perfect than my perfect bot?
nc chall.lac.tf 30001
Solution
The binary is a tic-tac-toe game against a minimax bot. The program prints the flag only if winner == player (player is 'X').
The vulnerability is in playerMove(). The bounds check logic is inverted:
The else runs when any part of the if is false, including when index is out of bounds (index < 0 or index >= 9). So we get an out-of-bounds write of 'X' relative to the global board.
From nm (these are PIE-relative symbol offsets; the relative layout is stable even with ASLR):
player@0x4050computer@0x4051board@0x4068
So board[-23] targets computer because 0x4068 - 0x4051 = 0x17 = 23. Choose inputs so: index = (x-1)*3 + (y-1) = -23, e.g. x = -7, y = 2.
This overwrites computer from 'O' to 'X', making computer == player == 'X'. Now when the bot makes a 3-in-a-row of 'X', checkWin() returns 'X' and the program treats it as a player win and prints the flag.
Flag: lactf{th3_0nly_w1nn1ng_m0ve_1s_t0_p1ay}
refraction
Description
The binary reads 0x100 bytes from stdin into __GNU_EH_FRAME_HDR (the .eh_frame_hdr / .eh_frame area), then immediately throws a C++ exception (throw "eh?";).
This means our only input is a controlled overwrite of the unwind metadata that libgcc/libstdc++ consults during exception unwinding.
Goal: forge unwind info so the unwinder “finds” a handler that ends up executing system("cat flag.txt").
Solution
We overwrite .eh_frame_hdr and the beginning of .eh_frame with a minimal, valid set of unwind records:
A forged
.eh_frame_hdrtable with 2 entries:one FDE covering
f()(where the exception originates)one FDE covering a fake “handler function” range
0x1200..0x1400(covers both main’s return IP0x125aand the chosen landing pad0x1213)
A CIE using augmentation
"zPLR"so we can provide:a personality (
__gxx_personality_v0)an LSDA pointer encoding
an FDE pointer encoding
Two FDEs:
FDE for
f(): we make unwinding pretend the caller frame is inside our fake handler range, and we prepare registers for the landing pad.DW_CFA_def_cfa_expression: sets the CFA to point at our command string in the overwrite buffer.DW_CFA_val_expressionfor RIP: spoofs the caller RIP intohandler_start+1so the next frame lookup uses our handler FDE.DW_CFA_val_expressionfor RSP: restores the real stack pointer (rbp+16) sosystem()has plenty of stack space. (If RSP stayed in our tiny.eh_framepage,system()crashes due to stack underflow.)
FDE for the handler range: provides an LSDA that catches
const char*and sets the landing pad to0x1213(call system@pltinsideg()’s catch block).
At the landing pad, empirically RDI ends up equal to the CFA-derived value on this target, so system() receives a pointer to our command string while still running on the real stack (thanks to the explicit RSP rule).
Run:
Local:
python3 solve2.py --localRemote:
python3 solve2.py
Solution code (solve2.py):
rev
flag-finder
Description
The challenge provides a web UI with a 19x101 checkbox grid. Pressing "Find" serializes the grid as a 1919-character string of # (checked) and . (unchecked) and tests it against a single huge JavaScript regex in script.js.
The regex encodes a nonogram: one set of constraints for each row and each column. Solving the nonogram reveals 3 lines of 3x5 pixel text spelling the flag.
Solution
Fetch
script.jsfrom the challenge and extract theconst theFlag = /^...$/;regex.Parse constraints from the regex.
Row constraints: after the
(?=^.{1919}$)marker, there are 19 capturing groups, one per row, that contain#and#{n}runs separated by\.+(at least one.). Converting each group into a list of run-lengths gives the row clues.Column constraints: at the start of the regex there is a large group of nested
(?=...)lookaheads. Each leaf lookahead constrains a single column by repeatedly jumping byWIDTH(.{col}X.{WIDTH-1-col}patterns). Counting the(?: ... # ... ){n}pieces yields the run-lengths for that column.
Solve the 19x101 nonogram.
Use a standard nonogram line-solver with DP: for a given line (row or column) with some forced cells (filled/empty/unknown) and a list of runs, enumerate valid placements via dynamic programming and compute which cells are always filled or always empty.
Propagate row/column deductions until no more changes.
If cells remain unknown, backtrack (try
#then.) with propagation at each step.
Decode the solved grid.
The text is arranged as 3 bands of 25 characters each.
For each band, take rows
6*band+1 .. 6*band+5(5 rows) and columns in 25 blocks of 3 pixels with 1-column gaps: blockkis columns4*k+1 .. 4*k+3.Map each 3x5 bitmap to a character (letters plus leetspeak digits/punctuation).
Decoded flag (from the solved grid): lactf{Wh47_d0_y0u_637_wh3n_y0u_cr055_4_r363x_4nd_4_n0n06r4m?_4_r363x06r4m!}
Solver (end-to-end: fetch regex, parse clues, solve, render):
helm hell
Description
We are given a Helm chart (helm-hell.zip). Rendering it always produces a ConfigMap with result: "false".
Solution
The core logic lives in work/helm-hell/templates/_helpers.tpl: thousands of define blocks that implement a tiny VM using only Go-template/Sprig primitives (dict, set, index, add, sub, mod, etc.).
Even though the final rendered output is always false, the VM still performs prefix-dependent work on .Values.input. We can exploit a deterministic side channel:
The VM uses a small tape
sea(a map keyed by stringified integers).Early in execution,
sea["2"]is set to1and later cleared back to0.The exact number of executed template statements and the current input index (
logbook) at the momentsea["2"]transitions1 -> 0increases when more of the provided input prefix matches the embedded expected flag.
So we:
Implement a minimal interpreter for this limited Go-template subset.
Execute the entry template
volumeWorker7940withprovisions = .Values.input.Stop exactly when
sea["2"]clears from1to0, returning(logbook, steps).Recover the flag one character at a time by trying a charset and choosing the character that maximizes
(logbook, steps)(using constant padding so the program never runs out of input).
Recovered flag: lactf{t4k1ng_7h3_h3lm_0f_h31m_73mp14t3s}
Solver Code
lactf-1986
Description
We are given a floppy disk image (attachments/CHALL.IMG) containing a DOS executable that checks a flag.
Solution
1) Extract the DOS executable from the FAT12 floppy image
2) Identify the flag-check algorithm
CHALL.EXE is a 16-bit MZ executable. The program’s main (in the unpacked load image) does:
Reads a line (up to 73 chars), strips the trailing newline.
Verifies the input begins with
lactf{.Computes a 20-bit hash of the full input string:
State is 20 bits (
0 .. 2^20-1).Update per byte
b:state = (state * 67 + b) mod 2^20
Uses that 20-bit state as the seed to generate a keystream using a 20-bit LFSR:
Let bits be numbered with bit 0 = LSB and bit 19 = MSB.
Feedback bit:
fb = bit0(state) XOR bit3(state)
Update:
state = (state >> 1) | (fb << 19)
For each position
i(0..72), the program advances the LFSR once, takes the low byte of the new state, XORs it with the input byte, and compares it against a fixed 73-byte table embedded in the program:
Rearrange:
So for a given seed state, the entire 73-byte plaintext is uniquely determined. The only remaining constraint is self-consistency: the seed must equal the 20-bit hash of the derived plaintext. The state space is only 2^20, so we can brute-force the seed.
3) Brute-force the 20-bit seed (single fixed point)
The ciphertext/expected table is stored in the EXE’s data segment at offset 0x146 and is 0x49 (73) bytes long.
Solver (standalone, includes extraction of the load image and the brute force):
Running it yields the flag:
ooo
Description
We are given attachments/ooo.py, which asks for a guess (flag) and validates it with a loop over adjacent character pairs. The core trick is that the script uses multiple different Unicode characters that look like o as distinct function names.
Solution
In attachments/ooo.py:
о(a, b)returnsa + b.ὄ(a, b)returnsa.ὂ(a, b)returnsb.
So the left side of the check is:
The right side indexes the list ὁ with:
Using the function definitions:
օ(x, y) = x * yȯ(x, y) = x % yơ(x, y) = x ^ y(XOR)
So:
because a*b is always divisible by a for nonzero a (and ord(...) is nonzero for normal characters).
Therefore the index simplifies to:
So the loop condition becomes, for i = 0..25:
where H is the list ὁ. This gives a recurrence:
We also know the flag starts with lactf{, which determines c[0] = ord('l') and uniquely fixes the rest.
Solver (prints a valid flag; the checker only constrains the first 27 characters, so we append } to match the usual flag format):
Flag:
starless-c
Description
We are given a single weird ELF (starless_c) and a remote service (nc chall.lac.tf 32223). The program acts like a tiny "maze": it reads single-character moves (w, a, s, d) and an action key (f).
The goal is to reach the code that prints flag.txt.
Solution
1) Identify the flag-print routine
Disassembling the mapped page at 0x42069000 shows it prints some text, then does:
sys_open("flag.txt", 0)sys_sendfile(1, fd, NULL, 0x100)sys_exit(0)
So if we can transfer control to 0x42069000, we get the flag from the remote filesystem.
2) Understand the "doors": patching NOP pages
The interactive loop exists at pages like 0x6767900c. For each move key, the code:
Reads the first byte of the target page base (e.g.
0x6768a000).If that byte is
0x90(NOP), it:Overwrites the target page's first 4 bytes with
31 c0 88 00(xor eax,eax; mov [rax],al) so executing that page base will crash.Stores the original 4 bytes (often
0x90909090) into some other page base (a 4-byte write).
Jumps to the target page's room loop at
target+0xc.
This effectively lets you "move" a 4-byte NOP sled (0x90909090) around between page bases, while consuming the NOP-ness of pages you step into.
3) The win condition is a chain of base jumps to the flag routine
Some page bases contain jmp rel32 at offset +4. If we replace their first 4 bytes with 0x90909090, they stop crashing and the jump executes.
There is a direct chain to the flag routine:
0x6767a000(base)jmp->0x676820000x67682000(base)jmp->0x6768a0000x6768a000(base)jmp->0x676910000x67691000(base)jmp->0x676920000x67692000(base)jmp->0x42069000
The f key jumps to 0x6767a000 (the "final door"). So we need the first 4 bytes of these bases to be NOPs at the moment we press f: 0x6767a000, 0x67682000, 0x6768a000, 0x67691000, 0x67692000.
4) Automate the maze with BFS (room + bitmask state)
We can treat each room base as a node. The only mutable state that matters is which room bases currently start with NOP (0x90) versus crash (0x31).
So we do a BFS over:
room: current room base addressmask: bitmask of NOP-status for each room base
Transitions are extracted from disassembly: for each room and each move key, record (target, dest) where dest is where the 4-byte copy goes if the target starts with NOP.
When a move goes to a target whose base is currently NOP:
clear the target's NOP bit (it gets patched to crash)
set the dest's NOP bit (it receives
0x90909090)
Once the required five bases are NOP, append f and the program jumps through the chain to 0x42069000.
Below is a complete solver that:
Uses
gdbonce to list the mapped RWX room pages.Disassembles each room's handler to extract the
(target, dest)pairs.Runs BFS to find the shortest winning input string.
Optionally connects to the remote service and prints the flag.
Running the solver produces an input sequence; sending it to the remote service prints the flag: lactf{starless_c_more_like_starless_0xcc}.
the-fish
Description
We are given fish.py, which implements a 1D esolang interpreter and runs a single-line program (fisherator) over the input flag. The program ultimately executes instruction n, which pops an integer and checks it against a fixed huge constant; if equal, it prints “Indeed, that is the flag!”.
Solution
The input string is first converted to a stack of ASCII codes. The fisherator program does two main phases:
Parse flag bytes into an integer
n(big-endian base-256).The stack is reversed (
r) so popping reads the flag left-to-right.A loop performs:
n = n*256 + next_byte.
Run a Collatz-style process on
nwhile building an accumulatoracc.Initialize
acc = 1.Repeat until
n == 1:acc = acc*2If
nis odd: setn = (3*n + 1)//2andacc = acc + 1Else: set
n = n//2
Finally, the program checks
accagainst the embedded constant.
So the constant is exactly the final acc. Since the loop updates acc as acc = (acc<<1) | (n&1), the binary representation of acc encodes the parity bits of n along the path to 1 (with a leading 1).
This is reversible from the end state n = 1:
Extract bits from
accleast-significant-bit first whileacc > 1(these correspond to the parities in reverse order).Rebuild the previous
n:If the extracted bit is
0(even-step), previousn = 2*current.If the bit is
1(odd-step), previousn = (2*current - 1) / 3(must divide evenly).
Once the starting n is recovered, convert it back to bytes (big-endian) to get the original flag string.
Recovered flag: lactf{7h3r3_m4y_83_50m3_155u35_w17h_7h15_1f_7h3_c011472_c0nj3c7ur3_15_d15pr0v3n}
the-three-sat-problem
Description
The provided binary attachments/three_sat_problem asks for a solution to a 3-SAT instance. If the input satisfies the embedded constraints, it prints the flag.
Solution
Static reversing (
objdump -d) shows:
The program reads a line into a global buffer at
.bssaddress0x15060.It requires the input length to be exactly
0x4ff(1279) characters.Each character must be
'0'or'1'.It calls a large, straight-line checker function at
0x1289and requires it to return success (AL==1).It additionally requires input byte
0x2f2to be'1'(the main function doestest byte [0x15352], 1).On success it prints a 40-byte string built by selecting 320 bits from the 1279-bit input using the 320-entry dword table at
.rodata0x13080.
Because the checker is straight-line (no conditional branches), we can solve it with symbolic execution:
Create a blank call-state at
0x1289.Make the 1279 input bytes symbolic, constrain each to
{0x30, 0x31}.Constrain
input[0x2f2] == '1'.Execute to a concrete return address.
Constrain the return value to
AL==1.Extract a model, then apply the output-bit mapping to recover the printed flag.
Running the script below produces the flag lactf{is_the_three_body_problem_np_hard} and also prints the full 1279-character certificate bitstring (second line) which can be fed back into the binary to verify.
web
append-note
Category: Web | Points: 233 | Solves: 58
Description
Our distributed notes app is append optimized. Reads are eventually consistent with the heat death of the universe! :)
Provided: app.py (Flask app), admin-bot.js (Puppeteer bot), an instancer giving a challenge URL and an admin-bot URL.
Solution
Source Analysis
The Flask app generates a random 8-hex-char SECRET = secrets.token_hex(4) stored as the first note. Three endpoints matter:
/append — requires an admin cookie. Takes content and url query params. Validates url has scheme http/https and hostname matching the challenge host. If validation fails, it reflects parsed_url.hostname unescaped in the error response:
If validation passes, returns 200 if content is a prefix of any note, else 404, and appends content to notes.
/flag — returns the flag if ?secret= matches SECRET. Has Access-Control-Allow-Origin: *.
Admin bot — sets an httpOnly, SameSite=Lax cookie for the challenge domain and navigates to our submitted URL, keeping the page open for 60 seconds.
Vulnerabilities
Reflected XSS in
/appenderror page:parsed_url.hostnameis rendered as raw HTML in atext/htmlresponse (Flask's default Content-Type for string returns). No CSP is set.Prefix oracle in
/append: the 200 vs 404 status code leaks whethercontentis a prefix of any note (includingSECRET).
Exploit Chain
Step 1: Reflected XSS via urlparse hostname injection
Python's urlparse is permissive — for a URL like http://<img src=x onerror=PAYLOAD>/path, it extracts <img src=x onerror=PAYLOAD> as the hostname. This hostname fails the challenge-host check, so it gets reflected in the 400 error page as live HTML.
The catch: urlparse.hostname lowercases everything. JavaScript is case-sensitive, so encodeURIComponent becomes encodeuricomponent and breaks. The bypass: percent-encode every byte of the JS payload (( → %28, A → %41, etc.) and wrap it in eval(unescape('...')). Both eval and unescape are already lowercase, and unescape is case-insensitive for hex digits (%4E and %4e both decode to N).
Using <img onerror> instead of <script> is critical — an unclosed <script> tag (no </script> since / terminates the hostname in URL parsing) does not execute in Chrome, but <img src=x onerror=...> fires immediately when the image fails to load.
The crafted url parameter:
The admin bot navigates to:
Since this is a top-level GET navigation, the SameSite=Lax admin cookie is sent. Auth passes, URL validation fails (hostname mismatch), and the XSS fires same-origin on the challenge domain.
Step 2: Same-origin prefix oracle brute-force
Running same-origin, the JS payload uses fetch() (cookies auto-included) to query /append?content=PREFIX&url=CHALLENGE_ORIGIN/ and reads response.status directly — 200 means the prefix matches, 404 means it doesn't.
For each of the 8 hex positions, all 16 candidates (0–f) are tested in parallel via Promise.all. This completes in 8 sequential rounds of 16 parallel requests — well within the bot's 60-second window.
Previously appended probe strings never cause false positives: a probe from round M is M+1 chars long, which is shorter than a round N probe (N+1 chars, N > M), and a shorter string cannot startswith a longer one.
Step 3: Flag retrieval and exfiltration
Once the SECRET is known, the payload fetches /flag?secret=SECRET (which has ACAO: *) and exfils both the secret and flag to ntfy.sh via cross-origin fetch POST (ntfy.sh returns Access-Control-Allow-Origin: *).
Solve Script
Flag
blogler
Description
The site hosts public user blog pages at /blog/<username>. If the user exists, the page renders their blog posts; if they do not exist, the server returns 404 with body username does not exist.
Goal: find the flag lactf{...}.
Solution
The intended weakness is simple user enumeration + content search:
Use the oracle on
GET /blog/<username>:Existing user:
200and a real HTML blog page.Non-existing user:
404with bodyusername does not exist.
Enumerate likely usernames (a dictionary wordlist is enough).
For each existing user page, search the HTML for the substring
lactf{.
This quickly finds a public user named exploiter whose blog page contains the flag:
https://blogler.chall.lac.tf/blog/exploiter->lactf{7m_g0nn4_bl0g_y0u}
Solution code (async dictionary brute + flag grep):
clawcha
Description
The server runs a "claw machine" gacha. The flag item exists server-side but has probability 1e-15, so you realistically only get it if you are the special owner user r2uwu2 (the server bypasses the probability check for owners).
Authentication is via a signed cookie username (cookie-parser signed cookies).
Solution
The bug is a logic mismatch in cookie-parser: after verifying a signed cookie, it also tries to parse any cookie value starting with j: as JSON (the "JSON cookie" feature). The app then uses the post-parsed req.signedCookies.username for authentication.
So we can register a new user whose username is a JSON-cookie payload that parses to the string r2uwu2, e.g.:
j: "r2uwu2"
cookie-parser will:
Verify the signature for the raw value
j: "r2uwu2"(valid, since the server signed it for us on/login).Parse it as JSON (because it starts with
j:), turning it into the stringr2uwu2.
Now req.signedCookies.username becomes r2uwu2, the app loads the real owner object from its users map, and /claw will always succeed for flag.
Exploit script:
Running it prints the flag from the server response.
glotq
Description
The service provides jq, yq, and xq “as a service” via three endpoints:
POST /jsonPOST /yamlPOST /xml
Each request contains a JSON/YAML/XML object with fields like command and args, and the server executes that command.
Solution
The core bug is that the server parses the request twice using different rules:
Security middleware decides how to parse the body based on the HTTP
Content-Typeheader.Handler decides how to parse the body based on the endpoint path (
/jsonalways uses JSON parsing, etc.).
Additionally, Go’s encoding/json matches JSON object keys to struct fields/tags case-insensitively, while the YAML parser used (gopkg.in/yaml.v3 with yaml:"command") is effectively case-sensitive for those keys.
So we can send a request to /json with Content-Type: application/yaml:
The middleware YAML-unmarshals the body and sees only lowercase
command/args, so we make those look safe (jq) and pass the allowlist.The
/jsonhandler JSON-unmarshals the same body, and because JSON matching is case-insensitive, we can provide capitalizedCommand/Argsthat override the effective values used by the handler.
We then execute the SUID helper /readflag (present in the container) by abusing man’s HTML mode:
man -H<browser> <page>runs<browser>to display the HTML output.Setting the browser to
/readflagruns it and prints/flag.txt.
Exploit payload (send to /json while lying about Content-Type):
One-shot solve script:
This returns the flag in the output field.
job-board
Description
The job board site has an internal (private) job posting whose description contains the flag. Applicants can submit a job application and then ask an admin recruiter (via an admin-bot) to view it.
Solution
The backend tries to HTML-escape user-controlled fields before inserting them into HTML templates, but the htmlEscape() implementation is incorrect: it only replaces the first occurrence of each special character (&, <, >, ", ').
In app.js:
Applications are stored server-side and later rendered at
/application/:id.The
whyfield is inserted intosite/application.htmlinside a<p>WHY</p>.The server calls
htmlEscape(why), but the buggy escaping lets us smuggle a real tag.
Because applications are persisted, this is a stored XSS. The admin bot logs in as the admin recruiter and then visits the URL we submit, so our XSS runs in the admin's browser context on job-board.chall.lac.tf.
XSS construction
We want the rendered HTML to contain a real element like:
But the server escapes the first </>/" it sees.
So we intentionally include two of each delimiter, so the first gets escaped and the second remains real:
Start with
"so the first double-quote becomes", leaving later quotes intact.Include
>>so the first>becomes>, leaving the second>real.Include
<<so the first<becomes<, leaving the second<real and starting the<img>tag.
Payload prefix:
Exfiltration
The JS in onerror:
Fetches
/and extracts any UUID-looking IDs from the HTML.In practice, the admin view includes a private job ID we cannot see as a normal user.
Fetches
/job/<uuid>for each discovered ID.Regex-searches the responses for
lactf{...}.Sends the flag out-of-band to a
webhook.siteendpoint usingfetch(..., {mode: 'no-cors'}).
The only manual step is solving the admin-bot reCAPTCHA to get it to visit our application URL.
Exploit code
solve.py (runs locally; prints the URL to submit to the admin bot and then polls for the exfiltrated flag):
Flag
lactf{c0ngr4ts_0n_y0ur_n3w_l7fe}
lactf-invoice-generator
Description
The site generates a PDF invoice from JSON input (name, item, cost, datePurchased). The PDF is rendered by a headless browser.
Solution
The backend builds an HTML template using user input directly (no escaping) and renders it with Puppeteer:
dist/invoice-generator/server.jsinserts${name},${item},${datePurchased}into HTML.page.setContent(invoiceHTML, { waitUntil: "load" })thenpage.pdf(...).
In the provided deployment, there is an internal service named flag on the Docker network:
dist/flag/flag.jsservesGET /flagwithFLAG: <flag>.dist/docker-compose.ymlshowsinvoice-generatordepends onflag, both on the same network.
Exploit: HTML-inject an <iframe> that loads http://flag:8081/flag. Since Puppeteer renders the HTML server-side (inside the container network), it can reach the internal flag host and the flag becomes visible in the rendered page, then embedded into the generated PDF.
One-shot exploit (replace URL with your instancer URL):
Reference solve script (does the same thing and extracts from bytes/strings output):
Flag obtained from the generated PDF: lactf{plz_s4n1t1z3_y0ur_purch4s3_l1st}
mutation mutation
Description
The site claims the flag is “constantly mutating” and that you can get it by inspecting the page.
Solution
The server serves two different HTML pages based on User-Agent:
A short decoy page (sent to
curl-like UAs) that contains a fakeREAL_FLAG.A much larger “real” page (sent to browser UAs) with heavily obfuscated JavaScript that computes the real flag string at runtime.
To solve, fetch the real page using a browser UA, extract the inline <script>...</script>, and execute it in Node with minimal DOM stubs. The script computes a constant F that is the real lactf{...} flag.
Code (one-shot extractor):
Resulting flag (note: contains Unicode confusables, emoji, and other non-ASCII characters; copy exactly):
narnes-and-bobles
Description
The site is a bookstore. You can register/login, add books to your cart, and checkout to download a zip of your purchased books. One book is Flag (flag.txt) but costs 1000000, while new users start with a balance of 1000.
Goal: bypass the balance check to buy the Flag book and read the flag from the downloaded zip.
Solution
Relevant code is in server.js:
Books are loaded from
books.jsoninto aMap(booksLookup). One book has a string price:The Part-Time Parliamenthas"price": "10"(string)Flaghas"price": 1000000(number)
/cart/addtries to prevent adding non-sample items you can’t afford:It queries the sum of existing non-sample cart items via SQL
SUM(...) AS cartSum.It computes the cost of the items being added via JS:
additionalSum = ... .map(...price...).reduce((l, r) => l + r, 0);
It blocks if
additionalSum + cartSum > balance.
Two JS/SQLite behaviors combine into a bypass:
When the cart is empty, SQLite
SUM(...)returnsNULL, which becomes JSnull.JS
+is concatenation if either side is a string. If the first added product has price"10", the reduce becomes a string:0 + "10" -> "010"(string)"010" + 1000000 -> "0101000000"(string)
Then the check becomes:
additionalSum + cartSumis"0101000000" + null=>"0101000000null""0101000000null" > 1000converts toNumber("0101000000null")=>NaNNaN > 1000isfalse, so the purchase is incorrectly allowed.
Exploit:
Register a new user (empty cart so
cartSumisnull).In a single
/cart/addrequest, add:The Part-Time Parliament(price"10", forces string concatenation)Flag(price1000000) withis_sample: falsefor both.
Checkout and read
flag.txtfrom the returned zip.
Exploit code (Python):
single-trust
Description
The app stores a JSON session object in a client cookie auth, encrypted with AES-256-GCM:
plaintext:
{"tmpfile":"/tmp/pastestore/<32 hex chars>"}cookie:
base64(iv).base64(authTag).base64(ciphertext)
On each request it decrypts the cookie and uses user.tmpfile as the file to read/write. The flag is in /flag.txt.
Solution
Node (Ubuntu 20.04 nodejs package, v10.19.0) accepts truncated GCM tags via decipher.setAuthTag(). Since the server does not enforce a 16-byte tag length, we can send a 1-byte tag, reducing authentication strength to 8 bits. We can then brute-force the tag byte for any modified ciphertext (~256 requests).
We cannot directly change the 32 unknown hex bytes (we don't know their plaintext, so we can't compute XOR deltas there). But we can avoid needing them:
Keep bytes 28..59 (the unknown hex) unchanged.
Rewrite only the first 28 bytes of plaintext from:
{"tmpfile":"/tmp/pastestore/to:{"tmpfile":"/flag.txt","x":"
Leave the last 2 bytes unchanged (
"}), so the unknown 32 bytes become the value of"x", andtmpfilebecomes/flag.txt.
Because AES-GCM encryption is XOR with a keystream, we can transform known plaintext bytes by XORing the ciphertext with P0 ^ P1 for those positions. After modifying the ciphertext, we brute-force a 1-byte tag until the server accepts it and returns /flag.txt in the page.
Exploit code (prints the flag):
the-trial
Description
The site shows a slider that generates a 4-letter word, then sends it to POST /getflag as application/x-www-form-urlencoded with the parameter word.
Solution
View source / DevTools shows the client-side JS builds a 4-letter string from an alphabet and submits it via:
fetch("/getflag", { method: "POST", body: "word=<generated>" })
The backend doesn't enforce the slider; it just checks the posted value. Bypass the UI and submit word=flag directly:
Python equivalent:
Flag: lactf{gregor_samsa_awoke_from_wait_thats_the_wrong_book}
bobles-and-narnes
Description
This challenge is a simple bookstore web app with a cart. The Flag “book” costs $1000000, but new accounts only have $1000. Checkout returns a ZIP of the purchased files, and the real flag is in flag.txt.
Solution
The key bug is in POST /cart/add:
The server computes how much to charge using the request body:
additionalSum = products.filter(p => !+p.is_sample).map(price).sum()So setting
is_sample: trueon the flag item makes it not counted (no “too poor” rejection).
But it inserts cart rows using Bun SQL’s object bulk insert:
await db\INSERT INTO cart_items ${db(cartEntries)}``When
db([...])is given an array of objects, the INSERT column list is taken from the first object only.If the first object omits
is_sample, the INSERT omits theis_samplecolumn for all rows, so every inserted row getsis_sample = NULL(even later objects that includedis_sample).
At checkout:
const path = item.is_sample ? samplePath : fullPathNULLis falsy, soitem.is_sampleselects the full file path (flag.txt), giving the real flag.The balance can go negative on checkout (no “enough money” check there), so we just need to pass the
/cart/addcheck.
Exploit strategy:
Add two products in one request:
Product 0: any cheap book, omit
is_sampleentirely (so the INSERT omits that column).Product 1: the flag book with
is_sample: true(so the price check skips charging it).
Checkout and unzip
flag.txt.
Solution code (solve_final.py):
extend-note
Description
Customers loved append-note so much, we decided to add an extended version! :)
Solution
extend-note is identical to append-note (part 1) except one line: the error page no longer reflects user input, eliminating the XSS vector used in part 1.
The app (Flask 3.0.0, Python 3.14) stores a random 8-hex-char SECRET in a notes list. Three endpoints:
/append?content=X&url=URL— requires admin cookie. Returns 200 if any note starts withcontent, else 404. Always appendscontenttonotes. Responds with a page that JS-redirects tourlafter 100ms./flag?secret=S— returns the flag ifS == SECRET. HasAccess-Control-Allow-Origin: *.After-request headers on all responses:
X-Content-Type-Options: nosniff,X-Frame-Options: deny,Cache-Control: no-store.
The admin bot visits any URL with an httpOnly, SameSite=Lax cookie for the challenge domain and waits 60 seconds.
The attack has three parts:
1. Same-site XSS via blogler
The blogler challenge (separate LACTF web challenge) runs on *.instancer.lac.tf — same site as extend-note. Blogler renders user blog posts through mistune.html() with Jinja2's |safe filter, giving us stored XSS on a same-site origin. Since both share eTLD+1 lac.tf, the admin's SameSite=Lax cookie is sent on all subresource requests from blogler to extend-note.
2. <link rel="prefetch"> XS-leak oracle
The challenge's protections (nosniff, X-Frame-Options: deny, no-store) defeat most XS-leak techniques. Comprehensive testing of every HTML element type revealed that <link rel="prefetch"> is the one that differentiates HTTP status codes:
<script>
onerror
onerror
<link rel="stylesheet">
onerror
onerror
<link rel="preload" as="fetch">
onload
onload
<link rel="preload" as="script/style/image">
onerror
onerror
<link rel="prefetch">
onload
onerror
<img>, <video>, <audio>, <object>
onerror
onerror
This gives a clean boolean oracle: onload = prefix matches (200), onerror = no match (404).
3. Extract SECRET and fetch flag
Probe the secret character by character (16 hex candidates per position, 8 positions) using the prefetch oracle, then fetch the flag from the CORS-enabled /flag endpoint.
Solve payload (hosted as a blogler blog post):
Deployment steps:
The entire extraction (8 characters × up to 16 probes each = 128 prefetch requests) completes in under 2 seconds. Results are exfiltrated via ntfy.sh.
Flag: lactf{1_R34LlY_n33D_T0_r3m3m83R_t0_R3M0V3_My_d38U9_5T4t3m3nt2}
Last updated