zwesty
Write-ups / Day 24 / Android
Day 24 · Sat, Oct 24 · Android

Emulator Alibi

Prey slaughtered

APK assets, DEX reading (baksmali, jadx), a bounded Dalvik interpreter, secondary DEX loaded from assets, JNI native code (x86-64), mixed-control Masteries

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-24-emulator-alibi (+ /time_trial, /mastery)
Solved
2026-10-24: Single Solve and Time Trial CORRECT on the first submit (09:00:51 and 09:00:58 ET, the Time Trial ranked). The ranked Mastery got 7 of 8 and expired (one set left out, nothing sent wrong). One untimed retry was CORRECT at 09:11:15 ET, 10 min 26 s after the first start request on this lane (09:00:49 ET)
Player
Zwesty (solo), with Claude Code (Opus 5.5): our automated morning solver (a scheduled script on our Kali VM) did the Single Solve, the Time Trial, and the ranked Mastery; a Claude helper agent read the stopped sets statically, extended the interpreter, ran the checks, and sent the untimed retry

The challenge

"Defeat app-owned environment checks on the same pinned emulator used by the solve oracle."

answer_id Question Format
answer "For case MOB-24-D222D6F58D in package com.huntress.mobile.d24.vd222d6f58d31: Defeat app-owned environment checks on the same pinned emulator used by the solve oracle. 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 (8 APKs, 8 answers).

Reading the prompt: what it said we'd need

Clue What it pointed to What I did about it
"app-owned environment checks" Emulator-detection code inside the app (EmulatorAlibi.CONTROL = "emulator_detection") Read statically: emulatorScore(s1,s2,s3) over Probe loops is pure arithmetic, so the Day 23 interpreter already evaluated it. Nothing had to be "defeated" at run time
"the same pinned emulator used by the solve oracle" The environment is fixed, so the answer is deterministic; and the emulator's ABI is x86_64 When a set needed a native library, only the lib/x86_64/ build was modelled
"the exact backend answer code returned" The code the app would post to its backend The answer is what Release.code() computes, as on Day 23
"ANS- … 16 uppercase hex" (format) One 64-bit value printed as hex Every computed value had to match that shape before sending

Safety

Static analysis only. Nothing was installed or executed. The DEX files were read with baksmali and jadx on our Kali VM and the native library with objdump. The interpreter evaluates only what it models (int/long arithmetic, a few library calls, the asset and class-loading calls listed below, a register-only subset of x86-64) under a step budget; anything else stops it. The app's loopback backend was never contacted. The APKs stayed on the VM.

What was in the files

  • Single Solve: emulator_alibi.apk, 12,704 bytes, with the old v1 asset: {"schema":"huntress.mobile.puzzle.v1","day":24,"codec":"split",…}, the answer cut into three parts plus an order list. Our Day 20 v1 reader rebuilt it: ANS-70755D5A…1B05.
  • Time Trial: an APK of the same size with a v2 asset ("codec":"analysis", no data): the answer is the return value of Release.code(), evaluated in 60,199 interpreter steps: ANS-C8BBDD46…38C9.
  • Ranked Mastery: 8 v2 APKs for packages from Days 2, 6, 8, 11, 14, 18, 20, and 24. Seven were plain arithmetic (425 to 111,647 steps). Set 7 (Day 20, LoadedLater.CONTROL = "dynamic_dex") was 12,768 bytes and carried an extra assets/payload.dex.
  • Untimed retry Mastery: a fresh variant, Days 3, 6, 9, 10, 15, 17, 23, and 24. Set 5 (Day 15, jni_secret) was 12,772 bytes and carried lib/x86_64/libarena.so.

So the Single kept the v1 template, and the v2 Masteries mix earlier days' controls under today's wrapper. Two of them needed more than arithmetic.

Single Solve

The solver got it with no stop, using our existing Android solver from Day 23.

started_at  2026-10-24T13:00:50.145Z
deadline    none (Single Solve)
answers     1   ANS-70755D5A…1B05
start        396 ms
artifact     424 ms
solve          1 ms
submit       760 ms
TOTAL       1580 ms   (submit +1.27 s after started_at)
result      CORRECT   flag a52aac8f…5077

Time Trial

CORRECT on the first attempt, ranked.

started_at  2026-10-24T13:00:56.843Z
deadline    2026-10-24T13:01:46.843Z   (50 s)
answers     1   ANS-C8BBDD46…38C9   (Release.code in 60,199 steps)
start        473 ms
artifact     402 ms
solve         30 ms
submit       826 ms
TOTAL       1732 ms   (submit +1.35 s after started_at)
result      CORRECT   flag d48c7214…6940

Mastery

Ranked attempt: 7 of 8, expired

started_at  2026-10-24T13:01:03.703Z
deadline    2026-10-24T13:05:23.703Z   (260 s)
answers     7 of 8 (set7 not in the submit)
start         91 ms after started_at
artifact     618 ms after started_at
solve        739 ms after started_at
submit       957 ms after started_at   (submit call 218 ms; TOTAL 1422 ms)
result      accepted, remaining 1; the solver stopped this lane, so the attempt expired at the deadline

