Windwos内核

通过IDT Hook键盘

IRETD(Interrupt Return Double-word)是 x86 架构中的一条汇编指令,用于从中断或异常处理程序中返回到调用程序。

  • 当一个中断或异常发生时,CPU 会将当前的 EFLAGS 寄存器、CS(代码段)寄存器和 EIP(指令指针)压入堆栈,并跳转到中断处理程序的入口地址。
  • 当中断处理程序完成时,IRETD 指令会从堆栈中弹出这些值并恢复它们,返回到中断发生前的状态。

当你 Hook 一个中断函数时,通常会使用“裸函数”(Naked Function),这是因为中断处理程序具有特殊的要求,不允许编译器在函数的入口和退出时自动插入额外的代码。这些额外的代码可能包括保存和恢复寄存器、设置堆栈帧等常规操作。为了确保中断处理程序的行为符合预期,裸函数就显得非常重要。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
__declspec(naked) void KeyboardProc()
{
__asm{
pushad
pushf
}

//写入代码
KdPrint(("KeyboardProc\n"));

__asm{
popf
popad
iretd//或者jmp OldKeyProc
}
}

Hook键盘中断的过程:

  1. 保存旧的中断函数
  2. 写上自己的函数Proc
  3. 在Unload函数上面替换回原来的KeyProc

以下是Hook键盘和UnHook的代码:

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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
VOID HookInterruptOnAllCPUs() {
KAFFINITY affinityMask;
ULONG numCPUs = KeQueryActiveProcessorCount(NULL);

for (ULONG i = 0; i < numCPUs; i++) {
affinityMask = (KAFFINITY)(1ULL << i);
KeSetSystemAffinityThread(affinityMask);

IDTR idtr;
__asm
{
sidt idtr;
}

unsigned int IDT_Base = MAKELONG(idtr.LowIDTBase, idtr.HiIDTBase);

m_InterruptGateDescriptor_ptr = (InterruptGateDescriptor**)(IDT_Base);

Old_KeyProc[i] = (m_InterruptGateDescriptor_ptr[0x93]->offset_high) << 16;
Old_KeyProc[i] += m_InterruptGateDescriptor_ptr[0x93]->offset_low;

__asm
{
//; 关闭WP标志位
mov eax, cr0
and eax, not 0x10000
mov cr0, eax
}
m_InterruptGateDescriptor_ptr[0x31]->offset_low = (unsigned int)KeyboardProc & 0xffff;
m_InterruptGateDescriptor_ptr[0x31]->offset_high = ((unsigned int)KeyboardProc & 0xffff0000) >> 16;
__asm
{
//; 恢复WP标志位
mov eax, cr0
or eax, 0x10000
mov cr0, eax
}
}

// 恢复线程的原始 CPU 亲和性
KeRevertToUserAffinityThread();
}

VOID UnHookInterruptOnAllCPUs() {
KAFFINITY affinityMask;
ULONG numCPUs = KeQueryActiveProcessorCount(NULL);
KIRQL oldIrql;

for (ULONG i = 0; i < numCPUs; i++) {
affinityMask = (KAFFINITY)(1ULL << i);
KeSetSystemAffinityThread(affinityMask);

// 提升到 HIGH_LEVEL 以禁用中断
KeRaiseIrql(HIGH_LEVEL, &oldIrql);

IDTR idtr;
__asm {
sidt idtr;
}

unsigned int IDT_Base = MAKELONG(idtr.LowIDTBase, idtr.HiIDTBase);

m_InterruptGateDescriptor_ptr = (InterruptGateDescriptor**)(IDT_Base);

__asm
{
//; 关闭WP标志位
mov eax, cr0
and eax, not 0x10000
mov cr0, eax
}
// 确保地址计算和赋值是正确的
m_InterruptGateDescriptor_ptr[0x31]->offset_low = Old_KeyProc[i] & 0xffff;
m_InterruptGateDescriptor_ptr[0x31]->offset_high = (Old_KeyProc[i] >> 16) & 0xffff;

__asm
{
//; 恢复WP标志位
mov eax, cr0
or eax, 0x10000
mov cr0, eax
}
// 恢复原始 IRQL
KeLowerIrql(oldIrql);
}

// 恢复线程的原始 CPU 亲和性
KeRevertToUserAffinityThread();
}

