Dual Protection WP
题目信息
题目文件:
Dual Protection.exeflag 格式:
ISCC{}
一、程序基本行为
程序运行后会提示输入:
Input Flag:
输入后只会输出两种结果:
Correct.
Wrong.
通过字符串可以直接看到:
Input Flag:%36sCorrect.Wrong.
其中 %36s 已经暗示输入长度和 36 很相关。
二、主逻辑分析
主逻辑位于 0x401100 附近,核心流程可以还原为:
memset(buf, 0, 0x63);
printf("Input Flag: ");
scanf("%36s", buf);
if (strlen(buf) != 0x24) {
return fail;
}
for (i = 0; i < 0x24; i++) {
sub_401000(buf, i);
sub_401050(buf, i);
sub_4010D0(buf, i);
}
seed = 0xdeadbeef;
if (CheckRemoteDebuggerPresent(GetCurrentProcess(), &debugged) && debugged) {
seed = 0x0badf00d;
}
code = VirtualAlloc(0, 0x118, 0x3000, 0x40);
memcpy(code, enc_blob, 0x118);
for (i = 0; i < 0x118; i++) {
seed = seed * 0x19660d + 0x3c6ef35f;
code[i] ^= (seed >> 24) & 0xff;
}
ok = code(buf);
puts(ok ? "Correct." : "Wrong.");
system("pause");
可以看出程序有两层保护:
先对输入做三轮逐字节变换。
再动态解密一段真正的校验代码。
三、长度检查
程序手动计算输入长度,随后比较:
if (strlen(buf) != 0x24)
因此输入长度必须为:
0x24 = 36
四、第一层保护:三轮逐字节变换
1. sub_401000
位于 0x401000,逻辑如下:
b = buf[i];
b ^= 0x55;
b = rol8(b, 2);
b = (b + i) & 0xff;
buf[i] = b;
2. sub_401050
位于 0x401050,函数内部构造了一个 8 字节数组:
key = [0x12, 0x34, 0x56, 0x78, 0x90, 0xAB, 0xCD, 0xEF];
逻辑如下:
b = buf[i];
b ^= key[i % 8];
b = (b + 0x7f) & 0xff;
buf[i] = b;
3. sub_4010D0
位于 0x4010D0,逻辑如下:
b = buf[i];
b ^= ((i + 0x20) & 0xff);
buf[i] = b;
4. 合并后的正向变换
对每个字符 buf[i],整体变换为:
b = buf[i];
b = rol8(b ^ 0x55, 2);
b = (b + i) & 0xff;
b = b ^ key[i % 8];
b = (b + 0x7f) & 0xff;
b = b ^ ((i + 0x20) & 0xff);
buf[i] = b;
五、第二层保护:动态解密 + 双重反调试
程序把数据区中的 0x118 字节拷贝到新申请的可执行内存,然后进行解密:
seed = 0xdeadbeef;
for (i = 0; i < 0x118; i++) {
seed = (seed * 0x19660d + 0x3c6ef35f) & 0xffffffff;
code[i] ^= (seed >> 24) & 0xff;
}
但是在此之前程序调用了:
CheckRemoteDebuggerPresent(GetCurrentProcess(), &debugged)
如果检测到调试器,则把种子改成:
0x0badf00d
这样解密结果就会完全不同。
此外,解密出的代码内部还会读取:
fs:[0x30]
也就是 PEB,再检查 BeingDebugged 标志。
因此这题存在双重反调试:
CheckRemoteDebuggerPresentPEB->BeingDebugged
如果直接挂调试器分析,解密出的校验逻辑会被故意破坏。
六、解密后真实校验逻辑
把动态解密出来的 0x118 字节代码反汇编后,可以发现它本质上只是逐字节比较输入缓冲区和一个固定常量数组。
常量数组为:
const = [
0xC1, 0x8D, 0xA9, 0x81, 0x8F, 0x88, 0xFB, 0xE5,
0x4A, 0xF1, 0xB5, 0x38, 0x80, 0xDD, 0x67, 0x89,
0x3E, 0xC9, 0xC9, 0x89, 0x3B, 0x80, 0x2C, 0xF5,
0x6E, 0xFD, 0x7D, 0x21, 0x5F, 0x80, 0xFC, 0xA9,
0x72, 0xFC, 0x82, 0x79
]
等价伪代码如下:
ok = 1;
for (i = 0; i < 36; i++) {
if (buf[i] != const[i]) {
ok = 0;
}
}
return ok;
因此题目本质上就是:
输入必须长度为 36。
输入经过三轮变换后必须等于
const数组。
七、逆向还原输入
正向变换为:
b = rol8(b ^ 0x55, 2);
b = (b + i) & 0xff;
b = b ^ key[i % 8];
b = (b + 0x7f) & 0xff;
b = b ^ ((i + 0x20) & 0xff);
那么逆向时按相反顺序恢复:
b = b ^ ((i + 0x20) & 0xff);
b = (b - 0x7f) & 0xff;
b = b ^ key[i % 8];
b = (b - i) & 0xff;
b = ror8(b, 2) ^ 0x55;
八、解题脚本
const = [
0xC1, 0x8D, 0xA9, 0x81, 0x8F, 0x88, 0xFB, 0xE5,
0x4A, 0xF1, 0xB5, 0x38, 0x80, 0xDD, 0x67, 0x89,
0x3E, 0xC9, 0xC9, 0x89, 0x3B, 0x80, 0x2C, 0xF5,
0x6E, 0xFD, 0x7D, 0x21, 0x5F, 0x80, 0xFC, 0xA9,
0x72, 0xFC, 0x82, 0x79
]
key = [0x12, 0x34, 0x56, 0x78, 0x90, 0xAB, 0xCD, 0xEF]
def ror8(x, n):
return ((x >> n) | ((x << (8 - n)) & 0xff)) & 0xff
flag = []
for i, c in enumerate(const):
x = c ^ ((i + 0x20) & 0xff)
x = (x - 0x7f) & 0xff
x ^= key[i % 8]
x = (x - i) & 0xff
x = ror8(x, 2) ^ 0x55
flag.append(chr(x))
print(''.join(flag))
运行结果:
ISCC{u6</LN-9&+;6ZSYnwE0>CtgCKI#5/(}
九、验证
将结果输入程序,输出:
Correct.
说明恢复出的输入正确。
十、最终 flag
ISCC{u6</LN-9&+;6ZSYnwE0>CtgCKI#5/(}
十一、简要总结
这题的关键点有三个:
输入长度固定为
36。输入先经过三轮字节级变换。
真正的校验逻辑被动态解密,并且夹带双重反调试。
虽然外层看起来比较复杂,但本质上最终仍然只是“变换后与常量比较”。
因此只要把动态解密出的比较逻辑提出来,再逆变换恢复原始输入,即可拿到 flag。
评论