《Windows內核安全與驅動編程》-第七章-串口的過濾學習

串口的過濾

7.1 過濾的概念

​ 舉例: 反病毒程序需要在不改變文件系統的上層和下層接口的情況下,在中間加入一個過濾層,這樣就可以在上層軟件讀取文件、下層驅動提供數據時,對這些數據進行掃描,看其中是否含有某個病毒的特徵碼。這是一個很典型的過濾過程。

7.1.1 設備綁定的內核API之一

​ 進行過濾的最主要方法是對一個設備對象進行綁定。Windows 系統之所以可以運作,是因爲 Windwos 中已經存在許多提供了各種功能的設備對象。這些設備對象接受請求,並完成實際硬件的功能。

​ 我們可以首先認爲: 一個真實的設備對應一個設備對象(實際的對應關係可能複雜的多),通過編程可以生成一個虛擬的設備對象,並“綁定”在一個真實的對象上。一旦綁定,則本來操作系統發送給真實設備的請求,就會首先發送到這個虛擬設備上。在 WDK 中,有多個內核API能實現綁定功能,下面是其中一個:

NTSTATUS IoAttachDevice(
	IN PDEVICE_OBJECT SrourceDevice,
	IN PUNICODE_STRING TargetDevice,
	OUT PDEVICE_OBJECT *AttachedDevice
)

SourceDevice 是調用者生成的用來過濾的虛擬設備; TargetDevice 是要被綁定的目標設備。注意這裏的 TargetDevice 並不是一個 PDEVICE_OBJECT ,而是一個字符串。該參數實際上是要被綁定的設備的名字。

​ Windows 中有許多設備,但是並不是每一個都有設備名。只有擁有設備名的設備纔可以用這個API綁定。

​ 還有一個情況,假如這個函數綁定一個名字所對應的設備,那麼這個設備如果已經被其他設備綁定了,那一起綁定該設備的一組設備,會被稱爲設備棧(之所以稱爲棧,是由於和請求的傳遞方式有關)。實際上,IoAttachDevice 總是會綁定設備棧上最頂層的那個設備。

​ 參數 AttachedDevice 是一個用來返回的指針的指針。綁定成功後,被綁定的設備指針被返回到這個地址。

7.1.2 設備綁定的內核API之二

​ 上一節提到,IoAttachDevice 無法綁定沒有名字的設備,另外還有兩個API : IoAttachDeviceToDeviceStackIoAttachDeviceToDeviceStackSafe。這兩個函數功能一樣,都是根據設備對象的指針進行綁定,區別是IoAttachDeviceToDeviceStackSafe更加安全,而且只有在 Windows 2000 SP4 和 Windwos XP 以上的系統中才有。一般都是用 IoAttachDeviceToDeviceStackSafe,但是當我們需要兼容低版本時候,應該使用另一個。

NTSTATUS IoAttachDeviceToDeviceStackSafe(
	IN PDEVICE_OBJECT SourceDevice,			//過濾設備
	IN PDEVICE_OBJECT TargetDevice,			//要被綁定的設備棧中的設備
	IN OUT PDEVICE_OBJECT *AttachedToDeviceObject //返回最終被綁定的設備
)

​ 與第一個API類似,只是 TargetDevice 換成了指針。

PDEVICE_OBJECT IoAttachDeviceToDeviceStackS(
	IN PDEVICE_OBJECT SourceDevice,			//過濾設備
	IN PDEVICE_OBJECT TargetDevice,			//要被綁定的設備棧中的設備
)

​ 這個函數也返回了最終被綁定的設備指針,但是它不能返回一個明確的錯誤碼。如果返回NULL,則標識綁定失敗了。

7.1.3 生成過濾設備並綁定

​ 在綁定一個設備之前,先要知道如何生成一個用於過濾的設備。函數 IoCreateDevice 被用於生成過濾設備:

NTSTATUS
IoCreateDevice(
	IN PDIRVER_OBJECT DriverObject,				//本驅動的驅動對象。即從 DriverEntry中傳入的參數
	IN ULONG DeviceExtensionSize,				//設備拓展,暫時填0
	IN PUNICODE_STRING DeviceName OPTIONAL,		//設備名,過濾設備一般不填,填NULL
	IN DEVICE_TYPE DeviceType,					//設備類型,保持和被綁定的設備類型一致
	IN ULONG DeviceCharacteristics,				//設備特徵,作者憑經驗填0 然後看是否排斥
	IN BOOLEAN Exclusive,						//選擇FALSE
	OUT PDEVICE_OBJECT *DeviceObject			
);

