保护模式下通过段划分内存的权限以及访问的权限
x86 和 x64都有六个段寄存器(Segment Register):
DS: Data Segment 数据段 可读可写不可执行
CS: Code Segment 代码段 可读可执行不可写
SS: Stack Segment 堆栈段 可读可写不可执行
ES、FS、GS 后续会说明
根据intel白皮书3a的介绍,CPU额外提供了三个数据段寄存器,可以作为程序额外的段,正常情况下可能只使用到DS、CS、SS这三个段寄存器
数据段中一般为全局变量,而局部变量一般会放入堆栈段中:
// test.cpp : 定义控制台应用程序的入口点。
//#include "stdafx.h"
#include <Windows.h>int value = 10;int _tmain(int argc, _TCHAR* argv[])
{OpenProcess(0, 0, 0);__asm {mov eax, value;}return 0;
}
此时将value定义在main函数外,视为全局变量,可以看到,编译器会将该变量放在DS段中:
当将value定义为局部变量时:
// test.cpp : 定义控制台应用程序的入口点。
//#include "stdafx.h"
#include <Windows.h>int _tmain(int argc, _TCHAR* argv[])
{char a[] = "1234";int value = 10;__asm {mov eax, value;}return 0;
}
此时的段会识别成SS堆栈段:
段选择子(Segment Selector)
通过段选择子(Segment Selector)来确定段的属性,默认UserMode
可见部分:
对于段的属性描述,通过3.4.2描述:
段选择子总共是16位,分成三部分进行解析:
Table Indicator表示从LDT或者是GDT表中查找段描述符,Index则表明表的下标
#include <Windows.h>
#include <iostream>#define TI 0x04
#define RPL 0x03
#define INDEX 0xfff8#define LDT 1
#define GDT 0using namespace std;void analysisSegmentSelector(WORD selector) {// RPLcout << "RPL: ";cout << (selector & 0x03) << endl;// TIcout << " Specifies the descriptor table to use: ";(selector & TI) == 1 ? cout << "LDT" << endl : cout << "GDT" << endl;// Indexcout << "Index: ";cout << ((selector & INDEX) >> 3) << endl;
}int main() {// 0000 0000 0010 0011WORD selector = 0x0023;analysisSegmentSelector(selector);return 0;
}
可以发现DS和SS的段选择子都是0x0023
,而CS的段选择子是0x001B
,通过解析可以得到都是通过GDT表进行查表,通过WinDBG中的r gdtr
查询,r默认输出的是通用寄存器和段寄存器:
可以通过r后跟特定寄存器查询,这里gdtr不代表真的有gdtr这个寄存器,只是通过sdtr来存储gdt表的地址
通过dq gdtr
或者 dq 0x80b98800
查询:
段描述符(Segment Descriptor)
可见部分的段描述符为64位QWORD
0x0023的Index为4,所以其对应的段描述符为:00cff300 0000ffff
0x001B的Index为3,所以其对应的段描述符为:00cffb00 0000ffff
intel白皮书3.4.5 Segment Descriptor描述了段描述符的组成部分:![[Pasted image 20240818003319.png]]
#include <Windows.h>
#include <iostream>using namespace std;#define SEG_BASE_24_31 0xff000000
#define SEG_BASE_16_23 0xff0000
#define SEG_BASE_0_15 0xffff#define SEG_LIMIT_16_19 0x000f0000
#define SEG_LIMIT_0_15 0xffff#define SEG_G 0x00800000
#define SEG_D_B 0x00400000
#define SEG_L 0x00200000
#define SEG_AVL 0x00100000
#define SEG_P 0x00008000
#define SEG_DPL 0x00006000
#define SEG_S 0x00001000
#define SEG_TYPE 0x00000f00// Descriptor Type
#define SYSTEM_TYPE 0
#define CODE_DATA_TYPE 1// Default operation size
#define BIT_16 0
#define BIT_32 1VOID analysisSegmentDescriptorOthers(DWORD descriptor) {DWORD seg_G = (descriptor & SEG_G) >> 0x17;DWORD seg_D_B = (descriptor & SEG_D_B) >> 0x16;DWORD seg_L = (descriptor & SEG_L) >> 0x15;DWORD seg_AVL = (descriptor & SEG_AVL) >> 0x14;DWORD seg_P = (descriptor & SEG_P) >> 0xf;DWORD seg_DPL = (descriptor & SEG_DPL) >> 0x0d;DWORD seg_S = (descriptor & SEG_S) >> 0x0c;DWORD seg_TYPE = (descriptor & SEG_TYPE) >> 0x08;cout << "Granularity: " << hex << seg_G << endl;cout << "Default operation size: ";seg_D_B == BIT_32 ? cout << "32-bit segment" : cout << "16-bit segment";cout << endl;cout << "64-bit code segment: " << hex << seg_L << endl;cout << "Available for use by system software: " << hex << seg_AVL << endl;cout << "Segment present: " << hex << seg_P << endl;cout << "Descriptor privilege level: " << hex << seg_DPL << endl;cout << "Descriptor type: ";seg_S == SYSTEM_TYPE ? cout << "system" : cout << "code or data";cout << endl;cout << "Segment type: " << hex << seg_TYPE << endl;
}VOID analysisSegmentDescriptor(DWORD64 descriptor) {DWORD base = ((descriptor >> 0x10) & SEG_BASE_0_15)^ ((descriptor >> 0x10) & SEG_BASE_16_23 )^ ((descriptor >> 0x20) & SEG_BASE_24_31 );DWORD limit = (descriptor & SEG_LIMIT_0_15) ^ ((descriptor >> 0x20) & SEG_LIMIT_16_19);cout << "Segment Limit: " << hex << limit << endl;cout << "Segment base address: " << hex << base << endl;analysisSegmentDescriptorOthers(descriptor >> 0x20);}int main() {// 0x0023// 0000 0000 0010 0011WORD selector = 0x0023;analysisSegmentSelector(selector);DWORD64 descriptor = 0x00cff3000000ffff;analysisSegmentDescriptor(descriptor);cout << "-------------------------------------------------" << endl;// 0x001B // 0000 0000 0001 1011selector = 0x001B;analysisSegmentSelector(selector);descriptor = 0x00cffb000000ffff;analysisSegmentDescriptor(descriptor);return 0;
}
关于段类型(Segment Type) 在3.4.5.1 Code- and Data-Segment Descriptor Types中的表Table 3-1. Code- and Data-Segment Types:![[Pasted image 20240818014721.png]]
最高位区分数据或代码段:0-Data,1-Code,低三位分别表示EWA和CRA,对比前面获取到的CS和DS的段描述符,也就是在Type的位置存在区别,DS的Type为0b0011
,CS的Type为0b1011
所以这里查表后的结果是:
CS段的属性为Code
,Description为: Execute/Read, accessed
DS段的属性为Data
,Description为:Read/Write, accessed
VOID checkSegmentType(BYTE segmentType) {segmentType & 0x8 ? cout << "Code " : cout << "Data ";cout << endl;cout << "Description: ";if (segmentType & 0x8) {cout << "Execute ";if (segmentType & 0x4) cout << "Conforming ";if (segmentType & 0x2) cout << "Read ";if (segmentType & 0x1) cout << "Accessed ";}else {cout << "Read ";if (segmentType & 0x4) cout << "Expand-down ";if (segmentType & 0x2) cout << "Write ";if (segmentType & 0x1) cout << "Accessed ";}cout << endl;
}
这里Accessed的含义是是否已经使用过该段,例如push esp
,此时会用到堆栈区域,该区域通过SS段选择子指向的段描述符进行管理,那么此时,即使将Accessed置0,内核也会将其设为1,表示该段在最后一次清零前被使用过
验证P段的有效性
首先找到GDT表中全0的部分:Index=9的部分,此时段选择子为0x004b
eq复制段选择子0x0023的部分到该位置下:
eq 807d3800+48 00cff300`0000ffff
此时0x004b指向的段为有效段,内联asm将ds修改为0x004b
mov ax, 0x4b;
mov ds, ax;
mov eax, dword ptr ds:[value]
mov value2, eax;
mov ax, es;
mov ds, ax;
P位指明段是否有效:
通过eq将P位设为0:
eq 80b98840+8 00cf7300`0000ffff
此时执行直接就报错了,再将P设置为1:
此时不重新编译程序,直接运行:
G Flag
G Flag决定limit的基础单位,若G = 0,limit的单位为byte,若G=1,limit的单位为一个页,那么此时对于:00cf7300 0000ffff
,limit的值为0xfffff,一个页的大小为4096字节=4KB=0x1000 Bytes,那么此时该段的最大空间为(0xFFFFF+1) * 0x1000
,这里的+1是因为0xfffff是最大索引,而不是最大长度,索引是从0开始的,所以其空间总共为0xFFFFF + 1
也就是0x100000
Determines the scaling of the segment limit field. When the granularity flag is clear, the segment
limit is interpreted in byte units; when flag is set, the segment limit is interpreted in 4-KByte units.
D/B Flag
该标志位,取决于当前段的类型,会有不同的效果:
•Executable code segment: The flag is called the D flag and it indicates the default length for effective addresses and operands referenced by instructions in the segment. If the flag is set, 32-bit addresses and 32-bit or 8-bit operands are assumed; if it is clear, 16-bit addresses and 16-bit or 8-bit operands are assumed.The instruction prefix 66H can be used to select an operand size other than the default, and the prefix 67H can be used select an address size other than the default.•Stack segment (data segment pointed to by the SS register): The flag is called the B (big) flag and it specifies the size of the stack pointer used for implicit stack operations (such as pushes, pops, and calls). If the flag is set, a 32-bit stack pointer is used, which is stored in the 32-bit ESP register; if the flag is clear, a 16-bit stack pointer is used, which is stored in the 16-bit SP register. If the stack segment is set up to be an expand-down data segment (described in the next paragraph), the B flag also specifies the upper bound of the stack segment.•Expand-down data segment: The flag is called the B flag and it specifies the upper bound of the segment. If the flag is set, the upper bound is FFFFFFFFH (4 GBytes); if the flag is clear, the upper bound is FFFFH (64 KBytes)
代码段下:
D/B = 0,则默认操作数是16bits
D/B = 1, 则默认操作数是32bits
决定堆栈的寻址空间是16bit还是32bit
代码段的段选择子为CS:在32位下,默认都是0x001B:
尝试修改cs:
发现是不存在mov cs, ax
这样的指令的,那么眼前只能通过jmp Far来实现CS的修改:jmp far 0x004B:0x00401F1C
:
此时由于0x004B的段描述符为空所以当想跨段跳转时会找不到段的信息,所以会进异常:
复制0x001B的段描述符到0x004B:
eq 807d3840+8 00cffb00`0000ffff
重新EA跨段跳转,成功将CS从0x001B
改为0x004B
:
注意这里修改CS之所以不会产生内核崩溃,是因为这里的CS只是对于当前进程而言,当进入内核时,会将这些段选择子的值都修复,从ring3进到ring0时也会有个上下文的转换,所以不会产生内核崩溃
目前默认的D/B=1,所以此时的默认寻址空间为32bit,所以操作数默认也是32bit,那么此时默认压栈的字节数为4bytes:
当D/B设置为0,由于此时CS是0x004B,所以指向的是修改后的段描述符,通过eq已将D/B位设置为0,
此时重新执行push eax,也只会压入2字节的栈,栈的寻址空间从32bit变为16bit
此时的esp从0x12FF8C
到0x12FF8A
:
在代码段是D/B位看的是D位,当该段为数据段时,那么D/B位看的是B位
将0x004b指向的段描述符修改为:0x008ff3000000ffff
eq 807d3800+48 008ff300`0000ffff
mov ax, 0x4b
mov ss, ax
此时已将ss修改为0x4b
此时的描述符已变成16bit寻址的数据段
向上拓展/向下拓展
正常情况,当D/B=1,limit=0xFFFFF,G=1,此时limit的描述空间为(0xFFFFF+1)* 0x1000,此时可描述空间为4GB,这4GB都可以访问,这种情况称为向上拓展,可以理解为include 4GB
而且当D/B=0时,limit=0xFFFFF,G=1,此时limit描述的4GB空间都无法访问,那么此时将limit设为0,那么limit描述的空间为0,反而4GB的空间都可以访问,这种情况称为向下拓展,可以理解为 exclude 4GB
此时将EWA设置为110,Type设置为Data,然后将limit设置为0,由于E=1,所以是向下拓展的,那么此时limit=0,可以描述空间除limit描述的部分,在32位下就是4GB:
总结:D位模式下,描述的是代码段下的寻址操作数,D=1=>32-bit, D=0=>16-bit
B位模式下,描述的是代码段或者数据段下的寄存器和寻址操作数,限制limit的描述空间
保护模式下,段模式为两种,一种是常用的段页模式,段保护+页保护机制,一种是纯段模式的保护机制
在纯段模式下,分为一致代码段和非一致代码段,在一致代码段下R3可以直接调用R0的代码
在段页模式下,R3是没办法直接调用R0的,因为存在页保护机制,无法直接从R3调用R0
段权限
[[裸函数]]:裸函数的概念和用法
在段选择子(Segment Selector),有RPL
在段描述符(Segment Descriptor),有DPL
另外还存在一个CPL,表示当前的权限,通过SS或者CS,一般情况下SS和CS的低2bit是一样,应该说必须是一致的,因为一个是跟堆栈段相关,一个是跟代码段相关,这两个段需要保持一致的权限,才能保证程序的正常运行
5.4 Type Checking中的Figure 5.3- Protection Rings:
该图写明了在保护模式下的权限分布,对于正常使用者,其实只需要了解有Ring 3和Ring 0即可,Ring 1和Ring 2在当前情况下不使用到
对于RPL(Request Privilege Level),位于段选择子Segment Selector中的低2位,所以其最大值为3
在数据段DS下,假设RPL=0,CPL=3,DPL=3,那此时能否访问得到,答案是可以的
前提是在数据段下,此时DS=0x0020
而此时通过GDT查表可以发现DPL=3,对于当前的代码段CS=0x0023,那么此时的CPL=3
在数据段下CPL <= DPL
时,即可访问到该数据段
在堆栈段下,必须DPL=CPL=RPL,
在代码段下,CPL=DPL即可
提权时,必须CS跟SS都改成0环权限才能进入0环,在一般情况下,CS的权限=SS的权限,只改一方都会进入失败
所以正常情况下只需要看CS即可知道SS的段权限
jmp far 0x0048:00401012
RPL = 0 去访问DPL=3的段,可以看到CS会自动设置成0x004B,自动修正成RPL=3,所以在CS段下RPL也不影响使用,只需要保证CPL == DPL
即可
跨段不提权:
JMP
代码实现跨段跳转:
#include "stdafx.h"void _declspec(naked) test1() {__asm{ret;}
}int _tmain(int argc, _TCHAR* argv[])
{char shellcode[] = {0,0,0,0,0x48,0};*(int*)&shellcode[0] = (int)test1; //将test1的地址给到shellcode中__asm {jmp far shellcode;}return 0;
}
此时就实现了jmp far,但是这里ret会报错,因为没有压入栈,所以我们可以通过标记的形式将ret压栈,再跳转即可:
int _tmain(int argc, _TCHAR* argv[])
{char shellcode[] = {0,0,0,0,0x48,0};*(int*)&shellcode[0] = (int)test1;__asm {push retaddr;jmp far shellcode;}
retaddr:return 0;
}
优化代码:CS从0x001b-0x004b-0x001b:
// test2.cpp : 定义控制台应用程序的入口点。
//#include "stdafx.h"void _declspec(naked) test1() {__asm{// do somethingpush eax;pop eax;ret;}
}void _declspec(naked) retfunc() {__asm{ret;}
}int _tmain(int argc, _TCHAR* argv[])
{char a[] = "12313";char shellcode[6] = {0,0,0,0,0x4b, 0};*(int*)&shellcode[0] = (int)test1;__asm {push retaddr;jmp far shellcode;}
retaddr:shellcode[4] = 0x1b;*(int*)&shellcode[0] = (int)retfunc;__asm {push retaddr2;jmp far shellcode;}
retaddr2:return 0;
}
修改shellcode[4] = 0x1b,然后再走一遍刚才的流程,通过jmp far跨段将CS修改回0x001b,这里设置成0x0018也行,反正程序会自动修正RPL=3
CALL
void callSegment1() {char shellcode[6] = {0,0,0,0,0x4b, 0};*(int*)&shellcode[0] = (int)test1;__asm {call far shellcode;}
}void callSegement2() {char shellcode[6] = {0,0,0,0,0x4b,0};*(int*)&shellcode[0] = (int)test1;__asm {call fword ptr shellcode;}
}
call有两种写法实现跨段,call far
和 call fword ptr
跨段前的寄存器:
EAX = CCCCCCCC EBX = 7FFD4000 ECX = 00000000 EDX = 00000001 ESI = 00000000
EDI = 0030FD08 EIP = 000D118D ESP = 0030FC2C EBP = 0030FD08 EFL = 00000202 CS = 001B DS = 0023 ES = 0023 SS = 0023 FS = 003B GS = 0000
跨段后的寄存器:
EAX = CCCCCCCC EBX = 7FFD4000 ECX = 00000000 EDX = 00000001 ESI = 00000000
EDI = 0030FD08 EIP = 000D1000 ESP = 0030FC24 EBP = 0030FD08 EFL = 00000202 CS = 004B DS = 0023 ES = 0023 SS = 0023 FS = 003B GS = 0000
EIP的变化不管,CS是目的,这里伴随的还有个ESP的压栈,压入了8字节的数据,当前32位的程序,并且D/B=1,所以单次压栈压入的是4字节:
那么0x001b就是CS的值,先压入CS,再压入返回地址:
但是实际上也是只执行了ret,所以CS的值没有变化,仍旧是修改后的0x004B:
而由于只做了一次ret,所以esp的值指向了压入的CS的值,没有回到原来的地方
那么此时直接运行程序,直接报ESP错误了,此时就是堆栈不平衡导致的后续程序运行出错:
既然程序默认会将0x1b压栈,就说明后续会做返回,那么此时提供了一个retf
用来做段返回:
void _declspec(naked) retSegment() {__asm {retf;}
}void callSegment2() {char shellcode[6] = {0,0,0,0,0x4b,0};*(int*)&shellcode[0] = (int)retSegment;__asm {call fword ptr shellcode;}
}
- 总结
- 段的有效性:P
- P = 1:段有效
- P = 0:段无效
- 段的数据类型:S
- S = 1:用户段
- S = 0:系统段
- 段的具体类型:Type
- S = 1
- Type > 7 : 代码段
- Type <=7 : 数据段
- S = 0
- Table 3.2. Sytem-Segment and Gate-Descriptor Types
- S = 1
- 段的有效性:P
文中涉及的代码已上传github:https://github.com/YuSec2021/segmentregister
参考文献:
- Intel® 64 and IA-32 Architectures Software Developer’s Manual Volume 3A: System Programming Guide, Part 1
- Intel® 64 and IA-32 Architectures Software Developer’s Manual Volume 2 (2A, 2B, 2C & 2D): Instruction Set Reference, A-Z