zwesty
Write-ups / Day 21 / Script
Day 21 · Wed, Oct 21 · Script

Actions Speak Louder

Prey slaughtered

Multi-language script analysis, GitHub Actions workflows as a script host, ATT&CK mapping

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-21-github-actions-persistence (+ /time_trial, /mastery)
Solved
2026-10-21: all three modes CORRECT on the first run, by 09:03:17 ET, 20 s after the solver's first start request (09:02:57 ET). Both timed modes were ranked runs. Late start: the scheduled 08:58 run didn't start because a leftover session on our Kali VM was still blocking it, and a catch-up run began at 09:01:40, about 3 min 39 s late. So the 3 min 17 s from the 9 AM release to the Mastery CORRECT includes that delay (on Day 20 this lane was done by 09:01:11)
Player
Zwesty (solo); solved by our automated morning solver (a scheduled script on our Kali VM, core chain then add-on readers merged), built with Claude Code (Opus 5.5) and started here by the catch-up run, with no manual work needed

The challenge

"A GitHub Actions workflow decides who can trigger it and which runner inherits the trust. Identify each trigger and trust boundary, follow the static handoff into the next language, and find the job that keeps coming back."

Every earlier day's script fields plus 44 new ones: 1,373 questions on the Single Solve (Day 20 had 1,329; none were dropped). Written from the solver's report, the start reply's question list, and the Game Master request log; the specimens weren't reopened.

The four new specimens each carried the lane's usual question family:

answer_id (per specimen) Question Format
…-role "Classify the inert source role." enum (benign_utility, downloader, dropper, impact, launcher, persistence_descriptor, relay, staged_chain, stager, uploader, advanced_syntax)
…-attack Map the decoded ordered behaviour graph to one primary ATT&CK technique, by the stated precedence technique or sub-technique ID, or none for benign
…-adapter, …-campaign, …-url / …-file-path Recover the modelled adapter, campaign ID, and indicator exact text
…-stage-count Recover the modelled stage count base-10 integer
…-steps Recover the ordered behaviour graph tokens joined by >
…-host-profile "Identify the script host or source container." exact text
…-obfuscation, …-stage-profile, …-next-language Encoding profile, multi-stage profile, next language in the handoff exact text

Reading the prompt: what it said we'd need

Clue What it pointed to What I did about it
"A GitHub Actions workflow" A new language family: workflow YAML (.yml) Relied on the core chain's general reader; it handled the four .yml polyglots with no change
"who can trigger it", "which runner inherits the trust", "each trigger and trust boundary" The workflow's on: triggers and the runner its jobs run on Not asked as fields today. The question set was the usual family; the host profile came back standalone for all four
"follow the static handoff into the next language" The next-language field The chain gave powershell, jscript, batch, and vbscript for the four specimens
"the job that keeps coming back" Persistence (the challenge ID says github-actions-persistence), for example a scheduled workflow No persistence-specific field was asked; the persistence_descriptor role exists in the enum but none of today's four was that role
"Actions Speak Louder" (title) GitHub Actions Confirmed the day's specimens

Safety

Script specimens are hostile: static analysis only, on our Kali VM. The solver parses the text of the encrypted evidence.zip members; no specimen was executed and no workflow was run. No code excerpts are quoted here, only the answer values, with URLs defanged.

