creative-commons-zeroNullconCTF 2026

Solutions to all challenges

cry

Booking Key

Description

A book cipher challenge using an abridged Alice's Adventures in Wonderland (Project Gutenberg #19033, "Storyland" series). The server encrypts a random 32-character password using a book cipher and sends the ciphertext (list of step counts). We must decrypt 3 passwords correctly to get the flag.

The encryption works by walking through the book text character-by-character: for each password character, it counts how many steps forward from the current position until that character is found. The count is appended to the cipher, and the cursor stays at the found position.

Solution

Key observations:

  1. The book text is from PG #19033, including the "Produced by..." credit header and a trailing newline (total 53597 chars).

  2. Given the cipher (list of offsets), we can try all possible starting positions and decrypt. Each starting position yields a unique candidate password.

  3. Most starting positions produce non-letter characters (spaces, punctuation), so we filter for candidates where all 32 characters are ASCII letters.

  4. To distinguish the correct candidate from false positives, we use two heuristics:

    • Violation count: For each step, check if the target character appears earlier than where the cipher says. The true password has 0 violations.

    • Uppercase ratio: Random passwords from 51 chars (25 upper, 26 lower) should have ~49% uppercase. False positives tend to land on common lowercase English text.

Algorithm:

  • For each starting position (0 to len(BOOK)-1), compute cumulative sums of cipher values to get the 32 character positions.

  • Filter: all positions must be letters.

  • Score: count violations (target char appearing before expected position) and uppercase ratio.

  • Pick the candidate with 0 violations and realistic uppercase ratio.

Flag: ENO{y0u_f1nd_m4ny_th1ng5_in_w0nd3r1and}

Going in circles

Description

We're given a server that reads a flag, generates a random 32-bit polynomial f, computes a CRC-like reduction of the flag modulo f in GF(2)[x], and prints both the result and f. Each connection gives a new random f but the same flag.

Solution

The reduce function performs polynomial long division in GF(2)[x] โ€” this is exactly how CRC checksums work (hence "going in circles"). Each connection gives us flag mod f for a random 32-bit polynomial f.

Since GF(2)[x] is a Euclidean domain, the Chinese Remainder Theorem applies. By collecting enough (remainder, f) pairs from the server and applying CRT in GF(2)[x], we can reconstruct the full flag polynomial once the product of the moduli exceeds the flag's bit length.

Key details:

  • The reduce function stops one step early (when bit_length <= 32 instead of < 32), so we compute the proper remainder via an extra gf2_mod(result, f) step

  • Random 32-bit polynomials often share small factors, so we use remove_common_factors to extract the coprime part of each new f relative to the accumulated modulus, maximizing information from each sample

  • ~50 samples are sufficient for a typical flag length (~300 bits)

Flag: ENO{CRC_is_just_some_modular_remainder}

Matrixfun II

Description

A "post-quantum cryptography" implementation that encrypts messages using an affine cipher over a custom alphabet. The server encrypts the flag and provides a chosen-plaintext oracle.

The encryption works as follows:

  1. Base64-encode the plaintext

  2. Pad with = to a multiple of 16 characters

  3. For each 16-character block, map characters to indices in a custom 65-character alphabet (a-zA-Z0-9+/=), then compute c = (A * m + b) mod 65 where A is a random 16x16 matrix and b is a random 16-vector

Solution

Key insight 1: Chosen-plaintext oracle allows full key recovery. By sending carefully crafted messages, we can recover A column-by-column and then compute b.

Key insight 2: MOD = 65 = 5 * 13 is composite. The alphabet has 65 characters, not a prime number. This means Z/65Z is not a field, so standard modular matrix inversion fails. Instead, we must use the Chinese Remainder Theorem to solve the linear system in GF(5) and GF(13) separately, then combine results.

Key recovery:

  • Send 12 zero bytes as reference. Base64 encodes to AAAAAAAAAAAAAAAA (all index 26).

  • For each position j (0-15), send 12 bytes crafted so exactly one base64 position changes from A to B (index 26 to 27, difference of +1).

  • The difference between each test cipher and the reference cipher gives column j of matrix A directly.

  • Then b = ref_cipher - A * [26]*16 (mod 65).

Decryption via CRT:

  • For each flag cipher block, solve A * x โ‰ก (c - b) (mod 65) by:

    1. Solving A * x โ‰ก (c - b) (mod 5) in GF(5)

    2. Solving A * x โ‰ก (c - b) (mod 13) in GF(13)

    3. Combining via CRT to get x mod 65

  • Convert recovered indices back to base64 characters and decode.

Flag: ENO{l1ne4r_alg3br4_i5_ev3rywh3re}

TLS

Description

"TLS 0.1" hybrid encryption protocol: RSA-1337 encrypts an AES-128-CBC key, and the server provides a decryption oracle. The server reveals whether the RSA-decrypted key value exceeds 2^128 ("something else went wrong") or not ("invalid padding" / other). The AES key is generated as bytes(8) + os.urandom(8), giving only 64 bits of entropy.

Additionally, the CRT reconstruction has a bug (% privkey.q instead of % privkey.p), but this doesn't affect decryption of small plaintexts where m_p == m_q.

Solution

Attack: Binary search via RSA homomorphism + key-size oracle (Manger-style).

The AES key k satisfies 0 <= k < 2^64. Using RSA's multiplicative homomorphism, multiplying the ciphertext by s^e mod n makes the server decrypt k * s mod n. Since k * s stays well below min(p, q) ~ 2^668, the buggy CRT still produces correct results.

The oracle boundary at 2^128 lets us binary search: choose s = ceil(2^128 / mid) so that k * s >= 2^128 iff k >= mid. This recovers the full 64-bit key in exactly 64 queries, then we decrypt the flag ciphertext locally with AES-CBC.

Flag: ENO{Y4y_a_f4ctor1ng_0rac13}

Tetraes

Description

A modified AES implementation ("TetraES") encrypts the key with itself and provides an encryption oracle. We must recover the key to get the flag.

Key differences from standard AES:

  • S-box collision: S[0x00] changed from 0x63 to 0x64, creating a collision with S[0x8C] = 0x64. The S-box is no longer bijective.

  • No key schedule: Round keys are simply byte-rotations of the original key (rotate(key, r+1)).

  • 16 rounds instead of 10.

  • Extra tweak: state[0][0] ^= r ^ 42 in each AddRoundKey.

Solution

The S-box collision S[0x00] = S[0x8C] = 0x64 is the critical vulnerability.

Core insight: If two plaintexts P1 and P2 differ only at byte position n by 0x8C, and the state byte at position n after the initial AddRoundKey is either 0x00 or 0x8C, then SubBytes produces the same output for both. Since the rest of the computation is identical, both plaintexts encrypt to the same ciphertext.

After initial ARK: state[n] = P[n] ^ K[n] (with an extra ^42 at position 0). So the collision occurs when P[n] ^ K_adj[n] in {0x00, 0x8C}, revealing K_adj[n] to within 2 candidates.

