zwesty
Write-ups / Day 5 / Script
Day 5 · Mon, Oct 5 · Script

Hatch Careful

Prey slaughtered

Static triage of Python carriers (pyc, PyInstaller, py2exe, cx_Freeze, PyArmor, zipapp, wheel), decoding packed configs without executing them, ATT&CK mapping, Batch/VBScript control-flow naming, 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-05-python-carrier-lab (+ /time_trial, /mastery)
Solved
Single Solve CORRECT 2026-10-10 00:48 UTC (the evening of 2026-10-09 in ET), the first of the seven Script days 3–9 to finish. Time Trial and Mastery CORRECT at 01:39–01:40 UTC. 57 h 31 min from the first start (2026-10-07 16:08 UTC) to the Mastery CORRECT. The Python part took 3 minutes; the rest was JScript and four control labels
Player
Zwesty (solo), with Claude Code (Opus 5.5) and the Homebase agent (GPT-6 Luna)

The challenge

"Nine Python behaviors come wrapped in fourteen disguises: bytecode, wheels, zipapps, frozen executables, and armored runtimes. Peel each carrier back to its intent without letting anything hatch, and identify the package that tells a different story than its payload."

357 questions (Single Solve and Time Trial) about one AES-encrypted evidence.zip (password infected), and 714 in Mastery (two sets). Day 4's 231 questions came back with new values, plus 126 new Python questions.