如果我们要知道键盘按下了什么,这就需要去键盘的特定端口拿扫描码:

in 指令:从指定的 I/O 端口读取数据到 CPU 的寄存器中。
out 指令:将 CPU 寄存器中的数据写入到指定的 I/O 端口。

1
2
3
4
5
__asm
{
in al,60h #从端口 0x60 读取一个字节的数据,并存储在寄存器 al 中。
mov Scancode,al
}

当然这会出现一个问题,扫描码只能被拿一次,如果我们再jmp到原来的keyproc,那么原来的KeyProc将会拿不到扫描码,所以我们要人工塞入扫描码

1
2
3
4
mov al,0d2h
out 64h,al#当你向端口 0x64 发送 0xD2 时,控制器就知道你要传递一个字节的数据给连接的 PS/2 设备。
mov al,ScanCode
out 60,al

但是IDT Hook并不流行,因为想写兼容性好比较难。。。

还有一种方法,是去从原来的KeyProc拿,后续会说

调用门

首先调用门不会引起进程切换

IDT 是专门用于处理中断和异常的,适用于中断门和陷阱门,而调用门设计用于在不同特权级别间执行函数调用,存储在 GDT 或 LDT 中。

调用门用于在不同特权级之间实现受控的程序控制转移 本质上,它只是一个描述符,一个不同于代码段和数据段的描述符,可以安装在GDT或者LGT中,但是不能安装在IDT(中断描述符表)中。

调用门描述符占用 8 字节(64 位)

1724140317571

字段说明:

  1. Offset 15:0(16 位):
    • 描述符低 16 位的目标代码段偏移地址。
  2. Selector(16 位):
    • 目标代码段选择子(Segment Selector),指向 GDT 或 LDT 中的一个代码段描述符。
  3. Param Count(5 位):
    • 参数数量(Parameter Count),指定在调用门使用时,传递给目标代码段的参数数量。这些参数从调用门调用者的堆栈复制到目标代码段的堆栈中。参数是2^5=32个参数。
  4. Reserved(3 位):
    • 保留位,通常为 0。
  5. Type:这是一个 4 位的字段,用于表示调用门的类型。对于调用门,它的值应该是 1100(对应 16 位调用门)或 1110(对应 32 位调用门)。该字段在描述符的低字节中。
  6. s:区别系统段还是数据段,0是系统段
  7. DPL(2 位):
    • 描述符特权级别(Descriptor Privilege Level),定义了调用门的特权级别。调用门的 DPL 值表示可以调用此门的最低权限级别。
  8. P(1 位):
    • 存在位(Present bit),如果为 1,表示描述符有效;如果为 0,表示描述符无效。
  9. Offset 31:16(16 位):
    • 描述符高 16 位的目标代码段偏移地址。
1
2
3
4
5
6
7
8
9
10
11
12
13
#pragma pack(push, 1)  // 强制1字节对齐
struct CallGateDescriptor {
unsigned short offset_low; // 目标代码段偏移地址的低 16 位
unsigned short selector; // 目标代码段的段选择子
unsigned char param_count : 5; // 参数数量
unsigned char reserved : 3; // 保留位(通常为 0)
unsigned char Type : 4;//属性 1110(对应 32 位调用门)
unsigned char s : 1;//0系统段 1数据段存储段
unsigned char dpl : 2; // 描述符特权级别(DPL)
unsigned char present : 1; // 存在位(P)
unsigned short offset_high; // 目标代码段偏移地址的高 16 位
};
#pragma pack(pop)

调用门的使用

使用调用门时,系统会从当前堆栈复制参数到目标堆栈,并跳转到目标代码段指定的入口地址,完成跨权限级别的调用。调用门主要用于特权级别之间的受控调用,比如从用户模式调用到内核模式。

特权级检查规则

在通过调用门进行特权级别转换时,CPU 会依次检查以下内容:

  1. CPL(当前特权级),由当前代码段的 CS 寄存器的低 2 位决定。
  2. RPL(请求特权级),由调用门选择子的低 2 位决定。
  3. 调用门描述符的 DPL,确保调用者的有效特权级(CPL 和 RPL)不高于调用门的 DPL。
  4. 目标代码段描述符的 DPL,确保调用者的 CPL 不高于目标段的 DPL。
  5. 目标代码段描述符的 C 位,决定是否允许一致性代码段在更低特权级别下被执行。