Attack:

  1. For each of the 16 byte positions, query the oracle with all 256 possible byte values (other bytes zero). Find which pair (v, v ^ 0x8C) produces identical ciphertexts.

  2. This narrows each key byte to 2 candidates (2^16 = 65,536 total keys).

  3. Brute-force locally using the self-encryption constraint encrypt(K, K) = given_ciphertext.

Flag: ENO{a1l_cop5_ar3_br0adca5t1ng_w1th_t3tra}


misc

rdctd 1

Description

We are given a PDF (attachments/Planned-Flags-signed-2.pdf) that contains 6 hidden flags. This task asks for the flag โ€œcontaining a 1โ€.

Solution

On page 3 the PDF contains a readable sentence with the first flag:

  • ENO{stability gradient 1 disrupted}

However, the PDF does not encode the separators as literal underscore characters. Instead, the โ€œ_โ€ separators are drawn as tiny line segments between words (visible by inspecting the pageโ€™s content stream, e.g. mutool show attachments/Planned-Flags-signed-2.pdf 37), so pdftotext extracts them as spaces. Therefore, to get the real flag we:

  1. Extract all ENO{...} occurrences with pdftotext -layout.

  2. For each match, split the inner text on whitespace and join with _.

  3. Pick the first normalized flag containing token 1.

Result:

  • ENO{stability_gradient_1_disrupted}

Run:

  • ./solve.sh

Submit:

  • /home/ubu/ctf/ctf-helper submit -d . --flag 'ENO{stability_gradient_1_disrupted}'

Solution code (solve.py and solve.sh):

rdctd 2

Description

We are given attachments/Planned-Flags-signed-2.pdf and told the PDF contains multiple hidden flags; for this challenge we must submit the one containing a 2.

Solution

The PDF contains clickable link annotations (/Subtype /Link) whose target URI embeds the flag, but with the braces escaped in the PDF string as \\{ and \\}.

You can spot it quickly by grepping PDF objects:

