{
    "componentChunkName": "component---src-templates-blog-post-js",
    "path": "/how-a-pdf-button-became-remote-code-execution-and-paid-for-my-year/",
    "result": {"data":{"site":{"siteMetadata":{"title":"catch { snail }","author":"Andrew"}},"markdownRemark":{"id":"2a0ab379-62ff-5459-87f7-b59561f73bf7","excerpt":"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…","html":"<p><em>Housekeeping: the target here is deliberately a mystery — call it <strong>$CORP</strong> — 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 <em>found</em>, not a launch button. Everything was reported through their program, fixed, and disclosed with permission. Now the fun.</em></p>\n<p>Some bugs you find. This one I <em>fell down</em>, 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.</p>\n<h2 id=\"recon-befriend-the-boring-features\" style=\"position:relative;\"><a href=\"#recon-befriend-the-boring-features\" aria-label=\"recon befriend the boring features permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Recon: befriend the boring features</h2>\n<p>Everyone hammers the login page and the search box. I go looking for features that <strong>make the server do work on my behalf</strong> — 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.</p>\n<p>$CORP had an <strong>Export to PDF</strong> 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 <em>something server-side is rendering HTML</em>, and rendering HTML server-side is where dreams (mine) and nightmares (theirs) are born.</p>\n<h2 id=\"hop-1-the-renderer-has-eyes-and-i-can-point-them\" style=\"position:relative;\"><a href=\"#hop-1-the-renderer-has-eyes-and-i-can-point-them\" aria-label=\"hop 1 the renderer has eyes and i can point them permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Hop 1: the renderer has eyes, and I can point them</h2>\n<p>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 <em>my</em> browser — from an IP in $CORP’s cloud range, with a headless-browser user agent.</p>\n<p>That’s the tell. The PDF is generated by a <strong>headless browser rendering my HTML on their server</strong>. Which means the fields I fill aren’t just text — they’re instructions to a browser sitting <em>inside their network</em>. 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.</p>\n<p>Congratulations to me: I now had <strong>server-side request forgery</strong>. 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.</p>\n<h2 id=\"hop-2-what-the-inside-browser-can-see\" style=\"position:relative;\"><a href=\"#hop-2-what-the-inside-browser-can-see\" aria-label=\"hop 2 what the inside browser can see permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Hop 2: what the inside browser can see</h2>\n<p>From inside a cloud network, the first two things worth pointing an SSRF at are always:</p>\n<ol>\n<li><strong>The cloud metadata endpoint</strong> — the magic internal address that hands out instance credentials. Classic, devastating, and increasingly locked down.</li>\n<li><strong>Internal-only services</strong> — 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.</li>\n</ol>\n<p>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 <em>some</em> 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.</p>\n<h2 id=\"hop-3-the-service-that-trusted-the-room\" style=\"position:relative;\"><a href=\"#hop-3-the-service-that-trusted-the-room\" aria-label=\"hop 3 the service that trusted the room permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Hop 3: the service that trusted the room</h2>\n<p>Sitting on an internal address was a small <strong>asset-processing microservice</strong> — 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.</p>\n<p>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 <strong>render a templated document</strong>. 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 <em>as template</em> rather than <em>as data</em>, I wasn’t supplying text — I was supplying <strong>code</strong>.</p>\n<h2 id=\"hop-4-from-hello-to-who-am-i\" style=\"position:relative;\"><a href=\"#hop-4-from-hello-to-who-am-i\" aria-label=\"hop 4 from hello to who am i permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Hop 4: from “hello” to “who am I”</h2>\n<p>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.</p>\n<ul>\n<li>First I sent the template’s version of <code class=\"language-text\">7 * 7</code> — a harmless arithmetic expression. The output came back <strong><code class=\"language-text\">49</code></strong>. Not the literal <code class=\"language-text\">7 * 7</code>. That single wrong-in-the-right-way number is the moment your stomach drops: the server <em>evaluated</em> my input. It’s the “hello world” of server-side template injection, and it’s never not a thrill.</li>\n<li>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.</li>\n<li>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. <strong>Remote code execution.</strong> Blind, awkward, five hops deep, tunneled through a PDF button — but unmistakably code execution inside $CORP’s cluster.</li>\n</ul>\n<h2 id=\"proving-impact-without-becoming-the-incident\" style=\"position:relative;\"><a href=\"#proving-impact-without-becoming-the-incident\" aria-label=\"proving impact without becoming the incident permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Proving impact without becoming the incident</h2>\n<p>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 <em>proves</em> severity and nothing that resembles an actual intrusion:</p>\n<ul>\n<li>Confirmed identity (the service account the code ran as) and its uncomfortable level of access.</li>\n<li>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.</li>\n<li><strong>Did not</strong> touch customer data, pivot further, dump environment secrets, or persist. The moment you can <em>see</em> the crown jewels, the responsible move is to photograph the open vault door and walk out, not step inside.</li>\n</ul>\n<p>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.</p>\n<h2 id=\"the-report-and-the-reward\" style=\"position:relative;\"><a href=\"#the-report-and-the-reward\" aria-label=\"the report and the reward permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The report and the reward</h2>\n<p>I submitted it as a single chained report rather than five little ones, because the <em>story</em> 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 <strong>Critical</strong>, 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.)</p>\n<p>Disclosure timeline, the part nobody reads but everybody should:</p>\n<ul>\n<li><strong>Day 0</strong> — report submitted with full chain and PoC.</li>\n<li><strong>Day 1</strong> — triaged, severity confirmed, a very nice human said “oh no” in a ticket.</li>\n<li><strong>Day 4</strong> — interim mitigation (the internal service got a network-level lockdown so the SSRF couldn’t reach it).</li>\n<li><strong>~Day 30</strong> — real fixes shipped at multiple layers.</li>\n<li><strong>Later</strong> — bounty paid, and permission granted to write this up anonymized.</li>\n</ul>\n<h2 id=\"what-each-hop-should-have-had-the-actual-lessons\" style=\"position:relative;\"><a href=\"#what-each-hop-should-have-had-the-actual-lessons\" aria-label=\"what each hop should have had the actual lessons permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>What each hop should have had (the actual lessons)</h2>\n<p>The tidy thing about this chain is that <strong>every single hop had a standard, boring defense that would have broken it</strong>, and severity came entirely from stacking the misses:</p>\n<ul>\n<li><strong>The PDF renderer</strong> 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.</li>\n<li><strong>The metadata endpoint</strong> was partially hardened, and it <em>showed</em> — that’s the one hop that fought back. Enforce the newer metadata-service protections fully and this door closes.</li>\n<li><strong>The internal service</strong> trusted the network instead of the caller. “Internal” is not an identity. Authenticate service-to-service calls; the private network is not a password.</li>\n<li><strong>The template renderer</strong> 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.”</li>\n<li><strong>The service account</strong> 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.</li>\n</ul>\n<p>Defense in depth isn’t a slogan; it’s the observation that this bug needed <em>five</em> failures in a row, and any one guardrail would have turned my five-figure Critical into a shrug.</p>\n<h2 id=\"the-takeaway-i-keep\" style=\"position:relative;\"><a href=\"#the-takeaway-i-keep\" aria-label=\"the takeaway i keep permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The takeaway I keep</h2>\n<p>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, <em>what does this let me make the server do next?</em> Recon rewards the stubborn. The button that pays for your year is usually the one everyone else scrolled past.</p>\n<p>Now if you’ll excuse me, I have a lot more boring buttons to click.</p>","frontmatter":{"title":"How a PDF button became remote code execution (and paid for my year)","date":"February 28, 2023","description":"A fictionalized writeup of the best bug I ever found: an 'export to PDF' feature that, five hops later, was running my code inside someone else's cluster. Target anonymized, chain real-in-spirit."}}},"pageContext":{"slug":"/how-a-pdf-button-became-remote-code-execution-and-paid-for-my-year/","previous":{"fields":{"slug":"/the-harness-ate-the-model/"},"frontmatter":{"title":"The harness ate the model"}},"next":null}},
    "staticQueryHashes": ["3435731857","426816048","63159454"]}