Get listed

Broken link hijacking: reclaim dangling DNS and dead script srcs

A dangling street sign with a coral arrow.

Broken-link hijacking is a URL you still publish, pointing at a resource you no longer own.

An abandoned S3 bucket name, a lapsed domain in an old script src, or a moved GitHub Pages project can be registered by someone else. Your page then loads their file.

The usual mistake is rotating content and never grepping for third-party hosts that 404.

This page is how those takeovers happen, and the inventory that finds the next dead link before someone else registers it.

watchTowr Labs published on 4 February 2025 that it had registered about 150 abandoned Amazon S3 bucket names. Those names still sat in other people’s HTML, docs, and update URLs. Over two months the buckets took more than 8 million HTTP requests, including software updates, unsigned binaries, JavaScript files, and CloudFormation templates. first-party writeup. The miss is a name you left pointing at a host you no longer pay for.

Educating users is not a control. The control is a list of outbound names, a delete that pairs the cloud resource with the DNS row, and a browser policy that refuses a script whose bytes or host you did not mint. Keep the XSS guide next to this page when a hijacked src becomes markup. Read vulnerable versus malicious dependencies when the name lives in package-lock.json instead of a script tag. The HTTP headers guide is the longer CSP treatment.

Three names that outlive the asset

Broken-link hijacking is not one CWE. It is a name that still resolves, or still sits in your HTML, after the thing it named is gone. Three shapes show up on real sites in 2026:

A leftover CNAME or a dead script src is still a pointer at an empty lot. The next tenant inherits every request that follows that name.

SecureCoding

  • Dangling DNS. A CNAME or A record still points at a SaaS hostname, an S3 URL, a GitHub Pages host, or an Azure app that you deleted. Microsoft’s subdomain-takeover page calls that leftover row a dangling DNS entry. Anyone who can provision the same resource name on that provider inherits your hostname.
  • Expired npm or CDN assets. A script src or a CSS href still names unpkg.com, cdn.jsdelivr.net, or a vendor host you stopped paying. A version you never pinned, or a host whose DNS later lapses, is a script you no longer chose.
  • Unclaimed social hrefs. A footer still points at x.com/oldbrand, github.com/old-org, or a Facebook page you let expire. The handle is the asset. When it is free, the link on your origin is an endorsement you no longer control.
NameWhat lapsesWhat you delete
CNAMEAzure, S3, Pages, Heroku hostThe DNS row in the same change as the resource
script srcunpkg, jsDelivr, vendor CDNThe tag, or pin version plus integrity
Social hrefX, GitHub, Facebook handleThe footer link the week the handle 404s
MX / NSMail or DNS host you leftThe record. An NS lapse is the whole zone

Delete the CNAME when you delete the host

Microsoft’s page walks the safe sequence. You provision app-contogreat-dev-001.azurewebsites.net. You point greatapp.contoso.com at it. You later delete the Azure app. If the CNAME stays, the name is advertised and empty. A new subscription that recreates the same Azure FQDN inherits the traffic.

watchTowr’s 150 names were the storage form of the same miss. The bucket was gone. The URL in someone else’s HTML was not. AWS later sinkholed those specific names. That is not a control you can lean on for the next unused bucket in your own account.

Pair the deletes. The change that removes the cloud resource also removes the DNS row. If the name must stay for a while, point it at a host you still own that serves a 410, not at a provider queue that anyone can claim.

# FIX: the same change removes the resource and the name you own
# 1. Take the app or bucket out of service.
# 2. Then:
# nsupdate or your DNS API:
# UPDATE delete cdn.your-app.example. CNAME
# 3. Wait for TTL, then confirm:
dig +short CNAME cdn.your-app.example
# Expect: empty. Not a vendor hostname you no longer pay for.

NS records are the expensive sibling. If a subdomain’s nameservers point at a DNS host you cancelled, the whole label is claimable at that host. Treat NS like a CNAME to a vendor you must still have a contract with. MX is the mail form: a leftover MX at a cancelled Google Workspace or Microsoft 365 tenant is inbound mail you no longer read.

Pin the bytes on every foreign script

A script src on a host you do not operate is a trust decision you refresh every time the browser fetches it. npm versions are immutable once published. That does not save an unversioned URL. https://unpkg.com/lodash follows a tag. A maintainer account that publishes a new latest, or a CDN host whose DNS later lapses, is a new file under a name your users already trust.

chalk@5.6.1 appeared on the registry on 8 September 2025 and was live for about two hours. The lockfile page covers the install-time side. The HTML side is the same clock: a page that loaded unpkg.com/chalk without a version and without a hash would have asked the CDN for whatever that name resolved to in that window.

Two controls, both required:

<!-- BAD: floating CDN name, no hash, no fallback plan -->
<!-- <script src="https://unpkg.com/lodash"></script> -->