This reveals an annotation like: URI(https://ctf.nullcon.net/ENO\\{input_sanitization_2_is_overrated\\}) which unescapes to the flag: ENO{input_sanitization_2_is_overrated}.

Automated extraction (script used):

Run it:

rdctd 3

Description

We are given attachments/Planned-Flags-signed-2.pdf, a โ€œpublishedโ€ document with redactions. The prompt says there are 6 hidden flags in the PDF; this challenge wants the one containing a 3.

Solution

The PDFโ€™s page 2 (section 3.2) is not real text: itโ€™s a blurred embedded raster image placed via /Im1 Do in the page content stream. So normal PDF text extraction canโ€™t recover it; we must extract the image and deblur it.

Steps:

  1. Locate the embedded image:

    • Clean/uncompress PDF to inspect streams (optional): mutool clean -d attachments/Planned-Flags-signed-2.pdf work/clean.pdf

    • Page 2 content uses an image XObject: mutool show -b work/clean.pdf 31 | rg "/Im1 Do"

    • The referenced XObject is the only image with dimensions 1042ร—337.

  2. Extract that image from the PDF.

  3. Apply a simple Wiener deconvolution with a Gaussian PSF to reverse the blur enough to read the repeated token.

  4. Read the third flag from the deblurred output:

    • ENO{semantic_3_inference_initialized}

Code

solve.py (writes deblurred images to solve_out/):

Run:

The deblurred token in solve_out/line_3.png is:

ENO{semantic_3_inference_initialized}

rdctd 4

Description

We are given attachments/Planned-Flags-signed-2.pdf which contains multiple hidden flags. For this sub-challenge we must submit the flag that contains the digit 4.

Solution

The visible example ENO{th1s is 4n eXample} is a decoy.

On page 4, inside the large redaction box under section 3.7, the PDF draws hundreds of tiny identical filled squares (vector rectangles) using re/f operations. Their positions form a 33x33 module grid with QR-code finder patterns. Because it is drawn inside the dark redaction area, it is not obvious by just looking at the page.

Steps:

  1. Extract the page 4 content stream.

  2. Interpret only the needed PDF drawing operators (q/Q, cm, re, f) to collect the centers of the repeated square modules.

  3. Map module centers to a 33x33 grid, render to a PNG (with a quiet zone).

  4. Decode the QR code with zbarimg to get the flag.

Decoded QR payload (flag): ENO{We_should_have_an_Ontology_to_4_categorize_our_ontologies}

Below is the full solver used.

rdctd 5

Description

A PDF with โ€œredactedโ€ content contains 6 hidden flags. This task asks for the flag that contains a 5.

Solution

The PDF still contains hidden text inside compressed object streams (in this case, an annotation/signature field). If you first decompress/clean the PDF and then search the resulting bytes for ENO{...}, the hidden flag becomes visible.

Run:

Solution code (solve.sh):

Flag:

ENO{SIGN_HERE_TO_GET_ALL_FLAGS_5}

rdctd 6

Description

We are given attachments/Planned-Flags-signed-2.pdf, which supposedly contains 6 hidden flags. This specific task asks for โ€œthe flag containing a 6โ€.

Solution

The flag is stored in the PDFโ€™s document metadata (the Producer field). Tools like pdfinfo/exiftool show it directly:

I also wrote a tiny extractor that searches the PDF bytes for ENO{...} (and falls back to mutool clean -d decompression if needed), then prints the first match containing the digit 6.

Run:

Flag:

ENO{secureflaghidingsystem76}

Seen

Description

We're given an index.html file containing a JavaScript flag checker. The input is validated against a string of Unicode variation selectors (U+FE00โ€“U+FE0F) โ€” invisible characters that encode the flag and a checksum.

Solution

The checker works as follows:

  1. A string s of 144 Unicode variation selectors (range 0xFE00โ€“0xFE0F) is decoded into 72 nibble-pairs, producing an array t of 72 bytes.

  2. The input flag (UTF-8 encoded) must have length t.length / 2 = 36.

  3. A generator gen = 0x10231048 is iterated per byte: gen = ((gen ^ 0xA7012948 ^ byte) + 131203) & 0xffffffff, and the result's low byte must match t[flagLen + i].

Since each byte position has only one valid candidate (the XOR and addition constrain it uniquely), we brute-force each byte independently:

Flag: ENO{W0W_1_D1DN'T_533_TH4T_C0M1NG!!!}

ZFS rescue

Description

We had all our flags on this super old thumb drive. My friend said the data would be safe due to ZFS, but here we are... Something got corrupted and we can only remember that the password was from the rockyou.txt file... Can you recover the flag.txt?

Given: nullcongoa_rescued.img (64MB ZFS pool image)

Solution

This challenge involves repairing a corrupted ZFS encrypted pool image and cracking the encryption passphrase.

Step 1: Analyze the image

The image is a 64MB ZFS file-based vdev. Initial analysis with zdb -l shows all 4 ZFS label nvlists have been zeroed (intentional corruption), but the uberblocks are intact. The data area is also intact.

Step 2: Reconstruct labels

A valid pool config nvlist was found at physical offset 0x40d000 (LZ4-compressed, 916 bytes -> 16KB). This was the pool's packed nvlist from the MOS. Key pool metadata extracted:

  • Pool name: nullcongoa

  • Pool GUID: 0x1c52777b2293a712

  • Vdev type: file, ashift=12

  • Best uberblock: txg=43

The nvlist was written to all 4 label vdev_phys areas with corrected state and txg fields. Label checksums (SHA-256 with ZFS endian conventions) were recomputed.

Step 3: Repair MOS object 61

Even with valid labels, zdb -e couldn't fully open the pool because MOS object 61 (PACKED_NVLIST) had all-zero data blocks across all 3 DVA copies. The known-good packed nvlist from 0x40d000 was copied into object 61's data block locations, then a cascade checksum repair was performed up the block pointer chain:

  1. Object 61 data block checksums (Fletcher4)

  2. L0 dnode block recompressed and checksummed

  3. Meta-dnode indirect block recompressed and checksummed

  4. MOS objset_phys checksummed

  5. Uberblock rootbp checksums updated

Script: repair_mos_obj61.py

Step 4: Patch vdev path for importability

The on-disk config stored the original vdev path from the challenge author's system. This was patched to a local path (cccccccc/nullcongoa.img) using fixed-length directory names to maintain string length. Script: make_importable.py

After patching, zdb -e -p cccccccc nullcongoa successfully opened the pool and revealed the encrypted dataset nullcongoa/flag.

Step 5: Extract encryption parameters

From zdb -e -p cccccccc -dddd nullcongoa 272 (the crypto key ZAP object):

Parameter
Value

Crypto suite

AES-256-GCM

Key format

passphrase

PBKDF2 iterations

100,000

PBKDF2 salt (uint64 LE bytes)

2600e6e9eda8b4d0

IV (12 bytes)

1dd41ddc27e486efe756baae

GCM tag (16 bytes)

233648b5de813aa6544241fa9110076b

Wrapped master key (32 bytes)

d7da54da...a99484d7

Wrapped HMAC key (64 bytes)

2e2ff1a7...0cc84272

DSL_CRYPTO_GUID

0x6877C9E7E0C39ED6

The pool creation commands (from SPA history) confirmed:

Step 6: Crack the passphrase (GPU-accelerated)

OpenZFS uses PBKDF2-HMAC-SHA1 to derive a 32-byte wrapping key from the passphrase, then AES-256-GCM to unwrap the master+HMAC keys with AAD = guid(8 LE) || suite(8 LE) || version(8 LE) (24 bytes).

A GPU-accelerated cracker was written using PyOpenCL targeting an AMD Radeon RX 9070 XT (via Mesa Rusticl). The OpenCL kernel computes PBKDF2-HMAC-SHA1 on the GPU, then AES-256-GCM tag verification is done on CPU.

At ~24,000 passwords/sec on GPU (vs ~768/sec on CPU), the passphrase was found in 20 seconds:

Passphrase: reba12345 (at position ~473k in rockyou.txt)

Step 7: Import and decrypt

Flag: ENO{you_4r3_Truly_An_ZFS_3xp3rt}

Zoney

Description

A DNS challenge where a flag is hidden somewhere at flag.ctf.nullcon.net on port 5054. The current TXT record says "The flag was removed."

Solution

The challenge name "Zoney" hints at DNS zone operations. Querying the current DNS records reveals:

  • A record: 10.13.37.1

  • TXT record: "The flag was removed."

  • SOA record: serial 1500

Standard zone transfer (AXFR) fails, but incremental zone transfer (IXFR) succeeds. IXFR returns the diff history between zone serial numbers, allowing us to see previous versions of the zone.

Requesting IXFR from serial 1000 returns the full history of 500 zone updates. Most are generic "Update #XXXX" TXT records, but serial 1337 contains the flag hidden among the updates, along with an A record change that makes it stand out:

The zone history also contains a deliberate red herring: serial 1498 is skipped (jumping from 1497 to 1499), with serial 1499 containing "Phew, removed the flag before anyone could get it" โ€” making it seem like the flag was at serial 1498. The actual flag was at serial 1337 all along.

Solution commands:

Flag: ENO{1337_1ncr3m3nt4l_z0n3_tr4nsf3r_m4st3r_8f9a2c1d}

emoji

Description

A zip file containing README.md with a single visible emoji (๐Ÿ’ฏ) followed by hidden Unicode characters.

Solution

The ๐Ÿ’ฏ emoji is followed by 28 invisible Unicode characters from the Variation Selectors Supplement block (U+E0100โ€“U+E01EF). These are zero-width characters that don't render visually, making them a steganographic channel.

Examining the codepoints reveals they encode ASCII with a simple offset: (codepoint - 0xE0100) + 16 = ASCII value.

Flag: ENO{EM0J1S_UN1COD3_1S_MAG1C}

DiNoS

Description

A DNS server at 52.59.124.14:5052 hosts the zone dinos.nullcon.net. The challenge hints that a flag is "mixed up with the herd" of dinosaurs (DNS records). The zone has DNSSEC enabled with NSEC records.

Solution

The challenge name "DiNoS" is a play on DNS + Dinosaurs. The zone uses DNSSEC with NSEC records, which have a well-known vulnerability: NSEC walking. Each NSEC record points to the next domain name in the zone, allowing complete zone enumeration without a zone transfer.

Querying ANY for the base domain reveals an NSEC record pointing to the first subdomain:

Each subdomain has a TXT record (random-looking data) and another NSEC record pointing to the next subdomain. Walking the entire chain reveals 512 TXT records, with the flag hidden as record #139.

Flag: ENO{RAAWR_RAAAAWR_You_found_me_hiding_among_some_NSEC_DiNoS}

DragoNflieS

Description

The DNS server at 52.59.124.14:5053/udp returns a fake TXT flag for flag.ctf.nullcon.net unless you use a "new DNS feature" that makes the server believe the query comes from an internal network.

Solution

The intended feature is EDNS Client Subnet (ECS) (EDNS option code 8). By adding an ECS option for an internal-looking subnet 10.13.37.0/24 (any IP inside it with prefix /24 or /32), the server returns a different TXT value, which is the real flag.

One-liner with dig:

Reference solver (Python, using dnspython) that retries a few times because the service drops some packets:

Flag:

Flowt Theory

Description

A "BillSplitter Lite" web application at 52.59.124.14:5069 that tracks expenses and settles debts between friends. The app mentions storing data in "super secure files" on the server and adding a "secret administrative fee of 0.01" to every calculation. The goal is to find the hidden administrative fee.

Solution

The application is a PHP web app running on Apache. By examining the functionality:

  1. Discovery: The app takes names[] and amounts[] via POST. Names are used as filenames (note the "Filename" placeholder hint). Amounts are written to files in a per-session user directory at /var/www/html/users/<session_id>/.

  2. Path Traversal (LFI): The view_receipt GET parameter reads files relative to the user directory but has no path traversal sanitization (unlike the POST name field which strips non-alphanumeric characters). Testing increasing depths of ../ revealed that 5 levels up reaches the filesystem root:

  1. Source Code Recovery: Reading the PHP source via LFI:

This revealed that the flag is read from /flag.txt and stored in a randomly-named secret_<8chars> file in each user's directory. The file content is "0.01\n" + flag, making the float value 0.01 (the "admin fee" shown in the vault balance).

  1. Flag Extraction: Since the flag originates from /flag.txt, reading it directly:

Flag: ENO{f10a71ng_p01n7_pr3c1510n_15_n07_y0ur_fr13nd}

The flag decodes to "floating point precision is not your friend" - the challenge name "Flowt Theory" (Float Theory) hints at the floating point theme, though the actual exploit is a classic Local File Inclusion via unsanitized path traversal in the view_receipt parameter.

Flowt Theory 2

Description

A "BillSplitter Lite" web application at 52.59.124.14:5070 that tracks expenses and settles debts. The app stores receipts as files on the server and adds a "secret administrative fee of 0.01" to every calculation. The goal is to find the hidden administrative fee. This is the sequel to "Flowt Theory" (port 5069) which had an unprotected LFI via the view_receipt parameter โ€” in this version, basename() was added to block path traversal.

Solution

The application is a PHP 8.0.30 app on Apache. The key difference from Flowt Theory 1 is that view_receipt now applies basename(), blocking ../ path traversal. However, the .lock metadata file is readable through basename() since basename('.lock') returns .lock unchanged.

  1. Understanding the architecture (from Flowt Theory 1 source via LFI): Reading FT1's source at http://52.59.124.14:5069/?view_receipt=../../../../../var/www/html/index.php revealed the full PHP code. On session initialization, the app:

    • Creates a per-user directory at /var/www/html/users/<random_hex>/

    • Generates a random filename secret_<8_alphanumeric_chars>

    • Writes the flag file: content is "0.01\n" + flag (so floatval() returns 0.01, the "admin fee")

    • Stores the secret filename in a .lock file in the same directory

  2. The basename() bypass via .lock: While basename() strips directory traversal (../../../../../flag.txt โ†’ flag.txt), it preserves dotfiles: basename('.lock') โ†’ .lock. The .lock file exists in the user's directory and is directly readable:

  1. Reading the flag: Using the leaked secret filename from .lock to read the actual flag file:

The secret file content is 0.01\n<flag> โ€” the first line is parsed as the 0.01 admin fee by floatval(), while the second line contains the flag.

Flag: ENO{s33ms_l1k3_w3_h4d_4_pr0bl3m_k33p_y0ur_fl04t1ng_p01nts_1n_ch3ck}


pwn

encodinator

Description

The service reads up to 0x100 bytes, base85-encodes them into an RWX mmap at a fixed address (0x40000000), and then calls printf(mapped_buf) โ€” a classic format string vulnerability. The binary is non-PIE and writable sections (including .fini_array) live at fixed addresses.

Goal: use the format string to gain code execution and read the flag from the remote instance (52.59.124.14:5012).

Solution

1) Identify the bug and the useful primitives

From main:

  • mmap(0x40000000, 0x1000, PROT_READ|PROT_WRITE|PROT_EXEC, ...) โ†’ fixed RWX region.

  • read(0, stack_buf, 0x100) โ†’ attacker-controlled bytes on the stack.

  • base85_encode(stack_buf, len, mapped) โ†’ attacker controls the format string bytes stored at 0x40000000.

  • printf(mapped) โ†’ attacker-controlled format string, with no extra arguments explicitly passed.

Even though only the format string is passed to printf, it is variadic, so it will still read โ€œargumentsโ€ from the callerโ€™s registers and then from the callerโ€™s stack. Crucially, at the call site the stack-based variadic arguments start at the beginning of stack_buf, so we can place pointers on the stack and reference them via positional specifiers like %25$hn.

This gives us arbitrary 2-byte writes via %hn to chosen addresses.

2) Avoid the โ€œbase85 wrapperโ€ problem