Group Files Questions Format
PowerShell (Day 1's samples) resource- / collection- / impact-sample.ps1 15: scenario, ATT&CK, three indicators each enum, T####[.###], exact text
JScript (Day 2's) 9 .js 72: role, ATT&CK, adapter, campaign, one indicator, function / invocation / property style role enum, ATT&CK, exact text
Batch 9 (5 .cmd, 4 .bat) 72: role, ATT&CK, adapter, campaign, one indicator, expansion, control, graph exact text
VBScript 9 .vbs 72: role, ATT&CK, adapter, campaign, one indicator, representation, transform, control exact text
Python (new) 14: 13 *-recovered.py + source-context.py 126: role, ATT&CK, adapter, campaign, one indicator, syntax, obfuscation, carrier, windows-chain role enum; ATT&CK; the four new fields are exact text ("Identify the exact source, bytecode, frozen, or protected carrier profile.")

The Batch expansion and Batch/VBScript control questions give no list of accepted words. They ask only for "the exact Batch control-flow form" or "the exact VBScript control-flow form". That is where all the time went.

Reading the prompt: what it said we'd need

Clue What it pointed to What I did about it
"Nine Python behaviors" The role enum: benign utility, stager, dropper, downloader, uploader, launcher, impact, staged chain, relay Roles taken from each decoded plan's steps
"fourteen disguises: bytecode, wheels, zipapps, frozen executables, and armored runtimes" One file per carrier, and a carrier question for each The carrier name is in the plan (hash_based_pyc_38, wheel_archive, zipapp_pyz, pyinstaller_onefile, pyarmor_pyinstaller …)
"Peel each carrier back to its intent" Each file packs an encoded plan; decode it to get role, adapter, campaign, indicator Our Python fixture decoder decodes MATERIAL with the file's own CODEC line
"without letting anything hatch" Don't run the .py files Constants read with ast.literal_eval, which accepts literals only (see Safety)
"the package that tells a different story than its payload" Carriers whose name hints at one thing while the plan says another Not asked as its own question in this delivery. Example: wheel-recovered.py is a memory stager, and py2exe-service is a five-stage chain with a scheduled-task name and a local proxy. Nothing to submit
(not in the prompt) the other 231 questions Day 4's Dim and Dimmer, with new values Reused Dim and Dimmer's solver, so the PowerShell, Batch, and VBScript plans came out on the first run

Safety

Static only. No script was run: not the Python fixtures, not the invoke-python-stage.ps1 hand-off precursor, and not the Batch, VBScript, or JScript files. The zip was read into memory and parsed. For Python, our decoder pulls the two constants out with ast.literal_eval, so nothing in a fixture can execute; a test checks that a non-literal MATERIAL is refused. The artifacts stayed on our Kali VM (and on the Homebase for the JScript part). No indicators are quoted below.

What was in the files

  • 45 files in one zip: Dim and Dimmer's 30 scripts with new values, the 14 Python carriers, and invoke-python-stage.ps1. Nothing asks about that last one directly. It is an inert "PowerShell-to-Python handoff precursor": a Start-Process of python.exe pyarmor-pyinstaller-recovered.py --handoff-id PSPY-AB93…D558, the same hand-off ID as in that carrier's plan.
  • Each Python fixture has MATERIAL_<id> plus CODEC_<id> = <representation>|<transform>|<key>, and load_, decode_, recover_, inspect_, surface_, obfuscation_, and plan_ functions, all guarded by HUNTRESS_INSPECT_ONLY. The representations are decimal_csv, hex_escape, hex_colon, base64_chunks, urlsafe_base64, integer_list, and reversed_integer_list. The transforms are reverse_then_xor, reverse_bytes, single_byte_xor, add_modulo_256, subtract_modulo_256, and identity.
  • The decoded plan names syntax, obfuscation, carrier, and windows_chain itself, and each script's surface_ 4-tuple says the same; the solver checks both agree.
File Role Carrier Syntax Obfuscation Windows chain
source-context.py benign_utility raw_source plain_functions transform_only direct_python_exe
pyc38- dropper hash_based_pyc_38 class_staticmethod string_table renamed_interpreter
current-pyc- launcher hash_based_pyc_current closure_factory chr_join pythonw_hidden_console
zipapp- relay zipapp_pyz lambda_dispatch reversed_fragments cmd_to_python
wheel- stager wheel_archive list_comprehension format_map powershell_to_python
pyinstaller-onefile- downloader pyinstaller_onefile generator_pipeline fstring_fragments mshta_to_python
pyinstaller-onedir- uploader pyinstaller_onedir dictionary_dispatch lambda_indirection rundll32_predecessor
py2exe-console- launcher py2exe_console match_case closure_cells regsvr32_predecessor
py2exe-service- staged_chain py2exe_windows_service walrus_binding exception_routing service_descriptor
cxfreeze- impact cx_freeze_executable decorator_wrapper bytes_translation scheduled_task_descriptor
cxfreeze-msi- dropper cx_freeze_msi iterator_protocol reflective_name_plan run_key_descriptor
pyarmor-source- stager pyarmor_obfuscated exception_expression marshal_envelope_plan wscript_to_python
pyarmor-pyinstaller- staged_chain pyarmor_pyinstaller dictionary_dispatch reflective_name_plan powershell_to_python
embedded-runtime- relay embeddable_python_runtime class_staticmethod marshal_envelope_plan renamed_interpreter
  • Hand-offs: zipapp → pyc38-recovered.py; py2exe-service → resource-sample.ps1; pyarmor-pyinstaller → local-dispatch.bat; embedded-runtime → pyarmor-pyinstaller-recovered.py.
  • VBScript added forms here: the representations base64_chunks (four Mid chunks), reversed_hex, and hex_dash, and the transform identity. Claude changed our VBScript decoder to take the separator, base, and order from the script's own Load code. There was also a new control form, On Error Resume Next … On Error GoTo 0 around the decode (three scripts).
  • Time Trial and Mastery used the same template with new values, but the Batch "control slot" could change between variants. The Single Solve's nine Batch scripts had no set "HUNTRESS_READY=1" + if defined wrapper. The first Time Trial variant had two, and that wrapper had no known label yet.

Single Solve

Claude, 2026-10-07 16:08–16:25 UTC: 261 of 357

  1. PowerShell, Batch, and VBScript from Dim and Dimmer's solver (16:10:44–16:10:52): six batches, all accepted: 15 PowerShell, 54 Batch (plans, roles, ATT&CK, two delayed expansions), and 64 VBScript (plans, roles, representation, transform, ATT&CK, the relay's select_case). 224 left.
  2. Python, all 126, no wrong submit (16:10:53–16:11:51). Three batches of plan fields (98: adapter, campaign, indicator, syntax, obfuscation, carrier, windows-chain), then 12 roles, then the two relays. The relay question lists relay itself, unlike Batch's. ATT&CK went in two steps: - 8 role-mapped IDs in one batch: none (benign), T1140 (droppers), T1105 (stagers, downloader), T1041 (uploader), T1486 (impact). Accepted. - The six launchers, chains, and relays, each alone: T1059.006, all accepted. Day 4's rule held: a hand-off takes its own interpreter's ID (Python), even when the next stage is PowerShell or Batch.
  3. VBScript On Error form (16:12): on_error_resume_next and on_error on host-context.vbs, both rejected. Claude stopped there. The other open VBScript forms (Do … Exit Do, While … Wend) had already been tried on Day 4, so they weren't retried.
  4. Batch hand-off ATT&CK (16:25): T1059.003 for chain and relay, accepted. 261 of 357.

Not attempted by Claude: the 72 JScript questions. Claude's safety filter had stopped one step of the JScript analysis on Day 2, and we didn't work around it, so that part went to the Homebase agent.

The Homebase agent, 2026-10-07 to 2026-10-09: 92 more

  • JScript (2026-10-07 17:58 UTC): the agent's static JScript analyser decoded the plans and answered all 72 in four batches. 333 of 357.
  • Batch and VBScript control, expansion (2026-10-08 and 2026-10-09): one answer per submit, from the agent's Batch and VBScript wording add-ons. Accepted: errorlevel_guard ×3 and call_subroutine ×4 (Batch), do_once ×3 and while_once ×2 (VBScript), the seven open Batch expansions (delayed, call_double, for_indirect, which follow the fragment variable family), and goto_dispatch on relay-handoff.cmd. That left 353 of 357. The Batch wording work stayed with the agent partly because Claude's safety filter stopped one step of that analysis, and we didn't work around it.
  • What didn't work: 21 rejected submits. 15 names on vbscript-benign-control alone (resume_next, error_suppression, error_trapping, error_handler, scoped_error_handler, continue_on_error, capture_and_raise …). 4 on vbscript-dropper-control, one of them an accidental resend the agent reported itself. 1 on vbscript-uploader-control. 1 on batch-launcher-control (h_fragment_for_loop). After that run, our runner was changed to refuse any value already rejected for that question.

The last four, 2026-10-09 evening (ET), Claude and Zwesty from our Kali VM

The open answers: three VBScript scripts with the On Error Resume Next form, and local-dispatch.bat. Zwesty and Claude worked back from the answers that had been accepted.

  • error_guard (00:34 UTC): accepted. Zwesty's idea: VBScript's On Error Resume Next is the error check, the same idea as Batch's if errorlevel 1 exit /b 1, which was errorlevel_guard. So mirror it. It was the first time error_guard was accepted anywhere. It was then sent to dropper and uploader: both accepted, 356 of 357.
  • local-dispatch.bat. Its slot between :HUNTRESS_SEPARATOR_DONE and echo( holds nothing. The rebuild is a for %%G in (0 1 2 3) do call set line, and an & rem HUNTRESS quote compound decoy sits elsewhere in the file.
  • for_loop (the FOR rebuild names it): rejected. no_wrapper: rejected.
  • Then Claude sorted every open Batch script by what was really in the slot. There were five kinds: call block, errorlevel, dispatch, if defined, and empty. The "nothing" words tried earlier (straight_line, linear …) had all gone to an if defined script, never to an empty-slot one. Candidates came from the generator's own "plain" words in other fields. The best was the JScript invocation-style value direct.
  • direct (00:48:18 UTC): CORRECT, 357/357, flag 405fa223…aedc. Day 5 was the first of the seven Script days to finish. direct was then accepted on 14 more questions across Days 3, 4, 6, 7, and 8, and finished Day 4. No support ticket was needed for any of the labels.

25 wrong submits in all on this day: 2 by Claude on 2026-10-07, 21 by the Homebase agent, and 2 by Claude on the last evening. Apart from the one duplicate, each was a deliberate test. The Python and JScript parts had none.

Solvers: our Day 5 solver wraps Dim and Dimmer's and adds the Python part, using our Python fixture decoder; a small helper sends a chosen subset. Its 3 tests check that it reproduces exactly the 259 answers accepted on the first day, that a wrong XOR key in decode_ stops the solver, and that a non-literal MATERIAL is refused. The timed runs used our runner with the solver chain (Power Play → Dim and Dimmer → Hatch Careful → Shell We Begin → Node by Node) plus the JScript, Batch-wording, and VBScript-control add-ons.

Time Trial

Timings below are the client's own, not the server's. The raw console output wasn't kept, so the blocks are built from the request log plus the TOTAL time the run reported.

attempt 1   started_at 2026-10-10T01:13:42.760Z
            stopped before submit: batch-impact-control and batch-relay-control carried the
            set "HUNTRESS_READY=1" + if defined wrapper, which had no label yet. Expired, nothing sent
retry       started_at 2026-10-10T01:39:54.467Z   (deadline not in the local logs)
coverage    357/357
submit      01:39:55Z, 357 answers
TOTAL       2.55 s (client side)
result      CORRECT   flag 195b396f…7e95

The readiness wrapper was named if_guard between the two attempts (on Day 3's challenge), from the pattern "a keyword in the code + one of five second words". 68 names had been rejected before it.

Mastery

started_at  2026-10-10T01:39:59.705Z   (deadline not in the local logs)
coverage    714/714 (two sets of 357)
submit      01:40:01Z, 714 answers
TOTAL       2.74 s (client side)
result      CORRECT   flag edae22a8…59b7

Correct on the first attempt.

Time to solve

The Single Solve was completed at 00:48:18 UTC on 2026-10-10, 56 h 40 min after it started: 261 of 357 answers (including all 126 Python ones) were in on the first afternoon, and the last four control labels came two days later. The first Time Trial attempt stopped before submitting and expired; the retry (2.55 s) and the Mastery (2.74 s, first attempt) were both CORRECT by 01:40:01 UTC, 57 h 31 min after the first start.

Takeaways

  • Compounding pays off. Dim and Dimmer's solver plus a new Python decoder gave 259 of 357 in under four minutes, including all 126 Python answers with no wrong submit.
  • Read constants with a parser, not an interpreter. ast.literal_eval pulls out MATERIAL and CODEC without running a line of the carrier. The plan inside then names its own carrier, syntax, and execution chain.
  • The own-interpreter rule held again. Python launchers, chains, and relays are T1059.006, even when the next stage is PowerShell or Batch.
  • Labels are a mechanism plus a purpose. error_guard came from mirroring the already-accepted errorlevel_guard, not from describing what On Error Resume Next does. 17 names had failed on that one question.
  • Sort the open questions by what is really there before guessing. The "nothing" words had all been sent to the wrong kind of script. Once the empty-slot script was on its own, the generator's own word direct worked first time.
  • Timed variants can bring forms the Single Solve never showed. The first Time Trial met the if defined wrapper. Stopping before submit cost one attempt and sent no wrong answer.