Hi everyone, in this article I'll be talking about an issue that one of my colleagues found during a web application pentest. When reading through the article, you'll notice that the issue has been exploited multiple times but in this article, I'll discuss why some can lead to infrastructure compromise which the rest will not be more than a simple plain XSS.
In this article, I'll cover the following:
- Overview of the web application
- The issue and it's impact
- Why the issue was exploitable
Overview
The web application, besides other features, also allowed it's customer to create invoices. The invoices had some fields such as name, address, organization name, the services used by the customer, etc.
The Issue and its Impact
As mentioned above, the application had a invoice feature which was vulnerable to XSS. It was observed that if XSS payloads were passed in the name field, an XSS would be triggered when the PDF was viewed. The PDF was displayed in the same domain as the target domain. So, it could be used to fetch the user's JWT tokens. But that wouldn't be much of an impact as we would only end up getting either our own JWT token or someone from our organization who had the permissions to view the invoice.
If you have an XSS in a PDF, you can potentially leverage it to get an SSRF. We could do an internal port scan, view internally hosted applications. Also, if the application is hosted on cloud, such as an EC2 instance, Azure VM, it could be used to compromise the VM. If the VM has permissions associated with it, they can also be leveraged. Let's see how.
In this article, we'll discuss about the latter part and how we circumvented the restrictions to achieve an SSRF. But if you are interested in XSS in PDF generation feature, one good article is here (as I had mentioned, that there a lot of instance in the internet).
The XSS was in the name field. So, we tried to fetch the IMDS endpoint.
IMDS — Services like EC2 has something called IMDS associated with it. IDMS stands for Instance Metadata Service (IMDS). You can think of it as an on-campus "information desk" for your EC2 instance.
When you run code inside an EC2 instance, that code often needs to know things about itself — like its IP address, its instance ID, or, most importantly, temporary security credentials (IAM roles) to access other AWS services like S3 buckets or databases from within the EC2 instance.
Instead of hardcoding these secrets into your application, your code simply sends a local HTTP request to a special, non-routable IP address: http://169.254.169.254.
A request to the https://169.254.169.254/latest/meta-data would help you get the EC2 instance's IP address, its instance ID, and the temporary security credentials.
A way to securely return them is by sending the request through an iframe because the response to the request would be visible in the iframe's body itself.
So, we thought of using the following payload in the name field.
<iframe src=https://169.254.169.254/latest/meta-data/iam/security-credentials/>The name field was updated to the following payload, and the Save button was clicked. But we hit an input length restriction error. Now what?

With a few more iterations we figured out that the anything over 63 characters was blocked. Fast forward half a day. We were able to fetch the IAM credentials associated with the EC2 instance. So, how did we achieve this?
He tried URL shortner. We shortened the URL using online URL shortners and successfully saved it as the name. Since, it was shorter than 63 characters, it was saved.
With further discussion with our team members, we came to know that we could also have tried spinning up an IP address, which when accessed, would redirect the user to https://169.254.169.254/latest/meta-data/iam/security-credentials/.
We exported an PDF and the IAM credentials were there.
Once we had the temporary credentials, our next step was to identify the permissions associated with those credentials. We used the following script to identify the permissions.
In case, due to lack of certain permissions, you are unable to fetch the permissions associated with the keys, your next motive should be to bruteforce all the permissions. This can be achieved using tools like — https://github.com/shabarkin/aws-enumerator. There are a lot of other similar tools that can help you achieve this.
The permissions returned were:
{
...
"Action":
[
s3:*,
logs:*,
dynamodb:*
],
"Resource":
[
*
]
...
}We knew that the PDFs generated had to be saved somewhere. In AWS, it's generally, an S3 bucket. We saw that we had S3 permissions. But we did not expect to find an s3:* . So, this means we could perform all the actions/operations on the S3 bucket and not just one, but all the buckets present in the AWS account.
Why? — You see, the resource key is set to * value, which means that I can perform operations on all the s3 buckets in the company's AWS accounts.
But this is not where the story ends. The logs were being saved in AWS CloudWatch service. The keys also had AWS CloudWatch permissions.
Let's say that our logs were saved in the /log/web-app-prod-us-xxxx , our keys should only have permissions to this log group. But due to the presence of Resource":[*] , we could access all the log groups for all the other web applications.
Besides, this, we could also query all the dynamodb tables.
We did not go that further, because that was beyond the scope of the pentest.
Why the issue was exploitable?
Before we discuss this, we need to set a foundation. We'll talk about a few terminologies before we can understand why the attack actually happened.
If you search for http://169.254.169.254 endpoint using Shodan, you'll notice that nothing pops up. But why?
Why is IMDS only accessible from "Inside"?
You might wonder how a single IP address — 169.254.169.254—can be used by millions of different AWS customers at the same time without causing a massive traffic jam. The secret lies in its classification as a Link-Local Address.
The "Link-Local" Magic
In the world of networking, the 169.254.0.0/16 range is reserved for Link-Local communication. These addresses are unique because they are designed to stay "local" to the specific network segment they are on.
Here is why this matters for your EC2 instance:
- No Passport Required: Your instance uses this address to talk to the underlying AWS hardware (the hypervisor) it sits on. It's like a direct intercom system between a hotel room and the front desk.
- The Router "Wall": By design, network routers are forbidden from forwarding link-local packets. If a request tries to leave the instance and head out to the open internet, the router simply drops it.
- Local Only: Because these packets can't jump across routers, the address
169.254.169.254always refers to the current machine you are standing on. It's the "localhost" of the AWS infrastructure.
Why is IMDS only accessible from "Inside"?
You might wonder how a single IP address — 169.254.169.254—can be used by millions of different AWS customers at the same time without causing a massive traffic jam. The secret lies in its classification as a Link-Local Address.
The "Link-Local" Magic
In the world of networking, the 169.254.0.0/16 range is reserved for Link-Local communication. These addresses are unique because they are designed to stay "local" to the specific network segment they are on.
Here is why this matters for your EC2 instance:
- No Passport Required: Your instance uses this address to talk to the underlying AWS hardware (the hypervisor) it sits on. It's like a direct intercom system between a hotel room and the front desk.
- The Router: By design, network routers are forbidden from forwarding link-local packets. If a request tries to leave the instance and head out to the open internet, the router simply drops it.
- Local Only: Because these packets can't jump across routers, the address
169.254.169.254always refers to the current machine you are standing on. It's the "localhost" of the AWS infrastructure.
Why this creates a "False Sense of Security"
Because this IP is only reachable from inside the instance, many developers originally thought, "If it's not on the public internet, it's safe" . So, if you can somehow get an SSRF, you can control the company's infrastructure, provided the EC2 instance has overly permissive permissions associated with it. And that's what actually happened in the above scenario.
But what we discussed above would only be possible in IMDSv1. Because with IMDSv1, a GET request is enough to send a request to IMDS. Had IMDSv2 been enabled, this would not have been possible, because with IMDSv2, you've to first send a PUT request with a custom header. You'll get a TOKEN. The token needs to be sent in a different request as a part of the request to get a access tokens. So, as you can guess, it's a two step process, which wouldn't have been possible in our scenario.
Hope you enjoyed reading the article. Please consider subscribing and clapping for the article.
In case you are interested in CTF/THM/HTB writeups consider visiting my YouTube channel.