利用核心知識,自己實現ReadProcessMemory
前言
轉眼來到科銳學習已經超過一年的時間了,眼看三階段已經進入尾聲,核心的學習也快要結束,記錄一下筆記和心得,也給剛接觸的朋友做一個參考。當然,學習新知識最好的辦法就是帶著目的來學習,所以在文章後半部分,介紹如何自己實現了Windows的一個API:ReadProcessMemory,為什麼要選這個API呢?首先在軟體保護上,任何開發者都不會希望別人可以隨意檢視自己的記憶體內容,於是多數人會選擇在這個函式上掛鉤,監視並保護記憶體,那麼我們可以繞過他的保護,用自己的函式來檢視程式記憶體。
本篇文章主要涉及兩個部分:
介紹核心基礎知識
自己實現ReadProcessMemory
環境介紹:真機64位Windows10,虛擬機器:32位XP_sp3
學習核心的本質其實是學習作業系統的原理,而學習的過程應該是從CPU架構出發,作業系統作為使用CPU提供功能的例子。
第一部分:核心基礎知識
核心可以分成兩類:
單核心:追求效能,大部分系統程式碼放在0環,代表:Linux
微核心:追求維護性,大部分系統程式碼在3環,代表:Minix(Linux作者老師的作品),其中著名的設計:動態連結庫,在Windows中也使用
Windows算是微核心和單核心特點都具有的作業系統。
80x86處理器的工作模式:
8086處理器有三種工作模式,分別是:真實模式,保護模式,虛擬86模式,其中關係為:
16位彙編中 iret可以進入保護模式。
分段式記憶體管理
如何保證操作記憶體的動作是否合法?
GDT、LDT
作業系統通電後進入真實模式,做了一系列初始化的動作後進入到保護模式,在保護模式中,CPU執行所有和記憶體有關的操作都會通過查表來確定操作是否合法,這個表就是GDT和LDT表,表的格式由CPU廠商決定,所以為了能相容多款CPU,作業系統程式碼裡多用條件巨集來實現。
地址的轉換
邏輯地址:在程式除錯中見到的地址,實際上是:段+偏移的形式
線性地址:邏輯地址轉實體地址的中間層,邏輯地址是段中的偏移地址然後加上基地址就是線性地址。
實體地址:實體記憶體條上的真實地址
邏輯地址如何轉換到實體地址?
首先通過邏輯地址的偏移查第一次表得到線性地址,再查第二次表得到實體地址。為什麼要查第二次表呢?因為第二張表實際上是為了實現虛擬記憶體,那麼就是說這段記憶體可能是在磁碟上的,訪問的時候會先查表,然後從磁碟上調到記憶體中,有些情況下(關閉了虛擬記憶體)查第一次表的結果等價於實體地址。
為什麼要叫線性地址?
從邏輯地址轉換到線性地址,是一塊平坦且連續的地址,實際上對應到實體地址上,並不是連續的。
如何計算線性地址的範圍?
例如:
設段A的基地址等於00012345H,段界限等於5678H,並且段界限以位元組為單位(G=0),那麼段A對應線性地址空間中從00012345H-000179BDH的區域。
如果段界限以4K位元組為單位 (G=1),那麼段A對應線性地址空間中從00012345H-0568B344H(=00012345H+5678000H+0FFFH) 的區域。
如何從邏輯地址 ===查表===>> 線性地址?
這個表也叫做分段表,結構如下圖:
這種奇葩的做法來源於為了相容286的歷史遺留問題。
描述符
用於表示上述定義段的三個引數的資料結構稱為描述符。每個描述符長8個位元組。在保護方式下,每一個段都有一個相應的描述符來描述。
儲存段描述符
儲存段是存放可由程式直接進行訪問的程式碼和資料的段。儲存段描述符描述儲存段,所以儲存段描述符也被稱為程式碼和資料段描述符。
描述符是一個8個位元組的結構,具體結構如下:
Limit 0:15項和Limit 16:19項一起構成20位的段界限。20位的段界限最大值為0xFFFFF,單位是位元組或者分頁(有Flags項Gr位來確定)。在分頁機狀態下最大可以表達4G的記憶體空間。
Base0:23項和Base24:31一起構成32位的段基址,是線性還是實體地址也取絕於分頁機制是否開啟。
整個的解析結構如下圖:
Access Byte
Pr:存在位,對於一個有效的記憶體分段此值必定為1。
Privl(2bit):優先順序位,取值從0-3,對應Ring0-Ring3級別。
Ex:可執行位,為1時表示此描述符對應是程式碼段,為0時為資料段。
DC:
對於資料段,表於資料段的增長方向,0表示向上。1表示向下,也就是偏移大於段基址。
對於程式碼段,表示是否遵循一致原則。當此位為1時,也就是遵循一致原則,不同優先順序程式碼跳轉時,優先順序同目的碼所在段一致。為0時,剛跳轉時優先順序不變。
R/W:讀寫位
對於資料段,為1時表示可寫,為0時表示不可寫。資料段總是可讀。
對於程式碼段,為1時表示可讀,為0時表示不可讀。程式碼段總是不可寫。
Ac:保留位,設定為0,當被訪問過時系統將其改寫為1。
Flags
Gr:表示段界限的單位也叫做粒度,為1時表示單位為4KB(一個頁面),為0時表示單位為1位元組。
Sz:區分是16位保護模式,還是32位保護模式。可以同時有兩種型別描述符在同一個GDT中。
全域性和區域性描述符表
每個任務的區域性描述符表LDT含有該任務自己的程式碼段、資料段和堆疊段的描述符,也包含該任務所使用的一些門描述符,如任務門和呼叫門描述符等。隨著任務的切換,系統當前的區域性描述符表LDT也隨之切換。
全域性描述符表GDT含有每一個任務都可能或可以訪問的段的描述符,通常包含描述作業系統所使用的程式碼段、資料段和堆疊段的描述符,也包含多種特殊資料段描述符,如各個用於描述任務LDT的特殊資料段等。在任務切換時,並不切換GDT。
通過LDT可以使各個任務私有的各個段與其它任務相隔離,從而達到受保護的目的。通過GDT可以使各任務都需要使用的段能夠被共享。
GDT儲存在GDTR暫存器, 通過彙編指令LGDT載入。它的操作碼是一個結構的地址,這個結構描述GDT的大小和地址。共6個位元組,如下:
Size項(2個位元組)是GDT的位元組數減1(這也意味著GDT大小不可能為0)。2個位元組對應最大值是65535,也就是說一個GDT最大也就是65536位元組(8192個記憶體分段)。
Offset項(4個位元組)指向GDT的線性地址(未開啟分頁機制則是實體地址)。
LDT存在LDTR暫存器中,存有區域性程式的描述符表,LDTR中的內容根據執行緒的切換不停切換,表中的內容由作業系統來修改,若我們拿到0環許可權,自己修改LDTR,改到目標程式,那麼修改自己的記憶體就相當於修改了目標程式的記憶體,這是核心修改的一個經典招式。
通過段選擇子確定邏輯地址到實體地址的轉化(未開啟分頁機制)
段選擇子
在保護方式下,虛擬地址空間(相當於邏輯地址空間)中儲存單元的地址由段選擇子和段內偏移兩部分組成。段選擇子長16位,在32位程式下,CPU的段暫存器中儲存的就是選擇子,其格式如下表所示:
段選擇子的高13位是描述符索引(Index):所謂描述符索引是指描述符在描述符表中的序號。
段選擇子的第2位是引用描述符表指示位,標記為TI(Table Indicator),TI=0指示從全域性描述符表GDT中讀取描述符;TI=1指示從區域性描述符表LDT中讀取描述符。 (windows不使用這種CPU的做法,而Linux使用)
RPL:特權級描述符,CPU比較這一項個描述符的特權級判斷訪問操作是否能進行下去
具體通過邏輯地址查詢線性地址的例子:
現有邏輯地址:23:13ac34b,假如段暫存器中的的選擇子Index為:0000000000100,RPL:11,先比對,是三環程式,繼續操作,去LDT表中的第4項拿到段首地址,加上偏移13ac34b,得到線性地址。
值得一提的是Windows中並沒有使用LDT,而Linux是使用了LDT的,但是有意思的是在閱讀Windows原始碼時發現微軟也留下了LDT的介面,難道微軟想什麼時候順便相容一下Linux?
分頁管理
80386開始支援儲存器分頁管理機制。分頁機制是儲存器管理機制的第二部分。上述的段管理機制實現虛擬地址(由段和偏移構成的邏輯地址)到線性地址的轉換,分頁管理機制實現線性地址到實體地址的轉換。
線性地址到實體地址的轉換
線性地址到實體地址的轉換方式受很多變數的影響,我們先以一個其中最具代表性的方式來講解基本概念和轉換流程,再來總結所有的轉換方式。
流程如圖所示採用了二級表的結構:
頁目錄表(PDE)
一級表稱為頁目錄表(Page Directory Entry),共有1024(1k)個表項,每個表項的大小是4bit,總大小為4k,表項內容包括了頁表的指標和指向頁表的屬性。
頁表(PTE)
二級表稱為頁表(Page Table Entry),每張頁表裡有1024(1k)個表項,每個表項的大小是4bit,總大小為4k,最多有1024(1k)張頁表,最大佔用空間為4M,而作業系統一般是動態申請頁表,大小大概在1M左右。表項內容包括了實體地址的指標和屬性。
CR3
控制暫存器CR3的高20位存放了指向頁目錄表的指標(這裡存的是實體地址,如果這裡存虛擬地址就會產生悖論),每個程式都會有一張PDE,切換程式其實就是CPU在切換CR3的值,這一點非常重要,是我們自己實現ReadProcessMemory的基礎!
表項
PDE和PTE的表項結構基本相似,略有差別,如下圖所示:
頁目錄表表項:
頁表表項:
結構基本類似,高20位存指向目標首地址,低12位表示指向目標的屬性
P:Present,存在標誌,該標誌標明當前表項所指向的頁或頁表是否存在於記憶體中。當標誌位置位表示該頁在記憶體中,當標誌位清零表示該頁不在記憶體中,若CPU試圖訪問則會產生一個缺頁異常,值得一提的是CPU並不會主動操作該標誌位,而是讓作業系統來維護。
R/W:Read or Write,讀寫標誌位,當標誌位置位,所指頁表或頁是可讀可寫的,清零表示所指頁表或頁是隻讀的。
U/S:User or Surpervisor,使用者許可權標誌,置位時表示普通使用者許可權也就是我們常說的3環許可權,清零則表示超級使用者許可權也就是0環許可權。
PWT:Page Write Through,頁直寫標記,控制頁或頁表的直寫或回寫快取策略。
PCD:Page Cache Disabled,頁層次的快取禁用,控制頁或頁表的的快取,置位時快取被禁止,清零時表示可以快取。
A:Accessed,訪問標誌,指明這個頁或頁表是否曾經被訪問過,當指向的頁或頁表第一次載入記憶體,會清零標誌位,當頁或頁表第一被訪問,改標誌位置位
D :Dirty,髒位(在PDE的表項中,該位是0,不使用此標誌位),指明該頁是否曾經被寫入過,,當指向的頁第一次載入記憶體,會清零標誌位,當頁第一次寫操作完成,改標誌位置位
PAT:Page Table Attribute Index(PTE表項中的第7位)頁屬性索引。
PS :Page Size(PDE表項中的第7位),該位指明指向的頁表尺寸,當改標誌清零,頁尺寸為4k。當改標誌被置位,頁的尺寸為32位定址的4M(實體地址擴充啟用,頁尺寸為2M)
G:Global,全域性標誌。
Avl:保留位。
32位線性地址結構:
線性地址的最高10位(即位22至位31)作為頁目錄表的索引
線性地址的中間10位(即位12至位21)作為所指定的頁目錄表中的頁表項的索引
線性地址的低12位作為32位實體地址的低12位。
轉換例項:
如何搭建雙機除錯環境,請自行谷歌
我們以GDT地址作為例子:
暫存器環境:
gdt內容:
dd :檢視虛擬記憶體地址
現在我們有線性地址:0x8003f000,CR3:39000
拆分線性地址:1000000000 0000111111 000000000000
PDE Index:0x200
PTE Index :0x3f
Offset :0x0
頁目錄表首地址為CR3的高20位,找到對應頁目錄表項:
!dd:檢視實體記憶體地址, 0x200 * 4是因為表項是4個位元組
表項 0x0003b163的前20位指向頁表的首地址,也就是0x0003b000,頁表的Index為0x3f,於是:
表項0x0003f163的前20位指向實體地址頁,也就是0x0003f000,加上Offset,最後得到實體地址:0x0003f000。
檢驗:
和虛擬地址對應的內容是相同的,說明虛擬地址:0x8003f000對映到實體地址:0x0003f000
所有轉換方式(查表方式):
以上我們所闡述的線性地址轉實體地址的方法適用於沒有實體地址擴充,且頁表大小為4k的情況。
如何決定適用哪種查表方式?
查表方式根據頁表大小來決定,而頁表大小根據以下標誌決定:
PG:分頁標誌,CR0的31位
PSE : 頁尺寸擴充標誌,CR4的第4位
PAE : 實體地址擴充標誌,CR4的第5位
PS :頁表尺寸,PDE表項中的第7位
未開啟PAE,頁表大小4k
以下圖片均來自Intel手冊,詳細解釋請參考手冊
未開啟PAE,頁表大小4M
當符合頁表大小是4M的情況下,只需要查一次PDE表再加上偏移就能得出實體地址。
開啟PAE分頁機制的36位物理定址
開啟PAE時,定址的方式有所不同,CR3裡儲存的不再是PDE的首地址,而是一個儲存了PDE指標的表的首地址,這張表我們稱作頁目錄指標表(PDPT),對於線性地址的拆分也有所不同,高兩位作為了PDPT的索引。
開啟PAE,頁表大小4K
當開啟PAE且頁表大小是4k的情況,需要查三次表,線性地址的21-29位作為PDE表的索引,12-20位作為PTE表的索引
開啟PAE,頁表大小2M
第二部分:ReadProcessMemory的實現
說了這麼多關於表的格式和查表的方法,在實踐中我們該如何利用呢?一個用處是我們經常需要把虛擬地址轉換成實體地址,明白其轉換原理利於分析問題,另外上面說了,每個程式都會有一套自己的分頁機制,切換程式實際上是切換CR3中的內容,那麼如何實現我們自己的ReadProcessMemory呢?
問題變成了以下4步:
拿到指定程式儲存在CR3中的內容
切換當前的CR3
讀取指定記憶體的內容
還原CR3
第一步,如何拿到指定程式儲存在CR3中的內容?
我們知道在3環程式裡,fs[0]儲存的是執行緒環境塊TEB,在0環,儲存的則是處理器控制區(_KPCR),部分核心資料結構如下圖:
從_EPROCESS中的程式連結串列我們可以遍歷所有程式,當匹配到目標程式時,拿出
目標程式DirectoryTableBase中儲存的地址。
NTSTATUS GetProcessDirBase(IN DWORD dwPID, OUT PDWORD pDirBase)
{
PEPROCESS Process;
PEPROCESS CurProcess;
CHAR *pszImageName;
DWORD dwCurPID;
DWORD i;
__try
{
__asm
{
//ETHREAD
mov eax, fs:[124h]
//Current EPROCESS
mov eax, [eax + 44h]
mov Process, eax
}
CurProcess = Process;
i = 0;
//traversing EPROCESS
do
{
pszImageName = (char*)CurProcess + 0x174;
dwCurPID = (*(DWORD*)((char*)CurProcess + 0x084));
if (dwCurPID == dwPID)
{
*pDirBase = (*(DWORD*)((char*)CurProcess + 0x018));
return STATUS_SUCCESS;
}
//Next
CurProcess = (*(DWORD*)((char*)CurProcess + 0x088)) - 0x88;
} while (CurProcess != Process);
}
__except (EXCEPTION_EXECUTE_HANDLER)
{
dprintf("[MyReadProcessMemory] GetProcessDirBase __except \r\n");
}
return STATUS_INVALID_DEVICE_REQUEST;
}
第二步,切換當前的CR3
切換CR3的值之前,我們需要遮蔽調當前CPU核心的中斷,以防執行緒切換,如果是多核的CPU,每個核心都需要遮蔽掉中斷。同時,為了預防記憶體屬性不可寫,暫時改掉CR0中表示所有記憶體屬性的標誌,讓所有記憶體暫時都可寫。
__asm
{
//Shielding interrupt
cli
//close memory protect
mov eax, cr0
and eax, not 10000h
mov cr0, eax
mov eax, cr3
mov dwOldDirBase, eax
//swap CR3
mov eax, dwDirBase
mov cr3, eax
}
第三步,讀取指定記憶體的內容
申請一段空間,暫存一下讀取的資料,記得要檢查目標記憶體地址是否有效
//Alloc ring0 Buff
char* szRing0Buf = (char*)MmAllocateNonCachedMemory(dwBufSize);
//check address invalid
if (MmIsAddressValid(dwTargetAdddress))
{
RtlCopyMemory(szRing0Buf, dwTargetAdddress, dwBufSize);
bIsRead = TRUE;
}
第四步,還原CR3
恢復記憶體屬性,恢復中斷
__asm
{
mov eax, dwOldDirBase
mov cr3, eax
//Reset memory protect
mov eax, cr0
or eax, 10000h
mov cr0, eax
//Restore interrupt
sti
}
總結
核心的學習也開始進入尾聲,溫故而知新,整理知識本身也是一種學習的過程。衷心感謝一年多以來錢老師,張老師,姚老師,戚老師,王老師,唐老師的指導。要畢業了,幫科銳宣傳下,科銳30期正在招生中
相關文章
- 【Redis核心知識】實現秒殺的三種方案2024-04-03Redis
- JavaSE核心知識2019-07-14Java
- flask核心知識2019-04-27Flask
- JavaWeb核心知識2020-09-24JavaWeb
- Nginx 實戰核心知識點整理(上)2022-07-05Nginx
- Java 核心知識點整理2019-04-16Java
- RabbitMQ必備核心知識2019-02-14MQ
- Objective-C 核心知識2018-12-09Object
- Docker 核心知識回顧2022-04-03Docker
- 利用 D 程式語言實現文字識別程式2024-11-03
- 詳解 MySQL 面試核心知識點2020-07-29MySql面試
- ES6核心知識總結2018-08-11
- 分散式計算,核心知識點2015-12-18分散式
- 自己實現一個 DFA 串模式識別器(一)2020-04-18模式
- 自己實現一個 DFA 串模式識別器(二)2020-09-20模式
- FFT Vivado IP核實現2020-11-09FFT
- Android小知識-利用OkHttp實現WebSocket通訊2018-10-29AndroidHTTPWeb
- 利用Wi-Fi訊號實現人體識別2017-06-02
- 自己實現AJAX2018-04-11
- 理解JavaScript的核心知識點:作用域2019-02-16JavaScript
- 開心知識問答 V3.0版2015-11-15
- 實現自己的promise2018-12-21Promise
- 一文吃透Tomcat核心知識點2023-05-08Tomcat
- 利用OpenCV和深度學習來實現人類活動識別2020-01-07OpenCV深度學習
- 物體識別桌利用智慧互動實現資訊傳播2023-02-21
- Vue核心知識-Vue的元件之render函式2018-09-08Vue元件函式
- Java核心知識1:泛型機制詳解2022-04-11Java泛型
- 專案管理PRINCE2核心知識點整理2023-04-16專案管理
- Java核心知識體系6:集合框架詳解2023-11-13Java框架
- Cocos Creator 3.8.x bundle核心知識點2024-08-04
- 自己動手實現OkHttp2019-04-16HTTP
- 自己實現HTML-Beautify2021-09-09HTML
- 自己實現鬥地主引擎2018-06-05
- 實現自己的http server2014-03-18HTTPServer
- 利用LLVM實現JS的編譯器,創造屬於自己的語言2018-08-31LVMJS編譯
- C# 利用repeater 的巢狀 實現一個自己想要的 日曆頁面2009-01-04C#巢狀
- PyQT5 實現下拉核取方塊2020-03-30QT
- 利用 Watermill 實現 Golang CQRS2020-12-28Golang