We do not control the format string directly; we control the input, which gets base85-encoded.

Key trick: choose an initial base85 output prefix (fmt) that is made entirely of valid base85 alphabet characters ('!'..'u', which includes %, digits, $, h, n, etc). Then base85-decode that prefix to the bytes we must send so that the program re-encodes them back into fmt.

To safely append raw pointers after this prefix (so they appear as stack arguments), we make len(fmt) a multiple of 10:

  • base85 encodes 4 input bytes โ†’ 5 output chars.

  • len(fmt) % 10 == 0 โ‡’ decoded prefix length is a multiple of 8 bytes โ‡’ appended pointers are 8-byte aligned for printf arguments.

  • Also, decoded length is a multiple of 4 bytes โ‡’ appending more bytes doesnโ€™t change the already-emitted base85 groups, so the output begins with our exact fmt.

3) Get code execution without libc

We avoid libc entirely (remote libc unknown) by:

  1. Using the format string to write a small execve("/bin//sh", NULL, NULL) shellcode into the already-mapped RWX region at 0x40000800.

  2. Overwriting .fini_array[0] (at fixed address 0x403188) to point to 0x40000800.

  3. When main returns, process shutdown runs .fini_array, jumping into our shellcode โ†’ spawns a shell on the socket.

.fini_array originally contains 0x4011e0 (__do_global_dtors_aux). We overwrite only the low 4 bytes using two %hn writes:

  • *(uint16_t*)0x403188 = 0x0800

  • *(uint16_t*)0x40318a = 0x4000

4) Why the argument numbering is 6 + ...

For printf positional parameters, numbering starts after the format string:

  • arg 1..5 are in registers (rsi, rdx, rcx, r8, r9)

  • arg 6 is the first stack variadic slot

If our decoded prefix occupies P bytes, the first appended pointer is at stack-slot (P/8), so its positional index is:

arg_base = 6 + (P / 8)

We solve this with a short fixed-point iteration: build fmt using a guessed arg_base, decode it to get P, recompute arg_base, repeat until stable.

5) Full exploit code

Run:

  • Local sanity check: python3 solve.py LOCAL

  • Remote: python3 solve.py

solve.py:

hashchain

Description

The remote service accepts 100 input lines. For each line it computes the MD5 digest (16 bytes). After exactly 100 lines it concatenates the 100 digests and jumps to them as machine code.

Goal: execute code that reads the flag and prints it back.

Connection: 52.59.124.14:5010

Solution

1) Turn each MD5 digest into a 2-byte โ€œgadgetโ€.

