zwesty
Write-ups / Day 26 / Android
Day 26 · Mon, Oct 26 · Android

Split Decision

Prey slaughtered

Split APK bundles (.apks: a ZIP of base.apk plus feature splits), the v1 and v2 puzzle assets, jadx reading, a bounded static Dalvik interpreter, 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-26-split-decision (+ /time_trial, /mastery)
Solved
2026-10-26: all three modes CORRECT. The solver stopped on the Single Solve (it couldn't match the .apks artifact; nothing sent); the Single was solved by hand (CORRECT 09:03:02 ET). The first ranked Time Trial expired (50 s clock, nothing sent: its variant used a new control); after the fix, one untimed retry was CORRECT at 09:08:31, and the ranked Mastery was CORRECT on its first run at 09:08:39. 7 min 40 s from the first start request on this lane (09:00:59 ET) to the Mastery CORRECT. No ranked Time Trial time
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 solved the Single by hand, added split-bundle support, ran the timed modes, and made the new solver the default

The challenge

"Reassemble evidence distributed across base and configuration splits."

answer_id Question Format
answer "For case MOB-26-BE982536CB in package com.huntress.mobile.d26.vbe982536cbed: Reassemble evidence distributed across base and configuration splits. 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

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

Reading the prompt: what it said we'd need

Clue What it pointed to What I did about it
"base and configuration splits" Android split APKs: one base.apk plus split APKs, delivered together as an .apks bundle The artifact was split_decision.apks, a ZIP of base.apk and feature.apk. The solver only looked for .apk files, hence its stop
"evidence distributed across" The answer isn't in one APK v1: one part of the answer sits in the feature split's JSON asset. v2: the base app's code reads a 16-byte asset from the split
"Reassemble" Put the pieces back in order v1: the Day 10 split rule (parts[i] goes to slot order[i]) once the feature part fills its empty slot
"the exact backend answer code returned for this case" The code the app would send to its loopback backend The backend was never contacted; the value comes from the bundle alone
"Split Decision" (title) Split APKs Confirmed the reading

Safety

Static analysis only. Nothing was installed or executed. The bundles were opened as ZIP files and their assets read as JSON or bytes; the DEX code was read in jadx and evaluated by the solver's bounded interpreter, which models only what it knows (int/long arithmetic, a few library calls, and now the split-asset read) under a step budget and stops on anything else. The app's loopback backend was never contacted. For this write-up the bundles were only listed (unzip -l), their puzzle assets printed, and their DEX string tables searched for the control names. The APKs stayed on our Kali VM.

What was in the files

  • Single Solve: split_decision.apks, 21,536 bytes: base.apk (12,704) and feature.apk (8,552).
  • base.apk carries the old v1 asset: {"schema":"huntress.mobile.puzzle.v1","day":26,"codec":"split","data":"", "parts":["","ANS-7","E37D…"],"order":[2,0,1]}. Its first part is empty.
  • feature.apk (split arena_feature, no code) carries assets/huntress_feature.json: {"codec":"split","day":26,"feature":"arena_feature","part":"4754…","part_index":0}.
  • Filling parts[0] from the feature split, then applying the Day 10 rule, gives ANS-7E37…EA01.
  • Time Trial: split_decision_time_trial.apks, 21,535 bytes, the same bundle shape, but a different kind of puzzle: the base asset is v2 ("codec":"analysis", no data) and the feature split holds a 16-byte assets/huntress_feature.bin and no JSON. In jadx, Release.code(ctx) → token() → Core.mergeSplit82(scanMergec4(mixFeature62(K))), and mergeSplit82 calls Assets.readSplit("huntress_feature.bin"): ctx.getApplicationInfo().splitSourceDirs, new ZipFile(path), getEntry("assets/" + name), read 16 bytes. The two big-endian longs are mixed in (the high one XORed, the low one added), then Mix.f1, Guard.finPartf9, an XOR constant, and 16 hex digits.
  • Mastery: 10 deliveries, all v2. Sets 1–9 are plain .apk files with earlier days' controls (from the DEX string tables); set 10 is a split bundle with today's control.
Set Day Control Artifact
1 2 deep_link_validator .apk, 12,704
2 5 backup_store .apk, 12,704
3 7 javascript_bridge .apk, 12,704
4 10 network_config .apk, 12,845 (+ res/xml/network_security_config.xml, resources.arsc)
5 12 sqlite_cache .apk, 12,704
6 14 predictable_rng .apk, 12,704
7 18 string_decryption .apk, 12,704
8 20 dynamic_dex .apk, 12,768 (+ assets/payload.dex)
9 24 emulator_detection .apk, 12,704
10 26 split_apk .apks, 21,535 (base + feature, huntress_feature.bin)

So, as on Days 24 and 25, the Single kept the v1 template and the timed modes used v2. What was new is that the two differed in kind: a v1 reader for the Single's JSON part said nothing about a v2 app that reads its split at run time.

Single Solve

The solver: stopped, nothing sent

started_at  2026-10-26 09:01:00.090 ET   (solver; artifact 01-split_decision.apks, 21,536 bytes)
deadline    none (Single Solve)
answers     none
result      STOP: answer: can't match artifact 'split_decision.apks' among []; not submitting.
            Timed modes not started.

The Day 25 solver collects .apk files only, so the bundle gave it an empty list. Stopping was right: nothing was sent.

By hand (helper)

The helper unpacked the bundle, read both JSON assets, and worked out one answer: the feature part into parts[part_index], then the Day 10 slot rule (accepted on 2026-10-10). The result, ANS-7E37…EA01, has 16 hex digits and fits the format. It was sent on the solver's open attempt.

started_at  2026-10-26 09:01:00.090 ET   (same attempt, submitted by the helper)
answers     1   ANS-7E37…EA01
result      CORRECT   flag 28426043…882b   (09:03:02.447)

The solver update (helper)

The helper built a new solver: the Day 25 one plus split-bundle support: - .apks bundles: the bundle must hold exactly one base.apk and only flat *.apk members. Every split with a huntress_feature.json must fill an empty parts[] slot (no overlaps), and no part may stay empty afterwards. A base that needs no feature parts goes to the Day 25 decoder unchanged. Plain .apk downloads keep the Day 25 path, so the mixed Masteries still work. - v2 base in a bundle (added after the Time Trial expired): a Day 26 interpreter that models getApplicationInfo() on the release Context, the splitSourceDirs field (that field only), and java.util.zip.ZipFile over the bundle's own split APKs, with each member's digest checked against the split's MANIFEST.MF. The Day 24 loop is reused with three asserted edits.

Checks: its tests, 10 of 10 (the Single answer, delivery names, a missing feature split stops, overlapping parts stop, two bases or nested members stop, plain .apk keeps the Day 25 path, the TT answer equals an independent hand transcription of the jadx code, Day 25 stops on the TT base, the TT without its split stops, a tampered split asset stops). A regression check against every answer the Game Master had already accepted: 358 of 358 reproduce, 0 mismatched.

Time Trial

First run (ranked): clock expired, nothing sent

09:04:33    start with our local folder name (`…split-decision__time_trial`): 404 challenge_unavailable, no attempt
started_at  2026-10-26 09:04:59.215 ET   (ranked, 31 attempts left)
deadline    2026-10-26 09:05:49.215 ET   (50 s)
answers     none
result      EXPIRED   (the Day 25 interpreter stopped on Context.getApplicationInfo(); the v2 split read was new)

The first start used our local folder name (__time_trial) instead of the route (/time_trial) and got a 404; the second started the ranked attempt. The solver then had only the v1 bundle support, so the v2 base stopped on an unmodelled call and the 50 s clock ran out with nothing sent. The fix above was written offline; it gives ANS-4F1B…038C for this expired variant, the same as the hand transcription.

Retry (untimed): CORRECT

One untimed retry (unranked, a fresh variant): we allowed ourselves one untimed retry of a failed first timed run.

started_at  2026-10-26 09:08:30.596 ET
deadline    none (untimed retry)
answers     1   ANS-2DF2…5A67
retry         62 ms after started_at
artifact    1000 ms after started_at   (21,535 bytes)
solve       1017 ms after started_at   (17 ms)
submit      1933 ms after started_at
result      CORRECT   flag 9210355a…afb1   (09:08:31.877)

So the lane has no ranked Time Trial time today.

Mastery

CORRECT on the first attempt, ranked (10 deliveries, 320 s clock; Day 25 had 9 APKs and 290 s). Sets 1–9 went through the Day 25 path; set 10 through the new split control.

started_at  2026-10-26 09:08:38.153 ET
deadline    2026-10-26 09:13:58.153 ET   (320 s)
answers     10   (ANS-212F…4E1C … ANS-3D19…E078)
start        133 ms after started_at
artifact     873 ms after started_at
solve       1151 ms after started_at   (278 ms)
submit      1663 ms after started_at
result      CORRECT   flag 331d201f…c437   (09:08:39.424)

Afterwards a regression rehearsal against the Day 25 solver gave 150 identical, 4 newly solved (all Day 26) and 0 changed, and at 09:09:08 ET this solver became our default Android solver for the following days.

Time to solve

All three modes were solved by 09:08:39 ET, 8 min 39 s after release (7 min 40 s from the first start request on this lane). The Single Solve was solved by hand; the ranked Time Trial expired with nothing sent, and the Time Trial was then solved on an untimed (unranked) retry; the Mastery was ranked and went through on the first submit.

Takeaways

  • The Single and the timed modes can differ in kind, not just in values. The Single's v1 JSON part said nothing about the TT's v2 code reading a split asset. Before a ranked Time Trial, check the timed variant's control against the solver, not only the Single's answer.
  • A new container breaks the collector before the decoder. The solver never reached the puzzle: it found no .apk in an .apks. Container shapes (.apks, .xapk, nested splits) belong in the pre-build list along with new controls.
  • The expired TT cost the ranked time, not a wrong answer. The interpreter stopped on an unmodelled call instead of guessing, so nothing wrong was sent, and the one untimed retry got the CORRECT.
  • Model only the one field and the bundle's own files. The split read was modelled as exactly splitSourceDirs and a ZipFile over the bundle's own digest-checked splits; anything else still stops.
  • Use the route, not a local folder name. …__time_trial is our work folder; the Game Master route is …/time_trial. The 404 came 25 s before the ranked start.
  • The Mastery mix keeps growing. 10 deliveries today, with Day 10's network_config and Day 24's emulator_detection in the v2 mix and today's split control last.