​ 這個看書看起來很複雜,參數很多。但是我們目前使用的時候,不需要了解太多。 值得注意的是在綁定一個設備之前,應該把這個設備對象的多個子域設置成和要綁定的目標對象一致,包括標誌和特徵。下面是一個示例的函數,這個函數可以生成一個設備,然後綁定在另一個設備上。

NTSTATUS ccpAttachDevice(
	PDRIVER_OBJECT driver,			//本驅動的驅動對象
	PDRIVER_OBJECT oldobj,			//要被綁定的目標設備
	PDRIVER_OBJECT *fltobj,			//生成的過濾設備對象 指針的指針
	PDRIVER_OBJECT *next			//被綁定的設備指針
)
{
	NTSTATUS status;
    PDEVICE_OBJECT topdev = NULL;
    
    //生成設備,然後綁定
    status = IoCreateDevice(driver,0,NULL,oldobj-> DeviceType,0,FALSE,fltobj);
    if(status != STATUS_SUCCESS)
    {
    	//失敗直接返回
        return status;
    }
    
    //拷貝重要標誌位
    if(oldobj->Flags & DO_BUFFERRED_IO)
    	(*fltobj)->Flags |= DO_BUFFERRED_IO;
    if(oldobj->Flags & DO_DIRECT_IO)
    	(*fltobj)->Flags |= DO_DIRECT_IO;
    if(oldobj->Flags & DO_POWER_PAGABLE)
    	(*fltobj)->Flags |= DO_POWER_PAGABLE;
    if(oldobj->Characteristics & FILE_DEVICE_SECURE_OPEN)
    	(*fltobj)->Characteristics |= FILE_DEVICE_SECURE_OPEN;
    
    //將設備綁定到另一個設備上
    topdev = IoAttachDeviceToDeviceStack(*fltobj,oldobj);
    if (topdev == NULL)
    {
    	//如果綁定失敗了,銷燬設備,返回錯誤
    	IoDeleteDevice(*fltobj);
    	*fltobj = NULL;
    	status = STATUS_UNSUCCESSFUL;
    	return status;
    }
    *next = topdev;
    
    //設置這個設備已經啓動
    (*fltobj)->Flags = (*fltobj)->Flags & ~DO_DEVICE_INITIALIZING;
    return STATUS_SUCCESS;
}
關於 deviceobject->flag &= ~DO_DEVICE_INITIALIZING;

當設置DO_DEVICE_INITIALIZING標誌時,I/O管理器將拒絕所有打開該設備句柄的請求以及向該設備對象上附着其他設備對象。
故驅動程序完成初始化後,須清除該標誌。

IoCreateDevice創建設備完成後,默認devieobject->flags==0xc0,是包含DO_DEVICE_INITIALIZING字段的,如果該設備是在DriverEntry例程中創建的,則DriverEntry例程結束後,I/O管理器會自動遍歷在DriverEntry中的設備列表,並清除DO_DEVICE_INITIALIZING標誌。但是WDM驅動中,設備對象是在DriverEntry返回後才創建的,所以I/O管理器不會自動清除該標誌,驅動程序需要自己清除。

7.1.4 從名字獲得設備對象

​ 在知道一個設備名字的情況下,使用函數 IoGetDeviceObjectPointer 可以獲得這個設備對象的指針。這個函數的原型如下:

NTSTATUS IoGetDeviceObjectPointer(
	IN PUNICODE_STRING ObjectName,			//設備名
	IN ACCESS_MASK DesireAccess,			//期望訪問的權限,一般都填 FLIE_ALL_ACCESS
	OUT PFILE_OBJECT *FlieObject,			//返回的一個文件對象,暫時沒有用,但是使用結束後需要解除引用。
	OUT PDEVICE_OBJECT *DeviceObject		//要得到的設備對象
);

使用示例如下:

#include <ntddk.h>
//因爲用到了RtlStringCchPrintfW,所以必須使用頭文件 ntstrsafe.h
//這裏定義 NTSTRSAFE_LIB 是爲了使用 ntstrsafe 靜態庫,兼容 Windwos 2000
#define NTSTRSAFE_LIB
#include <ntstrsafe.h>
...
//打開一個端口設備
PDEVICE_OBJECT ccpOpenCom(ULONG id, NTSTATUS *status)
{
    //外面輸入的是串口 id,這裏會改寫成字符串的形式
    UNICODE_STRING name_str;
    static WCHAR name[32] = {0};
    PFILE_OBJECT fileobj = NULL;
    PDEVICE_OBJECT devobj = NULL;
    
    //根據id轉換成串口的名字
    memset(name,0,sizeof(WCHAR)*32);
    RtlStringCchPrintfW(name,32,L"\\Device\\Serial%d",id);
    RtlInitUnicodeString(&name_str,name)
    //打開設備對象
    *status = IoGetDeviceObjectPointer(
    	&name_str,
        FILE_ALL_ACCESS,
        &fileobj,&devobj
    );
    //如果打開成功了,一定記得把文件對象解除引用。
    if(*status = STATUS_SUCCESS)
    {
        ObDereferenceObject(fileobj);
    }
    //返回設備對象
    return devobj;
}

