C# 讀寫檔案從使用者態切到核心態,到底是個什麼流程?

2022-06-20 12:05:03

一:背景

1. 一個很好奇的問題

我們在學習 C# 的過程中,總會聽到一個詞叫做 核心態 ,比如說用 C# 讀寫檔案,會涉及到程式碼從 使用者態核心態 的切換,用 HttpClient 獲取遠端的資料,也會涉及到 使用者態核心態 的切換,那到底這是個什麼樣的互動流程?畢竟我們的程式是無法操控 核心態 ,今天我們就一起探索下。

二:探究兩態的互動流程

1. 兩個態的交界在哪裡

我們知道人間和地府的交界處在 鬼門關,同樣的道理 使用者態核心態 的交界處在 ntdll.dll 層,畫個圖就像下面這樣:

作業系統為了保護 核心態 的程式碼,在使用者態直接用指標肯定是不行的,畢竟一個在 ring 3,一個在 ring 0,而且 cpu 還做了硬體保護兜底,那怎麼進入呢? 為了方便研究,先上一個小例子。

2. 一個簡單的檔案讀取

我們使用 File.ReadAllLines() 實現檔案讀取,程式碼如下:


    internal class Program
    {
        public static object lockMe = new object();

        static void Main(string[] args)
        {
            var txt= File.ReadAllLines(@"D:\1.txt");

            Console.WriteLine(txt);

            Console.ReadLine();
        }
    }

在 Windows 平臺上,所有核心功能對外的入口就是 Win32 Api ,言外之意,這個檔案讀取也需要使用它,可以在 WinDbg 中使用 bp ntdll!NtReadFile 在 鬼門關 處進行攔截。


0:000> bp ntdll!NtReadFile
breakpoint 0 redefined
0:000> g
ModLoad: 00007ffe`fdb20000 00007ffe`fdb50000   C:\Windows\System32\IMM32.DLL
ModLoad: 00007ffe`e2660000 00007ffe`e26bf000   C:\Program Files\dotnet\host\fxr\6.0.5\hostfxr.dll
Breakpoint 0 hit
ntdll!NtReadFile:
00007ffe`fe24c060 4c8bd1          mov     r10,rcx

哈哈,很順利的攔截到了,接下來用 uf ntdll!NtReadFile 把這個方法體的組合程式碼給顯示出來。


0:000> uf ntdll!NtReadFile
ntdll!NtReadFile:
00007ffe`fe24c060  mov     r10,rcx
00007ffe`fe24c063  mov     eax,6
00007ffe`fe24c068  test    byte ptr [SharedUserData+0x308 (00000000`7ffe0308)],1
00007ffe`fe24c070  jne     ntdll!NtReadFile+0x15 (00007ffe`fe24c075) 
00007ffe`fe24c072  syscall
00007ffe`fe24c074  ret
00007ffe`fe24c075  int     2Eh
00007ffe`fe24c077  ret

從組合程式碼看,邏輯非常簡單,就是一個 if 判斷,決定到底是走 syscall 還是 int 2Eh,很顯然不管走哪條路都可以進入到 核心態,接下來逐一聊一下。

3. int 2Eh 入關走法

相信在偵錯界沒有人不知道 int 是幹嘛的,畢竟也看過無數次的 int 3,本質上來說,在核心層維護著一張 中斷向量表,每一個數位都對映著一段函數程式碼,當你開啟電腦電源而後被 windows 接管同樣藉助了 中斷向量表 ,好了,接下來簡單看看如何尋找 3 對應的函數程式碼。

windbg 中有一個 !idt 命令就是用來尋找數位對應的函數程式碼。


lkd> !idt 3

Dumping IDT: fffff804347e1000

03:	fffff80438000f00 nt!KiBreakpointTrap

可以看到,它對應的核心層面的 nt!KiBreakpointTrap 函數,同樣的道理我們看下 2E


lkd> !idt 2E

Dumping IDT: fffff804347e1000

2e:	fffff804380065c0 nt!KiSystemService

現在終於搞清楚了,進入核心態的第一個方法就是 KiSystemService,從名字看,它是一個類似的通用方法,接下來就是怎麼進去到核心態相關的 讀取檔案 方法中呢?

要想找到這個答案,可以回頭看下剛才的組合程式碼 mov eax,6 ,這裡的 6 就是核心態需要路由到的方法編號,哈哈,那它對應著哪一個方法呢? 由於 windows 的閉源,我們無法知道,幸好在 github 上有人列了一個清單:https://j00ru.vexillium.org/syscalls/nt/64/ ,對應著我的機器上就是。

從圖中可以看到其實就是 nt!NtReadFile ,到這裡我想應該真相大白了,接下來我們聊下 syscall

4. syscall 的走法

syscall 是 CPU 特別提供的一個功能,叫做 系統快速呼叫,言外之意,它藉助了一組 MSR暫存器 幫助程式碼快速從 使用者態 切到 核心態, 效率遠比走 中斷路由表 要快得多,這也就是為什麼程式碼會有 if 判斷,其實就是判斷 cpu 是否支援這個功能。

剛才說到它藉助了 MSR暫存器,其中一個暫存器 MSR_LSTAR 存放的是核心態入口函數地址,我們可以用 rdmsr c0000082 來看一下。


lkd> rdmsr c0000082
msr[c0000082] = fffff804`38006cc0

lkd> uf fffff804`38006cc0
nt!KiSystemCall64:
fffff804`38006cc0 0f01f8          swapgs
fffff804`38006cc3 654889242510000000 mov   qword ptr gs:[10h],rsp
fffff804`38006ccc 65488b2425a8010000 mov   rsp,qword ptr gs:[1A8h]
...

從程式碼中可以看到,它進入的是 nt!KiSystemCall64 函數,然後再執行後續的 6 對應的 nt!NtReadFile 完成業務邏輯,最終也由 nt!KiSystemCall64 完成 核心態 到 使用者態 的切換。

知道了這兩種方式,接下來可以把圖稍微修補一下,增加 syscallint xxx 兩種入關途徑。

三:總結

通過組合程式碼分析,我們終於知道了 使用者態核心態 的切換原理,原來有兩種途徑,一個是 int 2e,一個是 syscall ,加深了我們對 C# 讀取檔案 的更深層理解。

圖片名稱