通过做实验来加深一下印象

首先是环境搭建,寻找32位虚拟机花了很长时间,最后在52破解上找到了合适的系统。如果你尝试自己搭建你会发现,win7 32镜像很难寻找,而且大部分不支持 Vmware Tools,帧率很低很卡,调试起来很不舒服。

而笔者给出的链接中的 32 位虚拟机还是很不错的,能调试,能装 Vmware Tools。还需要提醒一点的是,解压好之后把 CPU 个数和核心数全部改成 1,不然实验容易炸。

段描述符实验

代码段

首先根据 gdt 表,找到 32 位程序的 CS 中段选择子 0x1b 去找到对应的 32 位段描述符。

也就是图中 +0x18 偏移处的 00cffb00`0000ffff。

Base 分别在高四字节的首位各一个字节+低四字节的高 2 字节,刚好全是 0。

Limit 就是最低的 2 字节配上高字的低半个字节,组合起来也就是 0xFFFFF。

TYPE 值就是 0xb,查表得知,是代码段,可读可执行。

P DPL S 的结果是 0xf,表明数据是全1。

P 为 1,表明段有效。

DPL 为 3,表明该代码段的权限是 3 环的。

S 为 1,表明是代码/数据段

剩下还有一个 G 位为 1,表明段限长以页为单位。D/B 位为 1,表明是 32 位程序。


此时我做个小实验,我创建一个新的代码段描述符,我将 D/B 位改成 0,使之变为一个 16 位的代码段,直接抄 +0x18 处的段描述符,将 D/B 位修改一下得到 008ffb00`0000ffff。

从图中可以看到 +0x48 的位置是一个空的段描述符,直接将值写入。

用一个长调用去改段选择子为 0x4b。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include "stdafx.h"
void __declspec(naked)test(){
__asm{
mov eax,0x12345678;
retf;
}
}
int _tmain(int argc, _TCHAR* argv[])
{
printf("test=%p\n",test);
char bufcode[6]={0x00,0x00,0x00,0x00,0x4b,0x00};
*(int *)&bufcode[0]=(int)test;
__asm{
nop;
call fword ptr bufcode;
}
return 0;
}

可以发现,写的 mov eax,0x12345678 的字节码被解析成了 16 位的,也就是说,原本 mov eax,0x12345678字节码 B8 78 56 34 12 因为代码段位数的变化导致只能将 B8 78 56 视为 mov ax,0x5678 而只执行了这个,往后跳转到 34 字节开头的代码,从寄存器中也可以看到 eax 的值变为了 0x5678,表明执行了 16 位的代码而非 32 位的代码。

数据段

32 位的程序 ds 通常是 0x23,也就是 +0x20 的位置,gdt 表项为 00cff300`0000ffff。其实和代码段的差别就是 TYPE 域,其余基本一样,TYPE 为 3 是数据段,可读可写。

这里稍微改一改其它参数,例如改段基址为 1,同样是 0x4b 处的段描述符,改成 00cff300`0001ffff。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include "stdafx.h"

int a=0x12345678;
int _tmain(int argc, _TCHAR* argv[])
{
__asm{
mov ax,0x4b;
mov ds,ax;
mov ebx, 0x12345678;
mov eax,[ebx];
}
return 0;
}

结果

发现我们修改了 ds 之后再次尝试读取内存已经是读取基址(1)+ 偏移的形式了,其实平时我们给的读取地址都是偏移,只不过段描述符中的基址通常是 0。

读取全局变量 a 的值也可以发现,也是对应的读取了 + 1 上的偏移。

再尝试看看段长限制,因为代码段和普通数据段几乎都是 0-0xffffffff 的范围,所以这里换一个段描述符,尝试使用 fs 段寄存器,32 位通常的值是 0x3b,FS 段寄存器指向了存储了线程环境块(TEB)的地址。

尝试访问 fs dword ptr [0x1000],直接报访问错误,并且访问的真实地址我们看不到,这是被段长限制住了。

这里需要注意的是,线程会发生切换,线程切换的时候会修改 gdt 表,所以我们中断 windbg 看到的 gdt 表对应的 0x3b表项是不准确的[1]。通常情况下,fs 的段基址是 0x7FFDF000,同样为了找到真实的段基址,后续使用调用门提权的时候可以中断下来看看 gdt 表。

这里根据基址构建一个段描述符 7f40f3fd`f0000fff,windbg里面 dg 命令可以查看段选择子对应的信息。

这里将 TYPE 修改为向下扩展的类型,也就是将 3 变为 7,修改为 7f40f7fd`f0000fff。

