NullconCTF 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:
The book text is from PG #19033, including the "Produced by..." credit header and a trailing newline (total 53597 chars).
Given the cipher (list of offsets), we can try all possible starting positions and decrypt. Each starting position yields a unique candidate password.
Most starting positions produce non-letter characters (spaces, punctuation), so we filter for candidates where all 32 characters are ASCII letters.
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
reducefunction stops one step early (whenbit_length <= 32instead of< 32), so we compute the proper remainder via an extragf2_mod(result, f)stepRandom 32-bit polynomials often share small factors, so we use
remove_common_factorsto extract the coprime part of each newfrelative 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:
Base64-encode the plaintext
Pad with
=to a multiple of 16 charactersFor each 16-character block, map characters to indices in a custom 65-character alphabet (
a-zA-Z0-9+/=), then computec = (A * m + b) mod 65where 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
AtoB(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:Solving
A * x โก (c - b) (mod 5)in GF(5)Solving
A * x โก (c - b) (mod 13)in GF(13)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 from0x63to0x64, creating a collision withS[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 ^ 42in 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:
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.This narrows each key byte to 2 candidates (2^16 = 65,536 total keys).
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:
Extract all
ENO{...}occurrences withpdftotext -layout.For each match, split the inner text on whitespace and join with
_.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:
Locate the embedded image:
Clean/uncompress PDF to inspect streams (optional):
mutool clean -d attachments/Planned-Flags-signed-2.pdf work/clean.pdfPage 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.
Extract that image from the PDF.
Apply a simple Wiener deconvolution with a Gaussian PSF to reverse the blur enough to read the repeated token.
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:
Extract the page 4 content stream.
Interpret only the needed PDF drawing operators (
q/Q,cm,re,f) to collect the centers of the repeated square modules.Map module centers to a 33x33 grid, render to a PNG (with a quiet zone).
Decode the QR code with
zbarimgto 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:
A string
sof 144 Unicode variation selectors (range 0xFE00โ0xFE0F) is decoded into 72 nibble-pairs, producing an arraytof 72 bytes.The input flag (UTF-8 encoded) must have length
t.length / 2 = 36.A generator
gen = 0x10231048is iterated per byte:gen = ((gen ^ 0xA7012948 ^ byte) + 131203) & 0xffffffff, and the result's low byte must matcht[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:
nullcongoaPool GUID:
0x1c52777b2293a712Vdev 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:
Object 61 data block checksums (Fletcher4)
L0 dnode block recompressed and checksummed
Meta-dnode indirect block recompressed and checksummed
MOS objset_phys checksummed
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):
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.1TXT 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:
Discovery: The app takes
names[]andamounts[]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>/.Path Traversal (LFI): The
view_receiptGET 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:
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).
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.
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.phprevealed 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(sofloatval()returns 0.01, the "admin fee")Stores the secret filename in a
.lockfile in the same directory
The basename() bypass via
.lock: Whilebasename()strips directory traversal (../../../../../flag.txtโflag.txt), it preserves dotfiles:basename('.lock')โ.lock. The.lockfile exists in the user's directory and is directly readable:
Reading the flag: Using the leaked secret filename from
.lockto 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 at0x40000000.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 forprintfarguments.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:
Using the format string to write a small
execve("/bin//sh", NULL, NULL)shellcode into the already-mapped RWX region at0x40000800.Overwriting
.fini_array[0](at fixed address0x403188) to point to0x40000800.When
mainreturns, 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 LOCALRemote:
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 0cmd5[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:
eaxargs:
ebx, ecx, edx, esi, edi, ebp
We only need read, open, write, exit:
read= 3write= 4open= 5exit= 1
3) Build a tiny int 0x80 program from 2-byte gadgets.
We implement:
read(0, esp, 0x20)to get a NUL-terminated filename from the socket/PTY.open(esp, 0, 0)read(fd, esp, 0xff)write(1, esp, eax)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:
reads a line,
stores a 4-byte โhashโ into an internal buffer at the current offset,
asks for the next offset (minimum
4), and when the next offset would go out of bounds it printsBuffer full!and jumps to the buffer, executing the stored hash-words as native code. A per-connection leak prints the runtime address ofwin().
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 foraN9keeps 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:
connects and parses the leaked
win()address,brute-finds the two preimage lines,
sends them with offsets
0and4,sets the next offset huge and sends one more line to trigger โbuffer fullโ execution,
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_prefixpython3 solve.py
asan-bazar
Description
The service is a small โbazaarโ program compiled with ASAN/UBSAN. It:
Reads a
Nameinto a stack buffer and then doesprintf(name)(format string bug).Lets you โupdateโ a 128-byte ledger with
read(0, ledger + slot*16 + tiny, bytes)whereslot <= 128,tiny <= 15,bytes <= 8(out-of-bounds write).
There is a win() function that runs /bin/cat /flag.
Solution
Leak PIE base (format string) Send a name like
LEAK|%8$lx|%77$lx|%79$lx|END:%8$lxreliably leaks an address insidegreeting()(soPIE = leak - greeting_off).Due to stack alignment, the saved return address of
greeting()ends up at either the 77th or 79th โargumentโ position forprintf, so we leak both.
Identify which leaked slot is the saved return address
maincallsgreetingatPIE+0xDC04D, and the return address right after the call isPIE+0xDC052. Compare the leaked%77$lx/%79$lxagainstPIE+0xDC052to choose the correct case.Overwrite
greeting()โs saved RIP using the OOB write The write primitive is:destination:
ledger + slot*16 + tinylength:
bytes(we use 8)
The offset from
ledgerto the saved RIP is either:0x178โslot=23,tiny=80x188โslot=24,tiny=8
Write the 8-byte little-endian address of
win()there. Whengreeting()returns, it jumps towin()and prints the flag.ASAN note (why this works) ASAN protects the
ledgerstack object with redzones, but the out-of-boundsread()can be aimed directly at the saved return address inmainโs normal stack frame (which is not poisoned by ASAN).__interceptor_readchecks 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:
Prints a banner:
== BUG ATOMIZER == \nMix drops of pesticide. Too much or too little and it won't spray.\nmmap(0x7770000, 0x1000, PROT_RWX, MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED, -1, 0)โ creates an RWX pageReads exactly 69 bytes (0x45) from stdin into the mmap'd page at
0x7770000Prints an "ok" message
Executes a
jmpintended to jump to0x7770000(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 realflag.txtencrypt.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 byteK[i % 9]:
A lookup-like function
M()is applied to the key byte via a hugeif/elsechain:K2 = M(K)XOR with plaintext:
X = P ^ K2Apply 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 ^ K2values are hit โ we recover the set ofXvalues 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 firstibytes 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 lengthi(withexpected[0]being the constant empty-prefix line).For each position
i(0-based byte index), try candidate bytesband run the binary on:recovered_prefix + b + fillerwhere
filleris'A'repeated so total length ismax(15, i+1)(to satisfy the binaryโs minimum length and ensure it prints linei+1).
Parse the binary output; when output line
i+1matchesexpected[i+1], the guessed byte is correct.Repeat until all
len(expected)-1bytes 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:
f3applies a fixed byte-to-byte substitution (f1) to each character of the input.f8repeatedly adds a constantqto every byte (mod 256). Over all indices, this is equivalent to adding one final global shiftSto every byte, where each index contributes+iif the substituted byte at that index is even, otherwise-i(mod 256).f13base64-encodes the resulting byte sequence.
So the printed output is:
base64-decode โ shifted bytes
rfind
Ssuch that ifb = r - S (mod 256), thenS == sum_i ( i if b[i] even else -i ) (mod 256)bis the substituted plaintext; invert thef1substitution to recover the original inputwrap 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 andmemcpys a 0xbd-byte blob from.rodataat virtual address0x20d0(file offset0x20d0).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
r15is 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
ebxand 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โsinput[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 = 41Seed byte:
seed = blob[0xF9] ^ 0x7732-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*3r10 = 0xA97288ED + i*0x85EBCA6B(32-bit wrap)
For each i, it computes:
An index
idx_i(0..n-1) fromedx, a rotate, a simple xorshift-mix, andtable_a2[i].A target byte
expected_isimilarly fromedxandtable_cb[i].A per-round byte
base_lowfromr10 ^ 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
Forge admin session cookie (weak Flask
secret_key).
The app sets
app.secret_keyto a single random word generated byrandom_word.RandomWords().random_worddefaults to theLocalbackend and chooses a random key from its bundledwords.json.Because we can obtain a valid Flask
sessioncookie from/({"is_admin": false}), we brute-force the secret key offline by trying allwords.jsonkeys with length โฅ 12 untilitsdangerousverifies the cookie signature.With the cracked secret key we sign a new cookie with
{"is_admin": true}and access/fetch.
Use
/fetchfor file read and internal SSRF.
/fetchusespycurland allows fetchingfile://URLs (arbitrary file read as the web user).Listing
file:///reveals/flag.txtexists but is not readable by the web user, and/readflagexists as an executable that can output the flag./fetchcan also reach internal services. External port5004maps to internal5000, sohttp://127.0.0.1:5000/consoleis reachable.
Bypass Werkzeug debugger PIN trust and get RCE via gopher.
Werkzeugโs debugger requires a โtrustedโ cookie (
__wzd...) that normally gets set bycmd=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 extractENO{...}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 blocksH0..H3(8 bytes each)m = sha256(key)[0:24]split into 3 blocksM0,M1,M2(8 bytes each)For each block
i, the scheme selectsCi = M[ H[i*8] % 3 ]and outputs:S0 = H0 xor C0Si = Hi xor Ci xor S(i-1)fori>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 S0Ci = Hi xor Si xor S(i-1)fori>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:
0.0.0.0bypasses the private IP filter: The customurl_fetcheruses Python'sipaddressmodule to check if resolved IPs are private. In Python versions before 3.11,ipaddress.ip_address("0.0.0.0").is_privatereturnsFalse, yet connecting to0.0.0.0on Linux routes to the loopback interface (localhost). This bypasses the SSRF filter while still reaching the local Flask app.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:5000confirmed 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:
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">Submit the ngrok URL to the converter
WeasyPrint loads the sub-resource image โ
url_fetchersees0.0.0.0(not private) โ allows request โ connects to localhost:5000Extract PDF text: if "MISS" is absent, the character is correct
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:
Host an HTML page on a public server containing:
<a rel="attachment" href="file:///flag.txt">flag</a>Submit the public URL to the converter
The app fetches the HTML (passes validation since it's a valid HTTP URL)
WeasyPrint processes the HTML and encounters the attachment link
WeasyPrint's default URL fetcher reads
file:///flag.txtand embeds it in the PDFExtract 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:
Upload a WordPress XML file (stored on disk with a session-linked UUID)
Generate a static site by selecting a template name (loads
templates/<name>.htmlvia 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 filteredUploaded files are stored at
uploads/<session_id>/<original_filename>and the filename is user-controlledThe upload filename can have a
.htmlextension, matching what the template loader appendsThe session cookie (Go gorilla sessions, base64-encoded) contains the
id(upload directory UUID) anduploaded_filename
Attack chain:
Upload a file containing Pongo2 template code
{%include "/flag.txt"%}with filenameevil.htmlDecode the session cookie to extract the upload UUID
Use path traversal in the template parameter (
../uploads/<uuid>/evil) to load the uploaded file as a Pongo2 templatePongo2 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