If we can find a line whose MD5 digest starts with eb 0c (jmp +0x0c), execution jumps over the 12 โ€œjunkโ€ bytes to the final 2 bytes of the digest. If we brute-force preimages for chosen final 2 bytes, each input line becomes a reliable 2-byte instruction (or two 1-byte instructions) and execution naturally falls into the next digest.

So we brute for digests with:

  • md5[0:2] == eb 0c

  • md5[14:16] == <chosen 2 bytes>

2) Use i386 int 0x80 syscalls (not syscall).

syscall (0f 05) killed the process, but int 0x80 (cd 80) works. In this challenge, int 0x80 uses the i386 ABI:

  • syscall number: eax

  • args: ebx, ecx, edx, esi, edi, ebp

We only need read, open, write, exit:

  • read = 3

  • write = 4

  • open = 5

  • exit = 1

3) Build a tiny int 0x80 program from 2-byte gadgets.

We implement:

  1. read(0, esp, 0x20) to get a NUL-terminated filename from the socket/PTY.

  2. open(esp, 0, 0)

  3. read(fd, esp, 0xff)

  4. write(1, esp, eax)

  5. exit(0)

The correct filename on the server is ./flag.txt.

4) Exploit code

solve.py (final exploit):

i386_preimages.json (precomputed MD5 preimages used by the exploit):

5) Brute-force code used to generate gadgets

brutemd5_i386.c (multi-target brute for the i386 gadget set):

brutemd5_one.c (used to find an extra gadget, mov bl, 3 / suffix b3 03):

Flag: ENO{h4sh_ch41n_jump_t0_v1ct0ry}

hashchain v2

Description

The service at 52.59.124.14:5011 repeatedly:

  1. reads a line,

  2. stores a 4-byte โ€œhashโ€ into an internal buffer at the current offset,

  3. asks for the next offset (minimum 4), and when the next offset would go out of bounds it prints Buffer full! and jumps to the buffer, executing the stored hash-words as native code. A per-connection leak prints the runtime address of win().

Solution

1) Identify the hash

Send a line whose MD5 starts with x86 jmp -2 (eb fe) and then trigger execution of exactly 1 stored word. The known string aN9 has:

  • md5("aN9") = ebfe416b... Executing one stored word for aN9 keeps the TCP connection alive (infinite loop), while random strings quickly EOF. This confirms:

  • the hash is MD5(line) (newline not included),

  • the stored 4 bytes are digest[0:4] (the MD5 prefix), executed as code bytes.

2) Use the win() leak with a 2-word i386 stage

The leaked win() pointer looks like a 32-bit PIE address (e.g. 0x5656b25d), so we use i386 code:

  • push <win_addr>; ret

Machine code bytes (little-endian immediate) are:

  • 0x68 <win0 win1 win2 win3> 0xc3

We store 2 hash-words (8 bytes total):

  • word0 bytes: 68 win0 win1 win2 (must match 4 MD5 bytes)

  • word1 bytes: win3 c3 ?? ?? (only first 2 bytes matter; ?? arenโ€™t executed)

So we need:

  • one 32-bit MD5-prefix preimage for word0,

  • one 16-bit MD5-prefix preimage for word1โ€™s first 2 bytes.

3) Brute-force MD5 prefix preimages locally (fast) and send them

MD5 is fast enough to brute 32-bit prefix matches with multi-threading. We build a simple C bruteforcer that searches strings of the form HC4_<16 hex digits> until md5(candidate) starts with the requested 2 or 4 bytes. The exploit:

  1. connects and parses the leaked win() address,

  2. brute-finds the two preimage lines,

  3. sends them with offsets 0 and 4,

  4. sets the next offset huge and sends one more line to trigger โ€œbuffer fullโ€ execution,

  5. receives the flag printed by win().

Code: brutemd5_prefix.c

Code: solve.py

Build and run:

  • gcc -O3 -pthread brutemd5_prefix.c -lcrypto -o brutemd5_prefix

  • python3 solve.py

asan-bazar

Description

The service is a small โ€œbazaarโ€ program compiled with ASAN/UBSAN. It:

  • Reads a Name into a stack buffer and then does printf(name) (format string bug).

  • Lets you โ€œupdateโ€ a 128-byte ledger with read(0, ledger + slot*16 + tiny, bytes) where slot <= 128, tiny <= 15, bytes <= 8 (out-of-bounds write).

There is a win() function that runs /bin/cat /flag.

Solution

  1. Leak PIE base (format string) Send a name like LEAK|%8$lx|%77$lx|%79$lx|END:

    • %8$lx reliably leaks an address inside greeting() (so PIE = leak - greeting_off).

    • Due to stack alignment, the saved return address of greeting() ends up at either the 77th or 79th โ€œargumentโ€ position for printf, so we leak both.

  2. Identify which leaked slot is the saved return address main calls greeting at PIE+0xDC04D, and the return address right after the call is PIE+0xDC052. Compare the leaked %77$lx / %79$lx against PIE+0xDC052 to choose the correct case.

  3. Overwrite greeting()โ€™s saved RIP using the OOB write The write primitive is:

    • destination: ledger + slot*16 + tiny

    • length: bytes (we use 8)

    The offset from ledger to the saved RIP is either:

    • 0x178 โ†’ slot=23, tiny=8

    • 0x188 โ†’ slot=24, tiny=8

    Write the 8-byte little-endian address of win() there. When greeting() returns, it jumps to win() and prints the flag.

  4. ASAN note (why this works) ASAN protects the ledger stack object with redzones, but the out-of-bounds read() can be aimed directly at the saved return address in mainโ€™s normal stack frame (which is not poisoned by ASAN). __interceptor_read checks only the destination range, and that range is โ€œvalidโ€ shadow memory, so the write is allowed.

Solver (solve.py):

atomizer

Description

Category: pwn | Points: 335 | Solves: 56

I hate it when something is not exactly the way I want it. So I just throw it away.

Server: 52.59.124.14:5020

We're given a static x86-64 ELF binary (atomizer) assembled from NASM.

Solution

Binary analysis:

The binary does the following:

  1. Prints a banner: == BUG ATOMIZER == \nMix drops of pesticide. Too much or too little and it won't spray.\n

  2. mmap(0x7770000, 0x1000, PROT_RWX, MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED, -1, 0) โ€” creates an RWX page

  3. Reads exactly 69 bytes (0x45) from stdin into the mmap'd page at 0x7770000

  4. Prints an "ok" message

  5. Executes a jmp intended to jump to 0x7770000 (our shellcode)

The NASM relocation bug (red herring):

The distributed binary contains a buggy jmp instruction at 0x401083:

Due to a NASM bug, the relative displacement was calculated as target - 4 instead of target - RIP_after_instruction, causing the jump to land at 0x7B71084 (unmapped) instead of 0x7770000. This causes an immediate SIGSEGV when running the distributed binary locally.