可以发现,原来可以访问的地址变为不可访问

原来不可访问的地址变得可访问

向下扩展可以认为是原段长限制取反的结果。其余位就不做过多演示了,第一章讲的还是比较清楚哒。

门描述符实验

调用门

调用门是存在 gdt 当中的,属于系统段,这里直接构建一个可以跨段提权的调用门,先设好参数。

  • 调用门跳转地址为 0x401000(关闭随机地址的第一个函数的地址)。
  • TYPE设为0xC,表明是调用门。
  • 目标段选择子设为,0x8,这是一个 0 环的段选择子。
  • P DPL S=1110=0xE,P=1表示段有效,DPL=3表示请求的最低权限为 3 环,S=0表明是系统段。
  • ParamCount 设为 0。

据此构造的段描述符为 0040EC00`00081000,写到 gdtr+0x48 的位置,直接去调用,因为我们提了零环权限,所以可以直接读取 gdt 表项。

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
#include "stdafx.h"
#include<windows.h>
__int64 gdt_item;
void __declspec(naked)test(){
__asm{
mov ebx,0x80B93800;//gdtr的值
lea eax,[ebx+0x38];
mov eax,[eax];
mov dword ptr[gdt_item],eax;
lea eax,[ebx+0x38+4];
mov eax,[eax]
mov dword ptr[gdt_item+4],eax;
retf;
}
}
int _tmain(int argc, _TCHAR* argv[])
{
printf("test=%p\n",test);
char bufcode[6]={0x00,0x00,0x00,0x00,0x4b,0x00};
__asm{
nop;
call fword ptr bufcode;
}
printf("gdt_item=%llx\n",gdt_item);
system("pause");
return 0;
}

运行之后我们拿到当前线程的 fs 段描述符的值。

运行之后拿到了值 7f40f3fd`f0000fff,通过拆解,我们可以得到当前线程的 FS 段基址为 0x7ffdf000,段限长为 0xFFF。

由于该虚拟机在调用门函数内中断会直接死机,就不这么玩了,接下来我们看看传参。


构造两个参数的调用门,对应描述符为 0040EC02`00081000。

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
#include "stdafx.h"
#include<windows.h>
int arg1,arg2;
void __declspec(naked)test(int a1,int a2){
__asm{
int 3;
mov eax,[a1];
mov [arg1],eax;
mov eax,[a2];
mov [arg2],eax;
retf 8;
}
}
int _tmain(int argc, _TCHAR* argv[])
{
printf("test=%p\n",test);
char bufcode[6]={0x00,0x00,0x00,0x00,0x4b,0x00};
__asm{
nop;
push 0x12345678;
push 0x61626364;
call fword ptr bufcode;
}
printf("%d %d\n",arg1,arg2);
return 0;
}

修改,运行之后可以在内核调试器中中断下来。

同时我们可以观察到内核中栈的情况,从栈顶到栈底依次是。

  • EIP
  • CS
  • 第一个参数(最后被压入的才是第一个参数)
  • 第二个参数
  • ESP
  • SS

中断门

这里需要换一个表了,去寻找 IDT 表。

可以发现 +0x100 的位置是空的中断描述符,于是也新建一个中断门描述符,几乎与调用门描述符是一致的。填充一个 3 环可以请求,请求之后 CS 为 08 的中断描述符,0040EE00`00081000。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include "stdafx.h"
#include<windows.h>
void __declspec(naked)test(){
__asm{
int 3;
iret;
}
}
int _tmain(int argc, _TCHAR* argv[])
{
printf("test=%p\n",test);
__asm{
nop;
int 0x20;
}
return 0;
}

同样中断下来看看栈。

不难发现栈的情况从栈顶到栈底依次是

  • EIP
  • CS
  • EFLAGS
  • ESP
  • SS

同时值得注意的是,IF 位被置为 0,在调用门中,IF 是没有被置零的,中断返回使用的是 iret 指令,它会把中断重新打开。倘若在此使用 retf 4 将 EFLAGS 视为参数平衡栈,看似没有问题也能正确长返回。

此时返回可以发现,EFLAGS 的 IF 位没有被恢复,如果此时执行一个之前未执行过的函数,会因为缺页产生异常,将该函数代码的页面调入页内,如果此时 EFLAGS 的 IF 位是置 0 的,那么就会直接蓝屏。

复现一遍蓝屏

可以发现中断在了 kitrap0e 函数中,这是 0e 中断的处理函数,0e 就是页错误的中断号


Okay,前两章的理论+实验完美完成,也学到了不少东西。

参考文献