DiceCTF @ HOPE - web/payment-pal writeup

Sun Jul 24 2022

Hello, everyone! Recently our team was asked to host a CTF for HOPE, the "Hackers of Planet Earth" conference. While I didn't go to the conference in person, I did end up writing one challenge for the CTF, web/payment-pal, a hard client-side web that ended up getting 3 solves.

This was another one of my typical "onion" web challenges. Friends have started to notice that many of the challs I write involve a full web application where the solution requires layering multiple subtle vulns on top of each other (see noteKeeper, saasme, styleme, etc), and payment-pal was no different. It required chaining together some fun techniques, some of which I haven't seen in any other CTF challenge before.

I wrote the challenge a couple of days before the CTF started, and basically none of my teammates were able to fully test solve it, so I'm glad everything went well. DiceCTF @ HOPE was fun to organize, special thanks to the other organizers and for the HOPE staff for giving us this opportunity :)

By the way, my team is hosting corCTF 2022, and you should totally check it out. There'll be more hard web challenges from me and some friends that you should play!

Now, on to the writeup.


solves: 3

payment-pal was a simple app that let you register, deposit money, and transfer money to other users!

Well, the app was still in development, so you actually couldn't deposit money yet, so you were stuck at $0.

It was built in NodeJS with a GraphQL API layer to serve requests from the "database", a simple in-memory map. To get the flag, you would have to have an account balance of $0x12345, and make a GraphQL query to flag. But, new accounts don't start with any money, and depositing wasn't implemented yet.

The admin has an infinite balance, so the goal is clear - somehow force the admin account to transfer us enough money to get the flag. But, the path is much less clear. Let's take a look at the code.

There's a very obvious potential prototype-pollution vulnerability in the client-side JavaScript:

// script.js const DENYLIST = ["__proto__", "constructor", "prototype"]; // no const parseQs = () => { let obj = {}; let pairs = location.search.slice(1).split('&').filter(Boolean); for (let i = 0; i < pairs.length; i++) { if (DENYLIST.some(key => pairs[i].includes(key))) continue; let parts = pairs[i].split('='); let m; if (m = /(\w+)\[(\w+)\]/.exec(decodeURIComponent(parts[0]))) { obj[m[1]] = obj[m[1]] || []; obj[m[1]][m[2]] = decodeURIComponent(parts[1]); } else { obj[decodeURIComponent(parts[0])] = decodeURIComponent(parts[1]); } } history.pushState(null, null, location.pathname); return obj; };

This code parses the query string parameters, and then assigns it to obj, a blank object with a prototype. There's a DENYLIST however, making it so we can't use the necessary keywords... except that the keywords are run through decodeURIComponent right after! So, making a request to payment-pal with a query string like ?__proto_%5f[x]=1, Object.prototype.x should be polluted to 1.

Now, what should we pollute. This was one of the hardest parts of the challenge, and to figure it out, we can take a look at what the admin bot does. The admin bot source code was provided:

// admin-bot.js // npm install puppeteer const puppeteer = require("puppeteer"); // change these const USERNAME = "ADMIN_ACCOUNT"; const PASSWORD = "ADMIN_PASSWORD"; const SITE = "http://paymentpal.localhost"; const visit = async (url) => { let browser; try { browser = await puppeteer.launch({ headless: 'chrome', pipe: true, args: [ "--no-sandbox", "--disable-setuid-sandbox", "--js-flags=--noexpose_wasm,--jitless", ], dumpio: true }); let page = await browser.newPage(); await page.goto(SITE, { waitUntil: "networkidle2" }); await page.evaluate((username, password) => { document.querySelector("input[name=username]").value = username; document.querySelector("input[name=password]").value = password; document.querySelector("#login_btn").click(); }, USERNAME, PASSWORD); page.once('dialog', async dialog => { await dialog.dismiss(); }); await page.waitForNavigation(); // yeah, this is indeed the payment-pal website :') await page.waitForTimeout(1000); await page.evaluate(() => { document.querySelector("#logout_btn").click(); }); await page.waitForTimeout(2000); await page.goto(url); await page.waitForTimeout(10000); await browser.close(); browser = null; } catch (err) { console.log(err); } finally { if (browser) await browser.close(); } }; visit("https://yourwebsite/payload");

