← Back to list

The Bug I’m Proudest Of Is the One I Deleted

Three real findings on ScribbleMaps, and the SSRF I talked myself out of with a stopwatch. A coordinated-disclosure story about the only…

anshh.bohara · 2026-07-02 09:57 · 0 claps · 6.4 min read
#cybersecurity #bug-bounty #impact #welovecybersec
Open on Medium ↗
Wiki topics: GEN · Genomics & Sequencing 🌐 · Web Development 🔒 · Cybersecurity 💑 · Relationships

The Bug I’m Proudest Of Is the One I Deleted

Three real findings on ScribbleMaps, and the SSRF I talked myself out of with a stopwatch. A coordinated-disclosure story about the only bug-bounty skill that compounds: proving yourself wrong.

The finding I’m proudest of from this engagement never made it into the report. It was a server-side request forgery that, for about four glorious hours, I was certain reached Azure’s cloud metadata service — the kind of sentence that turns a Low into a “we should hop on a call.” Then I ran a test designed specifically to disprove my own claim, and it worked. The bug was real; the impact I’d imagined was not. I deleted the exciting part before submitting.

Most write-ups celebrate the crit. This one celebrates the retraction, because after a few years of doing this I’ve concluded that the rarest and most valuable skill in bug bounty isn’t finding bugs. It’s being the first person to call your own bluff.

Here are three real findings on ScribbleMaps, all confirmed on my own free test accounts and cleaned up afterward — and the one I unfound on purpose.

The Target, and the Origin With No Seatbelt

ScribbleMaps is a maps SaaS: you create, share, and embed interactive maps. The backend is Azure-hosted. There’s one architectural fact you need in hand before anything else makes sense, because it’s load-bearing for two of these bugs.

The map viewer renders inside widgets.scribblemaps.com — a separate origin that carries no Content-Security-Policy and shares the *.scribblemaps.com session. That viewer origin is iframed into every /maps/view/{code} page and into the embed snippet that customers paste onto their own websites. So whatever runs in the widget origin runs with no CSP to stop it, in a frame that appears on first-party pages and on third-party sites across the internet. File that away.

Bug 1: The Popup That Renders Whatever You Stored

Every shape and marker on a map has a title and a description, and those fields populate the popup that appears when you click it. The editor is well-mannered: type HTML into a description and it HTML-encodes it, so <img src=x onerror=…> round-trips back to you as harmless, visible text — &lt;img …&gt;.

That encoding is a client-side courtesy, and a client-side courtesy is not a security control. The save path is a two-step stream API, and it persists the field exactly as received — angle brackets, handler attribute, and all. So the value the editor politely escaped on screen is stored raw on the server. When the popup later renders in the viewer, it renders the raw version. The defense lives entirely in the browser of the person being defended, which is to say it doesn’t.

The result: stored HTML/JS that executes in widgets.scribblemaps.com — the CSP-less origin — for any viewer of a public or unlisted map, including anonymous, logged-out visitors, and including visitors on third-party sites that embed the map.

Now, the part that this entire article is about: the honest ceiling. A stored XSS wants to grow up into an account takeover, so I tried every escalation, and ScribbleMaps had thought about all of them:

Escalation tried Result Steal the session token BlockedScribbleMaps_Auth is HttpOnly; document.cookie exposes analytics only Take over the account via email change Blocked — changing the email requires a confirmation link sent to the current address Exfiltrate data cross-origin Blocked — the www API's Access-Control-Allow-Origin is locked to www only Cross-site CSRF Blocked — the session cookie is SameSite=Lax

So I’m not going to tell you this is silent account takeover, because it isn’t. What it is: arbitrary JavaScript in a trusted, CSP-less origin, for every viewer of a map — which is more than enough for convincing phishing overlays, defacement, or forced redirects under the ScribbleMaps brand and on every site embedding an affected map. That’s a Medium-to-High on its own merits and it doesn’t need inflating. Reported as exactly that. (I’m holding the click-to-fire delivery recipe out of this public version while the fix is in flight — the lesson is the point, not the kit.)

Bug 2: A Real SSRF Through “Import From URL”

ScribbleMaps can import a KML file from a URL. Browsers can’t fetch cross-origin KML, so the server does it for them — /api/import/kml?url=… fetches whatever URL you hand it, with no egress allow-list. That's a server-side request forgery, textbook.

I proved it was real without guessing, using an out-of-band callback. I pointed the importer at a listener I controlled and watched the request arrive — not from my IP, but from ScribbleMaps’ own Azure egress ranges (52.x / 20.x), carrying the application's Request-Context header. The server fetched on my behalf. It's unauthenticated. It's uncached — a fresh callback lands every single time. And it follows 302 redirects, which means any allow-list bolted on later would be bypassable by redirecting through an approved host. A clean, real SSRF primitive.

