The challenge
"Analyze a challenge-signed secondary DEX and recover the module contract."
| answer_id | Question | Format |
|---|---|---|
answer |
"For case MOB-20-… in package com.huntress.mobile.d20.…: Analyze a challenge-signed secondary DEX and recover the module contract. Submit only the exact backend answer code returned for this case." | pattern: "ANS- followed by exactly 16 uppercase hexadecimal characters; no spaces or explanatory text"; exact |
Mastery asked it once per APK (set1.answer … set5.answer), each for a case from an earlier day.
Reading the prompt: what it said we'd need
| Clue | What it pointed to | What I did about it |
|---|---|---|
| "secondary DEX" | Code shipped inside the APK but outside classes., loaded at run time |
Read the puzzle asset: its data field is the path assets/, not ciphertext |
| "Loaded Later" (title) | Dynamic code loading; the codec is literally named dynamic |
Parsed the second DEX statically instead of loading it |
| "challenge-signed" | The APK's signing manifest lists a digest for that DEX | Checked the META- SHA-256 digest against the DEX bytes before trusting it |
| "recover the module contract" | Constants inside the module class | Read the class's encoded static values: ANSWER (the code) and CONTRACT (a description) |
| "backend answer code" | An ANS- token, as on every Android day |
Required exactly one ANS- plus 16 hex reading |
Safety
APKs are hostile: static analysis only, on our Kali VM. The APK was never installed or run, and the secondary DEX was parsed as bytes, never loaded. classes.dex builds a loopback "evidence" URL from the answer and case ID; it was not contacted (not needed, and not in scope).
What was in the files
- Single Solve:
loaded_later.apk, 12,768 bytes. assets/huntress_puzzle.json: schemahuntress.mobile.puzzle.v1, day 20, codecdynamic, dataassets/payload.dex.assets/payload.dex, 508 bytes: a valid DEX 035 (Adler-32 checksum, SHA-1 signature, and file size all match). One class,Lcom/huntress/arena/payload/Module;, with twopublic static final Stringfields:ANSWER(ANS-ED49…C9FF) andCONTRACT(DAY20-SIGNED-MODULE:secondary DEX loading).META-INF/MANIFEST.MF: the SHA-256 digest forassets/payload.dexmatches the file, as do the other entries'. Signature blockMETA-INF/FIXTURE-.RSA.classes.dex:LoadedLaterwithCODEC="dynamic",CONTROL="dynamic_dex",DAY=20; noANS-string.- Time Trial:
loaded_later_time_trial.apk, 12,768 bytes: the same template (codecdynamic,payload.dex) with a different answer. - Mastery: five APKs (
delivery 1:…delivery 5:), one per earlier codec: Day 4xor, Day 7caesar, Day 10split, Day 15native, and Day 20dynamic. Only the fifth had apayload.dex.
Single Solve
The solver started it and stopped cleanly: "unknown puzzle codec 'dynamic' (day 20): fallback found 0 ANS readings; not trusted; not submitting". The Day 17 fallback tries its transforms on data, and today data was a file path, so there was nothing to find. The timed modes were left for Claude.
A Claude helper agent read the APK statically (above). There was one candidate, ANS-ED49…C9FF: the ANSWER field verbatim and the only ANS- string in the APK. It then resumed the solver's attempt (the start reply returned the same attempt and started_at) and sent it.
started_at 2026-10-20T13:00:45.112Z (started by the solver; resumed by Claude at 13:08:34)
deadline none (Single Solve)
answers 1 ANS-ED49…C9FF
artifact 393 ms
solve 1 ms
submit 721 ms
TOTAL 7 min 50 s from the solver's start reply to CORRECT (analysis and solver)
result CORRECT flag 70eac34a…fd5f (first send, nothing rejected)
Codec rule (dynamic): data names an APK member. Read it; require a valid DEX (magic, Adler-32, SHA-1, file size) whose MANIFEST.MF digest matches when the manifest lists it; parse its string, type, field, and class tables and the class's encoded static values; return the ANSWER static String field, which must match ANS-[0-9A-F]{16} exactly once.
Our solver: loads the Day 19 Android solver by its own path, so every older codec is unchanged. It adds a read-only DEX parser and the dynamic codec above. An unknown codec whose data names an APK member (.dex, .jar, .zip, .apk, .bin) is routed here too. If the member isn't a DEX, one layer of the Day 17/19 transforms (with the asset's own key fields) is tried, and only a fully valid DEX counts; a jar or zip member is opened for classes*.dex. It stops without sending on a digest mismatch, a bad DEX checksum, signature, or size, or zero or several ANSWER readings.
Tests (14, all pass with pytest): a minimal DEX builder; signed and unsigned payloads; digest mismatch; bad checksum; a decoy field; no ANSWER; two values stop; XOR- and Base64-wrapped payloads; a jar payload; unknown-codec routing; a missing member; an old codec with delivery N: Mastery names; the saved Single APK.
Offline checks and rehearsal (before any timed start): all 227 saved Android APKs gave 226 answers identical to the Day 19 solver and 1 newly solved (today's), 0 changed. A regression rehearsal against saved attempts from earlier days, run under the scheduled job's own environment, covered 113 attempts: 110 identical, 3 newly solved, 0 changed, per-set identical. It passed, and the new solver became the one the scheduled job uses.
Time Trial
CORRECT on the first attempt, ranked (50 s clock). The start reply showed unique_attempts_remaining: 31.
started_at 2026-10-20T13:09:02.827Z
deadline 2026-10-20T13:09:52.827Z (50 s)
answers 1 ANS-6934…00F8
start done +122 ms after started_at (phase length not recorded)
artifact 646 ms
solve 1 ms
submit 858 ms
TOTAL not recorded; submit +1.63 s after started_at
result CORRECT flag ed389c73…f6ec
Mastery
CORRECT on the first attempt, ranked (5 sets, 170 s clock).
started_at 2026-10-20T13:09:08.565Z
deadline 2026-10-20T13:11:58.565Z (170 s)
answers 5 (codecs xor, caesar, split, native, dynamic)
start done +133 ms after started_at (phase length not recorded)
artifact 709 ms
solve 2 ms
submit 1064 ms
TOTAL not recorded; submit +1.91 s after started_at
result CORRECT flag 16ba504e…87cc
Time to solve
All three modes were solved by 09:09:10 ET, 8 min 26 s after the solver's first start request at 09:00:44 ET. Almost all of that was the static analysis, new solver, tests, and rehearsal; the Single Solve went through at 09:08:35, and the ranked Time Trial and Mastery each went through on the first submit in under 2 seconds.
Takeaways
- A codec field can hold a pointer, not data. The Day 17 fallback assumed
datawas ciphertext; today it was the path of a file inside the APK. The solver now follows such paths for unknown codecs too. - Verify before trusting a secondary payload. The DEX's own checksum and SHA-1 plus the signing manifest's digest together show the bytes are the ones the app ships, before any field is read.
- Android was the day's last lane. The 8 minutes were the new DEX parser and its checks; the ranked runs took under 2 seconds each.