zwesty
Write-ups / Day 15 / Android
Day 15 · Thu, Oct 15 · Android

Native Library Secrets

Prey slaughtered

Static APK inspection, ELF parsing (sections, symbols), reading a JNI function's x86-64 disassembly, following a pointer into .rodata

STTMClaude Homebase Both

Flags are shortened to their first 8 and last 4 characters because they're unique per team. Answer codes are shortened the same way here.

Challenge ID
mobile-android-day-15-native-library-secrets (+ /time_trial, /mastery)
Solved
2026-10-15, all three modes; 10 min 49 s from first start to last CORRECT (09:00:40–09:11:29 ET)
Player
Zwesty (solo), with Claude Code (Opus 5.5): our automated morning solver (a scheduled script on our Kali VM) started it and stopped on a new codec; a Claude helper agent added the codec and finished all three modes

The challenge

Recover split configuration from DEX and a challenge-owned native library.

One question, answer: "For case MOB-15-… in package com.huntress.mobile.d15.…: Recover split configuration from DEX and a challenge-owned native library. Submit only the exact backend answer code returned for this case." Format: ANS- + exactly 16 uppercase hex characters, normalization exact. Mastery asks the same question five times (set1.answer to set5.answer), one per APK, named delivery N: native_library_secrets_mastery_0N.apk.

Reading the prompt: what it said we'd need

Clue What it pointed to What I did about it
"a challenge-owned native library" a .so bundled in the APK under lib/<abi>/, called from Java through JNI Listed the APK's members: one lib/x86_64/libarena.so. Parsed its ELF sections and symbols, then disassembled the two exported functions
"split configuration from DEX and … native library" part of the answer, or the key to it, in each place The DEX carries the labels (CODEC = "native", CONTROL = "jni_secret"); the library carries the value. The asset's data carries the same value again
"Recover" read it out, not run it All static: nothing installed, loaded, or executed
"backend answer code returned for this case" a runtime service As on every earlier day, the app points at a loopback URL that nothing serves; the code is in the files
ANS- + 16 uppercase hex, exact a strict check The solver checks the value against this format before sending, and sends it exactly as stored

The general method (the packaged puzzle asset) is in the Network Config Split write-up.

Safety

Static only, on our Kali VM. Each APK was opened as a zip in Python (python3 -I). The .so was read with readelf, objdump, and strings, and the DEX was turned into smali with baksmali to read its constants. Nothing was installed, loaded, or run, and the library was never dlopened. The APKs and extracted files stayed on the VM.

What was in the files

Single Solve APK (native_library_secrets.apk, 12,772 bytes, sha256 0cf5bc290da521e7…): AndroidManifest.xml, classes.dex, assets/huntress_puzzle.json (94 bytes), lib/x86_64/libarena.so (13,648 bytes), and a FIXTURE- signature. It is 68 bytes bigger than the Day 14 template (12,704).

  • The puzzle asset had the new codec: {"schema":"huntress.mobile.puzzle.v1","day":15,"codec":"native","data":"ANS-E28C…3B8F"}. So data already had the answer's shape.
  • libarena.so: an x86-64 ELF shared object built by GCC 13.3 from arena.c, not stripped. It exports two functions, huntress_arena_secret (17 bytes) and huntress_arena_day (15 bytes). The symbol table also names a local 21-byte object, arena_value, at 0x2000, the start of .rodata. The disassembly is tiny:
  • huntress_arena_secret: endbr64; push rbp; mov rbp,rsp; lea rax,[rip+0xff1]; pop rbp; ret. The lea points at 0x2000, so the function returns a pointer to arena_value.
  • huntress_arena_day: mov eax,0xf, so it returns 15.
  • .rodata holds ANS-E28C…3B8F and a NUL, the same value as the asset's data.
  • The DEX (baksmali): the class NativeLibrarySecrets holds the constants CODEC = "native", CONTROL = "jni_secret", and DAY = 15, and describe() returns DAY-15:jni_secret:challenge-owned JNI shared library. ChallengeActivity builds hxxp://127.0.0[.]1:19090/mobile/attempt/d15-…/evidence?case_id=… with a fixture client_secret=SECRET-…&proof= string. The SECRET-… value is a decoy for this question: wrong shape, and nothing serves that port.

So the native codec means: the answer is the string the JNI library's secret function returns. Two independent places agreed on it, the asset and the library.

What the timed variants changed: the Time Trial APK had the same template and size (12,772 bytes) with a new case ID, package suffix, and secret. The Mastery was compounding: five deliveries, and only the last was a Day 15 APK.

Set APK (sha256 first 16) Size Template Codec
1 7db4b9a394588ca3 12,704 Day 3 (intent_relay) base64
2 f5fea3c98289fb61 12,704 Day 7 (javascript_bridge) caesar, key 3
3 a103080f7fb12cf9 12,704 Day 9 (trust_manager) base64
4 ffa2e01552ca2be0 12,704 Day 13 (keystore_wrapper) base64
5 e7358b45db0421fa 12,772 Day 15 (jni_secret) native, with lib/x86_64/libarena.so