And here’s where I almost lied to myself.

The Timing Test That Killed My Best Claim

My first draft of this finding was thrilling. The importer, I wrote, reached 169.254.169.254 — Azure's Instance Metadata Service — and loopback besides. SSRF to cloud metadata is the escalation everyone wants; it's the difference between "the server made a request" and "the server handed me its credentials."

The responses looked right. Hitting the metadata address came back fast — roughly 0.39 seconds — with a 500. My brain, helpfully, supplied the story I wanted: it's reaching IMDS, the 500 is just the parser choking on a metadata response. I had the screenshots. I had the narrative. I had the dopamine.

Then I did the thing that separates a report that survives triage from one that gets embarrassingly walked back: I built a test whose entire purpose was to prove me wrong. A timing oracle, four samples per target.

Target Response What it should mean if my claim were true 169.254.169.254 (IMDS) ~0.39s, 500 "metadata is answering" 169.254.1.1 (dead link-local, nothing listening) ~0.39s, 500 — identical …but a dead address can't be "answering" 10.x (real RFC1918 host) hung ~14s a route that actually exists behaves nothing like the above

There it is. If IMDS were genuinely responding, it would not produce a response identical to a dead link-local address where nothing is listening. The two matched to the tenth of a second because they were the same event: the local network stack fast-failing anything in the link-local and loopback ranges before a single packet left the box. Meanwhile a real internal address — one the stack would actually try to route to — hung for fourteen seconds. The fast 500 I'd been admiring wasn't metadata talking. It was the absence of metadata, arriving quickly.

So I opened my draft and deleted the best paragraph in it. The IMDS claim was gone; the finding went in as what the evidence actually supported — a real, unauthenticated SSRF with no sensitive impact demonstrated. (XME aside: I also tested the KML parser for XXE. External entities are disabled. Another exciting headline that wasn’t true.)

It went in at Low. On purpose. That downgrade is the single thing in this engagement I’d put on a résumé.

Bug 3: Paying $0 for Pro

A palate cleanser. The Save dialog greys out “Secure (Pro)” — password-protected maps — and “Encrypted (Pro)” for free accounts. The gate is a client-side isPaid() check, which is to say it's a suggestion.

The server doesn’t enforce the plan. A free account that sets secure:true with a password (or encrypted:true) directly in the save request gets a genuine, server-enforced Pro feature for free. I verified the enforcement is real and not cosmetic: the resulting map's /json returns 401 without the correct password. The lock works perfectly; it's just that the store was handing out the locks to people who hadn't paid for them. Entitlement bypass, "Other" tier — good for getting on the board, honest about being small.

Why Retracting Yourself Is the Skill

Triagers are, professionally, the people who don’t believe you. The fastest way to lose them forever is to send one inflated finding — one “reaches IMDS!” that falls apart under a stopwatch they happen to own. After that, every report you send starts from a deficit.

The flip side is the asset: a researcher who has visibly downgraded their own work is one whose remaining claims get read at face value. When I write “this executes for anonymous viewers,” I want that taken as fact, and the currency I buy that credibility with is the IMDS paragraph I deleted before anyone else had to. Calibrated honesty isn’t humility for its own sake. It’s the highest-leverage move on the board, because it makes everything else you say worth more.

Takeaways

  1. Client-side encoding is not a control. If the editor escapes HTML but the save API stores it raw, the protection lives in the wrong place — the attacker’s browser. Encode on output, server-side, every time.
  2. Prove SSRF reach with a dead-host control, not a vibe. A fast response from a metadata address means nothing until you’ve timed it against an address where nothing is listening. Identical timing means your stack is fast-failing, not that metadata answered.
  3. Enforce entitlements server-side. A greyed-out button is UX. If the server honors secure:true from a free account, the paywall is decorative.
  4. Your credibility is the asset, and you spend it the moment you oversell. The retraction you perform on yourself is cheaper than the one triage performs on you.

Three findings reported through the program: a stored XSS that runs for anyone, a real SSRF that reaches nothing interesting, and a paywall made of wishes — each at the severity the evidence actually supports, the SSRF very much included. The bug I’ll remember is the one that doesn’t appear in the report at all, because I’m the one who caught it. Bring a stopwatch. Use it on yourself first.


메타데이터
post_id
f5d4247232e9
slug
the-bug-im-proudest-of-is-the-one-i-deleted-f5d4247232e9
url
https://medium.com/@anshbohra10/the-bug-im-proudest-of-is-the-one-i-deleted-f5d4247232e9
canonical_url
https://medium.com/@anshbohra10/the-bug-im-proudest-of-is-the-one-i-deleted-f5d4247232e9
author_url
https://medium.com/@anshbohra10
status
ok
fetched_at
2026-07-10 07:28:19