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

Hook Line and Signal

Prey slaughtered

APK assets, DEX reading (baksmali, jadx), static call-graph evaluation, a bounded Dalvik interpreter, decoy code

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-23-hook-line-and-signal (+ /time_trial, /mastery)
Solved
2026-10-23: Single Solve CORRECT (09:00:58 ET). The ranked Time Trial stopped on a new puzzle schema and expired with nothing sent. One untimed retry was CORRECT at 09:09:05; the ranked Mastery was CORRECT on its first run at 09:09:15. 8 min 20 s from the solver's first start request (09:00:55 ET) to the Mastery CORRECT
Player
Zwesty (solo), with Claude Code (Opus 5.5): our automated morning solver (a scheduled script on our Kali VM) did the Single Solve and stopped the ranked Time Trial; a Claude helper agent read the new APK statically, built the interpreter, ran the checks, and sent the retry and the Mastery

The challenge

"Separate real instrumentation signals from generated false positives."

answer_id Question Format
answer "For case MOB-23-40D151C015 in package com.huntress.mobile.d23.v40d151c0156f: Separate real instrumentation signals from generated false positives. Submit only the exact backend answer code returned for this case." pattern: "ANS- followed by exactly 16 uppercase hexadecimal characters"; exact

Time Trial asked the same for its own case; Mastery asked it once per APK (7 APKs, 7 answers).

Reading the prompt: what it said we'd need

Clue What it pointed to What I did about it
"instrumentation signals" Frida and hook detection code (method names like scanFridace, sealHook8c) Read them statically; nothing was hooked or run
"generated false positives" Decoy methods and constants that look important but aren't used Followed only the real call path from the answer method; the decoys are never called or read
"the exact backend answer code returned" The code the app would send to its backend The answer is what Release.code() computes, not a value stored in the APK
"ANS- … 16 uppercase hex" (format) One 64-bit value printed as hex The interpreter's result had to match that shape before sending

Safety

Static analysis only. Nothing was installed or executed. The DEX was read with baksmali and jadx on our Kali VM. Claude's interpreter evaluates only static methods doing int/long arithmetic and a few StringBuilder, String, and Long library calls, with a step budget; anything else (other library calls, instance fields, unknown opcodes) stops it. The app's loopback backend (127.0.0[.]1:19090) was never contacted; nothing serves it anyway. The APKs stayed on our Kali VM.

What was in the files

  • Single Solve: hook_line_and_signal.apk, 12,704 bytes. Its assets/huntress_puzzle.json was the old v1 schema: {"schema":"huntress.mobile.puzzle.v1","day":23,"codec":"xor","data":"EB8C…","key":81}, the Day 1–9 codec.
  • Time Trial: an APK of the same size with a new v2 asset: {"schema":"huntress.mobile.puzzle.v2","day":23,"codec":"analysis","build":"c4c2860ad9c2"}. No data, no key.
  • What the DEX does (from the static read, short and defanged):
  • ChallengeActivity: if Release.proof(ctx) equals the intent's answer, it shows "Evidence released: " + Release.code(ctx) and posts that code to the loopback backend.
  • Release.code() = "ANS-" + 16 hex digits of finSignal47(Mix.f1(token())) ^ K, where token() = mixFrida0c(scanFridace(mixFrida40(seed))). Pure long/int arithmetic; ctx is stored but never read.
  • scanFridace folds in three "signals" from Probe.s1/s3: brute-force loops over 0..4095 solving a small modular equation (((i*a+b)^c)&4095 == d, and a rotate form), plus instrumentationSignals = (a^b^c)&7.
  • The false positives: sealFridac7, sealHook8c, sealTrampolinef3 and the static KHook96, KPatcheb, KTrampolinea0 are never called or read on the code path.
  • Mastery: 7 APKs (12,704 to 12,845 bytes), all v2, for packages from Days 2, 4, 10, 12, 17, 22, and 23. So the timed variants used the v2 template with different arithmetic, while the Single Solve kept the old one.

Single Solve

The solver solved it with no stop, using the Day 20 Android solver's v1 XOR reader.

