zwesty
Write-ups / Day 3 / Script
Day 3 · Sat, Oct 3 · Script

Expansion Pack

Prey slaughtered

Static Batch deobfuscation (fragment variables; percent, delayed, CALL-double, and FOR-indirect expansion), decoding packed headers (Base64, XOR, hex, decimal, reversed text), ATT&CK mapping, finding a checker's label words by controlled tests, scripted timed runs

STTMClaude Homebase Both

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

Challenge ID
script-analysis-day-03-batch-expansion (+ /time_trial, /mastery)
Solved
2026-10-10 UTC (the evening of 2026-10-09 ET): Single Solve, Time Trial, and Mastery all CORRECT. 2 d 10 h 38 min from the first start (2026-10-07 15:02 UTC) to the Mastery CORRECT (2026-10-10 01:40 UTC). Almost all of that was the last 16 wording answers; both timed modes took under 3 s
Player
Zwesty (solo), with Claude Code (Opus 5.5) and the Homebase agent (GPT-6 Luna)

The challenge

"cmd.exe expands percents, delays expansion, CALLs twice, and bends FOR loops into indirection. Reconstruct every variable exactly as the interpreter would see it — without executing a single line — then follow the staged chain to its final handoff."

159 questions (Single Solve and Time Trial) about one AES-encrypted evidence.zip with 21 scripts, and 318 in Mastery (two sets, evidence-01.zip and evidence-02.zip). This was the day Batch arrived. It is Host With the Most (Day 2) plus 9 new Batch scripts, so 87 of the questions were old ground.

