zwesty
Write-ups / Day 30 / Android
Day 30 · Fri, Oct 30 · Android

Layered Protections

Prey slaughtered

APK triage (DEX string table, assets), stacked encodings (Base64, a position-keyed XOR, byte reversal), recovering a key schedule from a known prefix, a branch-aware evaluator for a small x86-64 JNI function

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-30-layered-protections (+ /time_trial, /mastery)
Solved
2026-10-30: all three modes CORRECT, both timed modes ranked. Our automated morning solver (a scheduled script on our Kali VM) stopped on the new v1 codec layered and sent nothing. A Claude helper agent decoded it and sent the Single: CORRECT at 09:04:41.9 ET. Time Trial CORRECT at 09:05:06.6 (first submit). Mastery: first submit 13 of 14 accepted (set 8 stopped), then the same ranked attempt resumed inside its clock: CORRECT at 09:06:19.9. 5 min 8.9 s from the solver's first start (09:01:11.0) to the Mastery CORRECT; 6 min 19.9 s from the 9 AM release
Player
Zwesty (solo), with Claude Code (Opus 5.5): the automated solver started the Single and stopped; a Claude helper agent built the Day 30 solver, sent all three modes, and fixed the Mastery's native set

The challenge

The prompt opens with "Unwind obfuscation, native indirection, runtime decryption, and integrity checks", and asks for proof of the same backend effect.

answer_id Question Format
answer For case MOB-30-FFC25D0C0D in package com.huntress.mobile.d30.vffc25d0c0db7: the prompt's task, then "Submit only the exact backend answer code returned for this case." pattern: ANS- followed by exactly 16 uppercase hex characters; exact

The Time Trial asked the same for its own case; the Mastery asked it once per delivery (14 deliveries, 14 answers).

Reading the prompt: what it said we'd need

Clue What it pointed to What I did about it
"Unwind obfuscation" Peel off encodings one at a time The v1 asset's data is three layers deep: Base64, a position-keyed XOR, then a byte reversal
"runtime decryption" A key applied while the app runs The asset carries an integer key (12); the XOR mask for byte i is key + i
"native indirection" Answer logic in a native library Not in the Single. It mattered in the Mastery: set 8 was a Day 16 JNI control whose native code has branches
"integrity checks" Guards before the real computation The set-8 native function checks its arguments (cmp / jne) before the arithmetic; the old evaluator stopped there
"Layered Protections" (title) Several protections stacked Confirmed twice: stacked encodings in the Single, mixed earlier controls in the Mastery

Safety

Static analysis only. Nothing was installed or executed. The APKs were opened as ZIP files, the puzzle assets were read as JSON, the DEX was read with baksmali and string-table reads, and the set-8 native function was evaluated by an instruction-level model in Python, not run. Nothing went near an emulator or the app's loopback evidence URL. The APKs stayed on our Kali VM.

What was in the files

  • Single Solve: layered_protections.apk, 12,704 bytes. assets/huntress_puzzle.json is the old v1 schema with codec: "layered", a Base64 data string (20 bytes once decoded), and an integer key of 12. The DEX has three classes. LayeredProtections holds only CODEC = "layered", CONTROL = "layered_protections", DAY = 30, and describe(): no decode method, so the layers had to be read from the data. ChallengeActivity only forwards the answer and case id to a local evidence URL.
  • Time Trial: layered_protections_time_trial.apk, 12,704 bytes, not layered. It's a v2 analysis puzzle (asset day: 30) whose answer is computed by DEX code (classes LayerKit, PeelStack, Mix, Release, Util). It went through the Day 25 static interpreter's path.
  • Mastery: 14 deliveries, all v2 analysis, each an earlier day's control (from each asset's day):
Set Day Artifact
1–7 1, 3, 5, 8, 9, 13, 14 .apk, 12,704
8 16 (JNI) .apk, 12,772 (+ lib/x86_64/libarena.so)
9 20 (loaded later) .apk, 12,768 (+ assets/payload.dex)
10, 11 22, 24 .apk, 12,704
12 26 (split) .apks, 21,535 (base + feature)
13 27 .apk, 12,704
14 30 .apk, 12,704

Same pattern as Days 24–29: a v1 codec for the Single, v2 code-reading puzzles for the timed modes.

Single Solve

The automated solver: stopped, nothing sent

started_at  09:01:10.993 ET   (started by the solver)
deadline    none (Single Solve)
result      STOP: unknown puzzle codec 'layered' (day 30): fallback found 0 ANS readings; not trusted; not submitting
            timed modes not started

Finding the layers (Claude helper agent)

