概述
在windows系統上,涉及到內核對象的功能函數,都需要從應用層權限轉換到內核層權限,然后再執行想要的內核函數,最終將函數結果返回給應用層。本文就是用OpenProcess函數來觀察函數從應用層到內核層的整體調用流程。
OpenProcess函數,根據指定的進程ID,返回進程句柄。源碼如下:
HANDLE WINAPI OpenProcess(DWORD dwDesiredAccess,BOOL bInheritHandle,DWORD dwProcessId)
{
NTSTATUS Status; //保存函數執行狀態
OBJECT_ATTRIBUTES Obja; //待打開對象的對象屬性
HANDLE Handle; //存儲打開的句柄
CLIENT_ID ClientId; //進程、線程ID
ClientId.UniqueThread = NULL;
ClientId.UniqueProcess = LongToHandle(dwProcessId); //將Long類型強轉成Handle類型
//初始化對象屬性
InitializeObjectAttributes(
&Obja,
NULL,
(bInheritHandle ? OBJ_INHERIT : 0),
NULL,
NULL
);
//嘗試打開進程
Status = NtOpenProcess(
&Handle, //保存進程句柄
(ACCESS_MASK)dwDesiredAccess, //預打開進程并獲取對應的權限
&Obja, //對象屬性
&ClientId //根據進程ID打開進程
);
//判斷是否打開進程成功
if ( NT_SUCCESS(Status) ) {
return Handle;
}
else {
//設置GetLastError()函數值
BaseSetLastNTError(Status);
return NULL;
}
}
其中一個重點問題是,OpenProcess函數直接調用了NtOpenProcess內核函數,沒有看到權限轉換相關代碼。NTOpenProcess源碼如下:
NTSTATUS NtOpenProcess (
OUT PHANDLE ProcessHandle,
IN ACCESS_MASK DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttributes,
IN PCLIENT_ID ClientId OPTIONAL
)
{
HANDLE Handle;
KPROCESSOR_MODE PreviousMode;
NTSTATUS Status;
PEPROCESS Process;
PETHREAD Thread;
CLIENT_ID CapturedCid={0};
BOOLEAN ObjectNamePresent;
BOOLEAN ClientIdPresent;
ACCESS_STATE AccessState;
AUX_ACCESS_DATA AuxData;
ULONG Attributes;
//當前代碼執行環境IRQL必須在APC_LEVEL等級以下,否則將觸發斷言
PAGED_CODE();
//宏函數調用,獲取當前線程先前模式,是內核線程還是用戶線程
PreviousMode = KeGetPreviousMode();
//用戶線程
if (PreviousMode != KernelMode) {
try {
//句柄寫入探針,將用戶地址空間,從交換空間中置換到內存中
//如果傳入的ProcessHandle地址是0,那么在訪問時,會出現訪問0地址異常,進而被catch捕獲
ProbeForWriteHandle (ProcessHandle);
//判斷第二個參數,是否屬于1/2/4/8/16,不符合前述要求則執行斷言
//判斷讀取的結構體是否為0或者大于0x10000,符合前述要求則執行斷言
//判斷ObjectAttributes地址是否和指定的第三個參數大小對齊,不對齊則拋出異常
//判斷地址是否是內核地址,如果是內核地址,則將用戶探針地址值修改為0
ProbeForReadSmallStructure (ObjectAttributes,
sizeof(OBJECT_ATTRIBUTES),
sizeof(ULONG));
//從對象結構中,獲取對象名稱
//如果是OpenProcess函數調用,那么此刻的名稱為空
ObjectNamePresent = (BOOLEAN)ARGUMENT_PRESENT (ObjectAttributes->ObjectName);
//計算應用層句柄權限
Attributes = ObSanitizeHandleAttributes (ObjectAttributes->Attributes, UserMode);
//判斷ClientId指針是否為空
if (ARGUMENT_PRESENT (ClientId)) {//不為空
//比對參數地址是否對齊,結構大小是否過大
ProbeForReadSmallStructure (ClientId, sizeof (CLIENT_ID), sizeof (ULONG));
//沒有問題的話,將參數賦值給局部變量CapturedCid
CapturedCid = *ClientId;
ClientIdPresent = TRUE;//設置標志位
} else {//指針為空
ClientIdPresent = FALSE;//設置標志位
}
} except (EXCEPTION_EXECUTE_HANDLER) {
//捕獲異常后,返回異常代碼
return GetExceptionCode();
}
} else {//內核線程
ObjectNamePresent = (BOOLEAN)ARGUMENT_PRESENT (ObjectAttributes->ObjectName); //判斷對象名稱是否為空
Attributes = ObSanitizeHandleAttributes (ObjectAttributes->Attributes, KernelMode); //調用者不是內核模式,那么打開句柄也不是內核句柄
//判斷ClientId是否為空指針
if (ARGUMENT_PRESENT (ClientId)) {
CapturedCid = *ClientId; //獲取進程ID
ClientIdPresent = TRUE; //成功獲取
} else {
ClientIdPresent = FALSE; //獲取失敗
}
}
//如果對象當前名稱為空并且進程ID為空,那么就返回錯誤
if (ObjectNamePresent && ClientIdPresent) {
return STATUS_INVALID_PARAMETER_MIX; //狀態無效參數混合
}
//創建一個訪問狀態,該狀態可用于系統調試,因為函數調用者可能有調試權限
Status = SeCreateAccessState(
&AccessState,
&AuxData,
DesiredAccess,
&PsProcessType->TypeInfo.GenericMapping
);
//如果訪問狀態創建失敗,返回失敗狀態碼
if ( !NT_SUCCESS(Status) ) {
return Status;
}
//調試訪問進入,設置對應權限和給予對應權限
if (SeSinglePrivilegeCheck( SeDebugPrivilege, PreviousMode )) {
if ( AccessState.RemainingDesiredAccess & MAXIMUM_ALLOWED ) {
AccessState.PreviouslyGrantedAccess |= PROCESS_ALL_ACCESS;
} else {
AccessState.PreviouslyGrantedAccess |= ( AccessState.RemainingDesiredAccess );
}
AccessState.RemainingDesiredAccess = 0;
}
//如果對象名稱存在的話
if (ObjectNamePresent) {
//使用對象名稱打開對象
Status = ObOpenObjectByName(
ObjectAttributes, //對象屬性
PsProcessType, //待打開對象類型
PreviousMode, //線程模式
&AccessState, //訪問狀態
0,
NULL,
&Handle //獲取打開對象句柄
);
//刪除訪問狀態
SeDeleteAccessState( &AccessState );
//如果對象打開成功
if ( NT_SUCCESS(Status) ) {
try {
//將句柄值寫入,指定的地址空間
//如果地址為0,那么將拋出異常
*ProcessHandle = Handle;
} except (EXCEPTION_EXECUTE_HANDLER) {
return GetExceptionCode ();
}
}
return Status;
}
if ( ClientIdPresent ) {
Thread = NULL;
//如果是OpenProcess調用,那么CapturedCid.UniqueThread為0
if (CapturedCid.UniqueThread) {
//根據線程ID,獲取EPROCESS和ETHREAD結構
Status = PsLookupProcessThreadByCid(
&CapturedCid,
&Process,
&Thread
);
//如果函數執行失敗,則刪除訪問狀態,并返回函數執行狀態
if (!NT_SUCCESS(Status)) {
SeDeleteAccessState( &AccessState );
return Status;
}
} else {//OpenProcess函數調用就是使用此處分支
//根據進程ID獲取EPROCESS
Status = PsLookupProcessByProcessId(
CapturedCid.UniqueProcess,
&Process
);
//如果函數執行失敗,則刪除訪問狀態,并返回函數執行狀態
if ( !NT_SUCCESS(Status) ) {
SeDeleteAccessState( &AccessState );
return Status;
}
}
//
// OpenObjectByAddress
//
//根據EPROCESS結構,獲取一個句柄
Status = ObOpenObjectByPointer(
Process,
Attributes,
&AccessState,
0,
PsProcessType,
PreviousMode,
&Handle
);
//刪除一個訪問狀態
SeDeleteAccessState( &AccessState );
//如果線程被打開了,減少對ETHREAD結構引用
if (Thread) {
ObDereferenceObject(Thread);
}
//減少對指定EPROCESS結構的引用
ObDereferenceObject(Process);
//判斷EPROCESS是否打開成功
if (NT_SUCCESS (Status)) {
try {
//將句柄值寫入,指定的地址空間
//如果地址為0,那么將拋出異常
*ProcessHandle = Handle;
} except (EXCEPTION_EXECUTE_HANDLER) {
return GetExceptionCode ();
}
}
return Status;
}
return STATUS_INVALID_PARAMETER_MIX;
}
出現上述情況,可能是源碼版本問題。
分析進入內核流程
在IDA工具觀察OpenProcess函數執行,OpenProcess函數經過一系列調用最終會調到跳板函數ZwOpenProcess:
; __stdcall ZwOpenProcess(x, x, x, x) public _ZwOpenProcess@16 _ZwOpenProcess@16 proc near ; CODE XREF: RtlpChangeQueryDebugBufferTarget(x,x,x,x)-18874↑p ; RtlQueryProcessDebugInformation(x,x,x)+8F↑p ; RtlpChangeQueryDebugBufferTarget(x,x,x,x)+62DD8↓p ; DATA XREF: .text:off_77EF5618↑o mov eax, 0BEh ; NtOpenProcess mov edx, 7FFE0300h ; 獲取_KUSER_SHARED_DATA空間中的SystemCall函數地址 ; KiFastSystemCall或KiIntSystemCall call dword ptr [edx] ; 進行函數調用 retn 10h _ZwOpenProcess@16 endp
可以看到,該函數保存了函數的功能號(SSDT/SSSDT表索引)0xBE,調用了地址為0x7FFE0300的函數。
首先了解下_KUSER_SHARED_DATA空間,該空間被用戶層和內核層所共享,通俗來講就是兩個虛擬地址掛載了同一張物理頁,最終達到數據共享的目的。兩個固定的地址如下:
用戶_KUSER_SHARED_DATA虛擬地址:0x7FFE0000(只讀權限)
內核_KUSER_SHARED_DATA虛擬地址:0xFFDF0000(讀寫權限)
1: kd> dt _KUSER_SHARED_DATA 0x7FFE0000 hal!_KUSER_SHARED_DATA ...... +0x300 SystemCall : 0x775464f0 <======0x7FFE0300 +0x304 SystemCallReturn : 0x775464f4 ...... 1: kd> u 0x775464f0 ntdll!KiFastSystemCall: 775464f0 8bd4 mov edx,esp 775464f2 0f34 sysenter
可以看到_KUSER_SHARED_DATA.SystemCall字段上的函數地址是KiFastSystemCall函數。之所以此處不是將進入內核的地址寫死,而是使用一個類似變量的SystemCall字段來記錄地址,就是為了方便改變進入內核的方式。如果當前處理器不支持快速調用進入內核,那么SystemCall保存的就應該是KiIntSystemCall函數地址。
KiFastSystemCall函數首先保存了應用層的棧頂地址,然后執行了sysenter指令,該指令就是修改段寄存器中的段選擇子。(具體內容可以參考intel白皮書的sysentry指令)
public _KiFastSystemCall@0 _KiFastSystemCall@0 proc near ; DATA XREF: .text:off_77EF5618↑o mov edx, esp ; 保存用戶空間的esp到寄存器edx中 sysenter ; 調用匯編指令sysenter ; 1.將 SYSENTER_CS_MSR 的值裝載到 cs 寄存器 ; 2.將 SYSENTER_EIP_MSR 的值裝載到 eip 寄存器 ; 3.將 SYSENTER_CS_MSR 的值加 8(Ring0 的堆棧段描述符)裝載到 ss 寄存器。 ; 4.將 SYSENTER_ESP_MSR 的值裝載到 esp 寄存器 _KiFastSystemCall@0 endp
快速調用進入內核和中斷調用進入內核,區別就是快速調用使用寄存器記錄段選擇子,相比如中斷調用訪問內存,執行效率更高。
其中修改的EIP指向內核函數KiFastCallEntry,該函數初始化剛剛進入到內核的線程環境等。
windows系統GDT表的0x30項,記錄了處理器當前核的KPCR結構。
mov ecx, 23h ; '#' push 30h ; '0' pop fs ; fs指向kpcr結構
拿出KPCR結構中的_KTRAP_FRAME結構,將應用層寄存器環境保存到該緩存空間中。
mov ecx, large fs:KPCR.TSS ; 獲取任務段 mov esp, [ecx+_KTSS.Esp0] ; 獲取處理器中記錄的內核ESP ; 此時esp指向了內核的_KTRAP_FRAME結構的HardwareSegSs位置 push 23h ; '#' ; 保存ss push edx ; 保存應用層的esp pushf ; 保存標志寄存器 loc_43568B: ; CODE XREF: _KiFastCallEntry2+23↑j push 2 add edx, 8 ; edx指向參數地址 popf ; 設置標志寄存器為2 or byte ptr [esp+1], 2 ; 標志寄存器低二位(索引為1),必須為1 push 1Bh ; 保存應用層cs段選擇子 push dword ptr ds:0FFDF0304h ; 保存SystemCallReturn函數地址 push 0 ; 設置錯誤碼ErrCode push ebp ; 保存寄存器環境 push ebx push esi push edi mov ebx, large fs:KPCR.SelfPcr ; ebx = 當前kpcr地址 push 3Bh ; ';' ; 保存fs段選擇子 mov esi, [ebx+124h] ; 獲取當前線程的ETHREAD結構 push dword ptr [ebx] ; 保存Used_ExceptionList地址 mov dword ptr [ebx], 0FFFFFFFFh ; 將KPCE中的異常鏈記錄抹除 mov ebp, [esi+_ETHREAD.Tcb.InitialStack] ; 獲取內核的初始化堆棧 push 1 ; 保存先前模式到KTRRAP_FRRAME結構中 ; 0:KernelMode ; 1:UserMode sub esp, 48h sub ebp, 29Ch mov [esi+_ETHREAD.Tcb.PreviousMode], 1 ; 設置線程先前模式為用戶模式 cmp ebp, esp ; 不相等,就跳轉異常處理 jnz short loc_43566B and [ebp+_KTRAP_FRAME.Dr7], 0 ; dr7 = 0 test byte ptr [esi+3], 0DFh ; 線程頭部說明中的Index字段 mov [esi+_KTHREAD.TrapFrame], ebp ; 保存TRAP_FRAME結構地址到線程對象的字段中 jnz Dr_FastCallDrSave loc_4356E8: ; CODE XREF: Dr_FastCallDrSave+D↑j ; Dr_FastCallDrSave+79↑j mov ebx, [ebp+_KTRAP_FRAME._Ebp] mov edi, [ebp+_KTRAP_FRAME._Eip] mov [ebp+_KTRAP_FRAME.DbgArgPointer], edx mov [ebp+_KTRAP_FRAME.DbgArgMark], 0BADB0D00h ; 設置調試掩碼 mov [ebp+_KTRAP_FRAME.DbgEbp], ebx mov [ebp+_KTRAP_FRAME.DbgEip], edi ; SystemCallRet覆蓋調試eip sti ; 允許發生中斷
當一切環境保存結束后,開始根據應用層傳入的索引值,完成對SSDT/SSSDT表的函數檢索與執行。
loc_4356FF: ; CODE XREF: _KiBBTUnexpectedRange+18↑j ; _KiSystemService+7F↑j mov edi, eax ; edi = 函數表索引 shr edi, 8 ; 表索引,用了13位,其中 ; 13位-0:ssdt表查詢 ; 13位-1:sssdt表查詢 and edi, 10h mov ecx, edi ; 此時ecx是一個標志寄存器, ; ecx = 0x0;ssdt表 ; exc = 0x10;sssdt表 add edi, [esi+_KTHREAD.ServiceTable] ; ssdt表地址 + 0(ssdt)/10(sssdt) ; ssdt表地址 ; sssdt表地址 mov ebx, eax ; ebx = 函數表索引 and eax, 0FFFh ; 去除索引號第十三位的標志位,使其成為一個單純的索引號 cmp eax, [edi+8] ; 判斷當前索引號是否在函數個數范圍內 jnb _KiBBTUnexpectedRange ; 函數索引越界,跳轉 cmp ecx, 10h ; 判斷是哪一個表項函數 jnz short SSDT mov ecx, [esi+_ETHREAD.Tcb.Teb] ; 獲取線程TEB結構 xor esi, esi ; esi = 0;
上述代碼比較精彩的部分就是,ssdt表和sssdt表的抉擇處理。ssdt表和sssdt表在內存上的排放情況如下所示:
0: kd> dd KeServiceDescriptorTable 841ac940 840a95f4 00000000 00000191 840a9c3c <===SSDT表 :函數地址表地址 略 函數個數 函數參數表地址 841ac950 00000000 00000000 00000000 00000000 841ac960 00000011 00000100 5385d2ba d717548f 841ac970 00000000 00000000 00000210 00000000 841ac980 840a95f4 00000000 00000191 840a9c3c 841ac990 95a65000 00000000 00000339 95a6602c 841ac9a0 00000200 866f1f78 00000000 00000000 841ac9b0 866f1eb0 866f1c58 866f1de8 866f1d20 0: kd> dd KeServiceDescriptorTableShadow 841ac980 840a95f4 00000000 00000191 840a9c3c <===SSDT表 :函數地址表地址 略 函數個數 函數參數表地址 841ac990 95a65000 00000000 00000339 95a6602c <===SSSDT表:函數地址表地址 略 函數個數 函數參數表地址 841ac9a0 00000200 866f1f78 00000000 00000000 841ac9b0 866f1eb0 866f1c58 866f1de8 866f1d20 841ac9c0 00000000 866f1b90 00000000 00000000 841ac9d0 840a2e49 840b7b60 840dc199 00000003 841ac9e0 86645000 86646000 00000120 ffffffff 841ac9f0 00000001 00000000 00000002 00000000
如果傳遞到內核的表索引是一個SSSDT表索引,那么表地址就會加上0x10,地址就會從ssdt起始地址指向sssdt起始地址,而后續代碼不需要做調整。
接下來就是將用戶空間的數據拷貝到內核空間,并且根據索引值查詢待調用的函數地址:
inc large dword ptr fs:6B0h ; KeSystemCalls mov esi, edx ; esi = 用戶空間參數首地址 xor ecx, ecx ; ecx = 0; mov edx, [edi+0Ch] ; edx = 函數參數表地址 mov edi, [edi] ; edi = 函數地址表地址 mov cl, [eax+edx] ; 在參數個數表中,根據索引查詢函數參數個數 mov edx, [edi+eax*4] ; 在函數地址表中,查詢函數地址 sub esp, ecx ; 堆棧向低地址提高指定字節空間,提升的空間用于存放函數參數 shr ecx, 2 ; 參數個數 = 字節數 / 4 mov edi, esp ; edi = esp cmp esi, ds:_MmUserProbeAddress ; 判斷參數地址是否在用戶空間 jnb loc_435995 ; 參數地址不在用戶就可能出現錯誤 loc_435767: ; CODE XREF: _KiFastCallEntry+329↓j ; DATA XREF: _KiTrap0E:loc_4389D8↓o rep movsd ; 將參數從用戶空間拷貝到內核空間
一切準備完畢之后,開始函數調用:
mov ecx, large fs:124h ; ecx = 當前線程 mov edi, [esp] mov [ecx+_KTHREAD.SystemCallNumber], ebx mov [ecx+_KTHREAD.FirstArgument], edi loc_435785: ; CODE XREF: _KiFastCallEntry+FD↑j mov ebx, edx ; ebx = 函數地址 test byte ptr ds:dword_52D048, 40h setnz byte ptr [ebp+12h] jnz loc_435B24 loc_435798: ; CODE XREF: _KiFastCallEntry+4BB↓j call ebx ; <===============進行函數調用
函數調用結束后,會進行系列的權限和標記檢查。如果和預期不服,則跳轉KeBugCheck2,執行藍屏代碼。
總結
本文總體上梳理了函數調用的流程。但是僅僅講述了快速調用的執行流程,關于int 2e的中斷調用并未提及。
其中在書寫文章過程中,涉及到的幾個有意思的點,具體如下:
(1)內核線程中所記錄的GDT、IDT表地址,如果將這兩個地址更換,是不是能達到類似內核重載的效果
(2)修改_KUSER_SHARED_DATA結構中的SystemCall,不對SSDT表進行Hook操作,是否也能達到過濾并且繞過檢測的效果
(3)同理,修改_KUSER_SHARED_DATA結構中的SystemCallReturn函數,是否能達到攔截某些數據信息的目的
上述思想有待考究實現。
0x00實驗室
雷石安全實驗室
合天網安實驗室
看雪學苑
E安全
GoUpSec
綠盟科技研究通訊
HACK學習呀
中國信息安全
安全圈
LemonSec
LemonSec