AWS S3 Bucket Security Testing: Finding Misconfigurations That Expose Data

S3 bucket misconfigurations are responsible for some of the largest data breaches in cloud computing history. Misconfigured buckets have exposed healthcare records, financial data, source code repositories, and customer PII at scale — often because a developer set a bucket to public for debugging and never changed it back, or because infrastructure-as-code templates ship with overly permissive defaults.

This guide covers how to enumerate and test S3 bucket security controls as part of a web application or cloud penetration test — from unauthenticated access checks through to IAM policy analysis and cross-account exposure patterns.

Understanding S3 Access Control Layers

S3 has four overlapping access control mechanisms, and the interaction between them is where misconfigurations live. A tester needs to understand all four to accurately assess exposure.

Control Layer Scope Where It Breaks
Block Public Access (BPA) Account or bucket level Disabled at account level; bucket-level override
Bucket ACL Per-bucket, per-object Deprecated but still active; AllUsers grants
Bucket Policy Bucket-wide JSON policy Wildcard principals (*); missing Condition blocks
IAM Identity Policy Per-user or per-role Overly broad s3:* grants; missing resource restrictions

AWS evaluates these in order. Block Public Access is the override switch — if enabled at the account level, it blocks public access regardless of bucket policies or ACLs. If BPA is disabled, the other layers determine access. A bucket policy with Principal: "*" and no Condition block grants read access to the entire internet even if there is no corresponding ACL grant.

Phase 1: Bucket Discovery

Before testing access controls, you need to know which buckets exist. Several discovery approaches work depending on scope and authorization level.

Passive Discovery from Application Artifacts

S3 bucket names and regions often appear in application source code, JavaScript bundles, API responses, and HTML comments. The naming conventions are predictable enough that once you have one bucket name you can often enumerate adjacent buckets.

# Extract S3 URLs from JS bundles loaded by the target app
# Replace target.com with your target
curl -s https://target.com | grep -oP 'https?://[a-zA-Z0-9._-]+\.s3[a-zA-Z0-9.-]*\.amazonaws\.com[^\s"<>]*'

# Also grep for virtual-hosted style references
grep -oP 's3://[a-zA-Z0-9._-]+' *.js

# Check AWS_DEFAULT_REGION leaks (sometimes exposed in headers)
curl -I https://target.com | grep -i 'x-amz-bucket-region'

Active Enumeration with S3Scanner

S3Scanner checks a list of candidate bucket names for public access and listing permissions:

# Install S3Scanner
pip3 install s3scanner

# Generate wordlist from target name + common suffixes
cat > candidate-buckets.txt << 'EOF'
target-app
target-app-prod
target-app-staging
target-app-dev
target-app-backup
target-app-assets
target-app-uploads
target-app-media
target-app-static
target-app-logs
target-com-assets
target.com
EOF

# Scan all candidates
s3scanner scan --buckets-file candidate-buckets.txt

# Also enumerate from a single known bucket
s3scanner dump --bucket known-bucket-name --dump-dir ./dump

S3Scanner reports public listing status, read access, and write access for each bucket. Buckets that allow anonymous listing are always critical findings — the object list itself often reveals sensitive file names even before you retrieve the files.

DNS-Based Discovery

Virtual-hosted style S3 URLs resolve as <bucket-name>.s3.amazonaws.com. DNS enumeration tools can brute-force subdomains across your target's domain and identify any that resolve to S3. A CNAME pointing to S3 with no bucket configured is a subdomain takeover candidate.

# Check if a domain uses S3 via CNAME
dig CNAME assets.target.com

# If it resolves to something like:
# assets.target.com. CNAME target-app-assets.s3.amazonaws.com.
# Then test if that bucket exists and is unclaimed

aws s3 ls s3://target-app-assets --no-sign-request 2>&1

Phase 2: Unauthenticated Access Testing

Once you have a bucket name, test each permission without credentials first. The --no-sign-request flag makes unauthenticated requests.

# Test unauthenticated listing
aws s3 ls s3://bucket-name --no-sign-request

# If listing works, download specific files
aws s3 cp s3://bucket-name/config.json . --no-sign-request

# Test write access (use a test prefix, clean up after)
echo "pentest" | aws s3 cp - s3://bucket-name/pentest-DELETEME.txt --no-sign-request

# Test delete access
aws s3 rm s3://bucket-name/pentest-DELETEME.txt --no-sign-request

# Enumerate objects without recursive listing (if listing is denied)
# Try guessing common paths
for path in backup config credentials secrets database; do
    aws s3 ls s3://bucket-name/$path/ --no-sign-request 2>&1 | grep -v 'NoSuchKey\|AccessDenied' && echo "FOUND: $path"
done

Direct HTTP Access

Test direct HTTPS access to the bucket endpoint before assuming the bucket is locked down. Some buckets block the AWS API but allow direct HTTP access due to inconsistent policy application:

# Path-style access (older format, still works)
curl -v https://s3.amazonaws.com/bucket-name/

# Virtual-hosted style (current default)
curl -v https://bucket-name.s3.amazonaws.com/

