第二届“云天杯”网络安全大赛部分WriteUp
Challenge
---
一、程序主逻辑分析
IDA 反编译 main 后,可以看到程序核心逻辑如下:
int __fastcall main(int argc, const char **argv, const char **envp)
{
char key;
_QWORD *addr;
int i;
addr = mmap(0, 0x48u, 7, 34, -1, 0);
if (addr == -1LL)
{
perror("mmap");
return 1;
}
*addr = enc_shellcode;
addr[1] = qword_404088;
addr[2] = qword_404090;
addr[3] = qword_404098;
addr[4] = qword_4040A0;
addr[5] = qword_4040A8;
addr[6] = qword_4040B0;
addr[7] = qword_4040B8;
addr[8] = qword_4040C0;
key = get_key();
for (i = 0; i <= 0x47; ++i)
*((_BYTE *)addr + i) ^= key;
((void(*)())addr)();
munmap(addr, 0x48u);
return 0;
}程序先通过 mmap 申请一段可读、可写、可执行的内存,然后把 enc_shellcode 拷贝进去。
之后调用 get_key() 获取 key,并对整段 shellcode 做异或解密:
*((_BYTE *)addr + i) ^= key;最后直接执行解密后的 shellcode:
((void(*)())addr)();二、第一层 key 分析
继续查看 get_key():
__int64 get_key()
{
int n0x1337;
int i;
unsigned int n0xA3;
n0xA3 = 0xA3;
if (is_debugged())
n0xA3 = 0xA2;
n0x1337 = 0x1337;
for (i = 0; i <= 4; ++i)
n0x1337 ^= n0xA3 << i;
return n0xA3;
}正常情况下:
n0xA3 = 0xA3;如果检测到调试器,则 key 会变成:
n0xA3 = 0xA2;因此程序存在反调试干扰。 不调试运行时,第一层 shellcode 解密 key 为:
0xA3所以第一层解密逻辑为:
shellcode = bytes(x ^ 0xA3 for x in enc_shellcode)---
三、提取出的 shellcode
第一层解密后,得到如下 shellcode:
sc = b"\xeb,^\xbf\x01\x00\x00\x00\xba\x14\x00\x00\x001\xc9\x8a\x04\x0e4Z\x88\x04\x0eH\xff\xc1H9\xd1u\xf0\xb8\x01\x00\x00\x00\x0f\x05\xb8<\x00\x00\x001\xff\x0f\x05\xe8\xcf\xff\xff\xff\x03\x0e\x18!\x035/\x05\x1b(?\x05\r344?('P"四、shellcode 反汇编
可以使用 pwntools 将 shellcode 转成汇编:
from pwn import *
context.arch = "amd64"
sc = b"\xeb,^\xbf\x01\x00\x00\x00\xba\x14\x00\x00\x001\xc9\x8a\x04\x0e4Z\x88\x04\x0eH\xff\xc1H9\xd1u\xf0\xb8\x01\x00\x00\x00\x0f\x05\xb8<\x00\x00\x001\xff\x0f\x05\xe8\xcf\xff\xff\xff\x03\x0e\x18!\x035/\x05\x1b(?\x05\r344?('P"
print(disasm(sc[:0x34]))反汇编结果:
0: eb 2c jmp 0x2e
2: 5e pop rsi
3: bf 01 00 00 00 mov edi, 0x1
8: ba 14 00 00 00 mov edx, 0x14
d: 31 c9 xor ecx, ecx
f: 8a 04 0e mov al, BYTE PTR [rsi+rcx]
12: 34 5a xor al, 0x5a
14: 88 04 0e mov BYTE PTR [rsi+rcx], al
17: 48 ff c1 inc rcx
1a: 48 39 d1 cmp rcx, rdx
1d: 75 f0 jne 0xf
1f: b8 01 00 00 00 mov eax, 0x1
24: 0f 05 syscall
26: b8 3c 00 00 00 mov eax, 0x3c
2b: 31 ff xor edi, edi
2d: 0f 05 syscall
2f: e8 cf ff ff ff call 0x3五、shellcode 逻辑分析
1. jmp-call-pop 结构
开头:
0: eb 2c jmp 0x2e程序会先跳到后面的 call 指令:
2f: e8 cf ff ff ff call 0x3call 指令会把下一条指令的地址压入栈中,然后跳回前面的代码。
后面紧接着就是加密 flag 数据,因此 call 压入栈中的地址就是加密 flag 的起始地址。
跳回前面后执行:
2: 5e pop rsi于是:
rsi = 加密 flag 数据地址2. 第二层 XOR 解密
核心循环如下:
8: ba 14 00 00 00 mov edx, 0x14
d: 31 c9 xor ecx, ecx
f: 8a 04 0e mov al, BYTE PTR [rsi+rcx]
12: 34 5a xor al, 0x5a
14: 88 04 0e mov BYTE PTR [rsi+rcx], al
17: 48 ff c1 inc rcx
1a: 48 39 d1 cmp rcx, rdx
1d: 75 f0 jne 0xf对应 C 伪代码:
for (int i = 0; i < 0x14; i++)
{
data[i] ^= 0x5A;
}这里可以看到第二层 key 来自指令:
34 5a xor al, 0x5a所以第二层解密 key 是:
0x5A3. 输出 flag
解密完成后,执行:
1f: b8 01 00 00 00 mov eax, 0x1
24: 0f 05 syscall在 Linux x86_64 中:
rax = 1 -> sys_write
rdi = 1 -> stdout
rsi -> flag 地址
rdx = 0x14 -> 输出长度所以这部分等价于:
write(1, flag, 0x14);之后执行:
26: b8 3c 00 00 00 mov eax, 0x3c
2b: 31 ff xor edi, edi
2d: 0f 05 syscall这对应:
exit(0);六、加密 flag 数据
shellcode 偏移 0x34 之后是加密数据:
03 0e 18 21 03 35 2f 05 1b 28 3f 05 0d 33 34 34 3f 28 27 50长度为:
0x14 = 20解密方式:
flag = bytes(x ^ 0x5A for x in enc_flag)其中最后一个字节:
0x50 ^ 0x5A = 0x0A也就是换行符。
七、完整 exp
# 从程序中提取出的 enc_shellcode
exp = bytes.fromhex(
"488ffd1ca2a3a3a319b7a3a3a3926a29a7ad97f92ba7adeb5c62eb9a72d6531b"
"a2a3a3a3aca61b9fa3a3a3925caca64b6c5c5c5ca0adbb82a0968ca6b88b9"
"ca6ae9097979c8b84f3"
)
# 第一层:main 中 get_key 正常返回 0xA3
shellcode = bytes(x ^ 0xA3 for x in exp)
# 第二层:shellcode 中 xor al, 0x5a
key2 = 0x5A
# shellcode 偏移 0x34 开始是加密 flag
enc_flag = shellcode[0x34:0x34 + 0x14]
flag = bytes(x ^ key2 for x in enc_flag)
print(flag.decode().strip())运行结果:
YTB{You_Are_Winner}re-mid
Solution
Step 1: 定位校验函数
在 main 中可以看到程序逻辑很直接:
printf("Enter the flag: ");
fgets(s, 100, stdin);
s[strcspn(s, "\n")] = 0;
if ( check_flag(s) )
puts("Correct! The flag is yours.");
else
puts("Wrong flag. Try again.");继续分析 check_flag,首先判断输入长度:
n = strlen(input);
if ( n != 23 )
return 0;随后对输入执行 4 步变换:
buf[i] = input[22 - i];
buf[i] = (buf[i] + i) & 0xff;
buf[i] ^= 0xAA;
buf[i] = (69 * buf[i] + 51) & 0xff;最后将变换后的 23 字节与栈上的目标常量比较。
Step 2: 提取目标字节
IDA 中对应的目标常量写入如下:
mov rax, 43FED8123A20DB26h
mov rdx, 0BB8585E6123A7DD2h
mov [rbp+var_50], rax
mov qword ptr [rbp+var_48], rdx
mov rax, 4CC21FE7B9590FBBh
mov qword ptr [rbp+var_48+7], rax由于 x86-64 是小端序,并且第三次写入从 var_48 + 7 开始,会覆盖前一段的最后 1 字节,因此目标字节为:
26 db 20 3a 12 d8 fe 43 d2 7d 3a 12 e6 85 85 bb 0f 59 b9 e7 1f c2 4cStep 3: 逆向恢复 flag
变换中乘以 69 是在模 256 下进行的,69 与 256 互素,逆元为 141。因此可以逐字节逆运算:
expected = bytearray(23)
expected[0:8] = (0x43FED8123A20DB26).to_bytes(8, "little")
expected[8:16] = (0xBB8585E6123A7DD2).to_bytes(8, "little")
expected[15:23] = (0x4CC21FE7B9590FBB).to_bytes(8, "little")
inv69 = pow(69, -1, 256)
reversed_input = []
for i, c in enumerate(expected):
x = ((c - 51) * inv69) & 0xff
x ^= 0xAA
x = (x - i) & 0xff
reversed_input.append(x)
flag = bytes(reversed_input[::-1]).decode()
print(flag)运行结果:
YTB{R3v3rs1ng_1s_Fun!!}