However, the server runs a corrected version of the binary where the jmp correctly targets 0x7770000. The challenge description ("I hate it when something is not exactly the way I want it. So I just throw it away") hints that the author discarded the buggy version for the server deployment.

Confirming code execution:

Timing tests confirm execution on the remote server:

  • Baseline payload (no valid code): connection closes in ~0.16s (crash)

  • Infinite loop (eb fe): connection stays open indefinitely (code running)

  • nanosleep(3s) shellcode: connection closes after exactly ~3.16s (code running, then clean exit)

Exploit:

With confirmed shellcode execution, the exploit is straightforward โ€” send a compact execve("/bin/sh", NULL, NULL) shellcode (25 bytes, well within the 69-byte limit). The server uses an inetd-style setup where fd 0 and fd 1 are the TCP socket, giving us an interactive shell. The flag is at /home/user/flag.

Flag: ENO{GIVE_ME_THE_RIGHT_AMOUNT_OF_ATOMS_TO_WIN}


rev

Coverup

Description

We are given:

  • output/encrypted_flag.txt: base64(ciphertext_bytes):sha1(ciphertext_bytes)

  • output/coverage.json: Xdebug code coverage while encrypting the real flag.txt

  • encrypt.php: the encryption routine

Goal: recover the plaintext flag.

Solution

1) Understand the encryption

Inside FlagEncryptor::encrypt($plaintext):

  • A 9-byte printable key is generated (not provided).

  • For each plaintext byte P[i] with key byte K[i % 9]:

  1. A lookup-like function M() is applied to the key byte via a huge if/else chain:

    K2 = M(K)

  2. XOR with plaintext:

    X = P ^ K2

  3. Apply the same M() again to the XOR result:

    C = M(X)

The output bytes C are base64-encoded, and sha1(C) is appended.

Important detail: M() is not injective (many different inputs map to the same output). So you cannot uniquely invert C -> X without extra information.

2) Use coverage as an oracle for actual branch inputs

coverage.json includes line coverage for the giant if/else chains:

  • In the first chain, only the 9 if ($keyChar == chr(N)) branches corresponding to the actual key bytes are hit โ‡’ we recover the set of key byte values.

  • In the second chain, only branches for the actual X = P ^ K2 values are hit โ‡’ we recover the set of X values used during encryption.

From the provided coverage, the executed key bytes are:

[49, 61, 65, 68, 86, 108, 111, 112, 122] โ†’ "1=ADVlopz"

3) Narrow down X[i] per position using collisions + coverage

We decode the base64 to get ciphertext bytes C[i].

For each byte value c, compute all preimages Pre(c) = { x | M(x) = c } from encrypt.php.

Then for each position i:

X_candidates[i] = Pre(C[i]) โˆฉ X_set_from_coverage

In this challenge, 43/49 positions become unique, and only 6 positions have 2 candidates.

4) Recover key order and plaintext by backtracking

The key order matters (it repeats every 9 bytes), but coverage only gives the set of key bytes. We solve by backtracking with constraints:

  • Plaintext is printable ASCII

  • Prefix is ENO{

  • Suffix is }

This yields a small number of plaintext candidates due to M() collisions; the intended one is the readable:

ENO{c0v3r4g3_l34k5_s3cr3t5_really_g00d_you_Kn0w?}

The recovered ordered key is:

=pVz1AlDo

5) Solver code

Run: python3 solve.py

Hashinator

Description

challenge_final reads a string from stdin (minimum length 15) and prints 32-hex โ€œhashโ€ lines:

  • Line 0 is a constant for the empty prefix.

  • Line i (1-based) is the hash of the first i bytes of the input.

The provided attachments/public/OUTPUT.txt is the program output for the real (unknown) flag, so it contains the correct hash for every prefix of the flag.

Solution

Because we have the target hash for every prefix, we can recover the flag one byte at a time with an oracle brute force:

  • Let expected[i] be the 32-hex hash line for prefix length i (with expected[0] being the constant empty-prefix line).

  • For each position i (0-based byte index), try candidate bytes b and run the binary on:

    • recovered_prefix + b + filler

    • where filler is 'A' repeated so total length is max(15, i+1) (to satisfy the binaryโ€™s minimum length and ensure it prints line i+1).

  • Parse the binary output; when output line i+1 matches expected[i+1], the guessed byte is correct.

  • Repeat until all len(expected)-1 bytes are recovered.

Verification: run challenge_final once on the recovered flag and check that all printed hash lines match OUTPUT.txt.

Recovered flag:

ENO{MD2_1S_S00_0ld_B3tter_Implement_S0m3Th1ng_ElsE!!}

Solver code used (recover_oracle.py):

Opalist

Description

We are given an Opal implementation (challenge_final.impl) and a captured output string (OUTPUT.txt). The program reads one line from stdin, transforms it, and prints a โ€œweirdโ€ base64-like string. The flag format is ENO{DECODED OUTPUT}, so we need to recover the original input line that produced the provided output:

YnpYZVeGc45lc2VUZ05h

Solution

From challenge_final.impl:

  • f3 applies a fixed byte-to-byte substitution (f1) to each character of the input.

  • f8 repeatedly adds a constant q to every byte (mod 256). Over all indices, this is equivalent to adding one final global shift S to every byte, where each index contributes +i if the substituted byte at that index is even, otherwise -i (mod 256).

  • f13 base64-encodes the resulting byte sequence.

So the printed output is:

  1. base64-decode โ†’ shifted bytes r

  2. find S such that if b = r - S (mod 256), then S == sum_i ( i if b[i] even else -i ) (mod 256)

  3. b is the substituted plaintext; invert the f1 substitution to recover the original input

  4. wrap in ENO{...}

Code (exact solver used):

Running it prints the decoded string R3v_0p4L_4_FuN!, so the flag is:

ENO{R3v_0p4L_4_FuN!}

stack strings 1

Description

The binary prints some text, asks for a โ€œmember codeโ€, and prints either โ€œACCESS DENIEDโ€ or โ€œACCESS GRANTEDโ€. Most strings are generated at runtime (โ€œstack stringsโ€), so strings is not useful.

Solution

Disassemble attachments/stackstrings_med and focus on the only real function (the mmap/memcpy/read/write one).

Key observations from the disassembly:

  • It mmaps 0xbd bytes and memcpys a 0xbd-byte blob from .rodata at virtual address 0x20d0 (file offset 0x20d0).

  • The pretty banner/prompt strings are temporarily decoded with XORs to print, then re-obfuscated. The validation bytes at offsets 0x95+ are never modified.

  • The required input length is computed from one byte in that blob:

    • len = blob[0xb8] ^ 0x36

  • A 32-bit constant r15 is assembled from 4 blob bytes (after per-byte XOR โ€œunmaskingโ€):

    • r15 = (b9^0x19) | (ba^0x95)<<8 | (bb^0xc7)<<16 | (bc^0x0a)<<24

  • Then, for each position i, the code computes a target byte from:

    • a per-round pseudo-random byte derived from ebx and rotates,

    • XORโ€™d with blob[0x95+i],

    • and compares it to a similarly derived byte from eax, r15, and rotates after XOR with the userโ€™s input[i].

