# 前言

上一篇我们学习了段权限检查和代码跨段跳转。本篇将深入探讨短调用、长调用和调用门的原理与实验。

# 短调用

指令格式:

1
CALL 立即数/寄存器/内存

发生改变的寄存器:ESPEIP

# 长调用(跨段不提权)

指令格式:

1
CALL CS:EIP    ; EIP 是废弃的(由段描述符决定)

发生改变的寄存器:ESPEIPCS

# 长调用(跨段并提权)

指令格式:

1
CALL CS:EIP    ; EIP 是废弃的(由段描述符决定)

# 执行(CALL)时

发生改变的寄存器:ESPEIPCSSS

注意:长调用执行后,堆栈已经不是原来的堆栈,而是 0 环的堆栈(ESP0)。

# 返回(RETF)时

发生改变的寄存器:ESPEIPCSSS

# 小结

  1. 跨段调用时,一旦有权限切换,就会切换堆栈
  2. CS 的权限一旦改变,SS 的权限也要随着改变,CS 与 SS 的等级必须一样
  3. JMP FAR 只能跳转到同级非一致代码段,但 CALL FAR 可以通过调用门提权,提升 CPL 的权限。

SS 与 ESP 从哪里来?参考 TSS 段(后续文章会详细讲解)。

# 调用门

指令格式:

1
CALL CS:EIP    ; EIP 是废弃的

执行步骤:

  1. 根据 CS 的值查 GDT 表,找到对应的段描述符 —— 这个描述符是一个调用门
  2. 在调用门描述符中存储另一个代码段的段选择子
  3. 段选择子指向的 段.Base + 偏移地址 就是真正要执行的地址。

# 门描述符

Param.Count:代表参数个数。

注意

  1. S 位(第 12 位)必须为 0—— 只有当 S=0 时,段描述符才是系统段描述符;此时当 Type 域为 1100 时,该描述符就是调用门描述符
  2. 低四字节的 16~31 位是决定调用的代码存在于哪个段段选择子
  3. 当长调用执行时: 真正要执行的代码地址 = 门描述符中段选择子指向的代码段的 Base + Offset ,其中 Offset 由门描述符高四字节的 16~31 位(高 16 位)与低四字节的 0~15 位(低 16 位)拼接而成。

# 实验 1:构造一个调用门(无参)

一、初步构造调用门

1
2
3
4
5
6
Offset in Segment 31:16 = 0x0000        ; 暂定
P = 1
DPL = 二进制:11
Param.Count = 二进制:00000
Segment Selector = 0x0008
Offset in Segment 15:00 = 0x0000 ; 暂定

由上述参数构造出的门描述符为: 0000EC00'00080000

二、确定 Offset in Segment

编译并执行以下代码,中断在 call 指令位置。

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
#include <stdio.h>
#include <windows.h>

void __declspec(naked) GetRegister()
{
__asm
{
int 3

retf // 注意返回,不能是 ret
}
}

int main(int argc, char* argv[])
{
char buff[6];
*(DWORD*)&buff[0] = 0x12345678; // 在这行设置断点
*(WORD*)&buff[4] = 0x48; // 段选择子所在偏移
__asm
{
call fword ptr[buff] // 长调用
}
getchar();
return 0;
}

右键查看反汇编(Go To Disassembly),查看 GetRegister 函数地址,本次实验为 00401020

至此,门描述符最终确定为: 0040EC00'00081020

三、将门描述符写入 GDT 表

四、继续执行第二步代码

运行前,寄存器与堆栈状态:

运行后,虚拟机成功中断至 WinDbg:

寄存器:

堆栈:

五、将代码修改为以下内容

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
#include <stdio.h>
#include <windows.h>

DWORD dwH2GValue;

void __declspec(naked) GetRegister()
{
__asm
{
pushad
pushfd

mov eax, 0x8003f00c // 读取高 2G 内存
mov ebx, [eax]
mov dwH2GValue, ebx

popfd
popad

retf
}
}

void PrintRegister()
{
printf("%x\n", dwH2GValue);
}

int main(int argc, char* argv[])
{
__asm
{
mov ebx, ebx
mov ebx, ebx
}

char buff[6];
*(DWORD*)&buff[0] = 0x12345678;
*(WORD*)&buff[4] = 0x48; // segment selector

__asm
{
call fword ptr[buff]
}

PrintRegister();

getchar();

return 0;
}

注意:若 GetRegister 函数起始地址发生改变,则需要修改相应的门描述符。

执行后,成功读取高 2G 内存,提权成功。

# 实验 2:构造一个调用门(有参)

一、初步构造参数

1
2
3
4
5
6
Offset in Segment 31:16 = 0x0000        ; 暂定
P = 1
DPL = 二进制:11
Param.Count = 二进制:00011 ; 注意:代表需要三个参数
Segment Selector = 0x0008
Offset in Segment 15:00 = 0x0000 ; 暂定

由上述参数构造出的门描述符为: 0000EC03'00080000

二、编译并执行以下代码

push 1 位置设置断点,查看 CateProc 的起始地址。

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
#include <stdio.h>
#include <windows.h>

DWORD x;
DWORD y;
DWORD z;

void __declspec(naked) CateProc()
{
__asm
{
pushad
pushfd

mov eax, [esp+0x24+8+8]
mov dword ptr ds:[x], eax
mov eax, [esp+0x24+8+4]
mov dword ptr ds:[y], eax
mov eax, [esp+0x24+8+0]
mov dword ptr ds:[z], eax

popfd
popad

retf 0xC // 3 个参数 × 4 字节 = 0xC,清理调用门复制到 0 环栈上的参数
}
}

void PrintRegister()
{
printf("%x %x %x \n", x, y, z);
}

int main(int argc, char* argv[])
{
char buff[6];
*(DWORD*)&buff[0] = 0x12345678;
*(WORD*)&buff[4] = 0x48;
__asm
{
push 1 // 参数 1
push 2 // 参数 2
push 3 // 参数 3
call fword ptr[buff]
}
PrintRegister();
getchar();
return 0;
}

至此,门描述符最终确定为: 0040EC03'0008DE30

三、将构造好的门描述符写入 GDT。

四、再次运行步骤二的代码,不设置断点,运行结果如下。

传入的参数被成功输出,有参调用门构造成功!

思考pushadpushfdpopfdpopad 这几条指令有什么意义?是必须的吗?

# 总结

  1. 当通过门,权限不变的时候,只会 PUSH 两个值:CS返回地址,新的 CS 值由调用门决定。
  2. 当通过门,权限改变的时候,会 PUSH 四个值:SSESPCS返回地址,新的 CS 值由调用门决定,新的 SS 和 ESP 由 TSS 提供。
  3. 通过门调用时,要执行哪行代码由调用门决定;但使用 RETF 返回时,由堆栈中压入的值决定 —— 也就是说,进门时只能按指定路线走,出门时可以 "翻墙"(只要改变堆栈里面的值就可以想去哪去哪)。
  4. :可不可以再建个门出去,也就是再用 CALL 出去?
    :当然可以了,前 "门" 进,后 "门" 出。

上一篇:保护模式(二)| 段权限检查与代码跨段跳转
下一篇:保护模式(四)| 中断门、陷阱门与任务段