Initializing Enclave...

Fixing Cross-Account S3 'Access Denied': Object Ownership Mismatch When Bucket Owner and Object Owner Differ

Threat/Impact Level: HIGH | Exploitability/Downtime Risk: HIGH | Time to Fix: 15 mins


TL;DR

  • What broke: Account B uploaded objects into Account A's S3 bucket. Account B retained object ownership. Account A's IAM principals hit Access Denied because IAM policies only govern resources the principal's account owns—they cannot override ownership held by a foreign account.
  • How to fix it: Enable S3 Object Ownership = BucketOwnerEnforced on the bucket. This disables ACLs entirely and forces all newly written objects to be owned by the bucket owner. Pair this with a bucket policy Deny on s3:PutObject unless s3:x-amz-object-ownership is absent (ACLs are already disabled, so the condition is implicit).
  • Use our Client-Side Sandbox above to paste your bucket policy and cross-account IAM role—it will auto-detect the ownership misconfiguration and emit corrected, least-privilege policies without sending your ARNs to any external server.

The Incident (What Does the Error Mean?)

Raw error returned to the calling principal in Account A:

An error occurred (AccessDenied) when calling the GetObject operation:
  Access Denied

Or via S3 server access logs / CloudTrail:

{
  "eventName": "GetObject",
  "errorCode": "AccessDenied",
  "errorMessage": "Access Denied",
  "userIdentity": { "accountId": "111111111111" },
  "requestParameters": { "bucketName": "corp-data-bucket", "key": "uploads/report.csv" }
}

Immediate consequence: Every object written by Account B is inaccessible to Account A's Lambda functions, Glue jobs, and IAM roles—even the bucket owner. The bucket policy is irrelevant here. S3 evaluates object-level ownership first. If the object owner is not the bucket owner, the bucket owner's policies do not apply to that object unless the object owner explicitly grants access via ACL or the bucket enforces ownership transfer at write time.


The Attack Vector / Blast Radius

This is not just an ops headache—it is an active data exfiltration and persistence vector.

Scenario 1 — Exfiltration via object squatting: A compromised Account B credential uploads malicious or sensitive objects into a shared bucket. Account A cannot read, delete, or audit those objects through standard IAM controls. Account A's security team is blind to the object's contents and cannot enforce data lifecycle policies on objects it does not own.

Scenario 2 — Ransomware-style lock-out: A malicious or misconfigured third-party pipeline writes thousands of objects into Account A's bucket. Account A cannot delete them (s3:DeleteObject is also blocked by ownership). Storage costs accrue; data pipeline SLAs break; incident response is blocked.

Scenario 3 — Compliance failure: PCI-DSS, HIPAA, and SOC 2 controls require the data custodian (Account A) to demonstrate full control over data at rest. Objects owned by a foreign account break that chain of custody. An auditor will flag this immediately.

Blast radius: All IAM roles, Lambda execution roles, Glue crawlers, EMR clusters, and SageMaker pipelines in Account A that read from this bucket are simultaneously broken. If this bucket is upstream of a data lake, the entire downstream DAG fails silently or with cryptic downstream errors.


How to Fix It (The Solution)

Root Cause in One Line

S3 access evaluation order: Object Owner ACL → Bucket Policy → IAM Policy. When the object owner is a foreign account, the bucket owner's IAM and bucket policies are evaluated only if the object owner has explicitly granted bucket-owner-full-control via ACL—or ACLs are disabled entirely via BucketOwnerEnforced.


Basic Fix — Require bucket-owner-full-control ACL on Upload (Legacy, Not Recommended)

This works only if you control Account B's upload code. Add a bucket policy that denies s3:PutObject unless the request includes the bucket-owner-full-control canned ACL.

# Bucket Policy on Account A's bucket
{
  "Version": "2012-10-17",
  "Statement": [
-   {
-     "Sid": "AllowCrossAccountPut",
-     "Effect": "Allow",
-     "Principal": { "AWS": "arn:aws:iam::222222222222:role/upload-role" },
-     "Action": "s3:PutObject",
-     "Resource": "arn:aws:s3:::corp-data-bucket/*"
-   }
+   {
+     "Sid": "AllowCrossAccountPutWithOwnerControl",
+     "Effect": "Allow",
+     "Principal": { "AWS": "arn:aws:iam::222222222222:role/upload-role" },
+     "Action": "s3:PutObject",
+     "Resource": "arn:aws:s3:::corp-data-bucket/*",
+     "Condition": {
+       "StringEquals": {
+         "s3:x-amz-acl": "bucket-owner-full-control"
+       }
+     }
+   }
  ]
}

Account B's upload code must also be patched:

