irc://blog.northstartproductionstudio.com/#shipped ← back to log

No telemetry. No tracking. No cookies. No accounts.

* ~ghost has joined #shipped

A honeypot that hands out fake AWS credentials — and knows who took them

· ~ghost

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.

#python #security #aws #honeypots

* ~ghost has left #shipped