-
-
[分享][讨论]KCTF 2026 · Rosetta Calibration — Write-up
-
发表于: 2026-8-25 18:12 231
-
KCTF 2026 · Rosetta Calibration — Write-up
目标:分析
kctf_rosetta.exe,求出 Name =KCTF的唯一 Serial。
这是一道典型的「注册码 / 序列号验证」类题目,属于 CTF 逆向的入门题。
0. 题目信息
Final EXE SHA256: caaa1c964b15d9b3250be0dc64412e5f64c29e297a971c226e8dd705d6b87e67
Public valid pair:
Name : CAAA1C964B15D9B3
Serial : $/.;-):,(-#!%*-&?+@
Target:
Find the unique Serial for Name KCTF.
- 文件类型:64 位 PE(x64),入口点
start @ 0x140001000,只有 5 个函数。 - 题目给了一个「合法对」:
Name=CAAA1C964B15D9B3,Serial=$/.;-):,(-#!%*-&?+@。 - 这个公开对是整个解题的钥匙:它既是验证我们理解是否正确的手段,也是"训练样本"。
1. 环境与工具
| 用途 | 工具 |
|---|---|
| 反汇编 / 反编译 | IDA Pro(Hex-Rays F5 反编译) |
| 代码验证 | Python 3(自己写注册机) |
| 运行验证 | 直接在命令行跑 kctf_rosetta.exe |
找关键代码的通用姿势:程序会打印提示语,先到字符串窗口(IDA 的 Strings / Shift+F12)看有哪些字符串,尤其是成功/失败提示,然后从它们 Ctrl+X(交叉引用)跳到引用它的代码。
本题的关键字符串:
[+] The symbols have found their meanings. ← 成功提示
[+] Calibration accepted. Key correct!
[-] Calibration rejected. Key incorrect. ← 比对失败
[-] The inscription cannot be translated. ← Serial 格式错误
[-] Invalid name format. ← Name 格式错误
顺着成功提示 0x14000211C 的引用,就找到了主函数 start。
2. 程序整体流程(反编译后)
1. 打印标题
2. 读取 Name (读到回车为止,存进 v16[],长度 v5)
3. 读取 Serial (读到回车为止,存进内存,长度 n19)
4. 检查 Name 格式:
- 长度 >= 1
- 每个字符在 ASCII 33 ~ 126(可打印字符)之间
5. 检查 Serial 格式:
- 必须正好 19 个字符
- 格式是 16 个"符号" + 3 个 '-' 分隔符(见第 3.2 节)
6. 把 16 个符号逐个映射成 0~15 的数字(见第 3.3 节)
- 要求 16 个数字互不重复,即恰好是 0~15 的排列
7. 用同样的 Name 算出 16 个数字(也是 0~15 的排列,见第 3.4 节)
8. 用 SSE 指令逐字节比较两组 16 字节:
pcmpeqb(比较) + pmovmskb(取出比较结果掩码),全 1 则通过
翻译成人话就是:
Serial 本质上是 Name 的"加密结果"。 把 Name 算出一个 16 字节序列,Serial 的 16 个符号解码后必须和它逐字节相等,且每个数字 0~15 恰好出现一次。
3. 详细逻辑链(核心推导)
3.1 Serial 为什么是 19 字符?分组为什么是 4-4-4-4?
主函数里有一行很关键的位操作:
n16912 = 16912; // 0x4210
...
while ( n0xE > 0xE || !_bittest(&n16912, n0xE) )
{
// 把第 n0xE 个字符当作"符号"映射成数字
...
}
if ( serial[n0xE] != 45 ) // 45 就是 '-',ASCII 破折号
goto reject;
16912 = 0x4210,拆开看二进制位:
0x4210 = 0x4000 + 0x200 + 0x10
= bit14 + bit9 + bit4
初学容易踩坑:
0x4210是 bit 14、9、4 置位,不是 bit 12!0x1000才是 bit 12,0x4000是 bit 14。
_bittest 判断第 n0xE 位是否为 1。当位置 n0xE 的 bit 是 1(且 n0xE <= 14)时,程序认为这里应该是一个 - 分隔符。所以:
索引: 0 1 2 3 | 4 | 5 6 7 8 | 9 | 10 11 12 13 | 14 | 15 16 17 18
内容: 符 符 符 符 | - | 符 符 符 符 | - | 符 符 符 符 | - | 符 符 符 符
分组: 4 个符号 | 4 个符号 | 4 个符号 | 4 个符号
结论:Serial = 16 个符号,分成 4-4-4-4,中间用 - 连接,共 19 字符。
验证公开对:$/.; - ):,( - #!%* - &?+@ ✅ 完全吻合。
3.2 符号字典("Rosetta 字母表")
sub_140001540 把一个字符按 char - 33 作为下标查表 dword_140002000(32 个 dword,覆盖 ASCII 33~64):
n0x1F = a1 - 33;
if ( n0x1F <= 0x1F )
return dword_140002000[n0x1F];
return 0xFFFFFFFF; // 查不到 → 非法字符
读出整张表(用 IDA 的 get_int 逐个读取更保险,别手数 hex)后,有效的符号只有 16 个,其余都是 -1:
| 字符 | ASCII-33 下标 | 值 |
|---|---|---|
! |
0 | 4 |
# |
2 | 7 |
$ |
3 | 12 |
% |
4 | 1 |
& |
5 | 9 |
( |
7 | 3 |
) |
8 | 11 |
* |
9 | 14 |
+ |
10 | 6 |
, |
11 | 13 |
. |
13 | 8 |
/ |
14 | 10 |
: |
25 | 15 |
; |
26 | 5 |
? |
30 | 0 |
@ |
31 | 3 |
⚠️ 一个反编译器容易看漏的细节:sub_140001520 里还有一句
return (n40 == 40) ^ sub_140001540(); // '(' 是 ASCII 40
也就是字符 ( 特殊处理:查表结果再 XOR 1。表里 ( = 3,3 ^ 1 = 2,所以 ( 实际映射到 2。
初学提醒:反编译结果 ≠ 逐字节事实。凡是有
==、setz、^这类可疑写法,回到汇编层sub_140001520看一眼(cmp bl, 28h; setz cl; xor eax, ecx),确认确实是(特例。
3.3 核心:Name 如何变成 16 个数字(sub_1400013E0)
v10[0] = xmmword_140002080; // 初始表 = {0,1,2,...,15}
v5 = 0; v3 = 0;
for ( i = 0; i < name_len; i++ )
{
v6 = name[i] ^ (name[i] >> 4); // 8 位混淆值
v5 += v6; // 累加和(8 位回绕)
v7 = ((v3 & 0xFF) ^ v6) & 0xF; // 交换位置
swap(table[i & 0xF], table[v7]);
v3 += 3;
}
// 输出 16 字节:
out[0..7] = table[(v5 + 0) & 0xF] ... table[(v5 + 7) & 0xF]
out[8] = table[(v5 & 0xF) ^ 8] // 等价于 table[(v5+8) & 0xF]
out[9..14] = table[(v5 + 9) & 0xF] ... table[(v5 + 14) & 0xF]
out[15] = table[(v5 - 1) & 0xF]
要点:
- 初始表
xmmword_140002080读出就是00 01 02 ... 0F,是一个 0~15 的排列。 - 洗牌只做交换,所以交换后仍然是 0~15 的排列。
- 输出的 16 个下标是
v5+0..7, v5+8, v5+9..14, v5+15,mod 16 后恰好覆盖 0~15 全部下标(因为(v5 & 0xF) ^ 8 == (v5 + 8) & 0xF,二进制下加 8 就是翻转 bit3)。 - 因此 Name 算出来的 16 个字节,天然就是 0~15 各一次 → 和 Serial 的检查(
n0xFFFF == 0xFFFF)自洽。
为什么是"排列"很关键:题目说 "Sixteen symbols. Sixteen meanings."(16 个符号、16 种含义)就是暗示这套一一映射。
3.4 判定
sub_1400013E0(name, name_len, &computed); // 算 Name 的 16 字节
if ( _mm_movemask_epi8(_mm_cmpeq_epi8(serial_nibbles, computed)) == 0xFFFF )
// 16 字节全相等 → 成功
4. 求解过程(如何得到 KCTF 的序列号)
第 1 步:用公开对建立信任
先不要急着解 KCTF。先确认你读懂的算法是对的 —— 拿公开对 CAAA1C964B15D9B3 反算一遍,如果结果等于题目给的 $/.;-):,(-#!%*-&?+@,说明算法理解正确。
第 2 步:写 Python 注册机
# ============ 1) 符号字典(来自 dword_140002000 + '(' 特例) ============
symbol_map = {
'!':4, '#':7, '$':12, '%':1, '&':9, '(':2, ')':11, '*':14,
'+':6, ',':13, '.':8, '/':10, ':':15, ';':5, '?':0, '@':3,
}
inv = {v: k for k, v in symbol_map.items()} # 数字 -> 符号(求序列号用反向表)
# ============ 2) Name -> 16 个数字(sub_1400013E0) ============
def name_to_nibbles(name):
table = list(range(16)) # 初始表 0..15
v3, v5 = 0, 0
for i, ch in enumerate(name.encode('latin1')):
v6 = ch ^ (ch >> 4)
v5 = (v5 + v6) & 0xFF
v7 = ((v3 & 0xFF) ^ v6) & 0xF
table[i & 0xF], table[v7] = table[v7], table[i & 0xF]
v3 = (v3 + 3) & 0xFF
out = [table[(v5 + i) & 0xF] for i in range(8)]
out.append(table[(v5 & 0xF) ^ 8])
out += [table[(v5 + i) & 0xF] for i in range(9, 15)]
out.append(table[(v5 - 1) & 0xF])
return out
# ============ 3) 用公开对验证 ============
pub_name, pub_serial = "CAAA1C964B15D9B3", "$/.;-):,(-#!%*-&?+@"
pub_syms = [c for c in pub_serial if c != '-']
assert name_to_nibbles(pub_name) == [symbol_map[c] for c in pub_syms], "算法理解错了!"
print("[*] 公开对验证通过")
# ============ 4) 求 KCTF 的序列号 ============
target = name_to_nibbles("KCTF")
assert sorted(target) == list(range(16)) # 必须恰好 0..15 各一次
serial = "".join(inv[v] for v in target) # 数字 -> 符号
serial = "-".join([serial[0:4], serial[4:8], serial[8:12], serial[12:16]])
print("[+] Serial for KCTF:", serial)
第 3 步:结果 & 实测
KCTF nibbles : [9, 10, 3, 12, 13, 14, 0, 15, 4, 7, 11, 1, 5, 6, 2, 8]
KCTF serial : &/@$-,*?:-!#)%-;+(.
运行程序验证:
$ printf 'KCTF\n&/@$-,*?:-!#)%-;+(.\n' | ./kctf_rosetta.exe
...
[+] The symbols have found their meanings.
[+] Calibration accepted. Key correct! ← 成功!
最终答案:Name = KCTF,Serial = &/@$-,*?:-!#)%-;+(.