🎥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:

  1. pt = phonetic_mapping(phonetic_mapping(flag))

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

  1. Injective mapping: ciphertext symbol -> plaintext bigram (all-different).

  2. Known prefix crib: because the flag starts with lactf{, the start of s2 = phonetic_mapping(phonetic_mapping("lactf{")) is fully known, which fixes many symbol->bigram assignments immediately.

  3. Regular-language constraint: s2 must be accepted by a DFA for “concatenation of NATO words” (or that plus a final padding X). This is enforced with OR-Tools CP-SAT AddAutomaton.

Once s2 is recovered, decode:

  • s2 -> s1 by tokenizing NATO words back into letters.

  • s1 -> flag by tokenizing the full PHONETIC_MAP words 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 shufflei

  • Position 1, 4, 7, ... → substituted by shufflej

  • Position 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:

  1. The ciphertext ends with a visible flag structure: zjlel{heqmz_dgk_tevr_tk_vnnds_c_imcqaeyde_ug_byndu_e_jjaogy_rqqnisoqe_cwtnamd}

  2. We know zjlellactf, which gives us initial mappings across all three ciphers.

  3. 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_article

  4. Verifying this hypothesis against the ciphertext shows perfect consistency across all three cipher mappings — no contradictions.

  5. 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:

  1. last = vec2[9]

  2. the coefficients c0..c8 of g(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 = s

  • c1 = 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):

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

  1. Confirm the interesting permission: ctf-app can impersonate ctf-deployer-sa:

  1. Use that to create a pod as ctf-deployer-sa with a hostPath mount of / and read the host’s k3s kubeconfig (/etc/rancher/k3s/k3s.yaml). This file contains client-certificate-data and client-key-data.

  1. Extract the client cert/key from k3s.yaml and 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") then decode("utf-16-be")

To reverse it, do the opposite:

  • encode("utf-16-be") then decode("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:

  1. Generating a QR for the flag (segno.make(..., mode='byte', error='L', boost_error=False, version=7)).

  2. Splitting the 45x45 module image into a 5x5 grid of 9x9 chunks (25 total).

  3. 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 nbytes with segno, 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 nbytes until reconstruction yields a decodable lactf{...}.

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 frame

  • bed_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 characters

  • vc (vowel + consonant) - 2 characters

  • vd (vowel + digit) - 2 characters

  • c (consonant) - 1 character

  • d (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 cd allows con(5)=r on the left (vowels max at depth 3) → B = cd

  • E has a right branch with depth 5: only vc allows con(5)=r on the right → E = vc

  • By elimination → C = vd

  • A = c (consonant), D = d (digit) chosen because it produces readable text

Step 4: Decode each fragment:

Fragment
Depths
Type
Characters

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:

  1. Break out of the print(<inp>) wrapper by starting our input with )or(, making the overall evaluated expression:

    • print() or (<our expression>)

  2. Modify a picklable object (exit, a _sitebuiltins.Quitter instance) to override its __reduce_ex__ method (without typing underscores, using dir(0)[41] which is the string "__reduce_ex__").

  3. Return that exit object, forcing the subinterpreter to pickle it.

  4. 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)

  • c to 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 to 588 % 128 = 76. This comparison can match.

  • 588 (no width prefix): A 32-bit literal. Since x is only 7 bits (0-127), x == 588 never 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'd prefix: truncate via value % 128, always reachable

  • Bare 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]; where 0020 is octal = 16 bytes

  • fgets(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 uses bytes[i] (byte i of the little-endian main pointer)

  • 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 history array as a tiny ROP stack (each command can store 6 bytes of an address).

  • A double leave; ret pivot to start executing from history.

  • Two more redirected fgets calls 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:

Write
Stack Location
What's There

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

  1. Libc leak (unsorted bin fd) using 1-byte clobber + puts

    • Allocate a small chunk and a 0x110 chunk.

    • Free the small chunk, then reallocate it with size=0 and overflow into the 0x110 chunk header to fake its size as 0x421 (unsorted bin sized) and place fake next chunk headers to satisfy free checks.

    • Free that “large” chunk into the unsorted bin.

    • Allocate a size=8 chunk from it: the program only reads 1 byte, so it overwrites only the low byte of the stale unsorted fd pointer. puts() then leaks the remaining bytes.

    • Reconstruct the leaked libc page and compute libc_base using the fixed relation (Ubuntu glibc 2.35-0ubuntu3.8): fd_page - libc_base == 0x21b000.

  2. Heap leak (safe-linking) from two adjacent 0x20 chunks

    • Free two adjacent 0x20 chunks into tcache.

    • Reallocate both with size=8 so only 1 byte is written, preserving most of the safe-linked fd values 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 0x2a0 in its heap page (after the tcache_perthread_struct chunk), so we select the candidate with that page offset.

  3. Tcache poisoning into _IO_2_1_stderr_

    • From the earlier unsorted remainder, allocate two 0x110 chunks V and W, free them so V is at the head of tcache[0x110].

    • Allocate the adjacent 0x20 chunk with size=0 and overflow into freed V’s tcache next pointer, overwriting it with a safe-linked pointer to _IO_2_1_stderr_ in libc.

    • Next malloc(0xf8) returns V (we use it for attacker-controlled _IO_wide_data), and the following malloc(0xf8) returns a chunk overlapping stderr, letting us overwrite the FILE object.

  4. FSOP on exit (wide stream path)

    • Overwrite stderr with a fake FILE:

      • Place the command string at the start so system(fp) uses it.

      • Set _mode=1 and vtable=_IO_wfile_jumps to take the wide-stream flush path.

      • Point _wide_data to our heap wide_data chunk.

      • Set _lock to a safe writable address that does not clobber wide_data->write_ptr/write_base.

    • Craft _IO_wide_data so flush sees pending output (write_ptr > write_base) and forces buffer allocation (buf_base == NULL), reaching _IO_wdoallocbuf and then a function pointer in the wide vtable.

    • Place a fake wide vtable pointer inside wide_data such that the vtable slot at +0x68 is system.

    • Trigger exit, which flushes _IO_list_all, invoking the chain and executing system("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 of run() return address

  • 18/19: low32 of main() return address (into libc)

  • 0x9a: *(u16*)rbx at 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):

  1. Run 1: set run() return to loop.

  2. Run 2: set run() return to loop, and zero *(u16*)rbx.

  3. Run 3: set run() return to loop, and write main return low16.

  4. Run 4: restore run() return to normal (main+0x1334) and write main return hi16, so main returns 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 @ 0x4050

  • computer @ 0x4051

  • board @ 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_hdr table 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 IP 0x125a and the chosen landing pad 0x1213)

  • A CIE using augmentation "zPLR" so we can provide:

    • a personality (__gxx_personality_v0)

    • an LSDA pointer encoding

    • an FDE pointer encoding

  • Two FDEs:

    1. 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_expression for RIP: spoofs the caller RIP into handler_start+1 so the next frame lookup uses our handler FDE.

      • DW_CFA_val_expression for RSP: restores the real stack pointer (rbp+16) so system() has plenty of stack space. (If RSP stayed in our tiny .eh_frame page, system() crashes due to stack underflow.)

    2. FDE for the handler range: provides an LSDA that catches const char* and sets the landing pad to 0x1213 (call system@plt inside g()’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 --local

  • Remote: 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

  1. Fetch script.js from the challenge and extract the const theFlag = /^...$/; regex.

  2. 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 by WIDTH (.{col}X.{WIDTH-1-col} patterns). Counting the (?: ... # ... ){n} pieces yields the run-lengths for that column.

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

  1. 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: block k is columns 4*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 to 1 and later cleared back to 0.

  • The exact number of executed template statements and the current input index (logbook) at the moment sea["2"] transitions 1 -> 0 increases when more of the provided input prefix matches the embedded expected flag.

So we:

  1. Implement a minimal interpreter for this limited Go-template subset.

  2. Execute the entry template volumeWorker7940 with provisions = .Values.input.

  3. Stop exactly when sea["2"] clears from 1 to 0, returning (logbook, steps).

  4. 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:

  1. Reads a line (up to 73 chars), strips the trailing newline.

  2. Verifies the input begins with lactf{.

  3. 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

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

  1. 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) returns a + b.

  • ὄ(a, b) returns a.

  • ὂ(a, b) returns b.

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:

  1. Reads the first byte of the target page base (e.g. 0x6768a000).

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

  3. 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 -> 0x67682000

  • 0x67682000 (base) jmp -> 0x6768a000

  • 0x6768a000 (base) jmp -> 0x67691000

  • 0x67691000 (base) jmp -> 0x67692000

  • 0x67692000 (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 address

  • mask: 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:

  1. Uses gdb once to list the mapped RWX room pages.

  2. Disassembles each room's handler to extract the (target, dest) pairs.

  3. Runs BFS to find the shortest winning input string.

  4. 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:

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

  2. Run a Collatz-style process on n while building an accumulator acc.

    • Initialize acc = 1.

    • Repeat until n == 1:

      • acc = acc*2

      • If n is odd: set n = (3*n + 1)//2 and acc = acc + 1

      • Else: set n = n//2

    • Finally, the program checks acc against 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 acc least-significant-bit first while acc > 1 (these correspond to the parities in reverse order).

  • Rebuild the previous n:

    • If the extracted bit is 0 (even-step), previous n = 2*current.

    • If the bit is 1 (odd-step), previous n = (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

  1. Static reversing (objdump -d) shows:

  • The program reads a line into a global buffer at .bss address 0x15060.

  • 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 0x1289 and requires it to return success (AL==1).

  • It additionally requires input byte 0x2f2 to be '1' (the main function does test 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 .rodata 0x13080.

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

  1. Reflected XSS in /append error page: parsed_url.hostname is rendered as raw HTML in a text/html response (Flask's default Content-Type for string returns). No CSP is set.

  2. Prefix oracle in /append: the 200 vs 404 status code leaks whether content is a prefix of any note (including SECRET).

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 (0f) 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:

  1. Use the oracle on GET /blog/<username>:

    • Existing user: 200 and a real HTML blog page.

    • Non-existing user: 404 with body username does not exist.

  2. Enumerate likely usernames (a dictionary wordlist is enough).

  3. 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:

  1. Verify the signature for the raw value j: "r2uwu2" (valid, since the server signed it for us on /login).

  2. Parse it as JSON (because it starts with j:), turning it into the string r2uwu2.

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 /json

  • POST /yaml

  • POST /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:

  1. Security middleware decides how to parse the body based on the HTTP Content-Type header.

  2. Handler decides how to parse the body based on the endpoint path (/json always 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 /json handler JSON-unmarshals the same body, and because JSON matching is case-insensitive, we can provide capitalized Command/Args that 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 /readflag runs 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 why field is inserted into site/application.html inside 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 &quot;, leaving later quotes intact.

  • Include >> so the first > becomes &gt;, leaving the second > real.

  • Include << so the first < becomes &lt;, leaving the second < real and starting the <img> tag.

Payload prefix:

Exfiltration

The JS in onerror:

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

  2. Fetches /job/<uuid> for each discovered ID.

  3. Regex-searches the responses for lactf{...}.

  4. Sends the flag out-of-band to a webhook.site endpoint using fetch(..., {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.js inserts ${name}, ${item}, ${datePurchased} into HTML.

  • page.setContent(invoiceHTML, { waitUntil: "load" }) then page.pdf(...).

In the provided deployment, there is an internal service named flag on the Docker network:

  • dist/flag/flag.js serves GET /flag with FLAG: <flag>.

  • dist/docker-compose.yml shows invoice-generator depends on flag, 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 fake REAL_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:

  1. Books are loaded from books.json into a Map (booksLookup). One book has a string price:

    • The Part-Time Parliament has "price": "10" (string)

    • Flag has "price": 1000000 (number)

  2. /cart/add tries 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.

  3. Two JS/SQLite behaviors combine into a bypass:

    • When the cart is empty, SQLite SUM(...) returns NULL, which becomes JS null.

    • 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 + cartSum is "0101000000" + null => "0101000000null"

      • "0101000000null" > 1000 converts to Number("0101000000null") => NaN

      • NaN > 1000 is false, so the purchase is incorrectly allowed.

Exploit:

  1. Register a new user (empty cart so cartSum is null).

  2. In a single /cart/add request, add:

    • The Part-Time Parliament (price "10", forces string concatenation)

    • Flag (price 1000000) with is_sample: false for both.

  3. Checkout and read flag.txt from 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:

  1. Keep bytes 28..59 (the unknown hex) unchanged.

  2. Rewrite only the first 28 bytes of plaintext from:

    • {"tmpfile":"/tmp/pastestore/ to:

    • {"tmpfile":"/flag.txt","x":"

  3. Leave the last 2 bytes unchanged ("}), so the unknown 32 bytes become the value of "x", and tmpfile becomes /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: true on 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 the is_sample column for all rows, so every inserted row gets is_sample = NULL (even later objects that included is_sample).

At checkout:

  • const path = item.is_sample ? samplePath : fullPath

  • NULL is falsy, so item.is_sample selects 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/add check.

Exploit strategy:

  1. Add two products in one request:

    • Product 0: any cheap book, omit is_sample entirely (so the INSERT omits that column).

    • Product 1: the flag book with is_sample: true (so the price check skips charging it).

  2. 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 with content, else 404. Always appends content to notes. Responds with a page that JS-redirects to url after 100ms.

  • /flag?secret=S — returns the flag if S == SECRET. Has Access-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:

Element
200 (text/html + nosniff)
404 (text/html + nosniff)

<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