The DEX had no decoder, so the helper worked from the data and the known answer shape. The key move was lining the data's last 4 bytes up with -SNA, the ANS- prefix reversed: XOR-ing them gave 28, 29, 30, 31, which is 12 + i for positions 16–19. So, outermost first:

  1. Base64-decode the data text.
  2. XOR byte i with (key + step × i) mod 256, with step 1 on the Single.
  3. Reverse the byte order.

The solver

Today's solver builds on the Day 29 solver and adds layered. It tries a small fixed grid of layer orders (Base64 or hex text; XOR or subtract; reverse before or after the mask), derives the step from the ANS- prefix (byte 0 must give key exactly, bytes 1–3 must agree on one step), and accepts only when exactly one distinct reading fits ANS- + 16 uppercase hex. Anything else stops cleanly. Every other codec and schema goes to the Day 29 path unchanged.

  • Decoded answer: ANS-3F02…43CA (20 characters, fits the format).
  • Its tests: 11 of 11 OK, including the saved Single, an encoder round trip, other keys, steps and orders, a delivery 2: question, every stop case (wrong key, broken prefix, bad data, lowercase), and Days 28 and 29 unchanged.
  • A regression check against every answer the Game Master had already accepted: 574 Android answers reproduced, 0 mismatched, 0 stopped.
reconnected to the solver's attempt 09:04:41 ET
answers     1   ANS-3F02…43CA
start → submit   1.19 s   (submit +211.7 s after the solver's started_at)
result      CORRECT   flag a00dbfe8…bb4a   (09:04:41.935 ET)

Time Trial

CORRECT on the first attempt, ranked (variant_fresh: true). The v2 puzzle went through the existing interpreter with no new code.

started_at  2026-10-30 09:05:05.896 ET
deadline    2026-10-30 09:05:55.896 ET   (50 s)
answers     1
start        234 ms   (after started_at)
artifact     560 ms
solve        565 ms
submit      1770 ms
result      CORRECT   flag 917a64e9…b9ed   (09:05:06.572 ET)

Mastery

CORRECT, ranked, in one attempt, but not in one run.

First run (ranked, started 09:05:11.968 ET, 7 min 20 s clock): 13 of 14 accepted, set 8 left out. Set 8 is the Day 16 JNI control. Its native function guards the arithmetic with argument checks (cmp ecx, 0xffff / jle / movzx / cmp / jne), and Day 24's straight-line evaluator stopped on the first cmp ("unsupported instruction"). The run sent the 13 it had: accepted, remaining 1, nothing wrong sent.

Fix: a branch-aware evaluator we had already built and staged after a Day 24 Mastery hit the same Day 16 variant. The helper added it to today's solver. It is Day 24's evaluator plus four things: it follows the program counter by address, cmp and test set flags (any other flag-writing instruction makes them undefined, and a jump on undefined flags stops), conditional and plain jumps inside the function, and movzx/movsx. Straight-line code runs exactly as before. Offline: set 8 decoded, the other 13 matched the accepted ones, tests still 11 of 11.

Resuming: rerunning on the same open attempt resumed it inside its clock; it was not a retry.

same ranked attempt, resumed 09:06:18 ET
started_at  2026-10-30 09:05:11.968 ET
deadline    2026-10-30 09:12:31.968 ET   (440 s)
answers     14
artifact    +67.1 s after started_at
solve       +67.8 s
submit      +69.2 s
result      CORRECT   flag 9b286d1c…74c6   (09:06:19.906 ET), ranked

Afterwards

  • After the three CORRECTs, today's solver became our Android solver for later days. The regression check against accepted answers then stood at 589 reproduced, 0 mismatched, 0 stopped.
  • The native-branch fix lives in the Day 30 solver only. It was not added to the Day 24 solver itself.

Time to solve

All three modes were solved by 09:06:19.9 ET, 6 min 19.9 s after release and 5 min 8.9 s after the solver's first start; the Single came 3 min 30.9 s after that start. The Time Trial went through on the first submit, and the Mastery's first run left one set open and was resumed inside the same ranked clock, so both timed modes were ranked.

Takeaways

  • A known prefix is a key-schedule oracle. With no decoder in the DEX, lining up ANS- (reversed, at the end) against the data gave the key and the step in one step.
  • Accept exactly one reading. The grid of layer orders can produce more than one plausible decode; the solver sends only when one distinct answer fits the format, and stops otherwise.
  • Staged fixes pay off later. The branch-aware evaluator was written two days earlier for another day's Mastery and unblocked this one in about a minute.
  • An open ranked attempt resumes; it doesn't retry. Sending the 13 sure answers first and the fixed 14th later kept the Mastery ranked.
  • The prompt listed every protection, but the Single used only encodings. The "native indirection" and "integrity checks" showed up in the Mastery's mixed controls, not in today's own asset.