So, it logs in to the admin account, and then logs out (?) before going to our site. This is a very weird admin bot scenario - how are we supposed to do anything if the admin logs out before we can even interact?

Looking at the client source again, we see this section after logging in:

// script.js if (isAdmin) { // enable WIP contacts feature let res = await graphql(`query { info { contacts } }`); if (res.errors || !res.data.info.contacts) return; let html = `<h3>contacts:</h3><ol>`; res.data.info.contacts.forEach(user => html += `<li onclick="transferToUser('${user}')" style="cursor:pointer">${user}</li>` ); html += `</ol>`; $("#contacts").innerHTML = html; }

So, if the user logged in is an admin, make a GraphQL query to retrieve the user's contacts, and then create HTML code representing a list. This is an obvious XSS entrypoint, but there's two problems - 1. we can't make ourselves admin, 2. and the only admin has its database entry frozen, so we can't add contacts to it.

There is no CSRF protection on the site, so it seems like if we want to get XSS on the site, we will need to make the admin to log into our account. But, if they're logged into our account, what can they even do anyway?

This seems even more confusing. But, with prototype pollution, maybe there's something we can do. The idea at this point is:

  1. register an account with XSS in the contacts

  2. force the admin to login as our account

  3. pollute something to enable isAdmin

  4. get self XSS

  5. ???

  6. profit

So, what can we pollute to make ourselves admin? Well, the obvious ?__proto__[isAdmin]=true doesn't work since GraphQL will return isAdmin: false, and we can't pollute anything to override that... or can we?

This was the key point of the challenge. Looking through the code, at first glance there's actually nothing useful to prototype pollute. But, the revelation is that we can actually prototype pollute the fetch options! This idea was taken from this blog post by PortSwigger, and this is why you should keep learning and stay up to date with research :)

So, for some reason we can pollute options for fetch, but what does that let us do? We can control the request options of fetch, so let's read the docs.

Keeping in mind everything we know about the challenge, and looking at the docs, one property should hopefully pop out to you:


Contains the cache mode of the request (e.g., default, reload, no-cache).

Cache! The admin logs in and out first, and some of their data will be cached. So, we can pollute the fetch request to read the cached results so we can use their admin status!

Is this possible? Well, at first glance actually no. From the admin bot source, we know the admin logs out, and when they log out, it makes a new request that responds with the error "You are not logged in" that caches over the old response with isAdmin: true. Damn.

But actually, if you were to test this with the given admin bot script, you'll notice that this doesn't actually happen! The admin bot actually never closes the alert() box after logging out, so the new request never happens, and the cache contains our old isAdmin: true request. Perfect.

Looking at the docs for Request.cache, we see the mode "force-cache":

force-cache — The browser looks for a matching request in its HTTP cache.

  • If there is a match, fresh or stale, it will be returned from the cache.
  • If there is no match, the browser will make a normal request, and will update the cache with the downloaded resource.

This sounds perfect. We can pollute this property with ?__proto_%5f[cache]=force-cache. So, we force the admin to login to our account by making a POST request to /graphql to login, then redirect to the prototype polluted cache=force-cache page to pass the isAdmin check and see our XSS contacts.

There's a small hiccup - we want to cache the admin's isAdmin: true result, but not the admin's contacts query result, since they will not have our XSS payload contacts. But this can be solved by first forcing our contacts to cache by making a request to /graphql?query={query { info { contacts } } after we login as our account, forcing the cache to take on our XSS payload.

If you do all this, you should now get XSS on the admin bot... as our own user. Which, has no access. Damn.

Okay, there's something we're missing... let's take a look at the server code. Specifically, how are we authenticated? Can we somehow forge a login as the admin?

Here's some of auth.js, which handles the encryption/decryption of our session cookie:

