Screenshots went round X this week of tweets from 2011. McDonald's shares what its Swedish team did, with a bit.ly link. Burger King points to its restaurant locator, with a bit.ly link. Under each, X now shows a preview card for a Wikipedia article: the September 11 attacks under one, a list of Nazi concentration camps under the other.
Neither company changed anything. Both links had died, and somebody else now owns their slugs. People replying to the screenshots say they did it themselves: find a dead bit.ly link, make a new one with the same ending, point it anywhere. X fetches the preview again, and a fifteen-year-old tweet from a brand with millions of followers shows whatever the new owner chose.
This post covers how that works, why random slugs alone don't stop it, and the rule rdyrct now follows: a slug is issued once, ever.
How the attack works
A short link is a slug on a domain: bit.ly/19BFiM. Three things have to be true for a stranger to take one over:
- The link dies. Its owner deletes it, the account closes, or the service expires it.
- The slug goes back in the pool. Nothing remembers it was ever used.
- Anyone can ask for a slug by name on the shared domain. Bitly sells custom back-halves on
bit.lyitself, so a stranger can type19BFiMand get it.
The tweet still holds the URL, and so does every email, PDF and printed QR code made with it. None of those can be edited. Whoever holds the slug decides where all of them go.
Link previews make it worse. X, Slack, WhatsApp and the rest fetch a URL again from time to time, so the old tweet doesn't show a dead link: it shows a fresh, convincing card for the new destination, under the original brand's name and logo.
Random slugs are not enough
rdyrct already broke the third condition. On the shared domain, nobody chooses a slug: every link gets seven random characters from a 31-character alphabet that leaves out lookalikes such as 0/o and 1/l. You can't ask for a specific one. Chosen slugs only exist on a custom domain, and only the domain's owner can make them.
That stops anyone targeting McDonald's link. It does not stop a lottery.
317 is about 27.5 billion slugs. Say a million of them belonged to links that have since been deleted. Each new link then has a 1 in 27,500 chance of landing on a dead one. The loop is cheap:
- Create a link.
- Search X and the web for the slug you got. Was it ever posted?
- No: delete the link and go again.
A plan's link cap doesn't slow this down, because the loop never holds more than one link at a time. Rate limits help, but over enough accounts and enough days somebody wins a dead link, and the bigger the service, the more dead links there are to win. The attacker can't pick whose link they get. They don't need to: any link that was once posted by somebody with an audience is a prize.
To aim at one particular link, they'd need about 14 billion tries on average. That's out of reach. A random dead link is not.
The rule: issue each slug once
The fix is to break the second condition. Once a slug has been issued, it never goes back in the pool, whatever happens to its link.
rdyrct keeps a table of every slug it has ever issued on the shared domain:
CREATE TABLE issued_slugs (
slug TEXT PRIMARY KEY,
issued_at INTEGER NOT NULL
) WITHOUT ROWID;
Nothing deletes from it. It holds no link, organization or user, so it outlives all three. Handing out a slug is one insert:
for (let attempt = 0; attempt < 5; attempt++) {
const slug = randomSlug();
const issued = await db
.insert(issuedSlugs)
.values({ slug, issuedAt: Date.now() })
.onConflictDoNothing()
.returning({ slug: issuedSlugs.slug });
if (issued.length > 0) return slug;
}
The primary key does the checking. If the slug was ever issued, the insert writes nothing and we draw again. Two requests that draw the same slug at the same moment can't both have it, because only one insert succeeds. There's no "check, then write" gap to race through.
A deleted link now answers 404 for good. Its slug can't come back.
Why record slugs when they're issued, not when they die
The obvious design is a list of dead slugs: when a link is deleted, add its slug. That's what we drafted first, with database triggers on delete. A review of the draft turned up the problem: links die in more ways than you'd think.
In rdyrct a slug on the shared domain stops being used when:
- a member deletes the link;
- a platform admin deletes it;
- its organization is deleted, and the links go with it;
- its owner deletes their account;
- a banned organization is purged after 90 days;
- someone clicks "Remove now" on one of a link's extra addresses, which marks the address retired without deleting anything;
- a link made on the homepage without an account isn't claimed within 24 hours, and the daily sweep removes it.
That's seven paths, and a trigger on delete misses the sixth. Any of them could gain an eighth next year. But there are only two places that hand out slugs: creating a link, and the homepage shortener. Guard those two and every way of freeing a slug is covered, including ones nobody has written yet.
When the table first went in, we filled it from everything the database still knew: every live and retired address, every homepage link, and every slug named in an abuse report. Slugs deleted before that left no trace, so a few could in principle still come round again. The pool they hide in is 27.5 billion wide.
A bug it fixed on the way
Before this change, rdyrct had two slug checks. The homepage shortener checked both the redirect store and the database. The check for a signed-in user's link only looked at organization links. So a new organization link could, at very long odds, land on a live homepage link's slug and take over its redirect. One table that both paths write to closes that too.
What this doesn't cover
- Custom domains. On
go.yourbrand.com, you choose your slugs, and only you can. Reusing one is your decision, so rdyrct leaves that alone. - A domain that changes hands. If a company lets its short-link domain lapse and someone else registers it, the new owner controls every link on it. No link service can stop that. Renew your domains.
- Someone editing a live link. Whoever controls the account (or has broken into it) can change where a link points. rdyrct checks a changed destination the same way it checks a new one, and holds it for review when it looks like a scam, because "pass the check, then swap the destination" is the standard trick.
An aside: should this be a Bloom filter?
"Have we seen this string before?" is the textbook question for a Bloom filter, so we did the sums. When a slug isn't in the redirect store, the Worker asks the database whether a just-created link is still on its way. A filter of every issued slug could answer "never issued, 404" without that read.
| Slugs issued | Filter size at 1% false positives |
|---|---|
| 1 million | 1.2 MB |
| 10 million | 12 MB |
| 100 million | 120 MB |
What it would save: Cloudflare D1 includes 25 billion row reads a month, then charges $0.001 per million. A scanner sending a billion misses a month would cost about a dollar. What it would cost: every fresh Worker loading a megabyte or more before its first miss, one writer to keep the filter current, and a 404 for any brand-new link the filter hasn't caught up with. The primary-key lookup wins at this size.
What to ask your link service
If your links are printed, posted or emailed anywhere you can't edit, ask:
- When I delete a link, can its slug ever go to someone else?
- Can anyone choose a slug on your shared domain?
- Do links on a free plan expire?
If the answers are yes, yes and yes, every link you've ever shared can be sent wherever a stranger likes, under your name. On rdyrct the answers are no, no and no.