# Regional endpoint
curl -v https://bucket-name.s3.eu-west-1.amazonaws.com/

# Test directory listing (XML response if allowed)
curl -s https://bucket-name.s3.amazonaws.com/?list-type=2 | xmllint --format -

Phase 3: Authenticated Access Testing

With valid AWS credentials (from a compromised IAM user, instance metadata, or assumed role), test the full permission set against each bucket in scope.

Permission Enumeration with IAM Policy Analysis

# List what the current caller identity can do
aws sts get-caller-identity

# Enumerate bucket access for all buckets in the account
aws s3api list-buckets --query 'Buckets[*].Name' --output text | tr '\t' '\n' > all-buckets.txt

# For each bucket, check the effective permissions
for bucket in $(cat all-buckets.txt); do
    echo "=== $bucket ==="
    # Check if block public access is on
    aws s3api get-public-access-block --bucket "$bucket" 2>&1 | grep -E 'BlockPublicAcls|IgnorePublicAcls|BlockPublicPolicy|RestrictPublicBuckets'
    # Check the bucket ACL
    aws s3api get-bucket-acl --bucket "$bucket" 2>&1 | grep -E 'URI|Permission' | grep -v 'Emails'
    # Check for a bucket policy
    aws s3api get-bucket-policy --bucket "$bucket" 2>&1 | python3 -m json.tool 2>/dev/null | grep -E 'Principal|Action|Effect'
    echo ""
done

Bucket Policy Analysis

The most dangerous patterns in bucket policies are wildcard principals and missing condition blocks. Retrieve and analyze each policy:

# Get and format the bucket policy
aws s3api get-bucket-policy --bucket target-bucket | jq '.Policy | fromjson'

# Check for wildcard principal (public access)
# Dangerous: "Principal": "*" with no Condition
# Dangerous: "Principal": {"AWS": "*"} with no Condition
# Safe: "Principal": {"AWS": "arn:aws:iam::123456789:root"} (specific account)

# Check bucket ACL for AllUsers or AuthenticatedUsers grants
aws s3api get-bucket-acl --bucket target-bucket | jq '.Grants[] | select(.Grantee.URI != null)'
# URI http://acs.amazonaws.com/groups/global/AllUsers = world-readable
# URI http://acs.amazonaws.com/groups/global/AuthenticatedUsers = any AWS account

Phase 4: Server-Side Encryption and Compliance Testing

Beyond access control, assess whether data at rest is encrypted and whether encryption is enforced at the policy level.

# Check bucket-level encryption configuration
aws s3api get-bucket-encryption --bucket target-bucket

# Check if bucket policy enforces encrypted uploads
# Look for deny statements on non-encrypted PUTs:
# "Condition": {"StringNotEquals": {"s3:x-amz-server-side-encryption": "AES256"}}

# Check for object-level encryption on a sample file
aws s3api head-object --bucket target-bucket --key sample.txt | jq '{ServerSideEncryption, SSEKMSKeyId}'

# Check MFA Delete is enabled (critical for preventing accidental/malicious deletion)
aws s3api get-bucket-versioning --bucket target-bucket | jq '{Status, MFADelete}'

# Check for lifecycle rules that might purge logs or backups
aws s3api get-bucket-lifecycle-configuration --bucket target-bucket 2>/dev/null | jq '.Rules[] | {ID, Status, Expiration}'

Phase 5: Cross-Account and CORS Misconfiguration

Cross-Account Bucket Access

Bucket policies can grant access to other AWS accounts. This is legitimate for multi-account architectures but frequently misconfigured. A policy that grants s3:GetObject to an entire account (arn:aws:iam::123456789:root) means any principal in that account can read from the bucket — including IAM users you don't control if the target granted access to a partner account that itself was compromised.

# Look for cross-account principals in bucket policies
aws s3api get-bucket-policy --bucket target-bucket | \
    jq '.Policy | fromjson | .Statement[] | select(.Principal.AWS | type == "string" or type == "array") | {Effect, Principal, Action}'

# Check if any cross-account role can assume into the target account
# and then access the bucket
aws sts assume-role --role-arn arn:aws:iam::TARGET-ACCOUNT:role/CrossAccountRole --role-session-name test 2>&1

CORS Misconfiguration

S3 CORS rules control which origins can make cross-origin requests to bucket objects. Wildcard origins combined with AllowedHeaders: ["*"] let any website read from the bucket using a victim's browser session — a common issue when developers configure CORS for a CDN and use * as a shortcut.

# Check CORS configuration
aws s3api get-bucket-cors --bucket target-bucket | jq '.CORSRules[]'

# Dangerous pattern:
# "AllowedOrigins": ["*"]
# "AllowedMethods": ["GET", "PUT"]
# "AllowedHeaders": ["*"]

# Safe pattern:
# "AllowedOrigins": ["https://app.target.com"]
# "AllowedMethods": ["GET"]