// auth.js const KEY = crypto.randomBytes(32); const encrypt = (data) => { const iv = Buffer.from(crypto.randomBytes(16)); const cipher = crypto.createCipheriv('aes-256-gcm', KEY, iv); let enc = cipher.update(data, 'utf8', 'base64'); enc += cipher.final('base64'); return [enc, iv.toString('base64'), cipher.getAuthTag().toString('base64')].join("."); }; const decrypt = (data) => { try { const [enc, iv, authTag] = data.split(".").map(d => Buffer.from(d, 'base64')); const decipher = crypto.createDecipheriv('aes-256-gcm', KEY, iv); decipher.setAuthTag(authTag); let dec = decipher.update(enc, 'base64', 'utf8'); return dec; } catch(err) { return null; } };

One core principle I keep in mind whenever I play CTFs is that authors are usually lazy. Usually, they won't implement some extraneous feature without it being important in some way. Red herrings are bad challenge design. So, when you see this custom encryption/decryption implementation instead of using something easy like express-session, hopefully at least an eyebrow is raised.

There is indeed a vulnerability in this code, in the decrypt function - there is a missing decipher.final() call. AES-GCM is AES in Galois Counter Mode helping to provide integrity, so that the encrypted message is immune to tampering. AES-CTR is malleable, where a bit flipped in the ciphertext flips it in the plaintext, which allows us to change what something decrypts to, but AES-GCM prevents this by having an "authorization tag", basically a message authentication code (MAC) that assures us that no tampering has been going on.

decipher.final() is missing from decrypt(), and this is terrible since that is when the verification that the authorization tag is valid happens. So, the authorization tag in this setup is actually useless. So, this setup actually just reverts back to AES-CTR, which we can flip bits on to forge a cookie.

Okay, so we can do some bit flipping to forge a session cookie for an arbitrary username. We don't know the admin's username, but we can get it with XSS!

When the admin logs in, the username appears in the query parameter:

// script.js const login = async () => { let username = $("input[name=username]").value; let password = $("input[name=password]").value; let res = await graphql(` mutation($username: String!, $password: String!) { login(username: $username, password: $password) { username } } `, { username, password }); if (res.errors) { location.href = '/?message=' + encodeURIComponent(res.errors[0].message); } else { location.href = '/?message=' + encodeURIComponent(`logged in as ${res.data.login.username}`); } };

So, if we could access past URLs of the admin, we could see their username. And, since we have XSS, we can do just that! By using the History API, we can force the admin bot to essentially do the equivalent of pressing the back button on their browser a certain amount of times until we get to the page with the URL parameter.

We can do this by opening a new tab to https://payment-pal.mc.ax with our XSS payload, then in our payload, using window.opener to control the previous window that has the admin's username in the history. By doing history.go(-num), we can go num pages back in history.

Going to the right page, we can then access the admin's username with window.opener.location.href, which we learn is admin-dicegang_pp_user. From there, we can bit flip a session of a user with the same length to that username, giving us access to the account.

Then, with admin access, we can transfer money to our own account, and get the flag!


Unintended Solution

There was a pretty funny unintended solution by team thehackerscrew that I would like to mention. The challenge was solved by three teams, XxTSJxX, Super Guesser, and thehackerscrew. The first two solved it basically intended, but the last solution was completely different and funny enough that I wanted to share it.

Looking at the admin bot source code closely, we see this:

// admin-bot.js await page.evaluate(() => { document.querySelector("#logout_btn").click(); }); await page.waitForTimeout(2000); await page.goto(url); await page.waitForTimeout(10000);

The admin clicks the logout button, waits 2 seconds, and then goes to our URL.

Normally, there would be an await page.waitForNavigation() here so that we know that the logout actually goes through. But, since there is an alert box on logout that the admin bot doesn't dismiss, this actually freezes the admin bot so nothing else happens.

So, I had to leave that out, instead opting for a 2 second delay. I thought that this would be fine, and no problems would arise from this. It was not fine. Because the admin bot never actually checks that it's logged out, if the log out doesn't go through, you could easily CSRF and force the admin to transfer you money.

To do this, they sent the server a bunch of requests, DoSing it until it didn't log out successfully, and then using the admin account to transfer money and get the flag. Putting the discussion of competition ethics aside, I thought it was pretty funny. Props to them :)

If you got this far, thanks for reading and I hope you learned something :)

Also, follow me on Twitter ;')

Also, sign up for corCTF 2022!

Thanks for reading!

~ Strellic