CTF周周练(2026年4月13日-)

该文章介绍了一项自2026年3月23日开始的CTF周练计划:每周完成5道题目(1道Web、2道Reverse、2道Misc),并每月参加一次比赛。

第四周

ISCC 2026破阵夺旗赛校级赛-消失的密钥(Web)

前期探测

访问首页

访问首页后,可以看到一个认证界面,页面要求输入一个名为step1的授权值。表面上看,页面只暴露了一个输入框,没有给出其它功能入口。

黑盒探测源码入口

在不知道源码的前提下,可以尝试 PHP CTF 题里常见的调试参数,例如:

  • ?source

  • ?debug

  • ?view

  • ?highlight

该题中,访问:http://39.105.213.28:12601/?source会直接返回高亮后的 PHP 源码。

源码审计

过滤

$step1 = $_GET['step1'];
$filtered = str_replace("key", "", $step1);
  1. 先把输入中的所有小写key删除;

  2. 删除之后的结果必须严格等于字符串key

POST 数据结构

$a = $_POST['a'] ?? null;
$obj_a = (object)$a;
$user_key = $obj_a->key;
if (isset($user_key) && $user_key === "1337") {
  1. $_POST['a']必须存在;

  2. 转成对象后,能访问到$obj_a->key

  3. 该属性值必须严格等于字符串"1337"

哈希比较

$val_a = $_GET['a'] ?? "";
$val_b = $_GET['b'] ?? "";
if ($val_a !== "" && $val_b !== "" && $val_a !== $val_b) {
if (md5($val_a) == md5($val_b)) {
  1. ab都不能为空;

  2. ab不能是相同字符串;

  3. md5($a)md5($b)在 PHP 的 == 判断下要“相等”。

第一关详细利用

通过“重复片段嵌套”构造输入:kkeyey,服务端执行:str_replace("key", "", "kkeyey") => "key",于是条件:$filtered === "key"成立,第一关通过。

第二关详细利用

在 PHP 表单处理中,像这样的 POST 数据:a[key]=1337,会被自动解析为:

$_POST['a'] = [
    'key' => '1337'
];

接着执行:$obj_a = (object)$a;,就会得到一个等价对象:$obj_a->key === "1337",于是第二关通过。

第三关详细利用

第三关判断的是:$val_a !== "" && $val_b !== "" && $val_a !== $val_b,以及:md5($val_a) == md5($val_b),也就是说ab必须是两个不同的非空字符串,但它们的 MD5 值在弱比较下要相等。

可以使用这组经典输入:

a=QNKCDZO
b=240610708

它们是两个不同字符串,满足:$val_a !== $val_b,但对应的 MD5 分别为:

md5("QNKCDZO")   = 0e830400451993494058024219903391
md5("240610708") = 0e462097431906509019562988736854

在 PHP 的弱比较 == 中,这类字符串会被解释成数值:0 × 10^若干次方,结果都等于数字0。因此:md5("QNKCDZO") == md5("240610708"),会被判断为真。

如果题目写成:md5($val_a) === md5($val_b),那么 PHP 会按字符串逐字节比较,两串 MD5 不完全相同,就一定失败。

完整利用链

GET 参数

?step1=kkeyey&a=QNKCDZO&b=240610708

POST 数据

a[key]=1337

利用效果

这组数据会依次满足:

  1. step1=kkeyey经过str_replace("key", "", ...)后得到key

  2. a[key]=1337被 PHP 解析后,可让$obj_a->key === "1337"

  3. QNKCDZO240610708的 MD5 在 PHP == 下相等;

  4. 服务端进入最终分支并输出 flag。

FLAG:CREDENTIAL_FOUND: ISCC{hash_collision_v1_0e_2x_stable}

ISCC 2026破阵夺旗赛校级赛-where's bunny(Reverse)

解题过程

本题合理的做法是:

  1. 先看 Strings;

  2. 通过字符串交叉引用(Xref)定位主逻辑。;

  3. 优先分析 main 或者校验函数;

  4. 把输入、输出、比较目标、固定常量先找出来。

使用快捷键 Shift+F12 打开字符串窗口,可以看到:

Enter Flag:
Congratulations! Flag is correct!
Flag is incorrect!
15727A0205634B35CA3E14B73A7DA4157D6B004BD9EB1D65

也可以在窗口中按下 Ctrl+F 调出搜索框,优先搜索这些特征字符串:

Enter Flag
Congratulations
incorrect
ISCC{

输入目标字符串并回车,即可快速定位。双击字符串,按 X 或 Ctrl+X 看交叉引用。定位到主函数,有这样一段常量初始化:

v78[0] = 5;
v78[1] = 344;
v78[2] = 13;
v78[3] = 21;
v78[4] = 34;
v78[5] = 55;
v78[6] = 89;
v78[7] = 144;
v78[8] = 233;
v78[9] = 377;

这是一组很眼熟的数字:5、13、21、34、55、89、144、233、377,其中多数属于斐波那契数列。程序随后把十个洞的“状态”先全部设为1

v68 = (void *)1;
v69 = 1;
v70 = 1;
v71 = 1;
v72 = 1;
v73 = 1;
v74 = 1;
v75 = 1;
v76 = 1;
v77 = 1;
v17 = 0;

然后进入关键循环:

for ( i = 1; i < 1000; ++i )
{
  v19 = v17 % -10;
  v17 = v17 % -10 + i + 1;
  *(&v68 + v19) = nullptr;
}

这段代码虽然写得别扭,但本质上是在不断把某些洞位置为0,表示“这个洞被排除掉了”。把这段循环翻译成 Python 后,十个洞最终的状态是:

v = [1] * 10
s = 0
for i in range(1, 1000):
    idx = s % -10
    s = s % -10 + i + 1
    v[idx] = 0

print(v)

结果是:[0, 1, 0, 1, 0, 0, 1, 0, 1, 0],也就是只有 4 个位置还活着:索引:1、3、6、8,对应 v78 中的数字分别是:344、21、89、233。这一步得出的结论极其重要:程序真正会用到的key,不是 10 个,而是 4 个:344、21、89、233。程序随后遍历 10 个洞。如果洞是存活状态,就把对应数字转成字符串,然后按“第几个存活洞”决定做哪种变换。

存活次序

洞索引

数字

作用函数

含义

函数内容

核心语句

第1个

1

344

sub_401FF0

第1段变换

RC4:

  1. 先初始化一个 0..255 的数组;

  2. 用 key 去打乱这个数组;

  3. 再逐字节生成伪随机流并和输入异或。

第2个

3

21

sub_402370

第2段变换

循环异或(XOR):

  1. 遍历输入字符串;

  2. 用另一个较短字符串循环取字符;

  3. 逐字节异或。

((_BYTE )v3 + v4 - 1) = ((_BYTE )v8 + v4 - 1) ^ ((_BYTE )v6 + v7);

第3个

6

89

sub_402410

第3段变换

按key循环逐字节相加:

  1. 取输入字节;

  2. 取key中当前循环位置的字节;

  3. 做加法

((_BYTE )v7 + i) = ((_BYTE )v4 + i) + v6;

第4个

8

233

sub_401B80

第4段变换

见下方。

先看sub_401AC0。这个函数先把数字转成字符串,例如:233 -> "233",然后调用sub_4024B0sub_4024B0很长,但它的结构特征是标准 SHA-256:

  • 64 轮常量

  • 8 个状态字

  • 消息扩展

  • 大量右旋和逻辑函数

所以这一段不是 MD5,而是 SHA-256。sub_401AC0随后把 SHA-256 的结果取前 16 个字节,转成 32 个十六进制字符。所以我们可以理解为:233 -> "233" -> SHA-256 -> 取前16字节 -> 十六进制字符串,这会得到:c0509a487a18b003ba05e505419ebb63,这 16 字节会被后续函数作为分组加密的key

sub_401B80这个函数有两个关键特点。它先把key的十六进制字符串还原成 16 字节,然后把它拆成 4 个 32 位整数。

sub_401880它对输入做 8 字节分组加密,这个函数的常量和结构特征非常明显:

  • 常量 0x9E3779B9

  • 两个 32 位字反复更新

  • 共 32 轮

这是标准 TEA(Tiny Encryption Algorithm)风格。

sub_401B80里,程序先调用:sub_403D20(8 - (*(_DWORD )(a2 + 16) & 7), 8 - ((_BYTE *)(a2 + 16) & 7));,这表示它会把数据补齐到 8 字节整数倍。补齐方式是:

  • 如果差 1 字节,就补 0x01

  • 如果差 2 字节,就补 0x02 0x02

这就是典型的 PKCS#7 风格填充。

函数sub_402FD0的功能很简单:

  1. 逐字节取二进制输出;

  2. 拆成高 4 位和低 4 位;

  3. 查表 "0123456789ABCDEF";

  4. 生成大写十六进制字符串。

最终和常量:15727A0205634B35CA3E14B73A7DA4157D6B004BD9EB1D65进行比较。所以完整正向流程已经可以写成:

inner_flag
  -> RC4(key="344")
  -> XOR(key="21")
  -> ADD(key="89")
  -> PKCS#7 padding to 8 bytes
  -> TEA encrypt(key = SHA256("233")[:16])
  -> Upper Hex
  -> compare with 15727A0205634B35CA3E14B73A7DA4157D6B004BD9EB1D65

反向步骤如下:

  1. 把目标十六进制串还原成二进制;

  2. 用 233 派生出 TEA key;

  3. 对每个 8 字节块做 TEA 解密;

  4. 去掉末尾填充;

  5. 撤销第 3 段:减去循环 key "89";

  6. 撤销第 2 段:再异或循环 key "21";

  7. 撤销第 1 段:再跑一次 RC4(key="344"),因为 RC4 加解密对称。

得到的就是花括号内部明文。

import hashlib

TARGET = bytes.fromhex("15727A0205634B35CA3E14B73A7DA4157D6B004BD9EB1D65")

def rc4(data: bytes, key: bytes) -> bytes:
    s = list(range(256))
    j = 0
    for i in range(256):
        j = (j + s[i] + key[i % len(key)]) & 0xff
        s[i], s[j] = s[j], s[i]

    i = 0
    j = 0
    out = bytearray()
    for b in data:
        i = (i + 1) & 0xff
        j = (j + s[i]) & 0xff
        s[i], s[j] = s[j], s[i]
        out.append(b ^ s[(s[i] + s[j]) & 0xff])
    return bytes(out)

def tea_decrypt_block(block: bytes, key_words):
    v0 = int.from_bytes(block[:4], "little")
    v1 = int.from_bytes(block[4:], "little")
    delta = 0x9E3779B9
    total = (delta * 32) & 0xffffffff
    k0, k1, k2, k3 = key_words

    for _ in range(32):
        v1 = (v1 - ((((v0 << 4) & 0xffffffff) + k2) ^ ((v0 + total) & 0xffffffff) ^ ((v0 >> 5) + k3))) & 0xffffffff
        v0 = (v0 - ((((v1 << 4) & 0xffffffff) + k0) ^ ((v1 + total) & 0xffffffff) ^ ((v1 >> 5) + k1))) & 0xffffffff
        total = (total - delta) & 0xffffffff

    return v0.to_bytes(4, "little") + v1.to_bytes(4, "little")

# 计算 TEA 密钥
sha16 = hashlib.sha256(b"233").digest()[:16]
tea_key = [int.from_bytes(sha16[i:i+4], "little") for i in range(0, 16, 4)]

# Stage 3: TEA 解密
stage3_padded = b"".join(
    tea_decrypt_block(TARGET[i:i+8], tea_key)
    for i in range(0, len(TARGET), 8)
)

# 去除 Padding
pad = stage3_padded[-1]
stage3 = stage3_padded[:-pad]

# Stage 2 & 1: 简单的位移和异或变换
stage2 = bytes((b - ord("89"[i % 2])) % 256 for i, b in enumerate(stage3))
stage1 = bytes(b ^ ord("21"[i % 2]) for i, b in enumerate(stage2))

# Stage 0: RC4 解密
plain = rc4(stage1, b"344")

print("ISCC{" + plain.decode("latin1") + "}")

FLAG:ISCC{deIylyrlsluhowuetocIiaa}

附录:IDA Pro 常用快捷键

视图与导航

快捷键 功能说明
Space (空格) 切换 文本视图 与 图形视图
Esc 返回上一个操作地址(后退)
G 快速跳转到指定地址或符号
Ctrl + S 查看并选择程序段(Segments)信息
Ctrl + 鼠标滚轮 在图形视图下放大或缩小界面
Alt + T 搜索文本内容
Alt + B 搜索十六进制字节序列

符号与数据定义

快捷键 功能说明
N 对符号/变量/函数进行 重命名
Y 修改变量或函数的 数据类型
U (Undefine) 取消定义(将代码/数据还原为原始字节)
A 将选中数据解释为 字符串
X 查看当前函数、变量的 交叉引用 (Cross Reference)
Shift + F12 打开 字符串窗口,一键检索所有字符串

注释与标签

快捷键 功能说明
: (冒号) 添加常规注释(仅显示在当前位置)
; (分号) 添加可重复注释(所有引用处均会显示)
Alt + M 添加标签(Mark)
Ctrl + M 查看已添加的标签列表

数据库管理

快捷键 功能说明
Ctrl + W 保存当前 IDA 数据库
Ctrl + Shift + W 拍摄 IDA 快照(备份当前进度)

Hex-Rays 伪代码 (F5) 专项

在按下 F5 进入反编译界面后,以下快捷键最为常用:

快捷键 功能说明
/ 在伪代码界面添加注释
** 显示/隐藏 变量与函数的类型描述(精简视图)
N / Y 在伪代码中同样生效,用于重命名和修改类型

动态调试执行控制

快捷键 功能说明
F9 运行程序(直到遇到断点)
F7 / F8 单步步入 (进入函数) / 单步步过 (跳过函数)
F4 运行到当前光标所在行(常用于跳出循环)
Ctrl + F2 重新开始调试(Restart)
Alt + F2 结束调试会话
Ctrl + F9 执行直到返回(执行到当前函数结束)
Alt + F9 执行直到回到用户代码段

动态调试断点与查找

快捷键 功能说明
F2 设置/取消 软件断点
Alt + B 显示断点列表窗口
Ctrl + G 输入地址并跟随
Ctrl + N 查找导入/导出符号名
Shift + F4 设置条件断点

动态调试窗口切换 (Alt 系列)

快捷键 对应窗口 快捷键 对应窗口
Alt + C CPU 主窗口 Alt + M 内存映射窗口
Alt + E 模块列表 Alt + K 呼叫堆栈 (Stack)
Alt + L 日志记录 Alt + O 调试选项

提示:IDA 中的 Esc 是逆向工程中最常用的按键之一,类似于浏览器的“后退”按钮。动态调试时,如果不小心跑进了系统 DLL,可以使用 Alt + F9 快速回到程序自身的代码区域。

数字中国创新大赛网络安全方向团队赛 Write Up 2026-04-12
你的openEuler第一课,开启国产系统之旅 2026-05-06