7.1.5 綁定所有串口

​ 計算機上到底有多少個串口,作者無法提供出很好的方法來判定。只能一個一個數。綁定所有的串口,只需要一次就可以了,不用去動態地追送串口的誕生與消亡。下面是一個簡單的函數,實現了綁定本機上所有的串口的功能。這個函數用到了前面實現的 ccpOpenComccpAttachDevice 函數。

​ 爲了後面的過濾,這裏必須把過濾設備和被綁定的設備的設備對象指針都保存起來。一個計算機上最大有多少串口應該去查閱對應的IBM PC 標準。這裏作者隨意寫一個自認爲足夠大的數字。

//假設計算機上最多隻有32個串口
#define CCP_MAX_COM_ID 32
//保存所有的過濾設備指針
static PDEVICE_OBJECT s_fltobj[CCP_MAX_COM_ID] = {0};
//保存所有的真實設備指針
static PDEVICE_OBJECT s_nextobj[CCP_MAX_COM_ID] = {0};

//這個函數綁定所有的串口
void ccpAttachAllComs(PDRIVER_OBJECT driver)
{
    ULONG i;
    PDEVICE_OBJECT com_ob;
    NTSTATUS status;
    for(i=0;i<CCP_MAX_COM_ID;i++)
    {
        //獲得串口對象引用
        com_ob = ccpOpenCom(i,&status);
        if(com_ob == NULL)
        	continue;
        //在這裏綁定,並不管綁定是否成功
        ccpAttachDevice(driver,com_ob,&s_fltobj[i],&s_nextobj[i]);
    }
}

7.2 獲得實際數據

​ 當虛擬設備已經綁定到了真正的串口設備時,如何從虛擬設備得到串口上流過的數據呢?答案是根據“請求”。操作系統將請求發送給串口設備,請求中就含有要發送的數據,請求的回答中則含有實際要接受的數據。下面分着這些“請求”,以便得到實際的串口數據流。

7.2.1 請求的區分

​ Windows 內核的開發者確定了很多的數據結構,在前面的內容中我們逐漸地和 DEVICE_OBJECT(設備對象)、FILE_OBJECT(文件對象)、DRIVER_OBJECT(驅動對象)。我們需要了解的是:

  1. 每個驅動程序只有一個驅動對象。
  2. 每個驅動程序可以生成若干個設備對象,這些設備對象從屬於一個驅動對象。
  3. 若干個設備(它們可以屬於不同的驅動)依次綁定形成一個設備棧,總是最頂端的設備先接收到請求。

​ 請注意,IRP 是上層設備之間傳遞請求的常見數據結構,但是絕對不是唯一的數據結構。傳遞請求還有很多其他方法,不同設備之間也可能使用不同的結構來傳遞請求。但是在本書中,在90%的情況下,請求與 IRP是等價概念。

​ 串口設備接受到的請求都是 IRP,因此只要對所有的 IRP進行過濾,就可以得到串口上流過的所有數據。串口過濾時只需要關心兩種請求: 讀請求和寫請求。對串口而言,讀指接受數據,而寫指發出數據。串口也還有其他的請求,比如打開和關閉、設置波特率等。但是我們的目標只是獲取串口上流過的數據,而不關心其他的問題。

​ 請求可以通過 IRP 的主功能號進行區分。 IRP 的主功能號是保存在 IRP 棧空間中的一個字節,用來標識它的功能大類。相應的,還有一個次功能號來標識這個 IRP 的功能細分小類。

​ 讀請求的主功能號是 IRP_MJ_READ,相應的寫請求的主功能號是 IRP_MJ_WRITE。下面的方法用於從一個 IRP 指針得到主功能號。(這裏變量irp 是 一個 PIRP,也就是 IRP的指針)

//irpsp 爲 IRP 的棧空間
PIO_STACK_LOCATION irpsp = IoGetCurrentIrpStackLocation(irp);
if(irpsp->MajorFunction == IRP_MJ_WRITE)
{
    //如果是寫請求
}
else if(irpsp->MajorFunction == IRP_MJ_READ)
{
    //處理讀請求
}

7.2.2 請求的結局

