• 【旧文章搬运】分析了一下360安全卫士的HOOK(二)——架构与实现


    原文发表于百度空间及看雪论坛,2009-10-14

    看雪论坛地址:https://bbs.pediy.com/thread-99460.htm

    刚发这篇文章的时候,因为内容涉及360的核心产品,文章被雪藏了一段时间
    ==========================================================================

    Author:achillis
    blog :https://www.cnblogs.com/achillis/

    上一篇的分析中漏掉了三个函数,现补上:
    NtSetSystemInformation   0x24 
    ProcessNotify     0x45 //这个并非Hook,只是HookPort安装的一个Notify
    KeUserModeCallback    0x4B
    这样一共是从0到0x4B,共0x4C个过滤函数,齐了~~

    上次先列出了360所hook的系统服务,让大家对它做了什么有了一些了解。这次分析的重点是360的KiFastCallEntry钩子安装全过程,更为重点的是360这样一个安全软件,是如何理好地处理好这众全多的Hook,即我所谓的“架构”问题。

    一、准备工作
    (1)准备要Hook的系统服务的服务号ServiceIndex,对于导出的服务,采用获取Zw*函数地址后再取服务号的方法,这个想必大家都很熟悉。未导出的,则根据不同系统版本,采用硬编码的方法。
    (2)准备缓冲区,存放原始服务例程地址、过滤开关、代理函数地址等,记为ServiceFilterInfoTable,这些都是HOOK中要使用到的数据。
    HookPort.sys申请了一块很大的内存用于存放这些数据,该内存大小为0x5DDC=6007*4=(6006+1)*4,为什么这么写呢?因为它实际上是下面一个结构:

    typedef struct _SERVICE_FILTER_INFO_TABLE{
    ULONG SSDTCnt;
    ULONG SavedSSDTServiceAddress[1001];  //起始偏移0001*4,保存被Hook的SSDT函数的地址
    ULONG ProxySSDTServiceAddress[1001];  //起始偏移1002*4,保存被Hook的SSDT函数对应的代理函数的地址
    ULONG SavedShadowSSDTServiceAddress[1001]; //起始偏移2003*4,保存被Hook的ShadowSSDT函数的地址
    ULONG ProxyShadowSSDTServiceAddress[1001]; //起始偏移3004*4,保存被Hook的ShadowSSDT函数对应的代理函数的地址
    ULONG SwitchTableForSSDT[1001];    //起始偏移4005*4,保存SSDT Hook开关,决定该函数是否会被Hook
    ULONG SwitchTableForShadowSSDT[1001];  //起始偏移5006*4,保存ShadowSSDT Hook开关,决定该函数是否会被Hook
    }SERVICE_FILTER_INFO_TABLE;

    考虑到服务数最多的就是Win7的ShadowSSDT服务了,共827个,所以这里使用1001个项。显然,每个表之间都有空隙,每个表内部也有大量空隙,觉得浪费内存了是吧?确实是这样的,但是不要忘了有一条法则,叫做“以时间换空间,以空间换时间”,这里虽然浪费了内存,但是保证了效率最高,因为被Hook的地方是调用非常非常频繁的KiFastCallEntry.

    (3)准备缓冲区,存放过滤函数的地址和规则表,记为FilterFunRuleTable。这个部分的实现虽然是在HookPort.sys中,但是它对外留出了这个接口,实际上却是由360SelfProtect.sys调用完成的。
    这个缓冲区的结构如下:

    #define FILTERFUNCNT 0x4C //过滤函数的个数
    typedef struct _FILTERFUN_RULE_TABLE{
    ULONG bSize; //本结构的大小,为0x144=0x51*4=(0x4C+5)*4=(FILTERFUNCNT+5)*4
    ULONG Unknown1; //未明确
    ULONG IsFilterFunFilledReady;    //标志,表明过滤函数表是否准备好
    ULONG FakeServiceRoutine[FILTERFUNCNT] //偏移为0xC,过滤函数数组,共有过滤函数0x4C个
    PULONG SSDTRuleTableBase;      //偏移为0x13C,是SSDT函数的过滤规则表,表的大小为SSDTCnt*4
    PULONG ShadowSSDTRuleTableBase;   //偏移为0x140,是ShadowSSDT函数的过滤规则表,表的大小为ShadowSSDTCnt*4
    }FILTERFUN_RULE_TABLE;

    这些只是一些准备工作,具体有些是什么时候完成的,在后面会讲到。

    二、KiFastCallEntry的Hook
    (1)目标KiFastCallEntry,但具体Hook在哪里最好?
    KiFastCallEntry是ring3经sysenter进入内核后的第一个必经之地(不考虑sysenter hook),但是KiFastCallEntry所作的工作也很多,比如设置ds,es,fs段寄存器的值,从ETHREAD中取ServiceTable,判断ServiceIndex是否合法,若合法则判断应使用哪张表(SSDT还是ShadowSSDT),然后从表中取出服务例程地址,从用户栈复制参数到内核栈,然后调用服务例程,调用完之后再做一点准备工作然后就由KiServiceExit再飞回ring3.大致的流程就是这样的.我贴一下关键的一段:

    kd> 
    8053d7e4 8b5f0c           mov      ebx,dword ptr [edi+0Ch] //edi指向SSDT或ShadowSSDT
    8053d7e7 33c9             xor      ecx,ecx //ecx清零
    8053d7e9 8a0c18           mov      cl,byte ptr [eax+ebx]  //cl得到参数的长度,即参数个数*4
    8053d7ec 8b3f             mov      edi,dword ptr [edi] //edi指向KiServiceTable或W32pServiceTable
    8053d7ee 8b1c87           mov      ebx,dword ptr [edi+eax*4] //eax是服务号,然后ebx得到服务函数地址
    8053d7f1 2be1             sub      esp,ecx //ecx得到参数的总长度,这里是开辟栈空间
    8053d7f3 c1e902           shr      ecx,2   //除以4,得参数个数
    8053d7f6 8bfc             mov      edi,esp  //准备复制参数
    8053d7f8 3b35b48b5580     cmp      esi,dword ptr [nt!MmUserProbeAddress (80558bb4)] //判断参数地址是否有效
    8053d7fe 0f83a8010000     jae      nt!KiSystemCallExit2+0x9f (8053d9ac)
    8053d804 f3a5             rep movs dword ptr es:[edi],dword ptr [esi] //复制参数
    8053d806 ffd3             call     ebx //调用系统服务

    我们不能hook在开头,这样太多的准备工作需要自己来完成,而且涉及到的操作太多的话,兼容性和稳定性就很成问题。显然我们又不能hook在call服务例程之后,这时该办的事都办完了,我们再接手就已经晚了,所以必须在call服务例程之前。KiFastCallEntry中的指令,真是有点寸士寸金的感觉,寄存器也不能随意改变,挑哪儿下手呢?来看看360是怎么做的:

    Hook之后:

    kd> 
    nt!KiFastCallEntry+0xcc:
    8053d7dc ff0538f6dfff     inc      dword ptr ds:[0FFDFF638h]
    8053d7e2 8bf2             mov      esi,edx
    8053d7e4 8b5f0c           mov      ebx,dword ptr [edi+0Ch]
    8053d7e7 33c9             xor      ecx,ecx
    8053d7e9 8a0c18           mov      cl,byte ptr [eax+ebx]
    8053d7ec 8b3f             mov      edi,dword ptr [edi]
    8053d7ee 8b1c87           mov      ebx,dword ptr [edi+eax*4]
    8053d7f1 e94a49e901      jmp      823d2140  //这里被改成了一个跳转
    8053d7f6 8bfc             mov      edi,esp
    8053d7f8 3b35b48b5580     cmp      esi,dword ptr [nt!MmUserProbeAddress (80558bb4)]
    8053d7fe 0f83a8010000     jae      nt!KiSystemCallExit2+0x9f (8053d9ac)
    8053d804 f3a5             rep movs dword ptr es:[edi],dword ptr [esi]
    8053d806 ffd3             call     ebx
    8053d808 8be5             mov      esp,ebp

    这个Hook的位置确实选得非常好啊,自己省去了很多准备工作,都由系统准备好了,此时edi指向服务表的基址(KiServiceTable或W32pServiceTable),ebx是刚刚取出的原始服务例程的地址,eax是服务号,这时再做处理不是就很容易了嘛,重要的几个信息都有了。

    来看看跳转的地址,又是个跳转,看来这里只是个中间跳

    kd> u 823d2140
    823d2140 e95d982a76       jmp      Hookport+0x79a2 (f867b9a2)

    f867b9a2这里才是真正的目的地,在HookPort.sys中.

    kd> u f867b9a2
    Hookport+0x79a2:
    f867b9a2 8bff             mov      edi,edi
    f867b9a4 9c               pushfd
    f867b9a5 60               pushad
    f867b9a6 57               push     edi //edi指向KiServiceTable或W32pServiceTable
    f867b9a7 53               push     ebx //ebx是原始的KiFastCallEntry从SSDT中取到的服务函数地址
    f867b9a8 50               push     eax //eax是服务号
    f867b9a9 e840ffffff       call     Hookport+0x78ee (f867b8ee) //KiFastCallEntryFilterFunc
    f867b9ae 89442410         mov      dword ptr [esp+10h],eax
    f867b9b2 61               popad
    f867b9b3 9d               popfd
    f867b9b4 2be1             sub      esp,ecx
    f867b9b6 c1e902           shr      ecx,2
    f867b9b9 ff3574d767f8     push     dword ptr [Hookport+0x9774 (f867d774)]  //这里是回跳的地址,push/ret方式跳回去
    f867b9bf c3               ret

    (2)360是如何具体地去安装这个Hook的?

    360首先获取ZwSetEvent的服务号,然后安装了一个SSDT Hook,目标就是NtSetEvent.
    然后,自己调用了一下ZwSetEvent,触发自己安装的Hook,代码如下:
    HANDLE g_FakeEventHandle = (HANDLE)0x288C58F1;
    ZwSetEvent(g_FakeEventHandle, NULL);   
    0x288C58F1,好奇怪,你看过谁家的句柄长成这个样子嘛,显然这是个假句柄了,它的作用就是在hook函数Fake_NtSetEvent中辨别一下是不是自己人,充当了暗号。
    Fake_NtSetEvent中确实也是这样写的:

    if ( EventHandle != g_FakeEventHandle || ExGetPreviousMode()==UserMode )// 不是我们自己调用,或者调用来自UserMode,直接调用原函数
    {
    result = OriginalNtSetEvent(EventHandle, PreviousState);
    }

    不是自己人就调用原函数去了,等对上暗号,是自己人了,才真正开始干事.

    先申请一个5字节的buffer,写入一个跳转指令,这个就是上面看到的中间跳,这里还有一个细节,判断了一下系统版本,Vista前后处理上有稍许不同,根据当前系统版本选择合适的JmpStub.
    再准备另外一个跳转指令,跳转目标就是刚才准备的中间跳的地址,这个跳转指令将要被写入KiFastCallEntry中。那么360是如何找到要写入的位置呢?继续看.
    跳转指令准备好之后,先还原刚才SSDT中安装的NtSetEvent钩子,然后从栈中回溯返回地址,也就是取[ebp+4]的值,并保存起来.上面对KiFastCallEntry的分析知道,SSDT中的函数都是从KiFastCallEntry中call过来的,那么返回地址肯定也在KiFastCallEntry中,具体地讲,就是8053d806处call ebx的下一条指令处。取得这个地址后,往上匹配寻找

    8053d7f3 c1e902           shr      ecx,2 
    8053d7f6 8bfc             mov      edi,esp

    找到之后,就确定了要Hook的位置。接下来怎么做写过hook的都知道,就是关写保护然后把刚才准备好的跳转指令写入再打开保护而已,不多说。

    (3)KiFastCallEntry被hook后,360是如何处理的?
    KiFastCallEntry被hook后,经二级跳跳到了JmpStub里。来看看:

    kd> u f867b9a2
    Hookport+0x79a2:
    f867b9a2 8bff             mov      edi,edi
    f867b9a4 9c               pushfd
    f867b9a5 60               pushad
    f867b9a6 57               push     edi //edi指向KiServiceTable或W32pServiceTable
    f867b9a7 53               push     ebx //ebx是原始的KiFastCallEntry从SSDT中取到的服务函数地址
    f867b9a8 50               push     eax //eax是服务号
    f867b9a9 e840ffffff       call     Hookport+0x78ee (f867b8ee) //KiFastCallEntryFilterFunc
    f867b9ae 89442410         mov      dword ptr [esp+10h],eax
    f867b9b2 61               popad
    f867b9b3 9d               popfd
    f867b9b4 2be1             sub      esp,ecx
    f867b9b6 c1e902           shr      ecx,2
    f867b9b9 ff3574d767f8     push     dword ptr [Hookport+0x9774 (f867d774)]  //这里是回跳的地址,push/ret方式跳回去
    f867b9bf c3               ret

    可以看到,edi,ebx,eax三个重要数据入栈后,调用了另一个判断函数。该函数逆向如下:

    ULONG __stdcall KiFastCallEntryFilterFunc(ULONG ServiceIndex, ULONG OriginalServiceRoutine, ULONG ServiceTable)
    {
    
    //判断是否是SSDT中的调用
    if ( ServiceTable == g_KiServiceTable && ServiceIndex <= g_SSDTServiceLimit )// 如果是正常的win32调用,则
    {
       if (ServiceFilterInfoTable->SwitchTableForSSDT[ServiceIndex] && HookOrNot(ServiceIndex, FALSE))
        {
         ServiceFilterInfoTable->SavedSSDTServiceAddress[ServiceIndex]=OriginalServiceRoutine;//保存原始例程,以便后面调用
        return ServiceFilterInfoTable->ProxySSDTServiceAddress[ServiceIndex];//返回我们代理函数的地址
        }
    }
    //判断是否是ShadowSSDT中的调用,过程同上
    if ( ServiceTable == g_GUIServiceTable && ServiceIndex <= g_GUIServiceTableLimit )
    {
      if ( ServiceFilterInfoTable->SwitchTableForShadowSSDT[ServiceIndex] && HookOrNot(ServiceIndex, TRUE) )
       {
        ServiceFilterInfoTable->SavedShadowSSDTServiceAddress[ServiceIndex]=OriginalServiceRoutine;
       return ServiceFilterInfoTable->ProxyShadowSSDTServiceAddress[ServiceIndex];  
       }                           
    }
      
    return OriginalServiceRoutine; // 不明调用,就直接返回原始例程
    }

    结合上面逆出来的代码和数据结构,相信不难看懂。对于一个调用,通过判断ServiceTable确定是SSDT调用还是Shadow调用,两者过程基本一样,以SSDT为例:

    首先根据ServiceIndex判断SwitchTable中的Hook开关是否打开,是则调用HookOrNot函数根据FilterFunRuleTable表中的Rule((根据PreviousMode有进一步判断))来判断是否需要Hook。经过这两重检查和判断,最终若需要Hook,就保存原始服务例程地址并返回我们的代理函数,若不需要hook,就直接返回原始例程。由于使用了良好的数据结构,这里的效率是非常高的。
    KiFastCallEntryFilterFunc的返回结果,要么是原始例程,要么是代理函数,返回至Jmpstub后,这个结果被保存在了[esp+0x10]处。不要忘了刚才有个pushad,所以[esp+0x10]处实际保存的是ebx的值,这样修改了栈中的ebx的值,再popad时,ebx的值就被修改为了KiFastCallEntryFilterFunc的返回值,再一个popfd恢复刚才保存的标志寄存器,然后执行被jmp指令覆盖掉的那两句指令,最后将刚才回溯到的返回地址压栈,一个ret就又飞回到了KiFastCallEntry中。若已Hook,此时ebx的值就已经被修改了,再下来call ebx时调用的就是刚才返回的代理函数了。很巧妙的处理啊。

    三、代理函数如何处理?
    Hook的细节搞清楚了,本来就已经差不多了,但是看了360代理函数中的处理,又发现一些有趣的东西。
    每一个代理函数,都会首先调用一个CallFilterFunByIndex,调用时传入的第一个参数就是过滤函数在过滤函数表中的索引,也就是我上一篇文章中所列出来的那些函数名称后面的索引。第二个参数则是栈中的参数数组,相当于一下把所有参数都传过去了。CallFilterFunByIndex会先根据FilterFunRuleTable->IsFilterFunFilledReady判断该表是否已经准备好(这个过滤函数表实际上是由360SelfProtect.sys调用HookPort.sys提供的接口填充的,所有过滤函数的实现也都在360SelfProtect.sys中),根据传入的过滤函数的索引在FilterFunRuleTable表(也就是我前面提到的第二张表)中查找对应的过滤函数,若过滤函数存在,就调用过滤函数,传入的参数同样有过滤函数索引和参数数组,在这个过滤函数中才真正实现了对参数的判断。
    判断完毕之后,若检查通过,就予以放行,此时再调用JmpStub中保存的原始服务例程的地址,调用完原始例程之后,若调用成功,还会有一个循环的检查,检查目标是调用原始服务例程后返回的结果。这些循环检查的函数哪儿来的呢?就是在CallFilterFunByIndex调用过滤函数时返回的,由于返回的这些函数是在调用原始服务例程之后对结果进行检查,所以称之为CheckResult系列函数,而过滤函数表中的那些函数则称为CheckArguments函数。
    为什么要有CheckArguments函数,又要有CheckResult函数?
    这里来个小小的科普,hook某函数后,检查参数的时机是怎么样的呢?参数是分IN、OUT的,一般来说,传入的参数要在调用原始函数前检查,传出的参数要在调用原始函数后检查,而有些则在调用前后检查都可以,但效果不同。大致可以分为四种情况:
    第一种以(Nt)TerminateProcess为例,它的原型是这样的:

    NTSTATUS
    NtTerminateProcess(
        __in_opt HANDLE ProcessHandle,
        __in NTSTATUS ExitStatus
        );

    除了状态码,它没有有效的返回值,它的作用更多的在于结束进程这个“过程”,等该函数返回的时候,进程已经被结束掉了,再来检查有个P用。所以这种强调过程而且重点不在返回值(即使它有)的函数,必须要在调用原函数前检查。

    第二种,一个例子是recv,原型如下:

    int recv (
    SOCKET s,       
    char FAR* buf, 
    int len,        
    int flags       
    );

    显然该函数的重点在于第二个参数中返回的数据,但是在调用原始函数之前,缓冲区里什么都没有,检查,怎么检查?像这种函数也是过程性的,但是它的第二个参数是OUT型的,就必须在调用原函数之后检查。

    第三类,比如CreateFile,OpenProcess,OpenThread,OpenEvent等函数,它们有一个共同的特征,就是传入特性相关的数据(文件名,pid等),返回一个句柄,重点不在过程而在于返回值。这类函数即可以在调用原函数前检查传入的参数,也可以在调用原函数后检查返回的句柄。但是一个是对象的名称等外在信息,一个直接指向对象,显然检查后者更为可靠一些,因为它更贴近对象本身。
    对于那些异步方式调用的函数更要注意了,比如异步方式调用的ReadFile或ReadFileEx,调用原函数之前显然缓冲区没有数据,调用了原函数缓冲区也不见得有数据,但是异步有个特征就是它的通知机制,可能是Apc,也可能是Event,这时就必须替换或其它方式处理它的通知机制才能在合适的时候拿到数据。
    科普就先到这儿。现在来解释为什么要有CheckResult系列函数的存在,相信大家就该明白了。以NtOpenSection这个服务的hook为例,我们知道有一个重点照顾对象叫做DevicePhysicalMemory,经常被大家用于各种XX中,一段典型的打开该对象的代码是:

    RtlInitUnicodeString(&physmemString, L"\Device\PhysicalMemory");
    
       attributes.Length              = sizeof(OBJECT_ATTRIBUTES);
       attributes.RootDirectory          = NULL;
       attributes.ObjectName            = &physmemString;
       attributes.Attributes            = 0;
       attributes.SecurityDescriptor      = NULL;
       attributes.SecurityQualityOfService    = NULL;
    
       status = ZwOpenSection(&g_hMPM, SECTION_MAP_READ|SECTION_MAP_WRITE, &attributes); 

    怎么检查?检查第三个参数attributes.ObjectName是否是?显然是不行的,看看MJ的《续PhysicalMemory攻击》你就知道检查这个参数有多困难。显然NtOpenSection属于我上面提到的第三类函数,看看360是怎么做的:

    代理函数Proxy_NtOpenSection首先调用CallFilterFunByIndex,CallFilterFunByIndex根据所传入的过滤函数索引在FilterFunRuleTable中找到Fake_NtOpenSection并调用之,然而Fake_NtOpenSection除了简单判断下调用者之外不做任何检查,只是返回了一个函数的地址给CallFilterFunIndex,记为CheckResult_After_NtOpenSection,CallfilterFunIndex再把这个地址返回给代理函数Proxy_NtOpenSection,此时Proxy_NtOpenSection调用原函数NtOpenSection,若返回不成功,就直接返回这个状态值,不成功当然就不管啦。若成功,就会调用刚才返回的CheckResult_After_NtOpenSection来检查返回的句柄所指向的对象是不是DevicePhysicalMemory,若是,就关掉该句柄,并返回一个禁止的状态码(STATUS_ACCESS_DENIED),若不是,绿灯大开,放行之.有时,FakeXXX函数返回的CheckResultXXX函数可能不止一个,此时ProxyXXX会根据返回的函数个数循环调用这些CheckResultXXX,有一个不通过即为不通过,只有所有CheckResultXXX都检查通过了,ProxyXXX函数才会返回原始结果给调用者,真是把关极严啊。
    值得一提的是,所有的过滤函数对于来自KernelMode的调用都不做处理,所以使用驱动来破坏360是轻而易举的,但是完全没有意义。按照MJ一贯的理念,如果你都能加载驱动了,那么所有ring0的保护也就失去了意义,再来保护完全是无用功,事实上也根本起不到保护效果了。

    四、总结回顾
    现在让我们站得高一点,略去一些细节,来总结一下360整个hook架构:
    1.HookPort.sys准备了ServiceFilterInfoTable(上面的表一),里面保存了SSDT函数的代理函数地址、原始例程地址、Hook开关,ShadowSSDT也是。
    2.HookPort.sys对外留出了三个接口(保存在了DEVICE_EXTENSION中),第一个用于准备FilterFunRuleTable(上面的表二),第二个用于向FilterFunRuleTable中注册过滤函数,第三个用于设置FilterFunRuleTable中的过滤规则。
    3.HookPort.sys安装KiFastCallEntry hook.
    4.HookPort.sys!KiFastCallEntryFilterFunc根据Hook开关和RuleTable中的过滤规则来决定某个系统服务是否会被Hook,被hook后将会调用Proxy函数。
    5.360SelfProtect.sys使用HookPort.sys提供的接口初始化RuleTable,并向RuleTable中注册过滤函数、设置过滤规则。
    6.每个Proxy函数使用CallFilterFunByIndex调用RuleTable中相应的FilterFun进行参数检查,通过后调用原函数,若调用成功再调用FilterFun函数提供的CheckResult函数检查结果(非SSDT、ShadowSSDT函数的Hook也采用同样结构)。

    很显然地:
    如果要增加一个系统服务的Hook,只需要打开ServiceFilterInfoTable中该服务Index对应的开关,并提供一个过滤函数就可以了。
    如果要去掉一个系统服务的hook,只需要关闭ServiceFilterInfoTable中该服务Index对应的开关,立刻生效,而无须其它改变。
    如果要修改一个系统服务的过滤函数,只需要使用HookPort的接口设置新的过滤函数就可以了,无须其它改变。

    所以说,这个架构设计非常好,易于修改,易于扩充,易于分工,无愧于优秀二字。

    结束语:单独写一个或几个函数的Hook,很多人都会,但是要实现这样一个产品级的优秀架构,就不是谁都能完成的了。分析了360这样一个安全产品所使用的Hook架构,让我收获颇多,也让大家领略了360安全卫士的技术魅力。文中如有错误,还请MJ和大家指出。

  • 相关阅读:
    部署应用映射外部目录、迁移与备份、容器保存镜像、镜像打压成压缩包、压缩包恢复为镜像、dockerfiles、私有仓库、docker-compose、部署多应用、容器间通信
    docker的介绍、架构图、乌班图、安装、远程仓库、镜像操作、容器操作、应用部署、
    flask-script(制定命令)、sqlschemy、orm的使用、线程安全、增删查改、多表操作、flask-sqlalchemy
    g对象、flask-session、数据库连接池、wtforms(forms组件)、信号
    中间件、猴子补丁、蓝图、请求上下文执行流程
    flask配置文件、模板、request对象的属性和方法、响应对象方法、闪现、session的使用、请求扩展、全局标签、全局过滤器、
    flask入门、配置文件、路由系统、路由的本质、CBV
    基本权限chmod、软件管理-二进制安装、源码安装、进程管理、系统服务、
    对爬取京东商品按照标题为其进行自动分类---基于逻辑回归的文本分类
    学习进度3.16
  • 原文地址:https://www.cnblogs.com/achillis/p/10181844.html
Copyright © 2020-2023  润新知