zwesty
Write-ups / Day 28 / Binary
Day 28 · Wed, Oct 28 · Binary

Family Resemblance

Prey slaughtered

Static ELF and PE triage (e_machine, e_shnum, COFF NumberOfSections), symbol-table counts, HCFG config records with CRC32, telling a look-alike build from a family by its config, field procedures read from the question text, plug-ins written for day >= N

STTMClaude Homebase Both

Flags are shortened to their first 8 and last 4 characters because they're unique per team.

Challenge ID
binary-reverse-engineering-day-28-family-clustering (+ /time_trial, /mastery)
Solved
2026-10-28: all three modes CORRECT, both timed modes ranked. Our automated morning solver (a scheduled script on our Kali VM) sent 348 of 392 Single Solve answers (all accepted) and left 44 open, so it didn't start the timed modes. A Claude helper agent wrote one new plug-in. Single CORRECT at 09:11:40.3 ET (one value rejected on the way), ranked Time Trial at 09:12:23.9, ranked Mastery at 09:14:21.9 (first run stopped before submit, resumed inside the same clock). 14 min 21.9 s from the 09:00 release to the Mastery CORRECT; the solver's exact first start on this lane wasn't recorded
Player
Zwesty (solo), with Claude Code (Opus 5.5): the automated solver answered 348 questions and stopped; a Claude helper agent wrote the Day 28 plug-in, sent the 44, ran both timed modes, and installed the plug-in into the solver

The challenge

