SKIP TO MAIN CONTENT

[ WRITEUP NODE / FIELD REPORTS ]

SOLVED CHALLENGES & FIELD ANALYSIS

SECURITY RESEARCH KNOWLEDGE TECHNIQUES
B3S/WRITEUPS/MIDNIGHT-PARCEL-CRYPTO
← BACK TO ARCHIVE
EVENT: Safaricom PwnZone 2.0 Qualifiers 2026CATEGORY: CryptographyPOINTS: 450 PTS

Midnight Parcel

AUTHORED BY:@bealthguy10/5/2026

Midnight Parcel -450MPTS

The streets are quiet, the lights are fading, and one delivery is still waiting to be collected.

Plus http://54.72.82.22:8440

The landing page presents itself as a simple parcel collection desk. Two cards are offered: a collection desk carrying a download link for parcel.json, and a desk console that accepts GET, POST and PATCH requests to local paths. The help text beneath the console documents GET /api/parcel and POST /api/receipt with a hex parcel string.

parcel.json was:

{"parcel":"f989a62eb693b12abd793ff509201480688cd318f1c25ed237a11f60d4205a8833b10d61c5a53091ba32fb8d86afae986ebbb995cb7abcca4f7f9d50047b69be3276bce6e68c835f2e494b283e13c4c1929896ee8533244aeea21df7f1677c43"}

96 bytes after hex decoding. That is 6 blocks of 16, which is consistent with AES-CBC.

GET /api/parcel returned the same blob. GET /api/receipt returned {"status":"damaged"}. POST /api/receipt with the original parcel returned {"status":"pending"} with HTTP 202. Every other input produced {"status":"damaged"} with 422. The status field therefore gives a clean valid versus invalid oracle, which is what makes a padding oracle attack practical here.

Checking the available methods returned little beyond what was already visible. OPTIONS on both endpoints listed GET, POST and PATCH. PATCH behaved identically to POST in this case. No other surface presented itself, so attention turned to the receipt oracle.

The last hex digit was flipped first, which produced a damaged status. The entire last byte was then brute-forced across all 256 values using a small script carrying Firefox headers and a thread pool, since single requests were slow. Two values returned a pending status: the original 43 and 29. Every other value returned damaged.

That result is ambiguous at first reading. In CBC mode P_last = Dec(C_last) xor C_prev_last, so modifying the last byte of the last ciphertext block randomises the entire final plaintext block and yields roughly one hit in 256. Two hits are possible by chance. Modifying the last byte of the preceding block, by contrast, flips only the final plaintext byte, so exactly one hit should correspond to a padding byte of 01. Byte 79, the last byte of block 4 and thus the control byte for the last byte of block 5, was tested next. Two hits appeared again: c1 (the original) and c2. These differ by 0x03, which is 0x01 xor 0x02. That is the classic signature of an original padding value of 02 02. One of the two hits is the genuine 02 02, the other is a forged 01.

Flipping the second-to-last byte confirmed the distinction: the forged 01 remained valid while the original 02 02 broke. The final two plaintext bytes were therefore 02 02.

Padbuster was attempted first, since it is the usual tool for this class of attack, with the hex sample embedded in a JSON POST body and damaged as the error string. It hung and was abandoned in favour of a purpose-built loop. For each target block the parcel is truncated so that the target block becomes last, after which the preceding block is forged byte by byte from the end:

pad = 16 - pos
forged[j] = intermediate[j] xor pad   for j > pos
brute-force forged[pos] until status is pending
intermediate[pos] = forged[pos] xor pad
plain[pos] = intermediate[pos] xor original_prev[pos]

Where two hits occurred on the first byte, the second-to-last-byte flip technique selected the 01.

Block 5 was processed first:

intermediate: 1f41d9d3dfb8ba671c7c79105c6ec6c3
plain: 2d3765353934393832353238627d0202 -> b'-7e594982528b}\x02\x02'

The block ends with } followed by 02 02, consistent with the tail of a flag. Block 4:

plain: 622d373930332d346634652d62343261 -> b'b-7903-4f4e-b42a'

Block 3:

plain: 3a207361666374667b38613939653662 -> b': safctf{8a99e6b'

Block 2:

plain: 6576656e696e672064656c6976657279 -> b'evening delivery'

Block 1:

plain: 5265636569707420666f722074686520 -> b'Receipt for the '

Concatenating blocks 1 through 5 and stripping the two padding bytes yields:

Receipt for the evening delivery: safctf{8a99e6bb-7903-4f4e-b42a-7e594982528b}

Block 0 is the IV, so nothing further is recoverable by this technique. The middle segment 8a99e6bb-7903-4f4e-b42a-7e594982528b follows a UUID shape of 8-4-4-4-12 hex groups, which is consistent with the surrounding content.

The intermediates and the full plaintext were saved under loot/, the request logs under evidence/, and the decrypt script under payloads/ for reruns.

An early assumption that this decrypted text was the flag itself proved incorrect. It is an intermediate token. The page script contains a fallback path that POSTs {"answer": ...} to /submit, an endpoint the desk console text does not advertise. Posting the decrypted token there returns the actual flag:

POST /submit
Content-Type: application/json

{"answer":"safctf{8a99e6bb-7903-4f4e-b42a-7e594982528b}"}

Response, saved under evidence/submit_response.json:

{"message":"safctf{f895fa37be9a374582ff7694a4748862}","ok":true}

Flag:

safctf{f895fa37be9a374582ff7694a4748862}

The reusable lesson from this exercise concerns truncation rather than tool choice. To decrypt block N, only blocks 0..N are sent, so that N becomes the last block and the padding check applies to it. Once that approach was in place each byte took roughly 15 seconds, and the plaintext emerged progressively across all six blocks.