​ 對請求的過濾,最終的結局有三種。

  1. 請求被允許通過了,過濾不做任何事情,或者簡單的獲取請求的一些信息。但是請求本身不受干擾,這樣系統行爲不會有變化。
  2. 請求直接被否決了。過濾禁止這個請求通過,這個請求被返回了錯誤,下層驅動程序根本收不到這個請求。常見的是上層應用程序彈出錯誤框提示權限錯誤或者是讀取文件失敗之類的信息。
  3. 過濾完成了這個請求。有時有這樣的需求,比如一個讀請求,我們想記錄讀到了什麼。如果讀請求還沒有完成,那麼如何知道會讀到什麼呢?只有讓這個請求先去完成再記錄。但是過濾完成這個請求時不一定要原封不動的完成,這個請求的參數可以被修改。

​ 當過濾了一個請求時,就必須把這個請求按照上面三種方法之一進行處理。當然,這些代碼會寫在一個處理函數中。

​ 串口過濾要捕獲兩種數據: 一種是發送出去的數據(即寫請求中的數據);另一種是接收的數據(即讀請求中的數據)。爲了簡單期間,我們只捕獲發送出去的數據,這樣只需要採取第一種處理方法即可。

​ 這種處理最簡單。首先調用 IoSkipCurrentIrpStackLocation 跳過當前棧空間;然後調用 IoCallDriver 把這個請求發送給真實的設備。由於真實的設備已經被過濾設備綁定,所以首先接收到IRP的是過濾設備的對象。代碼如下:

//跳過當前棧空間
IoSkipCurrentIrpStackLocation(irp);
//將請求發送到對應的真實設備。我們之前將真實設備都保存在s_nextobj數組中。
status = IoCallDriver(s_nextobj[i],irp);

這裏我理解的這兩個函數的作用是 IoSkipCurrentIrpStackLocation(irp) 不處理當前的IRP請求,將IRP棧空間指針上移(代表當前設備已經處理完成這個IRP請求了)。而後被跳過的這個IRP請求由**IoCallDriver(s_nextobj[i],irp)**傳遞給 s_nextobj[i] 設備處理。

7.2.3 寫請求的數據

​ 一個寫請求(即串口一次發送出的數據)保存在哪裏呢?回憶前面關於 IRP 結構的描述,一共有三個地方可以描述緩衝區。分別是 irp->MDLAddressirp->UserBufferirp->AssociatedIrp.SystemBuffer。不同的IO類別,IRP 的緩衝區不同。

  • SystemBuffer 一般用於簡單而不追求效率的解決方案: 把應用層(R3層) 內存空間中的緩衝數據拷貝到內核空間。
  • UserBuffer 則是最追求效率的解決方案。應用層的緩衝區地址直接放在 UserBuffer 裏,在內核空間中訪問。在當前進程和發送請求的進程一致的情況下,內核訪問應用層的內存空間當然是沒錯的。但是一旦內核進程已經切換,該訪問就結束了,訪問 UserBuffer 就會跳到別的進程空間了。因爲在 Windwos 中,內核空間是所有進程公用的,而應用增空間則是各個進程隔離的。
  • 一個更簡單的解決方案就是把應用層的地址空間映射到內核空間,這需要在頁表中增加一個映射。MDL 就能實現這個功能。MDL 可以翻譯成爲“內存描述符鏈”。IRP 中的 MDLAddress 域是一個 MDL 指針,從這個 MDL 中可以讀出一個內核空間的虛擬地址。這就彌補了 UserBuffer的不足,同時比 SystemBuffer 要輕量。

​ 回到串口的問題上,串口的寫到底是用哪一種方式?這裏作者並不清楚是哪一種,也沒有具體去調查。但是可以寫通用的讀方法。

PBYTE buffer = NULL;
if(irp->MdlAddress != NULL)
{
	//從 Mdl 裏讀數據
    buffer = (PBYTE)MmGetSystemAddressForMdlSafe(irp->MdlAddress);
}
else
	//從 UserBuffer 裏讀數據
	buffer = (PBYTE)irp->UserBuffer;
if(buffer == NULL)
	buffer = (PBYTE)irp->AssociatedIrp.SystemBuffer;

//緩衝區長度
ULONG length = irpsp->Parameters.Write.Length;

7.3 完整的代碼

7.3.1 完整的分發函數

​ 基於前面的描述,爲我們當前的過濾設備寫一個分發函數。該函數處理所有的串口寫請求,所有從串口輸出的數據都用 DbgPrint 打印出來。

