1501 字
8 分钟
ReverseNote(note)逆向:UPX 脱壳 + XOR 解密

ReverseNote(note)逆向:UPX 脱壳 + XOR 解密#

0. 题目信息#

项目内容
文件notenote.zip 内只有一个可执行文件)
类型ELF 64-bit LSB pie executable, x86-64
特征statically linked、no section header(加壳特征)
大小6360 字节(很小,明显是壳 + 极小逻辑)
运行行为提示 请输入密码:,输入错误输出 密码错误,再试一次吧。

运行一下:

Terminal window
$ ./note
请输入密码:abc
密码错误,再试一次吧。

典型的”密码验证”题:程序内置一个答案,输入对了就出 flag(或输入本身就是 flag)。

1. 第一步:识别壳(UPX)#

为什么先看壳? 加壳程序直接反编译只能看到解压 stub,看不到真正的逻辑。先用 file + strings 判断:

Terminal window
$ file note
note: 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 官方工具直接解:

Terminal window
$ upx -d -o note.unpacked note
$ file note.unpacked
note.unpacked: ELF 64-bit LSB pie executable ... dynamically linked ... not stripped

脱壳后 16 KB、not stripped(符号都在),从 6360 变 16232 字节,还原出原始程序。这一步没有任何技术含量,但漏掉它会卡住后面所有分析。

2. 静态分析:定位 main 与解密函数#

脱壳后文件带符号,直接用 Ghidra / IDA 打开(本环境用 REA 接 Ghidra 反编译)。符号表里直接看到两个关键函数:

  • main @ 0x101240
  • decrypt_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 python3
data = 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 喂回程序:

Terminal window
$ echo 'flag{pyrPGREJEB22LAdDfHNrf3kA55OKf86YFlnuG}' | ./note.unpacked
请输入密码:恭喜你,找到 Flag!

✅ 验证通过。

5. 解题思路总结(这类题通用)#

这类”输入密码”型逆向题的思路是固定的:

  1. 先识别壳再谈分析file 看到 no section header / 大小异常 / UPX 特征串 → 先脱壳。最常见的坑就是把壳当程序本身去逆。
  2. 找 main 看结构。反编译后先看整体骨架:输入在哪读、输出在哪打、中间调了什么函数。scanf 格式串、printf 的成功/失败字符串是定位逻辑的锚点。
  3. 找”真值”的来源strcmp 的第二个参数如果是某个函数算出来的,那个函数就是关键 —— 程序把 flag 加密存放、运行时解密比对,解密函数本身就是现成的解密器。
  4. 把算法搬到脚本里。XOR 自反,解密 = 加密;即使是非对称算法,也只需要把常量(密钥、密文、长度)抠出来写脚本。密文通常就在 .rodata/.data,用 Ghidra 里引用的地址减去镜像基址换算成文件偏移。
  5. 做出来必须验证。把 flag 输入程序,看到成功输出才算数,防止脚本抄错位(字节序、长度、偏移都要对齐)。

6. 一般操作方式(做题流程模板)#

按这个流程处理任何本地二进制逆向题,基本不会卡死:

① 环境准备:upx / IDA 或 Ghidra / gdb / python3
② file + checksec + 运行观察(输入随便给,看提示)
③ strings 快速看特征串(UPX!、flag、成功提示……)
④ 加壳?→ upx -d 或手动 dump(脱壳失败再看内存 dump)
⑤ 反编译定位 main → 梳理 输入/变换/比对 骨架
⑥ 深挖关键函数 → 提取密钥、密文、长度等常量
⑦ 写 solve.py 还原 flag
⑧ 回程序验证 → 完成

常见坑位备忘:

  • 地址换算:Ghidra 默认镜像基址 0x100000(或 0x0),文件偏移 = 虚拟地址 - 基址 + 段文件偏移;本体的 .rodata VA 0x2000 = 文件偏移 0x2000,所以密文 0x102010 → 文件偏移 0x2010。读错偏移是脚本解出乱码的头号原因。
  • scanf("%63s") 说明答案最长 63 字符,注意密文长度别超。
  • 解密方向:程序里叫 decrypt_flag 也可能实际是”加密”逻辑,别管名字,看数学关系;XOR 就原样复算,可逆性由运算符本身保证。
  • UPX 脱壳后验证:脱壳前后 file 对比,符号是否恢复(not stripped)决定后面分析难度。

参考#