July 31, 2026
How I Got My Highest Payout
The target was an application portal. You register, you fill out a genuinely ENORMOUS form, and at the end it generates a PDF of everything…
By Adhamkhairy
4 min read
The target was an application portal. You register, you fill out a genuinely ENORMOUS form, and at the end it generates a PDF of everything you submitted: income figures, tax numbers, personal identifiers, household details. Not just yours. Your whole family's.
Filling that form out properly took me close to an HOUR.
And that hour is why the bug was still there.
Nobody wants to fill out the form
Most hunters land on a target like this, see a multi-stage form with validation on every field, and go looking for something with a shorter path to a request. I get it. It's boring. You didn't get into this to type in fake tax figures for an hour.
But that friction is the whole reason the bug survived. Every previous hunter bounced off the setup, so nobody ever reached the endpoints that only appear after you have a real, completed application sitting in the database.
The harder a target is to set up, the fewer people have looked at what's behind the setup.
So I filled out the form. Twice I'd need a second account eventually anyway.
The request that looked wrong
Every data call on the platform looked like this:
POST /App/SomeController/SomeMethod HTTP/1.1
Host: portal.example.com
Authorization: Bearer <JWT>
Content-Type: application/json
{"UserId":"<blob>",
"ApplicationId":"<blob>",
"PageName":"...",
"Token":"<the same JWT again>"}POST /App/SomeController/SomeMethod HTTP/1.1
Host: portal.example.com
Authorization: Bearer <JWT>
Content-Type: application/json
{"UserId":"<blob>",
"ApplicationId":"<blob>",
"PageName":"...",
"Token":"<the same JWT again>"}The Bearer token already says who I am. So why is my UserId also in the body? And why is the token in there a second time?
Either those IDs are decoration, or they're the thing actually doing the lookup. Only one of those is interesting.
The wrong turn
I saw a JWT and went after the JWT, because that's the reflex.
Flipped a byte in the signature → 401. Set alg to none → 401.
Their authentication was flawless. Signature properly verified, no algorithm confusion, nothing. I kept poking anyway for a while because I'd already decided that was the bug.
Then it clicked, and the failure was the clue.
A verified token answers who is asking. It says nothing about what they're allowed to have. And I still had that unexplained UserId in the body.
The swap
Account A is me. Account V is the victim also me, different email, with a real completed application on it. That's what the second hour of form-filling bought.
Kept A's own untouched Bearer token. Swapped the body IDs for V's.
POST /App/AppCertification/GetAppCertification HTTP/1.1
Authorization: Bearer <A's own token>
{"UserId":"<V's>","ApplicationId":"<V's>", ...}
HTTP/1.1 200 OK
{"ReturnMessage":"Success","Username":"<V's>", ...}POST /App/AppCertification/GetAppCertification HTTP/1.1
Authorization: Bearer <A's own token>
{"UserId":"<V's>","ApplicationId":"<V's>", ...}
HTTP/1.1 200 OK
{"ReturnMessage":"Success","Username":"<V's>", ...}My session. V's data.
Then the PDF export endpoint, same trick:
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Length: 105590
Content-Disposition: attachment; filename=Profile-<V's reference>.pdfHTTP/1.1 200 OK
Content-Type: application/pdf
Content-Length: 105590
Content-Disposition: attachment; filename=Profile-<V's reference>.pdf103 KB. Filename stamped with the victim's reference number. That's not a leaked field; that's the entire application. Every number that household typed in, exported as a file, by me, from a free account I made in a minute.
Where most people stop
Here's the part I want you to actually read.
I had mass PII. Full dossier, whole family, one request. And there's an obvious objection sitting right on top of it:
You need two non-sequential IDs to pull this off. They aren't integers you can increment — they're long opaque ciphertext blobs. Unguessable. So the argument writes itself: sure it's a bug, but you'd need the victim's IDs, and you can't get those. Low severity. Targeted at best.
That's where the report ends for most people. You've got a working PoC, triage has a ready-made downgrade, you take the small win and move on.
I didn't want the small win. So instead of arguing the severity, I went looking for where those IDs actually live.
They're in the URL.
The full identity object , IDs and session token together, gets serialized into the query string during ordinary navigation. It's sitting in the address bar. On every session. For every user.
And once something is in a URL it isn't a secret anymore. It's in browser history on whatever machine you used, often a shared family, school, or library computer. It's in every access log along the path: web server, proxy, WAF, CDN. It's in public web archives, permanently, because crawlers capture URLs and archives keep them.
curl -s "https://web.archive.org/cdx/search/cdx?url=subdomain.target.example&matchType=domain&output=text&fl=original&collapse=urlkey" | grep -E "UserId"curl -s "https://web.archive.org/cdx/search/cdx?url=subdomain.target.example&matchType=domain&output=text&fl=original&collapse=urlkey" | grep -E "UserId"I confirmed real users' identity objects had reached a public archive. I counted them and stopped there, I did not touch a single real account. The specifics stay in the report.
So "the IDs are unguessable" was never a control. The application publishes them itself, on every page load, into places that keep them forever.
Why it broke
Authentication was excellent. Authorization was absent.
Two different questions, and the app only ever asked the first:
- Who is asking? → verify the JWT. Done perfectly. That's why my forged tokens got clean 401s.
- Is this person allowed to have this object? → never asked. Not on any endpoint.
Identity came from the verified token. The target record came from attacker-controlled input. Nothing ever checked that those two agreed.
Takeaway
Two things carried this one, and neither was clever.
The hour of form-filling. The setup friction that made me not want to test this target is exactly what kept everyone else out. Boring work is a moat, and you get to walk across it.
Not accepting the easy downgrade. "You'd need IDs you can't guess" sounds like a reason to stop. It's actually just the next question. Every time impact depends on something an attacker supposedly can't obtain, go find out whether they can. That's usually where the severity is hiding.
👉 Don't forget to follow me on LinkedIn for more writeups and cybersecurity content!
👉 If you enjoyed this writeup, leave a like and follow me for more hacking content!