NTSTATUS ccpDispatch(PDEVICE_OBJECT device,PIRP irp)
{
    PIO_STACK_LOCATION irpsp = IoGetCurrentIrpStackLocation(irp);
    NTSTATUS status;
    ULONG i,j;

    // 首先得知道發送給了哪個設備。設備一共最多CCP_MAX_COM_ID
    // 個,是前面的代碼保存好的,都在s_fltobj中。
    for(i=0;i<CCP_MAX_COM_ID;i++)
    {
        if(s_fltobj[i] == device)
        {			
            // 所有電源操作,全部直接放過。
            if(irpsp->MajorFunction == IRP_MJ_POWER)
            {
                // 直接發送,然後返回說已經被處理了。
                //PoStartNextPowerIrp通知電源管理器可以發送下一個電源IRP.每當你接收到一個電源IRP的時候都要進行這個調用
                PoStartNextPowerIrp(irp);
                IoSkipCurrentIrpStackLocation(irp);
                return PoCallDriver(s_nextobj[i],irp);
            }
            // 此外我們只過濾寫請求。寫請求的話,獲得緩衝區以及其長度。
            // 然後打印一下。
            if(irpsp->MajorFunction == IRP_MJ_WRITE)
            {
                // 如果是寫,先獲得長度
                ULONG len = irpsp->Parameters.Write.Length;
                // 然後獲得緩衝區
                PUCHAR buf = NULL;
                if(irp->MdlAddress != NULL)
                    buf = 
                    (PUCHAR)
                    MmGetSystemAddressForMdlSafe(irp->MdlAddress,NormalPagePriority);
                else
                    buf = (PUCHAR)irp->UserBuffer;
                if(buf == NULL)
                    buf = (PUCHAR)irp->AssociatedIrp.SystemBuffer;

                // 打印內容
                for(j=0;j<len;++j)
                {
                    DbgPrint("comcap: Send Data: %2x\r\n",
                        buf[j]);
                }
            }

            // 這些請求直接下發執行即可。我們並不禁止或者改變它。
            IoSkipCurrentIrpStackLocation(irp);
            return IoCallDriver(s_nextobj[i],irp);
        }
    }

    // 如果根本就不在被綁定的設備中,那是有問題的,直接返回參數錯誤。
    irp->IoStatus.Information = 0;
    irp->IoStatus.Status = STATUS_INVALID_PARAMETER;
    IoCompleteRequest(irp,IO_NO_INCREMENT);
    return STATUS_SUCCESS;	
}

7.3.2 如何動態卸載

​ 前面之說了如何綁定,並沒有說如何解除綁定。如果要把這個模塊做成可以動態卸載的模塊,則必須提供一個卸載函數。在卸載函數中應該完成解除綁定的功能;否則,一旦卸載就一定會藍屏。

​ 這裏涉及三個內核 API: IoDetachDevice,負責將綁定的設備解除; IoDeleteDevice ,負責把我們前面用 IoCreateDevice 生成的設備刪除以回收內存;還有一個是 KeDelayExecutionThread 純粹負責延時。這個三個函數的參數相對簡單,不做介紹。

​ 卸載過濾驅動還有一個關鍵問題: 我們要終止這個過濾程序,但是一些 IRP 可能還在這個過濾程序的處理過程中。要取消這些請求非常的麻煩。而且不一定成功,所以解決方案是等待5秒鐘來保證安全地卸載掉。只能確信這些請求會在5秒內完成,同時在等待之前我們已經解除了綁定,所以這5秒內不會再有新的請求發送過來。對於串口來說是可以的,但是並非對於所有的設備都是如此。後續會學習到不同的處理方案

#define DELAY_ONE_MIROSECOND(-10)
#define DELAY_ONE_MILLISECOND(1000*DELAY_ONE_MIRCOSECOND)
#define DELAY_ONE_SECOND(1000*DELAY_ONE_MILLISECOND)

void ccuUnload(PDRIVER_OBJECT drv)
{
    ULONG i;
    LARGE_INTEGER interval;
    
    //首先解除綁定
    for(i=0;i<CCP_MAX_COM_ID;i++)
    {
        if(s_nextobj[i]!=NULL)
        	IoDetachDevice(s_nextobj[i]);
    }
    
    //睡眠5秒等待所有的 IRP 請求完成
    interval.QuadPart = (5*DELAY_ONE_SECOND);
    KeDealayExecutionThread(interval);
    
    //刪除這些設備
    for(i=0;i<CCP_MAX_COM_ID;i++)
    {
         if(s_nextobj[i]!=NULL)
        	IoDeleteDevice(s_fltobj[i]);
    }
}

明日計劃

繼續學習驅動編程 鍵盤的過濾

想加一個新的堅持下去做的事,鍛鍊身體!(今天打卡 (1/1))

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章