第二届“云天杯”网络安全大赛部分WriteUp

2026 年 5 月 22 日 星期五(已编辑)
19
1
这篇文章上次修改于 2026 年 5 月 22 日 星期五,可能部分内容已经不适用,如有疑问可询问作者。

第二届“云天杯”网络安全大赛部分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 0x3

call 指令会把下一条指令的地址压入栈中,然后跳回前面的代码。

后面紧接着就是加密 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 是:

0x5A

3. 输出 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 4c

Step 3: 逆向恢复 flag

变换中乘以 69 是在模 256 下进行的,69256 互素,逆元为 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!!}

使用社交账号登录

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...