Because the final compare is dl_pre ^ input[i] == sil_pre ^ blob[0x95+i], we can directly recover: input[i] = dl_pre ^ (sil_pre ^ blob[0x95+i]).

Running the solver below outputs the exact member code / flag.

Usage:

  • python3 solve.py

stack strings 2

Description

A stripped 64-bit ELF (attachments/stackstrings_hard) prints some text, asks:

  • "Do you speak the Stack Sigil?"

and replies NO. unless the correct 41-byte input is provided.

Solution

The binary stores a 0xFA-byte encrypted blob in .rodata (copied via mmap + memcpy). It decrypts its printed strings in-place with XOR/rotations, so strings wonโ€™t show anything useful.

The input check is fully deterministic and can be inverted.

1) Recover parameters from the blob

From the blob bytes (still in encrypted/original form):

  • Required length: n = blob[0xF4] ^ 0xA7 โ†’ n = 41

  • Seed byte: seed = blob[0xF9] ^ 0x77

  • 32-bit constant: r15 = ((blob[0xF8]^0x13)<<24) | ((blob[0xF7]^0x4B)<<16) | ((blob[0xF6]^0xD3)<<8) | (blob[0xF5]^0x3A)

  • Two per-position tables used during verification:

    • table_a2[i] = blob[0xA2 + i]

    • table_cb[i] = blob[0xCB + i]

2) Understand the verification loop

Let the secret input be pw[0..n-1]. The verifier runs for i = 0..n-1 with state:

  • edx = 0x9E3779B9 + i*0x9E3779B9 (32-bit wrap)

  • r9 = i*3

  • r10 = 0xA97288ED + i*0x85EBCA6B (32-bit wrap)

For each i, it computes:

  • An index idx_i (0..n-1) from edx, a rotate, a simple xorshift-mix, and table_a2[i].

  • A target byte expected_i similarly from edx and table_cb[i].

  • A per-round byte base_low from r10 ^ r15, a rotate by (r9&7), and another xorshift-mix.

Then it selects curr = pw[idx_i] and uses prev as the previous selected byte (seed on the first round). The key relation implemented by the assembly is:

  • expected_i == rol8(prev, 1) + (base_low XOR curr) (mod 256)

This is directly invertible:

  • curr = base_low XOR (expected_i - rol8(prev,1)) (mod 256)

So we can compute curr for every round, place it into pw[idx_i], and update prev = curr.

3) Script to recover the flag

Running the following script prints the recovered 41-byte string, which is the flag.

Flag

ENO{W0W_D1D_1_JU5T_UNLUCK_4_N3W_SK1LL???}


web

Meowy

Description

The service is a Flask cat gallery with an admin-only /fetch feature. The server runs with Werkzeugโ€™s debugger enabled. Goal: retrieve ENO{...} from the server.

Solution

  1. Forge admin session cookie (weak Flask secret_key).

  • The app sets app.secret_key to a single random word generated by random_word.RandomWords().

  • random_word defaults to the Local backend and chooses a random key from its bundled words.json.

  • Because we can obtain a valid Flask session cookie from / ({"is_admin": false}), we brute-force the secret key offline by trying all words.json keys with length โ‰ฅ 12 until itsdangerous verifies the cookie signature.

  • With the cracked secret key we sign a new cookie with {"is_admin": true} and access /fetch.

  1. Use /fetch for file read and internal SSRF.

  • /fetch uses pycurl and allows fetching file:// URLs (arbitrary file read as the web user).

  • Listing file:/// reveals /flag.txt exists but is not readable by the web user, and /readflag exists as an executable that can output the flag.

  • /fetch can also reach internal services. External port 5004 maps to internal 5000, so http://127.0.0.1:5000/console is reachable.

  1. Bypass Werkzeug debugger PIN trust and get RCE via gopher.

  • Werkzeugโ€™s debugger requires a โ€œtrustedโ€ cookie (__wzd...) that normally gets set by cmd=pinauth.

  • The app blocks cmd=pinauth, so we canโ€™t unlock through the normal endpoint.

  • But we can:

    • Compute the correct debugger trust cookie name and value (Werkzeugโ€™s get_pin_and_cookie_name + hash_pin) using data we can read via /fetch (/etc/machine-id, /sys/class/net/eth0/address, and /etc/passwd).

    • Inject that cookie into an internal HTTP request using gopher:// (raw request smuggling) through /fetch.

  • With the trust cookie present, Werkzeug accepts __debugger__=yes&cmd=<python>&frm=0&s=<SECRET> and executes Python in the console frame.

  • Execute os.popen("/readflag").read() to print the flag, then extract ENO{...} from the response.

Solver code (run locally):

Pasty

Description

The service creates โ€œpastesโ€ and returns a URL like view.php?id=<id>&sig=<sig>. Viewing requires a valid signature. The provided sig.php implements the signing algorithm.

Goal: forge a valid signature for id=flag to read the flag paste.

Solution

From attachments/sig.php, let:

  • H = sha256(d) (32 bytes) split into 4 blocks H0..H3 (8 bytes each)

  • m = sha256(key)[0:24] split into 3 blocks M0,M1,M2 (8 bytes each)

  • For each block i, the scheme selects Ci = M[ H[i*8] % 3 ] and outputs:

    • S0 = H0 xor C0

    • Si = Hi xor Ci xor S(i-1) for i>0

Because d (the paste id) is known and S is returned by the server, we can compute Hi and solve for Ci:

  • C0 = H0 xor S0

  • Ci = Hi xor Si xor S(i-1) for i>0

Each Ci is literally one of the three 8-byte blocks of m, so a single observed (id, sig) often reveals all M0..M2 (otherwise a few created pastes will). Once m is recovered, we can compute valid signatures for any id, including flag.

Solution code (runs the full attack and prints the view.php response):

CVE DB

Description

A web CVE โ€œdatabaseโ€ exposes a search form (POST /search) and renders results as HTML. One CVE entry (CVE-1337-1337) hints that it โ€œleaks some very confidential flagโ€, but the likely flag-containing fields (product / vendor) are not rendered in the template.

Solution

The backend implements โ€œsearchโ€ without SQL. The query parameter is ultimately evaluated inside a MongoDB JavaScript predicate (e.g. a $where-style expression) that uses a JavaScript regex literal like /<USER_INPUT>/.test(...). Because user input is inserted unescaped into a regex literal inside executable JS, we can break out of the literal and inject additional boolean conditions that reference non-rendered fields like this.product.

We use the HTML response as an oracle:

  • If our injected predicate is true for CVE-1337-1337, the page contains 1 rendered result.

  • Otherwise, it contains 0 results.