The prompt, paraphrased (the exact wording wasn't kept): cluster the binaries by behaviour, and watch out for false friends.

Binary is cumulative: 392 questions, 14 per day for Days 1–28, all on one evidence.zip. Day 28 did two things at once: - Seven Day 28 role fields on three builds: false_friend, family_a, and family_b, each with a format and a machine, plus the false friend's config campaign. - 37 new field ids on older days' samples. Each restated bytes the solver had already seen (Days 1–26) with a new question whose procedure is spelled out in the question text.

answer_id (pattern) Question (shape) Format
d28_analysis_(false_friend\|family_a\|family_b)_(format\|machine) The build whose basename ends family-a.elf (etc.): classify by magic and parsed container; read the machine field enum / base-10 integer
d28_analysis_false_friend_config_campaign The false friend's HCFG record, CRC32-checked exact text
d01_structural_artifact_count, d01_structural_ordered_set_sha256 The d01-- members in archive order: count, and a SHA-256 over their digests integer / hex
dNN_analysis_*_symbol_count, d18_analysis_handler_symbols_sha256 Count (or hash) names in "the parsed symbol tables used by the ELF parser", sometimes filtered by a /regex/ integer / hex
dNN_analysis_has_*_runtime, dNN_analysis_has_decoy_branch "true exactly when the final bytes contain X or Y" true / false
d10_analysis_section_count "Read ELF e_shnum" integer
Others vtable symbol, CLI runtime version, JAR class count / name / methods / access flags, PK19 packing keys, overlay size, plug-in CRC32 and HCFG offset, Day 24/25 vulnerable-build configs, config host_model / ipv4 as asked

The Time Trial asked the same 392; the Mastery asked them once per set (12 sets, 4,704 answers).

Reading the prompt: what it said we'd need

Clue What it pointed to What I did about it
"cluster … by behaviour" Group builds by what they share Three ELF builds of one program; family A and B carry the same config as the shared Day 28 one
"false friends" A build that looks like the family but isn't The false friend is the same size and layout (one byte longer) with its own config: campaign FF28-000, its own XOR key, and CRC
"Family Resemblance" (title) Look-alikes that need a deeper check Confirmed the reading: format and machine are identical across all three; only the decoded config tells them apart
The 37 old-day questions Not mentioned by the prompt at all The question text carried the procedure (which table, which carrier, which predicate), so the plug-in reads it from there

Safety

Static analysis only. Nothing was executed. Members were read as bytes: ELF and PE headers, section and symbol tables, HCFG records and their CRC32 checks, JAR class files. No sample was run, loaded, or debugged. For this write-up only the helper's notes, the plug-in and its tests, and our own records of the day were read. The archives stayed on our Kali VM. Indicators below are defanged.

What was in the files

  • Single Solve, Day 28 members: d28--sample-family-a.elf and d28--sample-family-b.elf (22,922 bytes each, 1 byte apart) and d28--sample-false-friend.elf (22,923 bytes). All ELF64 little-endian x86-64 (e_machine 62), no .symtab.
  • One raw HCFG record in each, at offset 22,536. Family A and B: campaign BR28-000, equal to the already-accepted shared Day 28 config. False friend: campaign FF28-000, domain decoy-000[.]day28…, its own XOR key 0xd9, and CRC 0xdbaacf1c.
  • New JSON keys in this year's configs: ipv4, host_model, windows_path, linux_path, service.
  • The other 37 fields restate older days' bytes; no new member was needed for them.
  • Time Trial: the same 392 fields; the Single's rules answered every one.
  • Mastery: 12 sets of the same 392 fields. In sets 2–5, 7–9, and 12 (8 of 12), Day 10 is a native PE (d10--sample.exe), though its question still says "Read ELF e_shnum". Everything else was answered by the Single's rules.

Single Solve

The automated solver: 348 sent, 44 open

deadline    none (Single Solve)
answers     244 core (accepted), then 104 plug-in answers (accepted)
result      not CORRECT: 44 questions unanswered, no rejected values; timed modes not started

None of the 44 ids had appeared on any earlier day. Leaving them open was right: nothing was guessed.

The plug-in (Claude helper agent)

The helper wrote a new plug-in for days ≥ 28 and tested it on a staging copy of the solver: - Field kind from the id. Plain kinds (section_count, symbol_count, handler_symbols_sha256, …) are tried before <role>_<kind>, so handler_symbol_count isn't read as role handler. - Member from the question text: "basename ends X", the primary managed .dll via its runtimeconfig, or the one dNN-- member. Carrier from the text too: .hcfg section, first literal record, backward scan, or JAR constant-pool Base64. - How it decides what it owns: roles with an underscore (family_a, false_friend) are its own. For single-word roles that older plug-ins answer (vulnerable, loader, gcc, tcc, …) it takes only the kinds new on Day 28 (config_crc32_hex, hcfg_offset, config keys outside campaign/domain/mutex/url, and the Day 24/25 vulnerable configs that were never answered). It leaves Day 4's Go and Day 7's coroutine predicates, Day 6's methods/access flags, and Day 19's overlay size to the plug-ins that already own them. - A question it can't parse is left open (logged to stderr), never a crash and never a guess. - Batches and ranked values: a symbol-table field whose answer changes with the table choice is sent alone, with its values ranked.

Every answer came from the plug-in run on the saved bytes (one value each, except d13). Some examples: d01 artifact count 1, d10 section count 27, d19 packing keys 0x21 / 0xa4, the three Day 28 formats elf and machines 62, false-friend campaign FF28-000.

The one judgement call: d13 crystal_symbol_count. "The parsed symbol tables" is plural, and a pyelftools-style parser reads both .symtab and .dynsym, so the ranking was 1255 (both tables) first, 771 (.symtab only) second. On Days 5, 11, and 18 the two readings give the same number, so only d13 depended on it.

Offline on the staging copy: coverage 392 of 392, the 348 accepted answers reproduced with 0 mismatches. The helper then ran the staged solver, which reconnected to the open Single Solve attempt:

submit 1    all 44 merged            -> incorrect (nothing burned)
submit 2    43 without d13           -> accepted
submit 3    d13 crystal_symbol_count 1255 (.symtab + .dynsym) -> incorrect
submit 4    d13 crystal_symbol_count 771  (.symtab only)      -> CORRECT
result      CORRECT   flag d0e56ef7…41af   (09:11:40.3 ET, solved_by.at 13:11:40.302Z)

Rejected value: d13_analysis_crystal_symbol_count 1255 (not to be resent). The rule it taught: symbol counts use .symtab (SHT_SYMTAB) only. The plug-in's default table ranking was flipped to .symtab first before the timed runs, and the staged re-check gave 392/392 accepted reproduced, 0 mismatches.

Time Trial

CORRECT on the first attempt, ranked.

answers     392
total       4.0 s
result      CORRECT   flag 927a4482…5c8a   (09:12:23.9 ET)

Mastery

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

First run (ranked and streamed, started 09:12:32 ET, 9.5 min clock): stopped before submit, nothing sent. The solver's coverage check found d10_analysis_section_count uncovered in 8 of 12 sets, where Day 10 is a native PE. Fix: for a PE, answer the COFF NumberOfSections (a PE has no null section, as with the Go PE section_names earlier). Offline on the saved attempt, each set alone: 392/392 in all 12 sets (d10 = 27 for ELF sets, 9 for PE sets).

Resuming: an untimed retry was refused locally by the solver ("status None and the last submit wasn't CORRECT; nothing sent"), because the first attempt was still open. Rerunning the ranked, streamed run without the retry option resumed the same ranked attempt inside its clock; no new attempt was started.

answers     4,704 (12 sets × 392)   (same ranked attempt, resumed)
resumed run 7.95 s
result      CORRECT   flag 58cbe8af…2e5c   (09:14:21.9 ET), ranked

The CORRECT confirms the PE NumberOfSections form.

Checks and install

All after the three CORRECTs, on the staging copy: - Unit tests for the plug-in: 12 passed (synthetic members: hyphenated roles and the false-friend backward scan, .symtab-only counts and handler hash, PE NumberOfSections, predicates including UTF-16LE, central-directory order, .hcfg section vs literal, small-CRC padding, JAR fields and constant-pool Base64, PK19 key padding, primary .dll by runtimeconfig, a COFF long name after an aux record, owned kinds left unclaimed, streamed delivery). Staged suite with the Binary tests and Day 27: 53 passed. - A regression check against every answer the Game Master had already accepted (fast path off): live 136 sets with 1 error, staged 136 with 1 (Day 1's known path-only manifest error); 132 identical, 3 gained-only (today's Single +44, TT +44, Mastery +528), no changed answer; accepted vs staged agree on 392 / 392 / 4,704. The check reported a failure only for Day 1's path-only error, as on Days 24, 26, and 27. - Streamed-mode snapshot (staged): 53 Mastery attempts, 0 errors, identical to the whole-solve snapshot. - The plug-in and its tests were then installed into the live solver, with 7 protected files unchanged. Live tests 12 passed, live coverage 392 of 392, and a regression rehearsal against the previous days' puzzles passed 11 of 11.

Time to solve

The Mastery was CORRECT at 09:14:21.9 ET, 14 min 21.9 s after release; the Single Solve came at 09:11:40.3 once the new plug-in was written, and the ranked Time Trial at 09:12:23.9. Both timed modes were ranked, and the Mastery's first run stopped before submit and was resumed inside the same clock, 1 min 50 s after it started.

Takeaways

  • Leaving fields open beats guessing. The solver sent the 348 it was sure of and stopped on 44 with no rule; the helper added only those.
  • The question text was the spec. The 37 old-day fields named their own procedure (which table, which carrier, which predicate); reading it from the text is what made one plug-in cover them all.
  • "Symbol tables", plural, still meant .symtab only. Ranking the two readings and sending d13 alone cost one incorrect submit and nothing else.
  • A timed set can break a question's own wording. "Read ELF e_shnum" on a PE needed COFF NumberOfSections. The coverage check stopped the run before anything was sent.
  • An open ranked attempt resumes; it doesn't retry. The solver refuses a retry while the attempt is open; rerunning without it kept the Mastery ranked and inside its clock.
  • Clear ownership rules keep plug-ins from fighting. The new plug-in claims only kinds no older solver owns, so installing it changed no existing answer.