A honeypot that hands out fake AWS credentials — and knows who took them
In 2019, a misconfigured web application firewall let an attacker send one crafted
request to a server running on AWS. That request reached
169.254.169.254 — the EC2 Instance Metadata Service, a link-local
address every AWS instance can query to fetch its own configuration — and asked
it for the temporary IAM credentials attached to the instance's role. IMDS handed them
over, no questions asked, because at the time it didn't ask any. Those credentials were
then used to read S3 buckets containing roughly 100 million people's data. That's Capital
One's 2019 breach, and it's the textbook case for a whole attack class: Server-Side
Request Forgery against cloud metadata endpoints.
AWS's own response, a few months later, was IMDSv2: a second version of the same
service that requires a session token, fetched via a separate PUT request,
before any metadata GET succeeds. It's real defense-in-depth, and it's specifically
designed to make exactly that class of SSRF harder to weaponize blind.
Which raised an annoying question for us: if we wanted a decoy version of this endpoint — something that hands an attacker fake-but-perfect-looking credentials instead of real ones, wasting their next step and telling us it happened — does it need to fake the IMDSv2 handshake too, or is IMDSv1 close enough?
what it actually is
A drop-in decoy for the exact IMDS path Capital One's attacker hit: the IAM
security-credentials endpoint. Deployed via the same kind of network redirect that
exposed the real one — a NAT/iptables rule pointing 169.254.169.254
at this instead — it implements the real, current protocol, not the 2019 one:
PUT /latest/api/token
← imds_tok_<deterministic, per-source>
GET /latest/meta-data/iam/security-credentials/
X-aws-ec2-metadata-token: imds_tok_...
← ecsInstanceRole
GET /latest/meta-data/iam/security-credentials/ecsInstanceRole
X-aws-ec2-metadata-token: imds_tok_...
← { "AccessKeyId": "ASIA...", "SecretAccessKey": "...", ... }
Skip the token handshake and go straight for the credentials, the way a lot of automated SSRF-scanning tooling still does out of habit, and you get an ordinary 401 — the same thing a real IMDSv2-enforced instance would give you. No tell that you've found a trap instead of a locked-down real one.
The credentials themselves are deterministically derived from the requesting source
— the same fake AccessKeyId every time a given source hits the trap.
That's the actual point of the exercise: if the fake key later shows up in a real
aws sts get-caller-identity call somewhere, you can trace it straight back
to which SSRF-vulnerable endpoint leaked it, not just "someone, somewhere, found
something."
what it's honest about not doing
Real IMDSv2 tokens aren't tied to who's asking — they can't be, since IMDS is normally only reachable from the instance's own local network anyway. Ours deliberately are: the token is bound to the requester, so a stolen token replayed from a different source doesn't validate. That's a real, useful property for a trap that might sit behind several different vulnerable apps at once — but it's not how the protocol actually behaves, and we're not pretending otherwise. If you're building anything against this that cares about protocol fidelity down to that level, know the difference going in.
It also only mimics the one path it exists to protect. Real IMDS serves instance-id, hostname, user-data, and a lot more — none of that is faked here, because none of it is what this trap is for.
how it's tested
The interesting bug wasn't in the logic. It was in the wiring.
This module, like the rest of our honeytoken tooling, keeps its web framework
dependency optional — the core detection logic is plain Python, and FastAPI only
gets imported inside the function that builds the actual HTTP routes, so the module has
zero hard dependency on a framework at all. The unit tests for the core logic all
passed. Then a real end-to-end run against an actual FastAPI TestClient
— not mocked, an actual app, actual HTTP requests — came back with a 422 on
the very first call.
The cause: this file uses from __future__ import annotations, which
defers every type hint to a string instead of a real object. FastAPI needs those hints
resolved to real classes to know how to route a request — and the classes in
question were imported inside the function that builds the routes, specifically
to keep the dependency optional. From FastAPI's side, at the moment it needed to resolve
them, those names didn't exist anywhere it could see. The unit tests never touched that
path at all, because they only exercise the framework-agnostic core — which is
exactly why they couldn't have caught it.
Fixed by dropping the deferred-annotations behavior for this one file, with a comment
explaining why, so the next person who copies this module's structure into a new one
doesn't lose an afternoon to the same thing. Verified against a real
TestClient run afterward: full handshake, a wrong-token rejection, a
wrong-role rejection, all real HTTP round trips, not asserted mocks.
where it lives
Part of Leviathan Control's growing set of plant-and-detect modules — the same
deterministic, source-attributable pattern already used for scanner-bait HTTP paths and
RAG honeydocuments, applied here to a cloud metadata endpoint instead. None of it exists
to catch a sophisticated, patient attacker. It exists to make the boring, automated first
move — the scanner that tries 169.254.169.254 because it always
tries 169.254.169.254 — expensive to have made, and traceable when it
does.
Leviathan Control is open about its scope, not open source (yet) — reach out via northstartproductionstudio.com if you want to look at the code before you rely on it.