✓ Human-authored analysis; AI used for formatting and proofreading.
S3 bucket names are globally unique across every AWS account. When a bucket is deleted, the name returns to the global pool and the next AWS user to type aws s3 mb s3://that-name owns it. If a CDN origin, a Route 53 CNAME, or an application config inside another organisation is still pointing at that name, the new owner now serves content under the original organisation's domain.
Eight HackerOne reports on file describe this same shape:
-
Bime (121461) —
a2.bime.ioCNAME pointing atbimeio.s3.amazonaws.com, bucket deleted. -
Brave (1791558) —
package channel
brave-aptfor APT-based Linux distributions. -
Brave (1835133) —
release channel
brave-browser-rpm-staging-release-testfor RPM packages. - DoD (918946) — Department of Defense subdomain.
- HackerOne (1598347) — HackerOne's own subdomain.
- IBM (2498255).
- Khan Academy (1777077).
- Tendermint (1397826).
Five companies, three different reference shapes (DNS CNAME, CDN origin, package-distribution mirror), one configuration defect. The Brave pair is instructive: the same team had it happen twice, in two different package channels, because the workflow that creates the dangling reference is the same workflow that hides it.
Dangling and Its Consequences
Three commands establish the takeover:
# 1. find the dangling reference
dig assets.acme.example
# assets.acme.example → acme-cdn-assets.s3.amazonaws.com
# 2. confirm the bucket is unclaimed
aws s3 ls s3://acme-cdn-assets 2>&1
# NoSuchBucket: The specified bucket does not exist
# 3. claim it
aws s3 mb s3://acme-cdn-assets --region us-east-1
# make_bucket: acme-cdn-assets
The CDN now serves whatever the attacker uploads to that bucket, under the original domain, with the original TLS certificate, trusted by every browser that respects the original SAN. The attacker uploads a JavaScript file; the legitimate domain delivers it; the same-origin policy treats it as if the legitimate operator wrote it.
For software-distribution buckets (the Brave APT/RPM cases) the escalation path is even shorter: the attacker uploads a malicious .deb or .rpm, every CI pipeline pulling from that mirror installs it, the supply chain compromise is automatic.
Traditional Tooling Fails
CSPM products inventory in-account resources. The dangling reference is by definition not in the account anymore. The bucket was deleted, so there's nothing for the scan to evaluate. The DNS record sits in Route 53 or a registrar's panel; the CDN origin sits in CloudFront's distribution config. Neither tool cross-references the name against the account inventory.
The NoSuchBucket HTTP response that assets.acme.example returns is a valid response. Uptime monitors see "not 200, but not 5xx either" and don't page. The team's first signal is a customer reporting that assets.acme.example is serving something weird.
Teams Fail to Cleanup
The sequence is consistent across all eight reports:
- A team needs static assets / a package mirror / a marketing subdomain.
- They create the bucket and wire up a CDN origin or DNS CNAME.
- The feature is deprecated, deprovisioned, or rotated to a new bucket name.
- The bucket is deleted (often via a cleanup script that did the right thing).
- The CDN origin / DNS record is not removed. It lives in a different system, often owned by a different team, and the cleanup ticket got filed under the old project.
The bucket deletion command has a confirmation prompt. The CDN origin removal lives behind a different IAM role. The DNS change goes through a separate change-management process. A single team rarely owns all three; the cleanup that crosses those boundaries gets dropped.
The System Invariant
Every external reference to an S3 bucket name (DNS CNAME,
CDN origin, application config) must resolve to a bucket that
exists in the account that owns the reference.
The reference and the bucket are joined by name, not by ARN. That makes the pattern dangerous. ARNs in IAM policies fail loudly when the resource is gone; names in DNS records just keep resolving, returning NoSuchBucket until somebody claims the namespace.
Stave Model of Asset
The vulnerability lives in the reference, so Stave models the reference as a first-class asset:
{
"id": "acme-cdn-origin-assets",
"type": "s3_bucket_reference",
"vendor": "aws",
"properties": {
"s3_ref": {
"reference_kind": "cloudfront_origin",
"endpoint": "assets.acme.example",
"bucket": "acme-cdn-assets",
"bucket_exists": false,
"bucket_owned": false
}
}
}
reference_kind distinguishes a CDN origin from a DNS CNAME from an app config. It is useful for triage routing, not for the predicate. bucket_exists and bucket_owned are the two booleans the control consults; the collector that produces the observation does the lookup against the account's bucket inventory.
The Control Predicate
id: CTL.S3.BUCKET.TAKEOVER.001
name: Referenced S3 Buckets Must Exist And Be Owned
severity: critical
unsafe_predicate:
any:
- field: properties.s3_ref.bucket_exists
op: eq
value: false
- field: properties.s3_ref.bucket_owned
op: eq
value: false
Either condition fires the control. bucket_exists: false catches the deleted-bucket case. bucket_owned: false catches the wrong-owner case. The bucket exists, but it's in someone else's AWS account (the attacker has already claimed it, or it was always external).
Reproducing the Detection
The repository ships a self-contained example at stave/examples/s3-bucket-name-dangling/:
go run ./examples/s3-bucket-name-dangling before
The fixture under fixtures/before/ is a CloudFront origin referencing the bucket acme-cdn-assets, which the account does not own. Captured stdout (in expected/before-output.txt):
=== before (dangling) ===
status: NON_COMPLIANT total_assets=1 violations=1
CTL.S3.BUCKET.TAKEOVER.001 fired on 1 asset(s):
- acme-cdn-origin-assets severity=critical exposure_score=100.00
assertion: fires=true (expected) ✓
severity=critical because the takeover gives an attacker script-execution privilege under the legitimate domain. The blast radius is the entire user base of the dangling endpoint.
Why Z3 Doesn't Help
s3-public-read-policy, s3-public-list-policy, s3-broad-write-scope, and s3-tenant-prefix-isolation are reachability questions: "is there a principal/action/condition that satisfies the unsafe predicate?" An SMT solver helps because the search space is open.
Bucket takeover is a name-lookup problem. The bucket either exists in the account or it doesn't. The collector resolves that as a discrete fact at observation time; the predicate is a single equality check. CEL evaluates that in microseconds.
There is no logical structure for Z3 to chew on. The unsafe state is a flat false boolean.
This is the prevention article's other half: the same Stave that uses Z3 for reachability questions uses CEL for presence checks, because reaching for a heavyweight tool when a light one fits is the wrong instinct.
The Remediation
Two options, both small:
A. Claim the bucket name in your account:
aws s3 mb s3://acme-cdn-assets --region us-east-1
aws s3api put-public-access-block \
--bucket acme-cdn-assets \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,\
BlockPublicPolicy=true,RestrictPublicBuckets=true
The bucket can be empty. The point is to claim the namespace before an attacker does, then lock it down so it cannot be weaponised even by an internal misconfiguration later.
B. Remove the reference:
aws cloudfront update-distribution \
--id E1234567890ABC \
--if-match <ETag> \
--distribution-config file://updated-config-without-origin.json
…or aws route53 change-resource-record-sets for DNS, or a config push for application-level references. Option B is the right answer when the resource is deprovisioned, the reference shouldn't exist at all.
Do option A first if option B has any deployment delay. The window between "we know about the dangling reference" and "the reference is gone" is the window an attacker has to claim the namespace. Holding the name yourself is instant; DNS changes are not.
The Prevention Lesson
The eight reports happened because deletion ordering wasn't enforced. Two layers close that:
Provisioning policy. Buckets are created via an IaC module that records the bucket name in a registry the references (CDN origins, DNS records) read from. Deletion is gated on the registry showing zero references. The deletion confirmation prompt becomes a list of references that must be removed first, not a free-form Y/N.
Continuous reference scanning. A scheduled job reads the account's CloudFront distributions, Route 53 zones, and application config, then resolves every bucket name against the account's bucket inventory. Findings are s3_bucket_reference assets with bucket_exists: false — the shape this example produces. The scan runs daily; the SLA on CTL.S3.BUCKET.TAKEOVER.001 is one hour.
For new references, an SCP can deny CloudFront origin or Route 53 CNAME creation against any non-existent bucket name, but the cross-account nature of S3 names limits how completely you can enforce this, the bucket might exist in a different account, and the SCP only sees yours.
Checklist
- Every CloudFront distribution origin is verified against the account bucket inventory at provisioning time
- Every Route 53 CNAME pointing to
*.s3.amazonaws.comor*.s3-*.amazonaws.comis verified the same way - S3 bucket deletion workflow blocks on outstanding references in CDN config and DNS zones
- A scheduled job runs
stave applyagainst the account observation snapshot daily;CTL.S3.BUCKET.TAKEOVER.001pages on-call - When deprecating a bucket, the team claims an empty bucket of the same name as a holdover before deleting the reference
The reports span six years and span industries. The configuration that exposed each one was identical. The right number of dangling S3 references is zero.
The example at s3-bucket-name-dangling is a self-contained Go program that loads two fixture snapshots, runs pkg/stave.Apply, asserts that CTL.S3.BUCKET.TAKEOVER.001 fires on the dangling fixture and is silent on the claimed one, and exits zero when both assertions hold. A previous article, The Bucket You Deleted is Still in Your DNS, walks through one of the eight reports (Bime) in case-study depth; this article generalises across the cluster. Stave detects this pattern and 31 other H1-grounded scenarios from local AWS configuration snapshots, with no cloud credentials.
Top comments (0)