Single Solve

First try, by our automated morning solver (a scheduled script on our Kali VM): stopped cleanly. It started the attempt at 13:00:40 UTC, fetched and checked the APK, and our Android solver raised unknown puzzle codec 'native' (day 15): no decoder yet. Nothing was sent. That is the designed behaviour for a new codec.

The fix (Claude helper agent, about 10 minutes). After the analysis above, a new solver loads our existing Day 10 Android solver unchanged and adds only the native codec:

  • It opens every lib/<abi>/*.so in the APK in memory with a small pure-Python ELF reader (sections and symbols only).
  • It finds huntress_arena_secret and follows its return to the C string: x86-64 lea rax,[rip+d], or AArch64 adrp x0 + add x0, in case a variant ships an ARM build. If it can't follow the function, it falls back to the one ANS--shaped string in .rodata.
  • Every library and the asset's data must agree (whichever are present). A disagreement, two different values, or nothing found stops the run before anything is sent.
  • Other codecs go straight to the existing solver. A per-set mode lets the solver handle Mastery set by set under a clock.

Tests (12/12 on our Kali VM): synthetic x86-64 and AArch64 .so files built in the test, the .rodata fallback, every stop case, a Mastery delivery N: <file> case with per-set answers, the real Day 15 APK, and all 73 earlier saved Android attempts giving byte-identical answers to the existing solver. A regression rehearsal against the saved Android attempts checked 74: slowest 0.00 s, per-set identical, 73 identical, 1 newly solved, 0 changed. The rehearsal passed.

The answer was then sent through the solver's normal path. Its start reconnected to the same attempt, so started_at is still the morning solver's.

started_at  2026-10-15T13:00:40.194Z   deadline none (untimed)
answer      ANS-E28C…3B8F
timings     start 332  artifact 326  solve 1  ready 0  submit 945  TOTAL 1603 ms
vs started_at: start +603814, artifact +604140, ready +604141, submit +605086 ms
result      CORRECT  b98e264b…8061   (solved_by.at 13:10:44.574Z)

The value as stored was accepted first time, so there was no wording question.

Time Trial

The challenge list marked both timed routes as supporting ranked starts, so both were started as ranked runs. This one was ranked (hall_of_fame_eligible: True, 31 unique attempts left), unlike Days 13 and 14.

started_at  2026-10-15T13:11:18.755Z   deadline 13:12:08.755Z (50 s clock)
answer      ANS-6B57…5E4B   (native: library and data agree)
timings     start 615  artifact 310  solve 1  ready 0  submit 1197  TOTAL 2123 ms
vs started_at: start +113, artifact +423, ready +424, submit +1622 ms
result      CORRECT  7508702e…cd27   (solved_by.at 13:11:19.470Z)

Mastery

Five downloads, matched to their questions by the delivery N: rule from Day 10. Four sets used earlier codecs that the existing solver already knew; set 5 used native. All five answers went in one submit. Ranked (hall_of_fame_eligible: True).

started_at  2026-10-15T13:11:28.323Z   deadline 13:14:18.323Z (170 s clock)
answers     set1 ANS-D7CC…4653  set2 ANS-D4BE…3048  set3 ANS-4040…86B4  set4 ANS-881B…B2C3  set5 ANS-5826…21AF
timings     start 797  artifact 453  solve 2  ready 0  submit 758  TOTAL 2011 ms
vs started_at: start +92, artifact +545, ready +547, submit +1305 ms
result      CORRECT  600ee1a9…e5ba   (solved_by.at 13:11:29.120Z)

No wrong answers, no retries, and no HTTP 429s for this challenge.

Time to solve

All three modes were solved by 09:11:29 ET, 10 min 49 s from the first start to the last CORRECT; the Single Solve went through at 09:10:44, about 10 minutes after the solver stopped on the new codec. The ranked Time Trial and Mastery each went through on the first submit, at +0.7 s and +0.8 s.

Takeaways

  • A clean stop beats a guess. The solver's "unknown codec, not submitting" cost ten minutes, not an attempt; the timed modes then went in at well under a second each once the codec existed.
  • Two sources that agree are a strong check. The asset's data and the library's .rodata held the same value, so the solver requires them to agree rather than trusting either alone.
  • A tiny .so reads like a config file. Exported symbol, one lea, one string. readelf and objdump were enough; no Ghidra and no emulator.
  • The Mastery compounds the old days. Four of five sets were Day 3, 7, 9, and 13 templates, so keeping every earlier codec working (the 73-attempt identical check) mattered as much as the new one.
  • Timed runs were ranked again, after two days of unranked first starts.