# Test CORS from an attacker origin
curl -H "Origin: https://attacker.com" \
     -H "Access-Control-Request-Method: GET" \
     -X OPTIONS \
     https://bucket-name.s3.amazonaws.com/object.txt -v 2>&1 | grep -i 'access-control'

Phase 6: Logging and Monitoring Assessment

A bucket without access logging can be exfiltrated silently. Check whether logging is enabled and whether logs themselves are protected.

# Check S3 server access logging
aws s3api get-bucket-logging --bucket target-bucket

# Check if CloudTrail data events capture S3 activity
aws cloudtrail describe-trails | jq '.trailList[] | {Name, S3BucketName, IncludeGlobalServiceEvents}'

# Verify CloudTrail actually logs S3 data events (not just management events)
aws cloudtrail get-event-selectors --trail-name default-trail | \
    jq '.EventSelectors[] | {ReadWriteType, DataResources}'

# Check if GuardDuty S3 protection is enabled
aws guardduty list-detectors | jq -r '.DetectorIds[]' | \
    xargs -I{} aws guardduty get-detector --detector-id {} | \
    jq '.DataSources.S3Logs.Status'

Common High-Severity Findings

Finding Severity Exploitability
Public bucket listing + sensitive files Critical No auth required, direct download
Write access to unauthenticated callers Critical Malware hosting, data injection
ACL grants to AuthenticatedUsers High Any AWS account can read/write
Wildcard CORS origin with AllowedHeaders: * High Cross-origin data theft via victim browser
No encryption at rest, no upload enforcement Medium Compliance failure, no compensating control
Unprotected log bucket Medium Log tampering; destroys forensic evidence
MFA Delete disabled on versioned bucket Medium Ransomware can delete version history
Cross-account wildcard grants High Lateral movement from compromised partner

Testing Pre-Signed URL Security

Pre-signed URLs grant temporary access to private objects without requiring AWS credentials. They are commonly used for file download links in applications. Test these for expiry enforcement, parameter manipulation, and signature validation weaknesses.

# Generate a test pre-signed URL (with credentials)
aws s3 presign s3://target-bucket/document.pdf --expires-in 3600

# URL format:
# https://bucket.s3.region.amazonaws.com/object?X-Amz-Algorithm=...&X-Amz-Expires=3600&X-Amz-Signature=...

# Test: does the URL still work after expiry?
# (Wait for expiry window, then retry)

# Test: can you modify the object key while keeping the signature?
# Replace /document.pdf with /admin-document.pdf in the URL
# Should fail with SignatureDoesNotMatch

# Test: is the expiry window too long? (e.g. 7 days instead of 15 minutes)
# Extract and decode the X-Amz-Date and X-Amz-Expires parameters
python3 -c "
import urllib.parse
url = 'PASTE_URL_HERE'
params = dict(urllib.parse.parse_qsl(urllib.parse.urlparse(url).query))
print('Expiry (seconds):', params.get('X-Amz-Expires'))
print('Signed at:', params.get('X-Amz-Date'))
"

S3 Security Testing Checklist

  • Enumerate bucket names from JS bundles, API responses, and CNAME records
  • Test unauthenticated listing, read, and write with --no-sign-request
  • Check Block Public Access settings at both account and bucket level
  • Review bucket ACLs for AllUsers or AuthenticatedUsers grants
  • Parse bucket policies for wildcard principals and missing Condition blocks
  • Test CORS configuration — restrict origins, avoid AllowedHeaders: ["*"]
  • Verify server-side encryption is enabled and enforced via bucket policy deny
  • Check versioning status and MFA Delete for critical buckets
  • Confirm S3 access logging is enabled and log bucket is write-protected
  • Test pre-signed URL expiry windows and parameter manipulation
  • Check for cross-account grants that could be exploited via compromised partner accounts
  • Enumerate objects in publicly accessible buckets for sensitive file names and content
  • Check for static website hosting enabled — it disables private access by default
  • Test for subdomain takeover via unclaimed CNAME to S3

Remediation Priorities

Enable Block Public Access at the AWS account level first — it overrides all bucket-level settings and closes the most common exposure path in a single operation. Verify with aws s3control get-public-access-block --account-id YOUR-ACCOUNT-ID.

For bucket policies, replace Principal: "*" with explicit ARNs wherever possible. Where public read is genuinely required (static assets, CDN origin), restrict Action to s3:GetObject only, add a Condition block enforcing HTTPS (aws:SecureTransport: true), and set the most restrictive CORS origin list that works for your use case.

Enforce encrypted uploads at the bucket policy level — a deny statement on PutObject without s3:x-amz-server-side-encryption ensures no unencrypted object can be stored regardless of how the upload was initiated.

Ironimo scans cloud-connected web applications for S3 misconfiguration patterns — public bucket exposure surfaced by the app, pre-signed URL vulnerabilities, and CORS flaws that enable cross-origin data theft. It uses the same Kali Linux toolset professional pentesters run against AWS environments.

Run on-demand or on a schedule. Every finding includes the reproduction steps, the exact request that confirmed it, and a remediation path.

Start free scan
← Back to blog