What was in the files

  • Single Solve: evidence.zip, 133,401 bytes, 149 encrypted members (Day 20's 145 plus four workflows). The 44 new questions came from four GitHub Actions polyglots, 11 fields each, all with host profile standalone:
Specimen (polyglot-github_actions-…) Role Attack Adapter Stages Steps Obfuscation Stage profile Next language
benign-utility.yml (1,373 bytes) benign_utility none local_parser 2 acquire_remote>dispatch_process base64 two_stage_download_execute powershell
downloader.yml (2,598) downloader T1105 http_client 3 decode_embedded>write_file>dispatch_process hex_base64 three_stage_decode_write_launch jscript
launcher.yml (2,094) launcher T1059 process_builder 3 profile_host>decode_configuration>dispatch_language fragmented_hex cross_language_handoff batch
staged-chain.yml (1,969) staged_chain T1059 operation_graph 2 decode_configuration>return_configuration plain_hex single_descriptor vbscript

Indicators: campaign IDs GITHUB_ACTIONS-<8 hex>; URLs of the form hxxps://github_actions-…example[.]invalid/stage; the benign utility's file path /tmp/huntress-github_actions/.<16 hex>.stage. - Time Trial: evidence.zip, 133,499 bytes, the same 1,373 questions. All four workflows had the same shapes; only the campaign IDs, URLs, and file path changed. - Mastery: one 667,173-byte zip, 5 sets, 6,865 questions. Every set's four workflows matched the Single Solve's shapes with their own IDs.

An oddity, not looked into: two graphs don't fit their file names the obvious way. The "benign utility" has a download-and-execute graph but role benign_utility and attack none. The "staged chain" has the decode_configuration>return_configuration graph, which the attack rubric calls benign, but attack T1059. The Game Master accepted the core chain's values for all 44 fields, so the file names are not a guide to the answers. Why they differ wasn't checked, because the specimens weren't reopened.

Single Solve

The solver solved it with no stop. It picked up today's challenge ID with its core script solver, which uses the split-header reader added on Day 19, and sent two batches:

  • Core batch, 1,243 answers → accepted, 130 remaining. All 44 new fields were in it and all were accepted. The solver flagged 50 values it had seen rejected on other questions before (mostly none and T1105 attack values, two of them on today's new specimens) and sent them anyway, as it is set to; none was rejected here.
  • Merged add-on batch, 130 answers → CORRECT. These were the fields the solver's add-on readers own: 72 JScript, 16 Batch, 9 VBScript, and 11 each for the WSF, HTA, and SCT hosts.

The split between the two batches moved by a few fields against Day 20 (129 add-on fields then, 130 now). Three moved to the add-on batch (batch-launcher-expansion, batch-impact-expansion, vbscript-relay-control) and two moved back to core (batch-dropper-expansion, batch-chain-expansion). No code changed, so this follows the day's evidence; the exact reason wasn't checked.

started_at  2026-10-21T13:02:58.627Z
deadline    none (Single Solve)
answers     1,243 (core) + 130 (add-ons merged) = 1,373
start / artifact / solve timings: not recorded for the Single Solve
core submit       13:02:59 → 13:03:00   accepted, remaining 130
add-ons submit    13:03:01 → 13:03:02
TOTAL       ~3 s from the start reply to CORRECT (solved_by 13:03:01.459Z, +2.83 s after started_at)
result      CORRECT   flag cb3b42c5…9530

No new solver, tests, or rehearsal were written today: the code that ran was the same as on Days 19 and 20.

Time Trial

CORRECT on the first attempt, ranked (90 s clock, up from 50 s on Day 20; 1,373 answers). The start reply showed unique_attempts_remaining: 31.

started_at  2026-10-21T13:03:07.534Z
deadline    2026-10-21T13:04:37.534Z   (90 s)
answers     1373
start        579 ms
artifact     261 ms
solve         80 ms
submit      1332 ms
TOTAL       2253 ms   (submit +1.98 s after started_at)
result      CORRECT   flag a4f3167f…3745

Mastery

CORRECT on the first attempt, ranked (5 sets, 170 s clock).

started_at  2026-10-21T13:03:14.925Z
deadline    2026-10-21T13:06:04.925Z   (170 s)
answers     6865 across 5 sets
start        528 ms
artifact     386 ms
solve        380 ms
submit      1170 ms
TOTAL       2473 ms   (submit +2.29 s after started_at)
result      CORRECT   flag ae1ff972…40c1

Time to solve

All three modes were solved by 09:03:17 ET, 20 s after the solver's first start request at 09:02:57 ET, and each went through on the first submit; both timed modes were ranked. The finish, 3 min 17 s after release, was set by the late start: the catch-up run began at 09:01:40 instead of 08:58 and handled the Metadata, Warmups, and Android lanes first.

Takeaways

  • The general reader keeps paying off. A new host format (GitHub Actions YAML) went through the existing chain with no code change, for the third day running.
  • The prompt's story and the question set differ. Triggers, trust boundaries, and "the job that keeps coming back" weren't asked as fields; the usual 11-field family was. Read the questions, not just the prompt.
  • The rejected-before warning is advice, not a stop. 50 flagged values were right for these questions again.
  • This lane finished last only because it runs last. The 20 s solve was fine; the 3 min 39 s late start set the finish time.