Pragyan 2026 — Plumbing Writeup

A shipped Docker image whose final container looks spotless. Peeling back the OCI layers exposes the build history, a deleted state file hidden behind a whiteout, and an original output the developer thought they overwrote. Two leaks invert a custom block cipher and hand back the flag.

5 min read
ibrahimsql
932 words

Pragyan 2026 — Plumbing

A container-forensics challenge. We are given an already-built, already-shipped Docker image. The running container looks clean, but the image itself is a history book: every layer keeps what was copied, and every build command keeps what was typed — including the secret that was supposed to be piped through a script and forgotten.

  • Event: Pragyan 2026
  • Category: Forensics
  • Artifact: app.tar
  • Flag format: p_ctf{...}

1. What is inside the tar

Text
$ file app.tar
app.tar: POSIX tar archive
$ tar -xf app.tar -C x && find x -maxdepth 2
x/oci-layout
x/blobs/sha256
x/manifest.json
x/index.json

It is an OCI image layout — the on-disk format docker save / ctr image export produce. The layout is simple:

  • index.json — points at a manifest for app:latest
  • manifest.json — lists the config blob and the layer blobs
  • blobs/sha256/… — the actual content-addressed blobs

2. The config blob is the build history

The config blob (b3f4caf1…, referenced by manifest.json as Config) is the image configuration. Its history array records every Dockerfile instruction as created_by, exactly as docker history --no-trunc would show:

Bash
$ jq '.history[].created_by' <(cat blobs/sha256/b3f4caf1*)
...
"COPY process.py . # buildkit"
"COPY env /app/.env # buildkit"
"RUN /bin/sh -c echo \"p_ctf{d0ck3r_l34k5_p1p3l1n35}X|O\" | python3 process.py # buildkit"
"RUN /bin/sh -c rm /tmp/state_round7.bin # buildkit"
"RUN /bin/sh -c echo \"uhh it was here ;-;\" > /app/output.bin # buildkit"
"COPY DEV_NOTES.txt . # buildkit"
"COPY process2.py /app/process.py # buildkit"

There it is. The secret was typed into a RUN line. Removing a file in a later layer does not remove the command that created it — the history is permanent, and the flag is sitting in it.

The history also tells the whole cover-up story: a debug file state_round7.bin was deleted, the output was overwritten with a taunt (uhh it was here ;-;), a "clean" process2.py was copied over the real process.py, and DEV_NOTES.txt reads like a to-do list of hiding the evidence.

3. Recovering the deleted files

Deleting a file in Docker does not erase it; it adds a whiteout entry (a zero-byte .wh.<name> file) in the newer layer, while the real bytes stay in the older layer's tarball. Unpacking every layer shows exactly that:

Text
layer 7 : /app/output.bin        /tmp/state_round7.bin      <- the real data
layer 8 : /tmp/.wh.state_round7.bin                          <- whiteout (delete)
layer 9 : /app/output.bin                                    <- overwritten taunt
layer 10: /app/DEV_NOTES.txt
layer 11: /app/process.py                                    <- process2.py (empty)

Two files survived in layer 7 that later layers tried to erase:

Bash
$ xxd layers/7/tmp/state_round7.bin
00000000: 0b24 7a67 2f16 302b 273e 376f 282e 6a53   .$zg/.0+'>7o(.jS
$ xxd layers/7/app/output.bin
00000000: 2f62 3335 7758 7631 7438 4f02 3a71 2960   /b35wXv1t8O.:q)`

state_round7.bin is 16 bytes, output.bin is 16 bytes. Both are the leftovers of the build-time execution.

4. Reading the original script and key

The real process.py (from layer 5, before it was overwritten) is a toy block cipher:

Python
KEY = load_key()          # from /app/.env : AES_KEY=THIS_IS_AES_KEY!
ROUNDS = 10
DEBUG_ROUND = 7
 
def xor_block(a, b):
    return bytes(x ^ y for x, y in zip(a, b))
 
def permute(block):       # rotate left by 4 bytes
    return block[4:] + block[:4]
 
def encrypt_block(block, leak_debug=False):
    state = block
    for r in range(1, ROUNDS + 1):
        state = xor_block(state, KEY)
        state = permute(state)
        if leak_debug and r == DEBUG_ROUND:
            open("/tmp/state_round7.bin", "wb").write(state)   # the leak
    return state
 
data   = sys.stdin.buffer.read().strip()
block1 = data[:16]
block2 = data[16:32]
encrypt_block(block1, leak_debug=True)     # leaks round-7 state
open("/app/output.bin", "wb").write(encrypt_block(block2))

The .env copied into the image gives the key in the clear:

AES_KEY=THIS_IS_AES_KEY!

So: the first half of the secret leaks its internal state at round 7, and the second half is written, encrypted, to output.bin. Both halves are recoverable.

5. Inverting the cipher

Each round is state = rotate_left_by_4( state XOR key ). The inverse is the reverse order and reverse operation: state = rotate_right_by_4( state ) XOR key. With the leaked round-7 state we invert 7 rounds to get block 1; with the genuine ciphertext we invert all 10 rounds to get block 2:

Python
KEY = b"THIS_IS_AES_KEY!"
xor = lambda a, b: bytes(x ^ y for x, y in zip(a, b))
rot = lambda b: b[4:] + b[:4]        # forward permute
ror = lambda b: b[-4:] + b[:-4]      # inverse permute
 
def decrypt(state, rounds):
    for _ in range(rounds):
        state = xor(ror(state), KEY)
    return state
 
state7 = open("7/tmp/state_round7.bin", "rb").read()   # leaked at round 7
ct2    = open("7/app/output.bin", "rb").read()         # original ciphertext
 
block1 = decrypt(state7, 7)      # invert rounds 7 -> 1
block2 = decrypt(ct2, 10)        # invert rounds 10 -> 1
 
print(block1, block2)
# b'p_ctf{d0ck3r_l34'  b'k5_p1p3l1n35}X|O'

Re-encrypting block 1 and confirming it reproduces the leaked round-7 state (and re-encrypting block 2 to the stored ciphertext) proves the inversion is exact.

6. The flag

Stitching the two recovered halves:

print((block1 + block2).decode())
# p_ctf{d0ck3r_l34k5_p1p3l1n35}X|O
p_ctf{d0ck3r_l34k5_p1p3l1n35}

The X|O tail was just padding to fill the second 16-byte block. The flag matches the one embedded in the build history — the challenge's intended leak and its scaffolding agree.

7. Takeaways

  • RUN commands are immortal. A secret typed into a build step lives in the image config forever, no matter how many later layers rm it.
  • Deleting a file only writes a whiteout; the bytes remain in the layer that created them. docker save ships all of them.
  • .env copied into an image is not a secret store.
  • "The final container looks clean" says nothing about the layers underneath it. Always inspect history and every layer tarball, not the running filesystem.
---
Share this post:

What do you think?

React to show your appreciation

Related Posts