<!-- FIX: versioned URL, Subresource Integrity, CORS for the check -->
<script src="https://cdn.your-app.example/vendor/lodash-4.17.21.min.js"
 integrity="sha384-BASE64_OF_THE_FILE_YOU_HASHED"
 crossorigin="anonymous"></script>

Hash the file you actually committed. openssl dgst -sha384 -binary lodash-4.17.21.min.js | openssl base64 -A is the digest you put in integrity. Prefer serving that file from your origin. A first-party src plus CSP 'self' removes the vendor hostname from the inventory. If you must use a CDN, the hash is the belt that refuses a swapped body. MDN’s Subresource Integrity page is the contract: a mismatch means the script does not run. There is no silent fallback. Do not add a second script without a hash as a “backup.” That backup is the hole.

Unclaimed social hrefs are a brand hole

A footer href is an endorsement. When https://github.com/your-old-org 404s because you renamed the org, the next person who creates that org inherits every page that still points there. The same is true for an X handle, a YouTube vanity URL, and a Facebook page you stopped paying for. The control does not need a celebrity victim. It needs a crawl of your own markup.

<!-- FIX: keep the link only while the handle is still yours -->
<a href="https://github.com/your-org"
 rel="noopener noreferrer"
 target="_blank">GitHub</a>

rel="noopener noreferrer" is the tab isolation you actually need. Modern browsers default noopener on target=_blank. Set it anyway so an old WebView cannot use window.opener. That flag does not save a handle you no longer own. The weekly job that HEAD-requests every outbound social URL, and fails CI on a 404, is the control. A 404 this week is a claimable name next week. Take the <a> out in the same pull request that notices the miss.

Inventory, then expire

You cannot expire a name you have not listed. The list is three columns: the name, the owner, and the date you last proved it still points at you. DNS, HTML, and lockfiles are three sources. One spreadsheet that only covers production A records will miss the staging CNAME and the blog script.

Expire on a schedule, not on a feeling. A vendor contract that ends on Friday is a DNS delete on Friday, after a TTL-length bake. A marketing campaign that ends is a footer prune. A package you stopped importing is a script you remove, not a comment you leave for “later.”

CSP and SRI fail the load, not the page

Content-Security-Policy is the allowlist. Subresource Integrity is the hash. Together they turn a surprise host or a surprise body into a failed fetch. They do not invent a replacement. The page should still work without that widget. If the widget is load-bearing, host it yourself.

Content-Security-Policy:
 default-src 'self';
 script-src 'self';
 style-src 'self';
 img-src 'self' data:;
 object-src 'none';
 base-uri 'none';
 frame-ancestors 'none'

That policy has no CDN host. Add one host only after it is in the inventory and the file on it has an integrity value. script-src 'self' https://cdn.your-app.example is a decision you can defend. script-src * 'unsafe-inline' is theatre. The Helmet guide is the Express header pack. Write the directives for the scripts you actually serve. A nonce on first-party inline script is fine. A CDN script still needs the hash.

Prove your own zone

You are not walking a takeover. You are proving names you already own still resolve to hosts you still operate, and that a script you ship still matches the hash you stored.

  1. Export every record from the DNS host for zones you control. Keep CNAME, A, AAAA, MX, and NS.
  2. For each CNAME, resolve the target. If the target is a vendor hostname, confirm the resource still exists in an account you admin. A 404 at the vendor, a NoSuchBucket body, or a GitHub Pages “There isn’t a GitHub Pages site here” page is a delete ticket for the CNAME, not a reason to claim anything.
  3. Grep the repo for src="http, href="http, unpkg.com, jsdelivr, cdnjs, and every social host you have ever used.
  4. Recompute the SRI digest for every hashed file and compare it to the attribute in the template.
# Names you own. Empty CNAME is the post-delete proof.
dig +short CNAME cdn.your-app.example
dig +short MX your-app.example

# Page you ship. Expect your CSP and no unexpected script hosts.
curl -sI "https://your-app.example/" | grep -i content-security-policy

# Hash you stored. Must match the file in the repo.
openssl dgst -sha384 -binary vendor/lodash-4.17.21.min.js | openssl base64 -A
# Paste next to the integrity= value. They must be identical.

A CI job that fails on a dangling CNAME you listed, or on an SRI mismatch, is the five-minute proof. Do not register a vendor resource that is not yours to “see what happens.” The watchTowr number is already the existence proof. Your job is the delete.

Questions we keep getting

Is a 404 on a CNAME target already a takeover?

No. A 404 can be a vendor that only serves a subpath. Confirm the resource is gone in an account you admin, then delete the DNS row. The leftover row is the hole. The 404 is a hint.

Does SRI help if I load the script from my own origin?

A first-party src plus script-src 'self' is the stronger pair. SRI on a same-origin file still catches a bad deploy or a cache poison. Keep the hash if the file is vendored and rarely touched.

What about a CSS or font CDN?

Same inventory, same hash. style-src and font-src get the host you still pay for. A swapped stylesheet is defacement and often a script hop. Pin it or host it.