Group Files Questions Format
PowerShell (Day 1's samples) resource- / collection- / impact-sample.ps1 15 as Power Play
JScript (Day 2's samples) 9 .js files 72 as Host With the Most
Batch (new) host-context.cmd, memory-stage.bat, embedded-write.cmd, remote-write.bat, outbound-transfer.cmd, local-dispatch.bat, impact-plan.cmd, staged-handoff.bat, relay-handoff.cmd 72: eight per script (batch-<role>-<field>) role enum; attack T####[.###] or none; adapter, campaign, one indicator, expansion, control, graph exact text

The two hard fields had no word list. expansion asked to "identify the percent, delayed, CALL-double, or FOR-indirect expansion form". control asked for "the exact Batch control-flow form" and gave no hint at all.

Reading the prompt: what it said we'd need

Clue What it pointed to What I did about it
"expands percents, delays expansion, CALLs twice, … FOR loops into indirection" The four ways the scripts rebuild a variable, and the expansion question Read each script's rebuild line and sorted the nine into four forms. The checker's words turned out not to match the syntax (see below)
"Reconstruct every variable exactly as the interpreter would see it" Each script splits its plan over four set fragments, and stores it again as an encoded header Joined the fragments and decoded the header; both gave the same plan in all 9 scripts. 36 plan answers accepted on the first submit
"without executing a single line" Static only Scripts read as text on our Kali VM, never run
"follow the staged chain to its final handoff" staged-handoff.bat and relay-handoff.cmd pass control to a .js stage Roles staged_chain; ATT&CK T1059.003 (the script's own interpreter, cmd)
(Day 2's file stems, again) The PowerShell and JScript questions come back Reused Power Play's solver (15/15) and the Homebase agent's JScript analyser (72/72)

Safety

Static only. No script was run. The zip was opened with 7z -pinfected on our Kali VM and every file was read as text. The artifacts stayed in the VM and on the Homebase. Indicators below are defanged.

What was in the files

  • The PowerShell and JScript files are Days 1–2's fixtures with new values.
  • Each Batch file is an inert fixture with an --inspect branch and a --plan branch.
  • A rem HUNTRESS-CODEC: line names how the rem HUNTRESS-MATERIAL: header is packed: base64_literal|identity or |xor_5a, split_fragments|identity, plain_fragments|xor_5a or |rotate_left, reversed_text|reverse or |identity, decimal_bytes|reverse, and hex_pairs|reverse_xor (key 0x33, found by trying all 256 single-byte keys offline).
  • The INSPECT branch sets four fragment variables. The prefix changes per script (fragment*, item*, h*, FrAg*). It then jumps over a decoy separator line, sets a neutral_* decoy variable, has an optional control block, rebuilds the plan with one rebuild line, and prints it.
  • Noise that was never asked about: mixed CRLF/LF line endings, and HUNTRESS-EXTENSION: headers that disagree with the real extension.

The INSPECT tail of relay-handoff.cmd, the question that took longest:

goto :HUNTRESS_SEPARATOR_DONE
ver >nul && rem HUNTRESS separator double ampersand
:HUNTRESS_SEPARATOR_DONE
set "neutral_arithmetic=4da4d62c"
set "HUNTRESS_READY=1"
if defined HUNTRESS_READY set "item9=!item0!!item1!!item2!!item3!"
echo(!item9!

The decoded plans (all accepted on the first submit):

Script adapter graph indicator
host-context.cmd call same_language host_id host-b6bedbf92f
memory-stage.bat certutil.exe single url hxxps://0054cccedb.example[.]invalid/2c9a4553.dat
embedded-write.cmd expand.exe linear file_path C:\Users\Public\HuntressLab\stage-42d74e1982.dat
remote-write.bat bitsadmin.exe same_language url hxxps://70df8ff86d.example[.]invalid/72ba86f9.dat
outbound-transfer.cmd bitsadmin.exe fallback_retry upload_url hxxps://3476c73e76.example[.]invalid/upload
local-dispatch.bat call same_language file_path …\stage-2cd85b9cc9.dat
impact-plan.cmd compact.exe diamond note_name RECOVER-764985bced.txt
staged-handoff.bat cscript.exe cross_language next_stage stage-8cc3332b66.js
relay-handoff.cmd powershell.exe cross_language next_stage stage-5ac5ff8e88.js

Each campaign is campaign-<the same 10 hex characters>.

Time Trial and Mastery each delivered their own evidence in the same 21-script layout (Mastery: two sets). By then the solver covered every question, so both were answered on the first try. We didn't keep the timed artifacts, so I can't say how many values or forms differed between sets.

Single Solve

Day 1 (2026-10-07): 143 of 159 in three hours

  1. PowerShell (Claude, 15:02 UTC): Power Play's solver, 15/15 accepted.
  2. Batch plan fields (Claude, 15:04): adapter, campaign, graph, and the indicator for all 9, 36/36 accepted.
  3. Roles: a 9-role batch with relay was rejected. The question's own list leaves relay out, so the relay script is staged_chain: 8 + 1 accepted.
  4. ATT&CK: each candidate sent alone. 7 accepted straight away (none, T1105, T1140, T1105, T1041, T1059.003, T1486). Both hand-off scripts rejected their candidates (T1059.007, T1105, T1059.001). They were accepted at 16:25 as T1059.003, once the VBScript, Python, Bash, and Node hand-offs on Days 4–7 showed the rule: a hand-off takes its own interpreter's ID.
  5. Expansion: percent was rejected twice, alone, on scripts whose rebuild is plainly %a%%b%. delayed was accepted on the two !a!!b! scripts (relay, uploader). Claude then stopped guessing on this field.
  6. JScript: Claude's safety filter stopped one step of the analysis, and we didn't work around it. The Homebase agent's JScript analyser decoded the plans statically and answered all 72 between 17:55 and 17:58 UTC (4 wrong: one 27-answer style batch, then single tests). 16 left: 7 expansion, 9 control.

The long middle (2026-10-08 to 2026-10-09 evening): the wording hunt

These 16 were not decoding problems. The scripts were fully read; the checker wanted words we hadn't seen.

  • 2026-10-08, Homebase (17:34–17:39 UTC): one answer per submit. It got for_indirect (benign, a FOR loop over fragment*), errorlevel_guard (benign, chain), and call_subroutine (uploader). 25 rejected, including percent_expansion, call_double, and for_indirect on the launcher's FOR loop.
  • An evidence check: every header decodes to exactly the fragment plan, with no hidden keys. A Discord post said another team's agent was stuck at 157/159 on the same FOR-loop labels. A player's advice there was "Enumerate. The answers are there".
  • 2026-10-09, 12:51–16:07 UTC, Homebase probes: 82 rejected on this challenge, none accepted. Examples: separator double pipe, logical_or, call_reparse, call_second_pass, variable_substitution, guarded_for_indirect.
  • 2026-10-09, 17:53 UTC, Claude: theory: the checker says delayed for anything it doesn't see as its own FOR form. delayed accepted on the stager (a %a% rebuild) and the dropper (a call set %%a%% rebuild). Claude's safety filter then stopped one step of the analysis, and we didn't work around it; the rest went to the Homebase agent as a narrow task.
  • 2026-10-09, 18:14–18:27 UTC, Homebase: call_double on the launcher (a FOR loop over h*), and delayed on impact, chain, and downloader. 12 control names rejected. All 9 expansion answers were now done.
  • 2026-10-09, 20:18 UTC, Homebase reconciliation: goto_dispatch on the dropper, a rule found on Day 6.

How the expansion labels really work. They follow the fragment variable family, not the syntax the question names. percent is in the question's own text and was never accepted on any day:

Rebuild as written Fragment family Accepted label Day 3 scripts
set "x=!a!!b!…" any delayed uploader, relay
set "x=%a%%b%…" any delayed stager, downloader, impact
call set "x=%%a%%%%b%%…" h* → call_double; fragment*, item*, FrAg*, v_a* → delayed delayed dropper (FrAg*, after 22 CALL-double spellings were rejected), chain
for %%G in (0 1 2 3) do call set … h* → call_double; others → for_indirect call_double (launcher, h*), for_indirect (benign, fragment*) launcher, benign

The last five: control (2026-10-09 evening, Claude with Zwesty)

With 154/159, Zwesty and Claude worked back from the accepted answers instead of guessing forward:

  • Decoys never decide the label. The separator line (||, &&, & rem HUNTRESS quote compound, | findstr), the neutral_* variable, and comments are noise. Earlier names built on them (double_pipe, findstr_pipeline, quoted_compound…) all failed.
  • The "control slot" is everything between :HUNTRESS_SEPARATOR_DONE and echo(. Across all 63 Batch scripts on Days 3–9 it holds one of five things: a call :HUNTRESS_RECOVER block (call_subroutine), ver >nul + if errorlevel 1 exit /b 1 (errorlevel_guard), goto :HUNTRESS_DISPATCH + its label (goto_dispatch), set "HUNTRESS_READY=1" + if defined HUNTRESS_READY …, or nothing.
  • Nothing → direct (first accepted on Days 5 and 4), a word borrowed from the generator's own JScript style vocabulary. direct on the stager, downloader, and impact: all accepted at 00:49–00:50 UTC. Those three had rejected 25, 8, and 12 names that described their decoys.
  • The readiness wrapper was the wall. relay-handoff.cmd (batch-relay-control) was the test bench for the if defined form. 43 names were rejected on this one question and 13 on the launcher. That is most of the 68 rejected across the 13 readiness questions on Days 3–9. They included none, conditional_set, opaque_predicate, ready_guard, defined_ready, if_ready, flag_check, and all 8 known labels. Trying direct on the launcher, whose wrapper only empties cfg, showed the wrapper is its own named category.
  • Then the grid. Every accepted label is a keyword from the code plus one of five second words: subroutine, guard, dispatch, case, once. The wrapper's keywords are set, if, and defined, and only 4 of the 15 pairs had been tried. The first one sent, if_guard, was accepted on batch-relay-control at 01:36:10 UTC. if_guard on the launcher at 01:37:10: Single Solve CORRECT, 159/159, flag faafac50…0907. Support was never needed.

Final Batch answers for the two hard fields:

Script Expansion Control slot Control Rejected first (control)
host-context.cmd for_indirect if errorlevel errorlevel_guard 1
memory-stage.bat delayed empty (\|\| decoy) direct 25
embedded-write.cmd delayed goto :HUNTRESS_DISPATCH goto_dispatch 10
remote-write.bat delayed empty (quote-compound decoy) direct 8
outbound-transfer.cmd delayed call :HUNTRESS_RECOVER call_subroutine 0
local-dispatch.bat call_double readiness wrapper if_guard 13
impact-plan.cmd delayed empty (\| findstr decoy) direct 12
staged-handoff.bat delayed if errorlevel errorlevel_guard 0
relay-handoff.cmd delayed readiness wrapper if_guard 43

Cost, honestly: 171 rejected submits on this Single Solve: about 110 control, about 50 expansion, and the rest roles, ATT&CK, and JScript. Untimed, so the cost was time. From 2026-10-08 on, each was a single answer per submit.

Solvers: our chained Script-lane solver, which wraps every earlier day and includes a Batch reader, plus three add-ons: Batch expansion and control rules, VBScript control, and the Homebase agent's JScript analyser. All three add-ons came from the Homebase agent; Claude reviewed them and folded in each confirmed rule. After the final if_guard the solver reproduced every accepted answer on Days 3–9 with 0 conflicts.

Time Trial

Run from our Kali VM with our solver. The timings are the client's. The raw console output wasn't kept, so this block is built from the request log plus the client TOTAL.

started_at  2026-10-10T01:40:05.523Z   (deadline not in the request log)
coverage    159/159 (solver chain + Batch wording rules + JScript analyser)
submitted   159 answers, 01:40:07 UTC
TOTAL       1.96 s (client side)
result      CORRECT   flag c826ffb3…79a4

Correct on the first attempt.

Mastery

started_at  2026-10-10T01:40:10.872Z   (deadline not in the request log)
delivery    two sets (evidence-01 / evidence-02)
coverage    318/318
submitted   318 answers, 01:40:12 UTC
TOTAL       2.57 s (client side)
result      CORRECT   flag 971d1032…a8cb

Correct on the first attempt. Day 3 had no stopped attempt. Days 4 and 5 did: their timed sets carried the readiness wrapper before if_guard was known.

Time to solve

The Single Solve was completed at 01:37:10 UTC on 2026-10-10, 58 h 35 min after it started: 143 of 159 answers were in within three hours, and the last 16 wording answers took two more days. The Time Trial (1.96 s) and the Mastery (2.57 s) then each went through on the first attempt, 2 d 10 h 38 min after the first start.

Takeaways

  • The checker's labels are not the syntax. The question names "percent" and "CALL-double", yet a %a% rebuild is delayed and a CALL rebuild is call_double only with h* fragments. When a reading is certain and the word is rejected, the checker is keying on something else. Look for what varies with the accepted answers (here, the fragment family).
  • Find the slot, ignore the decoys. The fixtures plant separator lines, quote compounds, and neutral_* variables. The answer came from the one region every script shares (the control slot), split by what was really in it.
  • When guessing stops paying, enumerate the naming grid. 43 free-form names failed on one question. The keyword × second-word grid found if_guard on its first try.
  • Work back from what's accepted. direct (no wrapper) and error_guard came from mirroring accepted words, not from describing the code.
  • Split the work by who can do it, and keep one record. The Homebase agent did the JScript and much of the Batch wording; Claude reconciled both request logs against the server's remaining count.
  • One answer per submit. It made 171 wrong answers readable as evidence rather than noise.