需要说明的是:如果通过调用门把控制转移到了更高特权级的非一致代码段中,那么CPL就会被设置为目标代码段的DPL值,并且会引起堆栈切换。

人工造一个调用门

1.在GDT表找一个没用的项

2.三环就用 call 段选择子(末两位置为1,表示三环可以用):xxxx (段选择子后面瞎写都行,主要就是段选择子,就是gdt表的某项)或者jmp 段选择子:0,这样就不会把三环地址返回值压入栈,也就不回来了。call的话压入的是0环的栈,会导致段寄存器要切换

要切的是cs0 eip0 ss0 esp0

在0环, retf返回三环,从零环的栈弹出cs3 eip3 ss3 esp3,这样就又回到了三环

3.实现门描述符 (syscall dpl = 3 ,type = 386gate,seletor=8)

选择子填8应该是相对固定的

1724143131999

调用流程:

1724140856589

左边是三环的,右边是零环的,retf就回去三环了

模拟系统API的实现:

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
__declspec(naked) void Syscall() //一定要裸函数,不然代码不可控
{
__asm
{
pushad
//pushf 不要用到pushf,不然会蓝屏
push fs
mov ax,30h //给零环使用
mov fs,ax

}
KdPrint(("Syscall\n"));
__asm {
//popf
pop fs
popad
retf
}
}

VOID Register_CallGate(CallGateDescriptor* CallGateDescriptor_ptr)
{
//注册门
GDTR gdtr;
__asm
{
sgdt gdtr
}
unsigned int Base = gdtr.base;
GDTR* gdtr_ptr = (GDTR*)Base;
CallGateDescriptor* CallGateDescriptor_ptr = (CallGateDescriptor*)&gdtr_ptr[9];
Register_CallGate(CallGateDescriptor_ptr);

CallGateDescriptor_ptr->present = 1;//表示描述符有效
CallGateDescriptor_ptr->dpl = 3; //三环可以使用
CallGateDescriptor_ptr->offset_high = (unsigned int)Syscall >> 16;
CallGateDescriptor_ptr->offset_high = (unsigned int)Syscall & 0xffff;
CallGateDescriptor_ptr->param_count = 0;//参数个数为0
CallGateDescriptor_ptr->reserved = 0;
CallGateDescriptor_ptr->s = 0;
CallGateDescriptor_ptr->selector = 8;
CallGateDescriptor_ptr->Type = 0xc;
}

注意注意:

从三环进入零环,或者从零环进入三环,fs都得用户自己手动来切换,如果不切换,可能就会崩溃

另外Windbg只要产生断点异常,都会将fs改为30,方便拿参数,所以如果有时候会崩溃,要注意看fs有没有改回三环的0x3b

1724153322949

应该是call 48h:0,但是段选择子的后2位指定0环还是三环,这里要填三环,也就是11(3),所以应该是call 4bh:0

1724153481527

但是编译器不让过,所以我们选择用机器码实现

比如用x32dbg去做补丁

1724154158299

如果提供了参数,如何给0环传参呢?

这是三环代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
using namespace std;
int main()
{
cout << "syscall begin" << endl;
__asm
{
push 0x11223344
_emit 0x9a
_emit 0x00
_emit 0x00
_emit 0x00
_emit 0x00
_emit 0x4b
_emit 0x00
}

cout << "syscall end" << endl;
system("pause");
return 0;
}

在0环是可以找到对应参数的

1724163410254

但是我们知道GDT表是有限的,那么多API,难道每一个API都需要在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
28
29
30
31
32
33
34
35
36
37
38
39
40
//注册函数指针表,参数数量一样的做一个函数指针表
SYSCALL G_Sysservice[]={
&Syscall1,
&Syscall2,
&Syscall3
}

UCHAR G_SysParams[]
{
0,
4,
5
}

__declspec(naked) void Syscall()
{
__asm{
pushad
push fs
mov ax,30h //给零环使用
mov fs,ax

//从栈拷贝参数
movzx ecx,byte ptr[G_SysParams+eax*1]
mov ebx,ecx
shr ecx,2
sub esp,ecx
mov esi,edx //edx存着三环的栈
mov edi,esp
rep movsd

//调用函数
call dword ptr[G_Sysservice+eax*4];

add esp,ebx
pop fs
popad
retf
}
}