#少女前線 416とM16A1のあの頃 - 反骨MAX
1501 字
8 分钟
ReverseNote(note)逆向:UPX 脱壳 + XOR 解密
ReverseNote(note)逆向:UPX 脱壳 + XOR 解密
0. 题目信息
| 项目 | 内容 |
|---|---|
| 文件 | note(note.zip 内只有一个可执行文件) |
| 类型 | ELF 64-bit LSB pie executable, x86-64 |
| 特征 | statically linked、no section header(加壳特征) |
| 大小 | 6360 字节(很小,明显是壳 + 极小逻辑) |
| 运行行为 | 提示 请输入密码:,输入错误输出 密码错误,再试一次吧。 |
运行一下:
$ ./note请输入密码:abc密码错误,再试一次吧。典型的”密码验证”题:程序内置一个答案,输入对了就出 flag(或输入本身就是 flag)。
1. 第一步:识别壳(UPX)
为什么先看壳? 加壳程序直接反编译只能看到解压 stub,看不到真正的逻辑。先用 file + strings 判断:
$ file notenote: ELF 64-bit LSB pie executable, x86-64 ... statically linked, no section header
$ strings -a note | grep -i upx$Info: This file is packed with the UPX executable packer http://upx.sf.net $$Id: UPX 4.24 Copyright (C) 1996-2024 the UPX Team. All Rights Reserved. $两个强特征:
file输出 no section header —— 正常编译的 ELF 一定带 section header 表,没有就说明被处理过(UPX 会把 section 信息压缩掉);- strings 里直接出现 UPX! 和
This file is packed with the UPX executable packer。
结论:UPX 4.24 加壳,用 UPX 官方工具直接解:
$ upx -d -o note.unpacked note$ file note.unpackednote.unpacked: ELF 64-bit LSB pie executable ... dynamically linked ... not stripped脱壳后 16 KB、not stripped(符号都在),从 6360 变 16232 字节,还原出原始程序。这一步没有任何技术含量,但漏掉它会卡住后面所有分析。
2. 静态分析:定位 main 与解密函数
脱壳后文件带符号,直接用 Ghidra / IDA 打开(本环境用 REA 接 Ghidra 反编译)。符号表里直接看到两个关键函数:
main@ 0x101240decrypt_flag@ 0x101180
main 伪代码
undefined8 main(void){ char encrypted[43]; // local_c8,栈上 48 字节 char output[64]; // local_98 char input[76]; // local_58
memcpy(encrypted, &DAT_00102010, 0x2b); // 拷贝 43 字节"密文"到栈上 printf("请输入密码:"); __isoc99_scanf("%63s", input); // 读入最多 63 字符 decrypt_flag(encrypted, output); // 现场解密 -> output if (strcmp(input, output) == 0) { printf("恭喜你,找到 Flag!\n"); } else { printf("密码错误,再试一次吧。\n"); } return 0;}程序结构非常清晰,也是这类题的通用骨架:
输入 ──► 变换/解密 ──► 与"真值"比对 ──► 成功/失败输出关键认知:flag 不以明文存在程序里,而是把加密后的字节(0x102010 处 43 字节)存进二进制,运行时调用 decrypt_flag 现场解出真值再 strcmp。所以我们不用猜输入,直接把解密函数跑一遍即可。
decrypt_flag 伪代码
void decrypt_flag(char *in, char *out){ byte key[4] = { 0x42, 0x37, 0xa1, 0x7c }; // 来自 0x102004 int i = 0;
while (i < strlen(in)) { out[i] = in[i] ^ key[i % 4]; // 第一层:XOR 4 字节密钥 out[i] = out[i] ^ (i + 1); // 第二层:XOR 位置相关数 i++; } out[strlen(in)] = 0;}两层都是 XOR。XOR 自反(a ^ b ^ b == a),所以解密就是同样的操作再来一遍:
flag[i] = enc[i] ^ key[i % 4] ^ (i + 1)3. 提取密文 + 写解密脚本
密文在 .rodata 段偏移 0x2010(Ghidra 里地址 0x102010,PIE 基址 0x100000 + 0x2010),长度 0x2b = 43 字节:
2559c31f3c41df061b7af835057cec5e6169f30c1347fe2a294b890b1e1c8b132873ba6e3e57ea3a1e5af7解密脚本:
#!/usr/bin/env python3data = open('note.unpacked', 'rb').read()
enc = data[0x2010 : 0x2010 + 43] # .rodata 偏移 0x2010,43 字节key = [0x42, 0x37, 0xa1, 0x7c]
flag = bytes(enc[i] ^ key[i % 4] ^ (i + 1) for i in range(len(enc)))print(flag.decode())运行结果:
flag{pyrPGREJEB22LAdDfHNrf3kA55OKf86YFlnuG}4. 验证
把解出的 flag 喂回程序:
$ echo 'flag{pyrPGREJEB22LAdDfHNrf3kA55OKf86YFlnuG}' | ./note.unpacked请输入密码:恭喜你,找到 Flag!✅ 验证通过。
5. 解题思路总结(这类题通用)
这类”输入密码”型逆向题的思路是固定的:
- 先识别壳再谈分析。
file看到 no section header / 大小异常 / UPX 特征串 → 先脱壳。最常见的坑就是把壳当程序本身去逆。 - 找 main 看结构。反编译后先看整体骨架:输入在哪读、输出在哪打、中间调了什么函数。
scanf格式串、printf的成功/失败字符串是定位逻辑的锚点。 - 找”真值”的来源。
strcmp的第二个参数如果是某个函数算出来的,那个函数就是关键 —— 程序把 flag 加密存放、运行时解密比对,解密函数本身就是现成的解密器。 - 把算法搬到脚本里。XOR 自反,解密 = 加密;即使是非对称算法,也只需要把常量(密钥、密文、长度)抠出来写脚本。密文通常就在
.rodata/.data,用 Ghidra 里引用的地址减去镜像基址换算成文件偏移。 - 做出来必须验证。把 flag 输入程序,看到成功输出才算数,防止脚本抄错位(字节序、长度、偏移都要对齐)。
6. 一般操作方式(做题流程模板)
按这个流程处理任何本地二进制逆向题,基本不会卡死:
① 环境准备:upx / IDA 或 Ghidra / gdb / python3② file + checksec + 运行观察(输入随便给,看提示)③ strings 快速看特征串(UPX!、flag、成功提示……)④ 加壳?→ upx -d 或手动 dump(脱壳失败再看内存 dump)⑤ 反编译定位 main → 梳理 输入/变换/比对 骨架⑥ 深挖关键函数 → 提取密钥、密文、长度等常量⑦ 写 solve.py 还原 flag⑧ 回程序验证 → 完成常见坑位备忘:
- 地址换算:Ghidra 默认镜像基址 0x100000(或 0x0),文件偏移 = 虚拟地址 - 基址 + 段文件偏移;本体的
.rodataVA 0x2000 = 文件偏移 0x2000,所以密文 0x102010 → 文件偏移 0x2010。读错偏移是脚本解出乱码的头号原因。 scanf("%63s")说明答案最长 63 字符,注意密文长度别超。- 解密方向:程序里叫
decrypt_flag也可能实际是”加密”逻辑,别管名字,看数学关系;XOR 就原样复算,可逆性由运算符本身保证。 - UPX 脱壳后验证:脱壳前后
file对比,符号是否恢复(not stripped)决定后面分析难度。
参考
- UPX 项目:https://github.com/upx/upx
- 本文分析基于 Ghidra(REA 桥接)反编译输出