started_at  2026-10-23T13:00:56.109Z
deadline    none (Single Solve)
answers     1   ANS-75C47B4B…B1C1
start        332 ms
artifact     264 ms
solve          1 ms
submit      1858 ms
TOTAL       2456 ms   (submit +2.21 s after started_at)
result      CORRECT   flag 82bbd521…4555

Time Trial

Ranked attempt: stopped, nothing sent

started_at  2026-10-23T13:01:03.723Z
deadline    2026-10-23T13:01:53.723Z   (50 s)
answers     none
result      STOP: unexpected puzzle schema huntress.mobile.puzzle.v2; not submitting.
            The solver stopped this lane (it doesn't retry on its own); the attempt expired at the deadline.

This stop was correct. The chain only knew v1 assets, and a v2 asset holds no answer to decode, so any value sent would have been a guess.

The v2 rule and the solver

v2 rule: the answer isn't in the asset. It is the return value of the DEX's single (Context)String method that builds "ANS-". Evaluate its static call graph (arithmetic only) and ignore the decoy methods it never calls.

Our solver: the Day 20 Android solver unchanged for v1; for v2 (codec: analysis), a small read-only Dalvik interpreter over the DEX bytes, as described under Safety.

Bug on the way: the invoke branch reused the caller's method-index name, so the decode cache read the callee's code. It raised a TypeError on a wide-value marker: a clean stop, nothing sent. Fixed before any send.

Checks before sending: - 10 tests pass (pytest), including: a hand transcription of the jadx output for the Time Trial APK gives the same value as the interpreter; the Single Solve's v1 answer; a Mastery-shaped test (mixed v1/v2, and one APK streamed); a tampered DEX, an unknown v2 codec, and an exhausted budget all stop. - A regression rehearsal against the previous Android solver: 132 identical, 0 changed. - A regression check against every answer the Game Master had already accepted on the Android lane (284), checked against the request log: all reproduced, 0 mismatches.

Untimed retry: CORRECT

Claude then ran one untimed retry, using the Game Master's retry without asking for a ranked run, so the Time Trial is unranked. Release.code took 115,728 interpreter steps.

started_at  2026-10-23T13:09:04.605Z   (retry, untimed)
deadline    none
answers     1
retry         70 ms after started_at
artifact     304 ms
solve         65 ms
submit       847 ms
TOTAL       1216 ms from the retry reply   (submit +1.29 s after started_at)
result      CORRECT   flag ec076040…ddda

Mastery

CORRECT on the first attempt, ranked (230 s clock). All seven APKs were v2; they took 400 to 62,503 interpreter steps.

started_at  2026-10-23T13:09:13.523Z
deadline    2026-10-23T13:13:03.523Z   (230 s)
answers     7
start        126 ms after started_at
artifact     614 ms
solve        150 ms
submit      1056 ms
TOTAL       ~2.7 s   (submit +1.95 s after started_at)
result      CORRECT   flag 8df0a470…dc05

Afterwards a regression rehearsal over 135 saved attempts gave 132 identical, 3 newly solved, 0 changed, and all 292 accepted Android answers reproduced. At 09:10:00 ET the new solver became the one the scheduled job uses.

Time to solve

The Single Solve went through at 09:00:58 ET. The ranked Time Trial stopped on the new v2 schema and expired with nothing sent; after about 8 minutes of static reading, interpreter building, and checks, an untimed (unranked) retry went through at 09:09:05 ET. The ranked Mastery went through on its first submit at 09:09:15 ET, 8 min 20 s after the lane's first start and 9 min 15 s after release.

Takeaways

  • Timed sets can change format from the Single Solve. The Single was v1 and every timed APK was v2. A Single CORRECT doesn't prove the timed shape; solvers prepared ahead of the day should be tested on the newest Time Trial shapes too.
  • When the answer is computed, evaluate the code, don't run the app. A bounded interpreter for the narrow subset the method uses gave the answer without installing anything.
  • Make the interpreter stop on anything it doesn't understand. The one bug hit that stop, and nothing wrong was sent.
  • Cross-check one case by hand. The jadx transcription matching the interpreter was the test that made sending safe.
  • Decoys are named to be found. sealHook8c and KHook96 look central, but only the call graph from the answer method decides what matters.