How a PDF button became remote code execution (and paid for my year)
February 28, 2023
Housekeeping: the target here is deliberately a mystery — call it $CORP — and I’ve blurred anything that would fingerprint it or hand you a copy-paste weapon. This is a story about how the bug was found, not a launch button. Everything was reported through their program, fixed, and disclosed with permission. Now the fun.
Some bugs you find. This one I fell down, one small hole at a time, until I looked up and I was executing code inside $CORP’s infrastructure holding a receipt for the largest single bounty I’ve ever been paid. It started with the most boring button in the entire product.
Recon: befriend the boring features
Everyone hammers the login page and the search box. I go looking for features that make the server do work on my behalf — anything that fetches, renders, converts, or generates. Those features quietly hand the server your input and say “go do something with this,” which is the whole ballgame.
$CORP had an Export to PDF button on shareable reports. You style a report, click export, and a beautifully typeset PDF appears. Innocuous. Corporate. Deeply suspicious, because a pretty PDF means something server-side is rendering HTML, and rendering HTML server-side is where dreams (mine) and nightmares (theirs) are born.
Hop 1: the renderer has eyes, and I can point them
Reports let you add rich content — headings, images, a bit of formatting. I dropped in an image whose source I controlled and watched my server logs. Sure enough: seconds after I clicked export, a request hit my box. Not from my browser — from an IP in $CORP’s cloud range, with a headless-browser user agent.
That’s the tell. The PDF is generated by a headless browser rendering my HTML on their server. Which means the fields I fill aren’t just text — they’re instructions to a browser sitting inside their network. The report body wasn’t sanitizing as tightly as the preview suggested; a little probing showed I could smuggle in more than images — enough markup to make that internal browser fetch URLs of my choosing.
Congratulations to me: I now had server-side request forgery. I owned the eyes of a browser living inside $CORP’s perimeter. On its own, an SSRF is a decent-but-common finding — a few hundred dollars, a polite thank-you. I wanted to know how far the eyes could see.
Hop 2: what the inside browser can see
From inside a cloud network, the first two things worth pointing an SSRF at are always:
- The cloud metadata endpoint — the magic internal address that hands out instance credentials. Classic, devastating, and increasingly locked down.
- Internal-only services — the dashboards, admin panels, and microservices that assume “if you can reach me, you’re trusted,” because they live on a private network nobody’s supposed to touch.
The metadata endpoint was partially hardened — I could reach it but the creds it returned were scoped to something boring. A dead-ish end, and honestly a good sign they’d done some homework. So I turned to door #2, and pointed my captive browser at internal hostnames — the kind of names that only resolve inside a private network. Most timed out. One did not.
Hop 3: the service that trusted the room
Sitting on an internal address was a small asset-processing microservice — the kind of unglamorous internal tool that resizes images, stamps watermarks, munges documents. It had no login. Why would it? It lived on the private network, and the private network was “trusted.” I was on the private network now, wearing a PDF renderer as a disguise.
Poking its endpoints (blind, through the SSRF, reading responses reflected back into my PDF — a miserable, wonderful way to work) I found one that accepted a chunk of structured input and used it to render a templated document. And render-from-template is the four most dangerous words in web security, because template engines are, by design, tiny programming languages. If any of my input reached the template as template rather than as data, I wasn’t supplying text — I was supplying code.
Hop 4: from “hello” to “who am I”
Template injection is a beautiful bug class because you can escalate it in careful, non-destructive baby steps, and I did, because doing this politely on someone’s production box is the whole ethic.
- First I sent the template’s version of
7 * 7— a harmless arithmetic expression. The output came back49. Not the literal7 * 7. That single wrong-in-the-right-way number is the moment your stomach drops: the server evaluated my input. It’s the “hello world” of server-side template injection, and it’s never not a thrill. - From arithmetic you climb, rung by rung, toward the language’s guts — the objects and helpers the template engine exposes — feeling for one that touches the operating system. This is delicate: you’re enumerating capabilities, not smashing anything, and every step is one more careful probe rather than a payload.
- Eventually one expression came back carrying the output of a harmless, read-only system command — the equivalent of asking the box “who are you?” and having it answer with its own username. That reply was the entire ballgame: my input had crossed from the template into a shell. Remote code execution. Blind, awkward, five hops deep, tunneled through a PDF button — but unmistakably code execution inside $CORP’s cluster.
Proving impact without becoming the incident
Here’s where a bug bounty stops being a puzzle and becomes a matter of trust. I had RCE. I could have gone spelunking. Instead I did the minimum that proves severity and nothing that resembles an actual intrusion:
- Confirmed identity (the service account the code ran as) and its uncomfortable level of access.
- Grabbed a single non-sensitive marker to timestamp and prove control — think “I wrote a harmless file with a random string only I knew” — and nothing else.
- Did not touch customer data, pivot further, dump environment secrets, or persist. The moment you can see the crown jewels, the responsible move is to photograph the open vault door and walk out, not step inside.
Then I wrote it all up: the full chain (PDF → SSRF → internal service → template injection → RCE), the exact requests in order, the blast radius, and a suggested fix at every hop.
The report and the reward
I submitted it as a single chained report rather than five little ones, because the story is the severity — any one hop alone is medium at best, but stacked they’re a straight line from “anonymous button” to “code in your cluster.” Triage escalated it fast. It landed as Critical, and the payout was the biggest I’ve ever received for a single report — comfortably into five figures, enough that I refer to it privately as the bug that paid for my year. (Program rules mean I’ll leave the exact number and the target in the fog where they belong.)
Disclosure timeline, the part nobody reads but everybody should:
- Day 0 — report submitted with full chain and PoC.
- Day 1 — triaged, severity confirmed, a very nice human said “oh no” in a ticket.
- Day 4 — interim mitigation (the internal service got a network-level lockdown so the SSRF couldn’t reach it).
- ~Day 30 — real fixes shipped at multiple layers.
- Later — bounty paid, and permission granted to write this up anonymized.
What each hop should have had (the actual lessons)
The tidy thing about this chain is that every single hop had a standard, boring defense that would have broken it, and severity came entirely from stacking the misses:
- The PDF renderer should have treated report content as untrusted and run with network egress denied by default — a renderer that can’t make outbound requests can’t be an SSRF.
- The metadata endpoint was partially hardened, and it showed — that’s the one hop that fought back. Enforce the newer metadata-service protections fully and this door closes.
- The internal service trusted the network instead of the caller. “Internal” is not an identity. Authenticate service-to-service calls; the private network is not a password.
- The template renderer fed user input into the template context. Data is data; never let it become template. This is the difference between “renders a report” and “runs a program.”
- The service account was over-privileged, turning a foothold into a catastrophe. Least privilege means even a full compromise of that box is a bad day, not an extinction event.
Defense in depth isn’t a slogan; it’s the observation that this bug needed five failures in a row, and any one guardrail would have turned my five-figure Critical into a shrug.
The takeaway I keep
The severe bugs are rarely one genius exploit. They’re a chain of small, individually-forgivable oversights that someone patient lines up end to end. My whole edge was refusing to accept “that’s just a boring PDF button” and instead asking, at every hop, what does this let me make the server do next? Recon rewards the stubborn. The button that pays for your year is usually the one everyone else scrolled past.
Now if you’ll excuse me, I have a lot more boring buttons to click.