BuildNot yet confirmed elsewhere1 publisher3 min readPublished
GuardDuty says exfiltration and the patch is four hours out: revoke the sessions first
A time-based deny on aws:TokenIssueTime invalidates every credential the role already handed out while the running fleet refreshes through it. The stolen session outlives the patch window by 90 minutes.
The Engineer · Build desk

What happened
- GuardDuty raises Exfiltration:S3/AnomalousBehavior after an attacker pulls the instance role's temporary credentials from metadata and starts bulk-downloading a sensitive bucket.
- The development team's fix is four hours away, so the response has to work without any code change.
- The first action is put-role-policy attaching an inline deny named AWSRevokeOlderSessions, denying all actions where aws:TokenIssueTime is earlier than the current UTC time.
- Confirmation is get-role-policy for the attachment, then a CloudTrail lookup for AccessDenied on S3 GetObject after the cutoff timestamp.
- The walkthrough rejects the empty security group as a fix, on the grounds that the attacker's S3 calls originate from an entirely different source IP.
Why it matters
- capability Revoking by issuance time means you can cut the attacker off without knowing which key he took, where he is calling from, or how many copies exist.
- exposure Anyone who chooses to wait for the developers is exposed for the full remaining life of the stolen session, which in this example runs ninety minutes past the patch.
- constraint The bucket-wide deny is off the table during business hours: its blast radius is every principal in the account, so it trades a data read for an application outage.
- decision Revoke and isolate stop being alternatives; skipping isolation leaves the metadata harvest open, so the deny buys one credential cycle and nothing beyond it.
The condition key is doing all the work. aws:TokenIssueTime is a property of the session rather than of the caller [16], so the inline policy sorts credentials by when they were minted and takes no interest in who holds them or which network they are calling from. That is why one document can be both a kill switch and safe for production: the attacker's copy was issued before the cutoff and dies on its next request, while each instance in the fleet asks the metadata service for a fresh set and gets one stamped after the cutoff [1]. The walkthrough notes the deny is evaluated on every request, so there is no propagation window to sit through [16].
The arithmetic is what rules out waiting. The sample credentials in the post expire at 16:00Z against a revoke timestamp of 10:30Z [5]. That is five and a half hours of remaining validity on a session someone has already copied off the box, measured against a four-hour patch estimate [6]; the stolen session outlives the fix by ninety minutes [15]. Doing nothing until the developers ship is not a slower version of the same outcome.
Revocation is also not containment. The same auto-refresh that keeps legitimate callers alive is available to anyone still sitting on the instance, because harvesting is a curl against 169.254.169.254 and a copy of three values [13], and post-cutoff credentials satisfy the condition by construction [1][9]. So the two moves the post separates are complementary: swapping the instance into an empty security group does not stop the exfiltration, since the S3 calls come from a different source IP entirely [12], but leaving the instance reachable means the deny buys one round of credentials and no more.
The bucket policy is the option to keep filed away. A Deny on s3:GetObject with Principal "*" on the bucket does stop the attacker, and it equally stops the application servers reading patient data, the backup jobs, the analytics pipelines, and every other IAM principal in the account [14]. That trade converts a read of sensitive data into a full outage of everything that touches it, which is a worse incident report than the one you started with.
The unglamorous part is the discovery hop at the top. You need describe-instances against the instance ID from the finding to get the instance profile ARN, then get-instance-profile to resolve the role name [7]. In the example the profile and the role are both ProdDataAccessRole and the second call looks redundant, which is exactly the assumption that will cost time on a role where they differ. Then put-role-policy attaches the inline deny under the name AWSRevokeOlderSessions [4]; the console's Revoke Sessions tab writes the same document for you [8], which matters mostly because it is one fewer hand-typed UTC timestamp at the point in the night where a typo sets the cutoff in the wrong direction.
Validation is two calls: get-role-policy to confirm the document landed, and a CloudTrail lookup for AccessDenied on s3.amazonaws.com after the cutoff [10], plus a head-object from a healthy instance on the same role to prove production still reads [11].
What to watch
- Whether anything consuming that role holds credentials without a refresh loop, such as a set exported into an operator's shell: those consumers break rather than self-heal, which turns the revoke...
- Whether the compromised identity is an instance profile at all; for a pod or function role the describe-instances to get-instance-profile hop does not exist and role discovery has to come from...
- Whether CloudTrail actually shows AccessDenied after the cutoff, because an empty result means either the attacker stopped or he re-harvested post-revocation credentials, and those two read very...
Clarity's read
What the record supports and how the coverage leans. The claims behind it follow.
Reality
- Evidence42
- Adoption
- Insufficient
- Hype gap+12
- Incentives22
- Confidence40
Claim ledger
Ranked by verification strength, evidence, and original report placement.
- [1]
An EC2 instance that has not been isolated automatically requests new credentials from the metadata service; those new credentials carry a TokenIssueTime after the revocation timestamp and so pass the condition check.
- [2]
In the scenario, an EC2 instance with an attached IAM role holding s3:GetObject on a sensitive bucket is compromised; the attacker extracts temporary credentials from the instance metadata service and begins bulk-downloading objects.
- [3]
GuardDuty fires the finding Exfiltration:S3/AnomalousBehavior.
- [4]
The critical action is aws iam put-role-policy on the role with an inline policy named AWSRevokeOlderSessions: Effect Deny, Action ["*"], Resource ["*"], with Condition DateLessThan on aws:TokenIssueTime set to the current UTC time. This invalidates every set of temporary credentials issued before that timestamp.
- [5]
In the walkthrough's examples, the harvested credentials carry Expiration 2026-08-23T16:00:00Z and the revocation policy uses the timestamp 2026-08-23T10:30:00Z.
- [6]
The development team needs 4 hours to patch, so access has to be cut off before the fix lands.
- [7]
Role discovery is two calls: aws ec2 describe-instances on the instance ID from the finding, querying IamInstanceProfile.Arn (returning arn:aws:iam::123456789012:instance-profile/ProdDataAccessRole), then aws iam get-instance-profile to confirm the role name ProdDataAccessRole.
- [8]
The console equivalent is IAM, Roles, select the role, Revoke Sessions tab, Revoke active sessions, which attaches the same inline deny policy automatically.
- [9]
If the compromised instance is not isolated, the attacker retains the metadata-service harvest path and can obtain credentials issued after the revocation timestamp, which satisfy the DateLessThan condition, so session revocation alone is not containment.
- [10]
Verification is aws iam get-role-policy to confirm the inline policy is attached, then aws cloudtrail lookup-events filtered to EventSource s3.amazonaws.com from the revocation timestamp, looking for AccessDenied responses to GetObject calls on the bucket.
- [11]
Legitimate access is confirmed by running aws s3api head-object --bucket patient-records --key test-object.json from a healthy or test instance carrying the same role; successful metadata means production reads are intact.
- [12]
Moving the instance to an empty security group with aws ec2 modify-instance-attribute does not solve the problem: it cuts network access to and from the instance, but the attacker likely holds the credentials elsewhere and the S3 API calls come from a different source IP entirely.
- [13]
The standard attacker playbook is a curl to http://169.254.169.254/latest/meta-data/iam/security-credentials/ROLE, which returns AccessKeyId, SecretAccessKey, Token and Expiration; those three values are exported on any machine and used to run aws s3 sync against the bucket.
- [14]
A bucket policy with Effect Deny, Principal "*", Action s3:GetObject on arn:aws:s3:::patient-records/* stops the attacker and also stops the application servers reading patient data, backup jobs, analytics pipelines, and any other IAM principal in the account.
- [15]
The harvested session in the example has 5 hours 30 minutes of validity left at the moment of revocation, which is 1 hour 30 minutes longer than the 4-hour patch estimate.
- [16]
Every API call made with temporary credentials includes the aws:TokenIssueTime claim; the inline policy denies all actions for any credential set issued before the specified timestamp, and the deny is evaluated on every request, effective immediately with no propagation delay.
Sources
1 independent publisher whose own reporting we read for this story.
- dev.to# Stopping S3 Data Exfiltration in Real Time: A Step-by-Step Incident Response
1 article · August 23, 2026
Topics and entities
Follow any of these and your For You feed starts watching them — no settings page required.
Topics
Entities
- Amazon Web ServicesFollow
- AWS IAMFollow
- Amazon Simple Storage ServiceFollow
- Amazon EC2Follow
- Amazon GuardDutyFollow
- AWS CloudTrailFollow
- AWS STSFollow
- EC2 Instance Metadata ServiceFollow
- AWS CLIFollow
- Exfiltration:S3/AnomalousBehaviorFollow
- aws:TokenIssueTimeFollow
- dev.toFollow