✓ Human-authored analysis; AI used for formatting and proofreading.
In 2018, Spencer Gietzen at Rhino Security Labs published a catalogue of 24 ways to escalate from low-privilege IAM identities to full administrative access. Technique #1 was the simplest one in the entire catalogue:
aws iam attach-user-policy \
--user-name $YOUR_OWN_USERNAME \
--policy-arn arn:aws:iam::aws:policy/AdministratorAccess
Three command-line arguments. No second permission, exploit or social engineering. If your user has iam:AttachUserPolicy and the policy's Resource includes your own ARN, you are one API call away from being able to do anything in the AWS account.
The bug ships to production routinely because the policy that admits it looks reasonable on paper:
{
"Statement": [{
"Effect": "Allow",
"Action": [
"iam:GetUser",
"iam:ListAttachedUserPolicies",
"iam:AttachUserPolicy"
],
"Resource": "arn:aws:iam::111122223333:user/${aws:username}"
}]
}
Self-management is the framing. Users should be able to read their own profile, list policies attached to themselves, and (the developer who wrote this thinks) attach policies needed for their work. The bug is the third action, the others are read-only.
Catastrophic Combination
Most IAM privesc techniques require chaining: one action sets up the privilege, a second action exercises it. Self-attach is the technique that needs no chain.
Step 1: aws iam attach-user-policy --user-name eve \
--policy-arn arn:aws:iam::aws:policy/AdministratorAccess
Step 2: <whatever the attacker wants>
AdministratorAccess grants Action: * on Resource: *. After Step 1, Eve has admin. Forever, until detected. There is no stage 2 prerequisite. Detection requires either an alarm on iam:AttachUserPolicy events (CloudTrail + EventBridge + SNS) or a daily IAM-policy diff that catches the new attachment.
The relevant AWS managed policies that grant admin or near-admin when attached:
| ARN | What it grants |
|---|---|
arn:aws:iam::aws:policy/AdministratorAccess |
Action: * on Resource: * — full admin |
arn:aws:iam::aws:policy/IAMFullAccess |
All of IAM — escalate further by creating roles, attaching policies to others |
arn:aws:iam::aws:policy/PowerUserAccess |
Everything except IAM — read-write across services |
Any one of these, attached to Eve, ends the game.
The System Invariant
A user must not have
iam:AttachUserPolicywhoseResourceincludes its own ARN.
Attaching policies to yourself is the privesc. The Resource scope makes the action a self-promotion primitive.
In Stave's observation schema, the engine pre-computes a boolean by walking each user's attached policies:
{
"id": "arn:aws:iam::111122223333:user/eve",
"type": "aws_iam_user",
"properties": {
"identity": {
"kind": "user",
"escalation": {
"attach_user_policy_self": {
"present": true,
"target_user_arn": "arn:aws:iam::111122223333:user/eve",
"resource_scope": "self"
}
},
"policies": {
"attached_policies": [...]
}
}
}
}
escalation.attach_user_policy_self.present is the engine's verdict; attached_policies[].statements carries the evidence. CEL reads the boolean to fire the control. Z3 reads the statements to enumerate the specific managed policy whose attachment closes the escalation.
The Stave Control
id: CTL.IAM.ESCALATE.ATTACHUSERPOLICY.001
name: Principal Must Not Escalate via iam:AttachUserPolicy On Self
severity: critical
unsafe_predicate:
all:
- field: properties.identity.kind
op: eq
value: user
- field: properties.identity.escalation.attach_user_policy_self.present
op: eq
value: true
Two leaf clauses, both required. Severity critical because the unsafe state is one API call away from full account admin. There's no graduated risk between "user has self-attach" and "user is admin" only the attacker's choice of when to make the call.
Why CEL is Not The Whole Conversation
CEL detects the unsafe state. The customer reading the finding asks the natural follow-up:
Specifically, what policy would they attach? AdministratorAccess?
Or something narrower that still gives them what they want?
The answer matters because the prevention recommendation depends on it. If the only admin-granting policies in the AWS catalogue are well-known (and they are: AdministratorAccess, IAMFullAccess, PowerUserAccess), an SCP can deny attaching those ARNs to user identities. If there are also custom managed policies in the account that grant admin, the SCP needs to be broader.
CEL says "self-attach is admitted." Z3 enumerates the specific dangerous attachments.
The Z3 Witness Model
The companion program at stave/examples/iam-attach-user-policy-self/z3prove/ encodes:
0 = arn:aws:iam::aws:policy/ReadOnlyAccess intended (no privesc)
1 = arn:aws:iam::aws:policy/AdministratorAccess DANGEROUS
2 = arn:aws:iam::aws:policy/IAMFullAccess DANGEROUS
3 = arn:aws:iam::aws:policy/PowerUserAccess DANGEROUS
The Go side reads the user's policy statements and decides whether iam:AttachUserPolicy with the user's ARN as Resource is admitted by any Allow statement. If yes, every witness is attachable; if no, none are attachable.
Z3 then discharges:
unsafe = admitted ∧ dangerous ∧ ¬intended
Output for the broad policy:
=== before (self-attach allowed) ===
user: arn:aws:iam::111122223333:user/eve
policy statements: 2
[0] Effect=Allow Action=[iam:GetUser iam:ListAttachedUserPolicies] Resource=arn:aws:iam::111122223333:user/eve
[1] Effect=Allow Action=iam:AttachUserPolicy Resource=arn:aws:iam::111122223333:user/eve
iam:AttachUserPolicy on self admitted: true
dangerous witnesses: [...]
verdict: SAT — witness: attach arn:aws:iam::aws:policy/AdministratorAccess → user becomes admin
The witness is concrete: attach arn:aws:iam::aws:policy/AdministratorAccess to Eve, Eve is admin. The statement-level evidence in the output makes the finding reviewable: "yes, statement [1] is the one that grants the dangerous action; everything in [0] is read-only."
After the policy is fixed:
=== after (self-attach removed) ===
policy statements: 1
[0] Effect=Allow Action=[iam:GetUser iam:ListAttachedUserPolicies iam:ChangePassword] Resource=arn:aws:iam::111122223333:user/eve
iam:AttachUserPolicy on self admitted: false
verdict: UNSAT — no admin-granting policy is attachable
UNSAT iam:AttachUserPolicy is gone, so no managed policy is attachable, so no admin-granting policy is reachable. Note that iam:ChangePassword survives. That is a self-management primitive that's safe. It doesn't escalate; it just lets users reset their own password.
How This Bug Reaches Production
The pattern is consistent in incident write-ups and penetration-test reports:
- The team needs users to be able to manage their own profile (display name, MFA device, password).
- They write a "self-service IAM" policy. The first draft uses
Action: ["iam:Get*", "iam:List*", "iam:UpdateLoginProfile", "iam:ChangePassword"]and is correctly read-only. - A user requests the ability to "attach the boto3 SDK policy" to themselves for a one-off task.
- The path of least resistance is adding
iam:AttachUserPolicyto the existing self-service policy. - The added action is reviewed in isolation — "this lets the user attach a specific SDK policy, fine." The reviewer doesn't run a privesc analysis against the combined policy.
The bug is in step 4: iam:AttachUserPolicy is not a self-service primitive, even though iam:ChangePassword is. The two actions look the same in the developer's mental model. Both are "the user managing their own profile". But only one of them admits attaching AdministratorAccess.
The Remediation
Three options, ordered by leverage:
Option A: Remove iam:AttachUserPolicy from the self-service policy. Users who need new permissions go through a request workflow that lands on a privileged admin role, not on the user's own attach action.
{
"Statement": [{
"Effect": "Allow",
"Action": [
"iam:GetUser",
"iam:ListAttachedUserPolicies",
- "iam:AttachUserPolicy",
"iam:ChangePassword"
],
"Resource": "arn:aws:iam::111122223333:user/${aws:username}"
}]
}
Option B: Add a Condition denying admin-granting policy ARNs. If the team has a real use case for users to self-attach narrow policies (rare):
{
"Effect": "Allow",
"Action": "iam:AttachUserPolicy",
"Resource": "arn:aws:iam::111122223333:user/${aws:username}",
"Condition": {
"ArnNotLike": {
"iam:PolicyArn": [
"arn:aws:iam::aws:policy/AdministratorAccess",
"arn:aws:iam::aws:policy/IAMFullAccess",
"arn:aws:iam::aws:policy/PowerUserAccess",
"arn:aws:iam::*:policy/*Admin*"
]
}
}
}
This works but is brittle. Any new admin-granting managed policy AWS publishes or any custom managed policy in the account that pattern-matches none of the above defeats the deny.
Option C: SCP at the org level denying any user from attaching policies to themselves. The simplest layer:
{
"Sid": "DenyUserSelfAttach",
"Effect": "Deny",
"Action": "iam:AttachUserPolicy",
"Resource": "arn:aws:iam::*:user/${aws:username}",
"Condition": {
"ArnNotLike": {
"aws:PrincipalArn": "arn:aws:iam::*:role/IAMAdministrator"
}
}
}
Only the explicit IAMAdministrator role can call iam:AttachUserPolicy on user resources. Every other identity, including the user themselves, hits the deny. This is the only layer that catches custom managed policies that an admin published earlier and forgot about.
The Prevention Lesson
Three layers, in priority order:
Service Control Policy Option C above is the strongest layer. Denies the action class for non-bootstrap roles. Out-of-scope for this article: SCPs require AWS Organizations and the operator's role to be the org admin; small AWS estates skip them and rely on the next two layers.
IaC module enforcement. The Terraform / CDK / Pulumi module that creates a self-service IAM policy takes parameter flags for "what kinds of self-management are allowed" and never admits the iam:AttachUserPolicy shape on a user's own ARN. The module is the only sanctioned way to write a self-service policy in the org; bypassing it requires a code review that explicitly authorises the bypass.
CI invariant check. stave apply runs in CI against the pre-merge observation snapshot. A PR that introduces a user with escalation.attach_user_policy_self.present: true fails on CTL.IAM.ESCALATE.ATTACHUSERPOLICY.001 with exit code 3. The example shipped with this article is the template with same predicate and exit code.
Checklist
- No user has
iam:AttachUserPolicywhose Resource includes the user's own ARN - Self-service IAM policies are limited to read-only and password-reset actions:
iam:Get*,iam:List*,iam:ChangePassword,iam:UpdateLoginProfile,iam:ResyncMFADevice - Service Control Policy denies
iam:AttachUserPolicyon user resources for any principal except the IAM-admin role -
stave applyruns in CI; PRs withattach_user_policy_self.present: truefail - CloudTrail alarm fires on every
AttachUserPolicyevent whose target is a user (not a role); SOC reviews each event
The Rhino catalogue lists 24 IAM privesc techniques. This one is technique #1 because it is the cheapest. The right prevention is not "audit IAM more carefully", humans miss this in code review consistently. The right prevention is making the configuration shape impossible to ship: the SCP refuses to apply it, the IaC module refuses to write it, the CI gate refuses to merge it.
The example at iam-attach-user-policy-self has two binaries side by side: a CEL evaluation via pkg/stave.Apply (asserts the unsafe state when self-attach is admitted) and a Z3 SAT prover (extracts the specific managed policy ARN typically arn:aws:iam::aws:policy/AdministratorAccess whose attachment closes the escalation). The Z3 binary lives in a sibling Go module so its libz3 link stays out of Stave's main vendored tree. Stave detects this pattern and 31 other H1-grounded scenarios from local AWS configuration snapshots, without cloud credentials.
Top comments (0)