Injection pattern (conceptual):

  • Close the serverโ€™s regex literal, append our conditions, then open a new harmless regex literal to keep the overall expression syntactically valid.

A working payload for prefix-testing the hidden product field:

  • a/.test(this.description)&&this.product&&this.product.match(/^<prefix>/)&&/a

This makes the predicate true only when this.product starts with <prefix>. Repeating this test character-by-character yields the full product string, which is the flag.

Below is the complete extraction script used:

Running it recovers the flag:

ENO{This_1s_A_Tru3_S1mpl3_Ch4llenge_T0_Solv3_Congr4tz}

Web 2 Doc 1

Description

A Flask app converts URLs to PDFs using WeasyPrint 68.1. A protected /admin/flag endpoint returns 200 OK only when the correct flag character is guessed at a given index, otherwise 404. It requires the request to come from localhost (is_localhost(request.remote_addr)) and must NOT have the X-Fetcher: internal header.

The converter flow: user submits URL โ†’ server validates (blocks private IPs) โ†’ fetches HTML (adds X-Fetcher: internal) โ†’ passes to WeasyPrint โ†’ PDF returned. WeasyPrint loads sub-resources (images, CSS, fonts) through a custom url_fetcher that blocks private IP addresses.

Solution

Two key bypasses were needed:

  1. 0.0.0.0 bypasses the private IP filter: The custom url_fetcher uses Python's ipaddress module to check if resolved IPs are private. In Python versions before 3.11, ipaddress.ip_address("0.0.0.0").is_private returns False, yet connecting to 0.0.0.0 on Linux routes to the loopback interface (localhost). This bypasses the SSRF filter while still reaching the local Flask app.

  2. Flask runs on port 5000 internally: The external service is on port 5002 (likely behind a reverse proxy), but Flask's default port 5000 is the actual internal listener. Testing 0.0.0.0:5000 confirmed the oracle worked.

Oracle mechanism: WeasyPrint renders alt text for <img> tags when the image fetch fails (HTTP 404/error), but suppresses it when the fetch returns HTTP 200 (even if the response isn't a valid image). Since /admin/flag returns 200 for correct guesses and 404 for wrong ones, checking for the presence of alt text in the PDF reveals whether a character guess is correct.

Attack flow:

  1. Host an attacker server via ngrok that serves HTML pages with <img src="http://0.0.0.0:5000/admin/flag?i=N&c=X" alt="MISS">

  2. Submit the ngrok URL to the converter

  3. WeasyPrint loads the sub-resource image โ†’ url_fetcher sees 0.0.0.0 (not private) โ†’ allows request โ†’ connects to localhost:5000

  4. Extract PDF text: if "MISS" is absent, the character is correct

  5. Batch 15 characters per request with unique markers (M065, M066, etc.) to speed up extraction

Flag: ENO{weasy_pr1nt_can_h4v3_bl1nd_ssrf_OK!}

Web 2 Doc 2

Description

A URL-to-PDF converter service (WeasyPrint 68.1) identical to Web2Doc v1, but with the /admin/flag endpoint removed. The goal is to read /flag.txt from the server filesystem.

The service takes a URL, fetches the HTML content, and converts it to PDF using WeasyPrint. Direct file:// and localhost URLs are blocked at the application level, but WeasyPrint processes sub-resources from the fetched HTML using its default URL fetcher.

Solution

The key vulnerability is WeasyPrint's <a rel="attachment"> feature, which embeds referenced files directly into the PDF as attachments. When the HTML contains an anchor tag with rel="attachment" and an href pointing to a file:// URL, WeasyPrint's internal URL fetcher resolves and attaches the file โ€” bypassing the application's URL validation which only checks the top-level URL.

Attack flow:

  1. Host an HTML page on a public server containing: <a rel="attachment" href="file:///flag.txt">flag</a>

  2. Submit the public URL to the converter

  3. The app fetches the HTML (passes validation since it's a valid HTTP URL)

  4. WeasyPrint processes the HTML and encounters the attachment link

  5. WeasyPrint's default URL fetcher reads file:///flag.txt and embeds it in the PDF

  6. Extract the attachment from the PDF using pdfdetach

Exploit server (server.py):

Solve script (solve.py):

Extraction:

Flag: ENO{weasy_pr1nt_can_h4v3_f1l3s_1n_PDF_att4chments!}

WordPress Static Site Generator

Description

A web application at 52.59.124.14:5001 converts WordPress export XML files into static HTML websites using Go's Pongo2 template engine. The goal is to read /flag.txt from the server.

The app has two steps:

  1. Upload a WordPress XML file (stored on disk with a session-linked UUID)

  2. Generate a static site by selecting a template name (loads templates/<name>.html via Pongo2)

Solution

The vulnerability is a Pongo2 Server-Side Template Injection (SSTI) combined with path traversal on the template parameter.

Key observations:

  • The template parameter constructs the path templates/<user_input>.html โ€” path traversal is not filtered

  • Uploaded files are stored at uploads/<session_id>/<original_filename> and the filename is user-controlled

  • The upload filename can have a .html extension, matching what the template loader appends

  • The session cookie (Go gorilla sessions, base64-encoded) contains the id (upload directory UUID) and uploaded_file name

Attack chain:

  1. Upload a file containing Pongo2 template code {%include "/flag.txt"%} with filename evil.html

  2. Decode the session cookie to extract the upload UUID

  3. Use path traversal in the template parameter (../uploads/<uuid>/evil) to load the uploaded file as a Pongo2 template

  4. Pongo2 executes {%include "/flag.txt"%} and returns the flag

Flag: ENO{PONGO2_T3MPl4T3_1NJ3cT1on_!s_Fun_To00!}

Virus Analyzer

Description

A web application ("Virus Analyzer") at 52.59.124.14:5008 accepts ZIP file uploads, extracts them, and serves the extracted files. No source code was provided. The server runs PHP 8.3.30 on PHP's built-in development server.

Solution

Reconnaissance: The application accepts ZIP uploads, extracts them to /uploads/{random_hash}/, and lists the extracted files with download links. The X-Powered-By: PHP/8.3.30 header identifies the backend. The 404 page format confirms PHP's built-in development server (php -S).

Identifying the vulnerability: Uploading a .php file inside a ZIP showed it in the file listing, but accessing it returned a 404 from the built-in server. Testing other extensions (.txt, .html, .xml, etc.) all worked fine - only .php was blocked.

The source code (recovered after exploitation) revealed the "safety measure":

This uses find -name '*.php' to delete PHP files after extraction. On Linux, glob matching is case-sensitive, so *.php only matches lowercase .php files.

Exploitation: PHP's built-in development server treats .PHP, .Php, .pHP (any case variation) as PHP files and executes them. By uploading a webshell with an uppercase .PHP extension, the deletion command misses it while the server still executes it as PHP.

Flag: ENO{R4C1NG_UPL04D5_4R3_FUN}

Last updated