# Account B — boto3 upload call
- s3_client.put_object(
-     Bucket='corp-data-bucket',
-     Key='uploads/report.csv',
-     Body=data
- )
+ s3_client.put_object(
+     Bucket='corp-data-bucket',
+     Key='uploads/report.csv',
+     Body=data,
+     ACL='bucket-owner-full-control'
+ )

Limitation: This relies on Account B being cooperative and correctly configured. Any upload that omits the ACL header will be denied, which breaks pipelines until Account B fixes their code.


Enterprise Best Practice — S3 Object Ownership: BucketOwnerEnforced (Disable ACLs)

This is the AWS-recommended, permanent fix for all new and existing buckets used in cross-account patterns. It disables ACLs entirely. All objects written to the bucket are automatically owned by the bucket owner regardless of which account uploaded them.

Step 1: Enable BucketOwnerEnforced via Terraform

 resource "aws_s3_bucket_ownership_controls" "corp_data" {
   bucket = aws_s3_bucket.corp_data.id
   rule {
-    object_ownership = "BucketOwnerPreferred"
+    object_ownership = "BucketOwnerEnforced"
   }
 }

Step 2: Remove the ACL condition from the bucket policy (ACLs no longer exist)

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCrossAccountPut",
      "Effect": "Allow",
      "Principal": { "AWS": "arn:aws:iam::222222222222:role/upload-role" },
      "Action": [
        "s3:PutObject",
+       "s3:GetObject",
+       "s3:DeleteObject"
      ],
-     "Resource": "arn:aws:s3:::corp-data-bucket/*",
-     "Condition": {
-       "StringEquals": { "s3:x-amz-acl": "bucket-owner-full-control" }
-     }
+     "Resource": "arn:aws:s3:::corp-data-bucket/*"
    }
  ]
}

Step 3: Remove ACL header from Account B's upload code (it will be rejected if sent)

 s3_client.put_object(
     Bucket='corp-data-bucket',
     Key='uploads/report.csv',
     Body=data,
-    ACL='bucket-owner-full-control'
 )

Step 4: Migrate existing orphaned objects (objects uploaded before BucketOwnerEnforced was set still have the old ownership):

# Copy objects in-place to transfer ownership to bucket owner
aws s3 cp s3://corp-data-bucket/ s3://corp-data-bucket/ \
  --recursive \
  --metadata-directive REPLACE \
  --source-region us-east-1 \
  --region us-east-1

⚠️ This in-place copy re-writes objects under Account A's identity, transferring ownership. Test on a non-production prefix first. Large buckets require S3 Batch Operations.


💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.


Prevention in CI/CD

1. Checkov — Block BucketOwnerPreferred or ObjectWriter at Plan Time

Add to your .checkov.yml:

checks:
  - CKV2_AWS_65  # Ensure S3 bucket ACL is disabled (BucketOwnerEnforced)

Run in pipeline:

checkov -d ./terraform --check CKV2_AWS_65 --hard-fail-on HIGH

2. OPA / Conftest Policy for Terraform Plans

# policy/s3_ownership.rego
package terraform.s3

deny[msg] {
  resource := input.resource_changes[_]
  resource.type == "aws_s3_bucket_ownership_controls"
  resource.change.after.rule[_].object_ownership != "BucketOwnerEnforced"
  msg := sprintf(
    "S3 bucket '%v' must use BucketOwnerEnforced. Got: %v",
    [resource.name, resource.change.after.rule[_].object_ownership]
  )
}
terraform show -json tfplan.binary | conftest test -p policy/ -

3. AWS Config Rule — Continuous Compliance

Deploy the managed rule s3-bucket-acl-prohibited to all accounts in your AWS Organization via CloudFormation StackSets. This flags any bucket not set to BucketOwnerEnforced within minutes of creation.

# config-rule.yaml
AWSTemplateFormatVersion: '2010-09-09'
Resources:
  S3AclProhibitedRule:
    Type: AWS::Config::ConfigRule
    Properties:
      ConfigRuleName: s3-bucket-acl-prohibited
      Source:
        Owner: AWS
        SourceIdentifier: S3_BUCKET_ACL_PROHIBITED

4. SCPs — Preventive Control at the Org Level

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyS3PutWithACL",
      "Effect": "Deny",
      "Action": "s3:PutObject",
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "s3:x-amz-acl": [
            "public-read",
            "public-read-write",
            "authenticated-read"
          ]
        }
      }
    }
  ]
}

Attach this SCP to the OU containing all workload accounts. This does not block bucket-owner-full-control ACL (for legacy compatibility) but kills public ACL grants at the organization boundary.

Related Diagnostics

"Part of the Security Utility Matrix."

View all 140 Security Tools →