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