What went wrong. Set 7's token() passes through Core.lateModule14, which reads assets/payload.dex through Context.getAssets().open(…), wraps the bytes in a ByteBuffer, loads them with InMemoryDexClassLoader, and calls com.huntress.arena.payload.Module.share(long) by reflection. The Day 23 interpreter refuses any Context call, so it stopped on that set alone; the per-set solve sent the other seven.

Nothing was burned. The submit in the request log has 7 keys and no set7.

The fix, part 1: the payload DEX (helper)

The helper built a new solver on top of the Day 23 one, keeping its interpreter and only adding: - const-class, Class[]/Object[] arrays, Long.TYPE/Integer.TYPE; - a model of the asset path: Context.getAssets → AssetManager.open (the asset read from the APK zip, its MANIFEST.MF SHA-256 checked) → InputStream.read/close → Arrays.copyOf → ByteBuffer.wrap; - InMemoryDexClassLoader (the second DEX parsed with the same checked parser) → loadClass → getDeclaredMethod (static, primitive parameters only) → Method.invoke, run as a child interpreter on the same step budget; - Long.valueOf/longValue, String.indexOf/substring. Any other Context call still stops.

Set 7 of the failed Mastery: Release.code in 325 steps, equal to a hand transcription of the jadx output (the payload's Module.share included).

Untimed retry: a second unseen control

We allowed ourselves one untimed retry of a failed first timed run, so the helper used retry in untimed (unranked) mode. The retry's fresh variant had a different mix, and its solve stopped before submitting on set 5: NativeLibrarySecrets.<clinit> calls System.loadLibrary("arena"), and the token passes through static native long nativeShare(long) in lib/x86_64/libarena.so (not stripped). Short, defanged objdump excerpt: mov rax,rdx; imul rax,<const>; rol rax,0x34; add …; ret.

The fix, part 2: the JNI function (helper)

  • System.loadLibrary loads lib/x86_64/lib<name>.so from the APK (manifest digest checked).
  • A static call to a DEX method with no code is resolved as the JNI symbol Java_<mangled class>_<method> in the ELF64 symbol tables and evaluated by a small x86-64 evaluator: capstone decoding plus a model of x86-64 register arithmetic only (mov, add/sub/xor/and/or, imul, rotates and shifts, not/neg, bswap, lea without memory, push/pop, ret). Memory operands, branches, calls, 8/16-bit writes, and any read of JNIEnv*/jclass stop it. Only the x86_64 build is used (the pinned emulator's ABI).
  • Set 5: equal to a hand transcription of jadx plus objdump (248 steps).

The attempt was still open and untimed, and nothing had been sent on it, so it was resumed. That is the same retry, not a second one.

Checks before the submit: - 20 tests pass: the set 7 and set 5 hand transcriptions; the Day 23 solver stops on both; the failed Mastery keeps its 7 accepted answers; one-APK streamed delivery; tampered or missing payload.dex and libarena.so stop; the shared budget; the x86-64 model (zero-extension, memory/branch/call/JNIEnv stops); JNI name mangling. - A regression check against every answer the Game Master had already accepted: 301 accepted Android answers reproduced, 0 mismatched, 0 stopped. - A regression rehearsal against the previous solver: 139 attempts, 137 identical, 2 newly solved (both Day 24 Masteries), 0 changed, slowest 0.19 s.

started_at  2026-10-24T13:07:39.705Z   (retry, untimed)
deadline    none
answers     8
first pass  stopped in the solver on set5 before submitting; fixed; same attempt resumed at 13:11:12
start       +213.95 s after started_at   (resume)
artifact     387 ms
solve        202 ms
submit       766 ms
TOTAL       ~1.35 s for the resumed pass   (submit +215.30 s after started_at)
result      CORRECT   flag 5742e932…eaf1

This Mastery has no ranked time. Afterwards this solver became our default Android solver for the following days.

Time to solve

All three modes were solved by 09:11:15 ET, 11 min 15 s after release (10 min 26 s from the first start request on this lane). The Single Solve and the ranked Time Trial went through on the first submit; the ranked Mastery expired at 7 of 8, and the Mastery was then solved on an untimed (unranked) retry.

Takeaways

  • v2 Masteries replay older days' controls. Today's control was plain arithmetic, but the Mastery sets came from Days 2–24, including the Day 20 DEX reflection and the Day 15 JNI secret. Pre-build static models for every earlier control and test them inside a v2 wrapper.
  • Still not seen in a v2 Mastery: the Day 1, 5, 7, 13, 16, 19, and 21 controls. Those are the next pre-builds.
  • Leaving a set out is better than guessing it. The solver sent the 7 it could prove and burned nothing.
  • A clean stop inside an untimed retry can still be saved. The retry's solve stopped before submitting, so the same attempt was resumed after the JNI fix instead of using a second retry.
  • Model only what the code uses, and stop on the rest. The x86-64 model handles register arithmetic only; that was enough for nativeShare, and anything wider stops cleanly.