Attack Progression Schema — GitHub Actions to AWS Account Compromise
Incident summary · Read the progression down the left rail; the numbered badges
show where each step landed in the estate. Numbering is order of advance through the
estate, not wall-clock time.
How the attacker advanced
Seven phases. Each names the move it makes through the estate and the access held
once it was done. Numbers match the badges on the diagram.
P1CI/CD compromise
Moving untrusted code into a shared cache Then held: Arbitrary code execution inside the org's CI
1Open a malicious PR (fix/branch → dev) carrying attacker code
2Comment /lint — the bot command never checks the commenter's org membership
3lint-dispatch.yaml dispatches e2e.yaml on the dev ref, with the PR as an input
4e2e.yaml checks out the PR head and runs tests/init.sh
P2Supply-chain poisoning
Building and pushing the backdoored image Then held: A backdoored production container image
5Write a backdoored app.py into the Actions cache — cache scope is the ref, and the run's ref was dev
6The trusted release job restores the poisoned app.py from cache
7Build executes on the in-cluster self-hosted runner
8Push the image with a backdoored entrypoint as ci-apps
P3Tenant foothold
Moving in the unprivileged tenant Then held: Code execution in any pod scheduled to the node
9app-1 pulls and runs the backdoored image
10In-memory beacon starts — plain Python running compiled bytecode fetched from the C2 with exec(): arbitrary file read, process env read, SOCKS proxy
11app-1's own env names the monitoring service — abuse its arbitrary file read to steal the monitoring-sa token
12Connect straight to worker node 1's kubelet with that token, then wait for an operator to start a node debug pod
13Exec into that node debug pod through the kubelet
P4Node compromise
Full node compromise, via a debug Pod Then held: Root on the unprivileged node group and kernel-level persistence
14A node debug pod mounts the host filesystem at /host by design — chroot /host gives root on worker node 1
15Write the LKM + eBPF rootkit as a static pod to /host/etc/kubernetes/manifests and restart the kubelet to load it
16Rootkit harvests AWS credentials from the release-orchestrator job on the runner
17Push a backdoored orchestrator image with the stolen ci-orchestrator credentials
P5Cross-tenant escalation
Backdoor again, full cluster compromise Then held: cluster-admin, plus the ACK controller's IRSA role
18The privileged tenant pulls the backdoored orchestrator image
19The backdoored pod opens the channel outbound and hands the cluster-admin orchestrator-sa token to an attacker-controlled endpoint
20Deploy rootkit + implant to every node as a DaemonSet — through the API server, not over the network
21Steal the ack-lambda-controller IRSA token from the node
P6Cloud persistence
Moving into AWS land Then held: Durable AWS access that survives cluster rebuild
22Create ephemeral Lambdas — save-logging, iac-* — via PassRole onto a privileged role
23Use orchestrator-sa's cluster-admin to add a cross-account IAM user to aws-auth
24save-logging creates the durable save-logging-info implant and an hourly EventBridge rule, both named to sit beside the existing functions
25The rate(1 hour) rule invokes save-logging-info — the implant runs on a schedule, with no operator action
26iac-network peers the VPC and adds the routes; iac-rds and iac-eks open the RDS and EKS API security groups
P7Exfiltration & impact
Your network is my network Then held: Account credentials and both application databases
27save-logging-info exfiltrates the harvested AWS admin credentials on every hourly run
28pg_dump both the app-1 and the app-2 database over the peered link