zwesty
Write-ups / Day 20 / Android
Day 20 · Tue, Oct 20 · Android

Loaded Later

Prey slaughtered

APK asset analysis, DEX file format (header checksums, string/type/field/class tables, static field values), JAR manifest digests

STTMClaude Homebase Both

Flags are shortened to their first 8 and last 4 characters because they're unique per team.

Challenge ID
mobile-android-day-20-loaded-later (+ /time_trial, /mastery)
Solved
2026-10-20: all three modes CORRECT on the first submit, by 09:09:10 ET, 8 min 26 s after our solver's first start request (09:00:44 ET). Both timed modes were ranked runs
Player
Zwesty (solo), with Claude Code (Opus 5.5): our automated morning solver (a scheduled script on our Kali VM) started the Single Solve and stopped; a Claude helper agent did the analysis, built the solver, and sent all three modes

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.dex, loaded at run time Read the puzzle asset: its data field is the path assets/payload.dex, 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-INF/MANIFEST.MF 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: schema huntress.mobile.puzzle.v1, day 20, codec dynamic, data assets/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 two public static final String fields: ANSWER (ANS-ED49…C9FF) and CONTRACT (DAY20-SIGNED-MODULE:secondary DEX loading).
  • META-INF/MANIFEST.MF: the SHA-256 digest for assets/payload.dex matches the file, as do the other entries'. Signature block META-INF/FIXTURE-.RSA.
  • classes.dex: LoadedLater with CODEC="dynamic", CONTROL="dynamic_dex", DAY=20; no ANS- string.
  • Time Trial: loaded_later_time_trial.apk, 12,768 bytes: the same template (codec dynamic, payload.dex) with a different answer.
  • Mastery: five APKs (delivery 1: … delivery 5:), one per earlier codec: Day 4 xor, Day 7 caesar, Day 10 split, Day 15 native, and Day 20 dynamic. Only the fifth had a payload.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 data was 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.