S3 bucket policy confused-deputy patterns

S3 bucket policy confused-deputy patterns

TL;DR: S3 bucket policies are evaluated against the requesting principal and the calling AWS service. When a bucket policy uses a Condition block to restrict by aws:SourceAccount, aws:SourceArn, aws:PrincipalOrgID, or aws:SourceVpc, mistakes in the condition shape (missing keys, wrong key, mixing principal and source) create confused-deputy paths where one AWS service can act on the bucket on behalf of an unintended principal. Multi-tenant SaaS on AWS has repeatedly shipped variants of this bug. Companion to cloud-iam-misconfig-patterns and aws-imds-ssrf-pivot.

The confused-deputy concept

“Confused deputy” is a 1980s security term — a privileged service does work on behalf of a caller, and the privilege of the service is conflated with the privilege of the caller. The fix is to require the caller to identify themselves in the request, and the policy enforces both identities.

For S3, the typical confused deputy is:

  • A trusted AWS service (CloudFront, CloudTrail, AWS Config, SNS, Lambda, AWS Backup) writes to the bucket.
  • The bucket policy allows the service principal (e.g., cloudfront.amazonaws.com).
  • Without a source-account / source-arn condition, any AWS customer’s CloudFront distribution can be configured to write into the bucket — because all CloudFront distributions present the same service principal.

Pattern 1 — CloudTrail / Config / VPC Flow Logs without aws:SourceAccount

Documentation snippets historically showed:

1
2
3
4
5
6
{
  "Effect": "Allow",
  "Principal": {"Service": "cloudtrail.amazonaws.com"},
  "Action": "s3:PutObject",
  "Resource": "arn:aws:s3:::audit-bucket/AWSLogs/*"
}

Any AWS account’s CloudTrail can write to your bucket — fine if you only care about ingestion, but if a downstream consumer trusts “logs in this bucket are mine”, you’ve been confused.

Fixed shape:

1
2
3
4
5
6
{
  "Condition": {
    "StringEquals": {"aws:SourceAccount": "1234567890"},
    "ArnLike": {"aws:SourceArn": "arn:aws:cloudtrail:us-east-1:1234567890:trail/*"}
  }
}

Pattern 2 — Lambda role chain without aws:SourceArn

A Lambda role allowed to read a bucket — but the bucket policy allows lambda.amazonaws.com to PUT objects without source-arn restriction. Any Lambda in any account can write.

Pattern 3 — Cross-account read with aws:PrincipalOrgID typo

1
2
3
4
5
{
  "Condition": {
    "StringEquals": {"aws:PrincipalOrgId": "o-abc1234"}
  }
}

Note the lowercase Id. The condition key is aws:PrincipalOrgID (uppercase ID). The wrong-cased key isn’t an error; it’s silently ignored. Without the condition the policy effectively allows everyone.

Same pattern with aws:sourceAccount vs aws:SourceAccount and other case-sensitive keys.

Pattern 4 — Public read with bucket-key encryption

When a bucket uses SSE-KMS with bucket keys, the KMS key policy can be confused-deputy’d. A KMS key policy that allows S3 service principal to use the key — without a source-account or via-S3 condition — lets S3-from-any-account use the key (encrypting / decrypting objects on behalf of arbitrary callers).

Pattern 5 — Pre-signed URL trust assumption

Pre-signed URLs are generated by callers with valid credentials. If your IAM role has s3:GetObject and you give it to a Lambda exposed via API Gateway, an attacker can call the Lambda to mint pre-signed URLs for any object the role can access. The “deputy” is your Lambda.

Defence: filter object keys the Lambda is allowed to sign for; don’t expose a sign-anything wrapper.

Recon approach

Audit S3 bucket policies in the target account:

  • aws s3api list-buckets
  • For each: aws s3api get-bucket-policy --bucket NAME
  • Parse the policy JSON; look for:
    • Service principals without aws:SourceAccount / aws:SourceArn.
    • Wildcard principals ("Principal": "*").
    • Mis-cased condition keys.
    • aws:PrincipalOrgID without an aws:PrincipalArn constraint when broad.

Public tools: cloudsplaining, PMapper, Prowler, policy_sentry.

Workflow to study

  1. Create two AWS accounts.
  2. In account A, create a bucket and apply a cloudtrail.amazonaws.com policy without aws:SourceAccount.
  3. In account B, configure CloudTrail to deliver to account A’s bucket. Observe success.
  4. Add aws:SourceAccount condition to account A’s bucket. Observe account B’s CloudTrail fails to deliver.

Defensive baseline

  • Always pair service-principal allowances with aws:SourceAccount (and aws:SourceArn when known).
  • Audit policy keys with a case-sensitive validator.
  • Use aws:PrincipalOrgID to restrict cross-account by org.
  • For KMS bucket keys, restrict via kms:ViaService condition.
  • EventBridge cross-account targets — similar shape.
  • SNS / SQS subscription — service principals on policies.
  • Cognito identity pool federation — confused-deputy across federated identities.
  • AssumeRoleWithWebIdentity flows — confused-deputy at the federation layer.

The class is “service principal allowed to act on a resource without identifying the upstream caller”. Audit every such policy.

References