首页
社区
课程
招聘
[原创]浅探内联挂钩的水有多深
发表于: 2024-8-13 07:12 8901

[原创]浅探内联挂钩的水有多深

2024-8-13 07:12
8901

众所周知内联挂钩用途广泛,我们小学二年级就学过使用DetoursMinHook这样知名而优秀的第三方库,抑或手动在函数入口覆写一条跳转指令即可实现。后者的弊端倒是容易窥见,但前者中提到的Detours作为微软的官方手笔却没有成为一边倒的首选,在我见过的一些大型企业IT项目的实践中,让其它第三方Hook库(如MinHookmhook)占了一席之地。

我知道这是各个地方企业IT的实践所出真知,而理论上的缘由我还是想自己去找一找,作为微软的迷弟也想知道为何Detours不尽人意。于是有了现在这半纸拙笔作为答卷,以及综合改进后的结果SlimDetours,供相互学习交流。

正文介绍4个内联挂钩时值得考虑的问题,分别在DetoursMinHookmhook三者间做对比,以及在成果SlimDetours中的实现。在SlimDetours的技术Wiki中有对应原文,将随项目保持更新。

原文: 技术Wiki:应用内联钩子时自动更新线程

内联挂钩需要修改函数开头的指令实现跳转,为应对有线程正好运行在要修改的指令上的可能,需要更新处于此状态的线程避免其在修改指令时执行非法的新老共存的指令。

Detours提供了DetourUpdateThread函数更新线程,但需要由调用方传入需要进行更新线程的句柄:

也就是说,需要由调用方遍历进程中除自己以外的所有线程并传入给此函数,用起来比较复杂且不方便。

Detours更新线程非常精细,它通过使用GetThreadContextSetThreadContext准确地调整线程上下文中的PC(程序计数器)到正确位置,实现参考Detours/src/detours.cpp于4b8c659f · microsoft/Detours

[!TIP]
虽然它的官方示例“Using Detours”中有DetourUpdateThread(GetCurrentThread())这样的代码,但这用法无意义且无效,应使用其更新进程中除当前线程外的所有线程,详见DetourUpdateThread。但即便以正确的方式更新线程,也会带来一个新的风险,见 技术Wiki:更新线程时避免堆死锁

MinHook做的比较好,它在挂钩(和脱钩)时自动更新线程,并且像Detours一样准确地更新线程上下文中的PC(程序计数器)。

mhook在挂钩(和脱钩)时自动更新线程,实现参考mhook/mhook-lib/mhook.cpp于e58a58ca · martona/mhook

但它更新线程的方式比起上述几个则有点笨拙,若线程正好位于要修改指令的区域则等待100毫秒,最多尝试3次:

SlimDetours兼顾了以上优点,在挂钩(或脱钩)时遍历进程的所有线程,然后沿用Detours的方式更新线程上下文。

挂起当前进程中除当前线程外的所有线程,并返回它们的句柄:

精准更新线程上下文PC(程序计数器):

恢复挂起的线程和释放句柄:

要点:

完整实现参考KNSoft.SlimDetours/Source/SlimDetours/Thread.c于main · KNSoft/KNSoft.SlimDetours

原文: 技术Wiki:更新线程时避免堆死锁

原版Detours使用了CRT堆(通过new/delete),更新线程时如果挂起了另一个也使用此堆且正持有堆锁的线程,Detours再访问此堆就会发生死锁。

Raymond Chen博客“The Old New Thing”的文章《Are there alternatives to _lock and _unlock in Visual Studio 2015?》中详细讨论的挂起线程时出现CRT堆死锁问题正是同一个场景,也提到了Detours,这里引用其原文不再赘述:

Furthermore, you would be best served to take the heap lock (Heap­Lock) before suspending the thread, because the Detours library will allocate memory during thread suspension.
此外,最好在挂起线程前占有堆锁(Heap­Lock),因为Detours库将在线程挂起期间分配内存。

SlimDetours提供了示例:DeadLock演示Detours死锁的发生与在SlimDetours中得到解决。

其中一个线程(HeapUserThread)不断调用malloc/free(等效于new/delete):

另一个线程(SetHookThread)不断使用DetoursSlimDetours挂钩和脱钩:

[!NOTE]
SlimDetours会自动更新线程(参考 技术Wiki:应用内联钩子时自动更新线程),所以不存在DetourUpdateThread这样的函数。

同时执行这2个线程10秒,然后发送停止信号(g_bStop = TRUE;)后再次等待10秒,如果超时则大概率发生死锁,将触发断点,可以在调试器中观察这2个线程的调用栈进行确认。例如指定使用Detours运行此示例"Demo.exe -Run DeadLock -Engine=MSDetours",以下调用栈可见堆死锁:

使用SlimDetours运行此示例"Demo.exe -Run DeadLock -Engine=SlimDetours"则能顺利通过。

mhook使用Virtual­Alloc分配内存页代替Heap­Alloc分配堆内存,是上文末尾提到的一个解决方案。

MinHookSlimDetours都新创建了一个私有堆供内部使用,避免此问题的同时也节约了内存使用:

[!NOTE]
Detours已有事务机制,SlimDetours新添功能“延迟挂钩”也用了SRW锁,所以此堆无需序列化访问。

MinHook在其初始化函数MH_Initialize中创建,而SlimDetours在首个被调用的内存分配函数中进行一次初始化时创建,故没有也不需要单独的初始化函数。

原文: 技术Wiki:分配Trampoline时避免占用系统保留区域

Windows自NT6起引入ASLR,随之为系统DLL在用户模式下预留了一段区域,使得同一个系统DLL在不同进程中都能映射到这片保留区域的同一位置,加载一次后即可复用该次重定位信息避免后续加载再次进行重定位操作。

这个机制在《Windows Internals 7th Part1》第五章《Memory management》的“Image randomization”小节有详细说明,此处不再赘述,只给出我参考该书并经过分析得到的确切保留范围是:
32位进程:[0x50000000 ... 0x78000000],共640MB
64位进程:[0x00007FF7FFFF0000 ... 0x00007FFFFFFF0000],共32GB

挂钩库分配Trampoline时一般优先从挂钩目标函数附近寻找可用的内存空间,如此挂钩系统API时十分容易占用这个保留区域,导致本应加载到该位置的系统DLL加载到别地并额外进行重定位操作。

Detours作为微软官方的挂钩库,已考虑到系统保留区域不能给Trampoline使用这一点,但它硬编码了仅适用于NT5的[0x70000000 ... 0x80000000]地址范围进行规避:

同样注意到此问题的jdu2600Detours开了一个非官方的PR microsoft/Detours PR #307 想更新这个范围以适配最新的Windows。

MinHookmhook都是熟知的Windows API挂钩库,遗憾的是它们似乎都没有考虑到这个问题。

32位系统ASLR的预留范围大小仅640MB,直接规避即可。而对于64位系统则复杂一些,ASLR的预留范围有32GB,太大而不可能全部规避。结合ASLR的排布规则和Trampoline的选址需求,视Ntdll.dll之后1GB范围为要规避的保留范围是合理的,这个考虑与上面提到的PR一致。要注意这个范围可能被分成两块,例如以下场排布场景:

Ntdll.dll被ASLR随机加载到保留范围内较低的内存地址,后续DLL随后排布触底时,将切换到保留范围顶部继续排布,在这个情况下“Ntdll.dll之后的1GB范围”便是2块不连续的区域。

SlimDetours的具体实现与规避范围均有别于上述PR,更进一步的,为NT5与NT6+分别考虑,并调用NtQuerySystemInformation获得比硬编码更确切的用户地址空间范围,协助约束Trampoline的选址,参考KNSoft.SlimDetours/Source/SlimDetours/Memory.c于main · KNSoft/KNSoft.SlimDetours

原文: 技术Wiki:实现延迟挂钩

通常挂钩DLL中函数的做法需要先将对应的DLL加载到进程空间并定位它的地址(例如,使用LoadLibraryW + GetProcAddress)。

对于为特定程序设计的钩子,通常它们的目标函数将迟早被调用,DLL也是进程需要的,所以早些加载对应的DLL没什么问题。而对于被注入到不同进程中的钩子(尤其是全局钩子),它们不知道各个进程是否需要此DLL,所以通常它们仍将DLL加载到各个进程空间并挂钩函数,即使进程本身并不想要这个DLL。

试想一下一个有不少依赖项的全局钩子试图挂钩各种系统DLL的函数,则会将所有涉及的DLL都带入到所有进程进行加载和初始化,开销极大。

“延迟挂钩”是此问题的一个好方案。即如果目标DLL已加载则立即执行挂钩,否则等到目标DLL加载到进程的时候挂钩。

显然,实现“延迟挂钩”的关键是在第一时间获得加载DLL的通知。“DLL加载通知”机制自NT6被引入,这正是我们需要的。

参考LdrRegisterDllNotification函数,DLL加载(与卸载)的通知将被发送给由此函数注册的回调,并且DLL映射的内存区域在那时可用,同时我们可以进行挂钩。

尽管Microsoft Learning提示相关API可能会被更改或删除,但它们的用法一直没变,仅自NT6.1将所持的锁由LdrpLoaderLock变为了专用的LdrpDllNotificationLock。总之,请保持回调尽可能简单。

[!TIP]
如果你想了解Windows上“DLL加载通知”的内部实现,参考我为ReactOS贡献的ReactOS PR #6795。不要参考WINE的实现,因为它截至此文编写时存在错误,例如,它的LdrUnregisterDllNotification没有检查节点是否处于链表中就进行了移除。

SlimDetours提供了SlimDetoursDelayAttach函数注册延迟挂钩,具体可参考该函数声明上方的注释以及示例:DelayHook

该示例中,先调用了SlimDetoursDelayAttach注册对User32.dll!EqualRectAPI的延迟挂钩,并通过检查它和LdrGetDllHandle的返回值确认此时User32.dll并未加载:

然后调用LdrLoadDll加载User32.dll

此时若User32.dll成功加载,则之前注册的延迟挂钩应已挂钩完成,进而验证延迟挂钩回调被正确调用以及User32.dll!EqualRect函数被成功挂钩:

很久以前问过我的一个导师,当时他简单回答了一句“Detours有时不稳定”,然后顿了顿补充“别的有时也不稳定,不一样”。之后我没有细问,也没有细究,毕竟直到后来当我在某处主笔时,才有去权衡。即使只站在企业安全的角度,或者只站在追求稳定的角度,权衡的结果都未必只有一个。

经过这番“一探”,绝不是“哪一个Hook库更好”可以断下的结论,毕竟之前也说了这只是“半纸”答卷。相信看到这里,也会冒出更多问题——

我对这两个问题答案的猜想都和它们的名字有关,一个要“Min”,一个要“Microsoft”,便有不得不妥协的地方。那我再起一个SlimDetours便是,Detours的骨架最好,以此为基础由C++改为C,让它放下对kernel32.dll的执念转而直面ntdll.dll,再补过上面正文的4个问题,便不比已有的轮子差了,至此可告一段落。此时松口气回想一下导师的那个回答了又像是没回答的“如答”,好像确实他都回答了。现在如若换成我,我也是这个回答,不过不会带有犹豫。实践自是出真知,但与理论一致后才觉得更牢靠。

后面的路虽然可能还很长,但都不急于一时了,十分欢迎一同学习和交流,有时会有这些胡思乱想:

最后,有怀疑、错漏的地方欢迎挑战和指出,以及各种意见和建议,就如高质量代码也正是在来回的CR中铸就的一样。本人最近在考虑新的工作机会,如有技术上合适的地方尚希不吝相告。

<hr>

本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 (CC BY-NC-SA 4.0) 进行许可。

Ratin <ratin@knsoft.org>
中国国家认证系统架构设计师
ReactOS贡献者

LONG WINAPI DetourUpdateThread(_In_ HANDLE hThread);
LONG WINAPI DetourUpdateThread(_In_ HANDLE hThread);
while (GetThreadContext(hThread, &ctx))
{
    ...
    if (nTries < 3)
    {
        // oops - we should try to get the instruction pointer out of here.
        ODPRINTF((L"mhooks: SuspendOneThread: suspended thread %d - IP is at %p - IS COLLIDING WITH CODE", dwThreadId, pIp));
        ResumeThread(hThread);
        Sleep(100);
        SuspendThread(hThread);
        nTries++;
    }
    ...
}
while (GetThreadContext(hThread, &ctx))
{
    ...
    if (nTries < 3)
    {
        // oops - we should try to get the instruction pointer out of here.
        ODPRINTF((L"mhooks: SuspendOneThread: suspended thread %d - IP is at %p - IS COLLIDING WITH CODE", dwThreadId, pIp));
        ResumeThread(hThread);
        Sleep(100);
        SuspendThread(hThread);
        nTries++;
    }
    ...
}
NTSTATUS
detour_thread_suspend(
    _Outptr_result_maybenull_ PHANDLE* SuspendedHandles,
    _Out_ PULONG SuspendedHandleCount)
{
    NTSTATUS Status;
    ULONG i, ThreadCount, SuspendedCount;
    PSYSTEM_PROCESS_INFORMATION pSPI, pCurrentSPI;
    PSYSTEM_THREAD_INFORMATION pSTI;
    PHANDLE Buffer;
    HANDLE ThreadHandle, CurrentPID, CurrentTID;
    OBJECT_ATTRIBUTES ObjectAttributes = RTL_CONSTANT_OBJECT_ATTRIBUTES(NULL, 0);
 
    /* Get system process and thread information */
    i = _1MB;
_Try_alloc:
    pSPI = (PSYSTEM_PROCESS_INFORMATION)detour_memory_alloc(i);
    if (pSPI == NULL)
    {
        return STATUS_NO_MEMORY;
    }
    Status = NtQuerySystemInformation(SystemProcessInformation, pSPI, i, &i);
    if (!NT_SUCCESS(Status))
    {
        detour_memory_free(pSPI);
        if (Status == STATUS_INFO_LENGTH_MISMATCH)
        {
            goto _Try_alloc;
        }
        return Status;
    }
 
    /* Find current process and threads */
    CurrentPID = NtGetCurrentProcessId();
    pCurrentSPI = pSPI;
    while (pCurrentSPI->UniqueProcessId != CurrentPID)
    {
        if (pCurrentSPI->NextEntryOffset == 0)
        {
            Status = STATUS_NOT_FOUND;
            goto _Exit;
        }
        pCurrentSPI = (PSYSTEM_PROCESS_INFORMATION)Add2Ptr(pCurrentSPI, pCurrentSPI->NextEntryOffset);
    }
    pSTI = (PSYSTEM_THREAD_INFORMATION)Add2Ptr(pCurrentSPI, sizeof(*pCurrentSPI));
 
    /* Skip if no other threads */
    ThreadCount = pCurrentSPI->NumberOfThreads - 1;
    if (ThreadCount == 0)
    {
        *SuspendedHandles = NULL;
        *SuspendedHandleCount = 0;
        Status = STATUS_SUCCESS;
        goto _Exit;
    }
 
    /* Create handle array */
    Buffer = (PHANDLE)detour_memory_alloc(ThreadCount * sizeof(HANDLE));
    if (Buffer == NULL)
    {
        Status = STATUS_NO_MEMORY;
        goto _Exit;
    }
 
    /* Suspend threads */
    SuspendedCount = 0;
    CurrentTID = NtGetCurrentThreadId();
    for (i = 0; i < pCurrentSPI->NumberOfThreads; i++)
    {
        if (pSTI[i].ClientId.UniqueThread == CurrentTID ||
            !NT_SUCCESS(NtOpenThread(&ThreadHandle,
                                     THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT | THREAD_SET_CONTEXT,
                                     &ObjectAttributes,
                                     &pSTI[i].ClientId)))
        {
            continue;
        }
        if (NT_SUCCESS(NtSuspendThread(ThreadHandle, NULL)))
        {
            _Analysis_assume_(SuspendedCount < ThreadCount);
            Buffer[SuspendedCount++] = ThreadHandle;
        } else
        {
            NtClose(ThreadHandle);
        }
    }
 
    /* Return suspended thread handles */
    if (SuspendedCount == 0)
    {
        detour_memory_free(Buffer);
        *SuspendedHandles = NULL;
    } else
    {
        *SuspendedHandles = Buffer;
    }
    *SuspendedHandleCount = SuspendedCount;
    Status = STATUS_SUCCESS;
 
_Exit:
    detour_memory_free(pSPI);
    return Status;
}
NTSTATUS
detour_thread_suspend(
    _Outptr_result_maybenull_ PHANDLE* SuspendedHandles,
    _Out_ PULONG SuspendedHandleCount)
{
    NTSTATUS Status;
    ULONG i, ThreadCount, SuspendedCount;
    PSYSTEM_PROCESS_INFORMATION pSPI, pCurrentSPI;
    PSYSTEM_THREAD_INFORMATION pSTI;
    PHANDLE Buffer;
    HANDLE ThreadHandle, CurrentPID, CurrentTID;
    OBJECT_ATTRIBUTES ObjectAttributes = RTL_CONSTANT_OBJECT_ATTRIBUTES(NULL, 0);
 
    /* Get system process and thread information */
    i = _1MB;
_Try_alloc:
    pSPI = (PSYSTEM_PROCESS_INFORMATION)detour_memory_alloc(i);
    if (pSPI == NULL)
    {
        return STATUS_NO_MEMORY;
    }
    Status = NtQuerySystemInformation(SystemProcessInformation, pSPI, i, &i);
    if (!NT_SUCCESS(Status))
    {
        detour_memory_free(pSPI);
        if (Status == STATUS_INFO_LENGTH_MISMATCH)
        {
            goto _Try_alloc;
        }
        return Status;
    }
 
    /* Find current process and threads */
    CurrentPID = NtGetCurrentProcessId();
    pCurrentSPI = pSPI;
    while (pCurrentSPI->UniqueProcessId != CurrentPID)
    {
        if (pCurrentSPI->NextEntryOffset == 0)
        {
            Status = STATUS_NOT_FOUND;
            goto _Exit;
        }
        pCurrentSPI = (PSYSTEM_PROCESS_INFORMATION)Add2Ptr(pCurrentSPI, pCurrentSPI->NextEntryOffset);
    }
    pSTI = (PSYSTEM_THREAD_INFORMATION)Add2Ptr(pCurrentSPI, sizeof(*pCurrentSPI));
 
    /* Skip if no other threads */
    ThreadCount = pCurrentSPI->NumberOfThreads - 1;
    if (ThreadCount == 0)
    {
        *SuspendedHandles = NULL;
        *SuspendedHandleCount = 0;
        Status = STATUS_SUCCESS;
        goto _Exit;
    }
 
    /* Create handle array */
    Buffer = (PHANDLE)detour_memory_alloc(ThreadCount * sizeof(HANDLE));
    if (Buffer == NULL)
    {
        Status = STATUS_NO_MEMORY;
        goto _Exit;
    }
 
    /* Suspend threads */
    SuspendedCount = 0;
    CurrentTID = NtGetCurrentThreadId();
    for (i = 0; i < pCurrentSPI->NumberOfThreads; i++)
    {
        if (pSTI[i].ClientId.UniqueThread == CurrentTID ||
            !NT_SUCCESS(NtOpenThread(&ThreadHandle,
                                     THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT | THREAD_SET_CONTEXT,
                                     &ObjectAttributes,
                                     &pSTI[i].ClientId)))
        {
            continue;
        }
        if (NT_SUCCESS(NtSuspendThread(ThreadHandle, NULL)))
        {
            _Analysis_assume_(SuspendedCount < ThreadCount);
            Buffer[SuspendedCount++] = ThreadHandle;
        } else
        {
            NtClose(ThreadHandle);
        }
    }
 
    /* Return suspended thread handles */
    if (SuspendedCount == 0)
    {
        detour_memory_free(Buffer);
        *SuspendedHandles = NULL;
    } else
    {
        *SuspendedHandles = Buffer;
    }
    *SuspendedHandleCount = SuspendedCount;
    Status = STATUS_SUCCESS;
 
_Exit:
    detour_memory_free(pSPI);
    return Status;
}
NTSTATUS
detour_thread_update(
    _In_ HANDLE ThreadHandle,
    _In_ PDETOUR_OPERATION PendingOperations)
{
    NTSTATUS Status;
    PDETOUR_OPERATION o;
    CONTEXT cxt;
    BOOL bUpdateContext;
 
    cxt.ContextFlags = CONTEXT_CONTROL;
    Status = NtGetContextThread(ThreadHandle, &cxt);
    if (!NT_SUCCESS(Status))
    {
        return Status;
    }
 
    for (o = PendingOperations; o != NULL; o = o->pNext)
    {
        bUpdateContext = FALSE;
        if (o->fIsRemove)
        {
            if (cxt.CONTEXT_PC >= (ULONG_PTR)o->pTrampoline &&
                cxt.CONTEXT_PC < ((ULONG_PTR)o->pTrampoline + sizeof(o->pTrampoline)))
            {
                cxt.CONTEXT_PC = (ULONG_PTR)o->pbTarget +
                    detour_align_from_trampoline(o->pTrampoline, (BYTE)(cxt.CONTEXT_PC - (ULONG_PTR)o->pTrampoline));
                bUpdateContext = TRUE;
            }
        } else
        {
            if (cxt.CONTEXT_PC >= (ULONG_PTR)o->pbTarget &&
                cxt.CONTEXT_PC < ((ULONG_PTR)o->pbTarget + o->pTrampoline->cbRestore))
            {
                cxt.CONTEXT_PC = (ULONG_PTR)o->pTrampoline +
                    detour_align_from_target(o->pTrampoline, (BYTE)(cxt.CONTEXT_PC - (ULONG_PTR)o->pbTarget));
                bUpdateContext = TRUE;
            }
        }
        if (bUpdateContext)
        {
            Status = NtSetContextThread(ThreadHandle, &cxt);
            break;
        }
    }
 
    return Status;
}
NTSTATUS
detour_thread_update(
    _In_ HANDLE ThreadHandle,
    _In_ PDETOUR_OPERATION PendingOperations)
{
    NTSTATUS Status;
    PDETOUR_OPERATION o;
    CONTEXT cxt;
    BOOL bUpdateContext;
 
    cxt.ContextFlags = CONTEXT_CONTROL;
    Status = NtGetContextThread(ThreadHandle, &cxt);
    if (!NT_SUCCESS(Status))
    {
        return Status;
    }
 

[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课

最后于 2024-8-13 14:07 被Ratin编辑 ,原因:
收藏
免费 9
支持
分享
最新回复 (7)
雪    币: 193
活跃值: (1332)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2
支持!非winapi的挂钩支持怎么样? fastcall以及类函数挂钩
2024-8-13 09:13
0
雪    币: 1796
活跃值: (2060)
能力值: ( LV4,RANK:50 )
在线值:
发帖
回帖
粉丝
3
signed 支持!非winapi的挂钩支持怎么样? fastcall以及类函数挂钩
感谢支持。
对于各种不同调用约定,并不是挂钩库内部有处理而支持的,是调用方自己负责。
不论是不是WinAPI,也不论是什么调用约定,归根到底【调用方一定要确保自己写的函数与原函数声明一致】。
不仅stdcall, fastcall, cdecl这些,C++类中的函数我也成功安排过的。
2024-8-13 09:30
0
雪    币: 3785
活跃值: (3947)
能力值: ( LV4,RANK:50 )
在线值:
发帖
回帖
粉丝
4
感谢分享。2.2和2.3的问题我以前都遇见过,最终也是采取也类似作者使用的方法解决。
2024-8-13 10:22
0
雪    币: 3246
活跃值: (374)
能力值: (RANK:20 )
在线值:
发帖
回帖
粉丝
5
系统不支持,hook很难做到100%安全。
比如正在执行detour_thread_suspend()时,某个线程调用了CreateThread()又搞出来一个新线程
2024-9-9 15:24
0
雪    币: 1796
活跃值: (2060)
能力值: ( LV4,RANK:50 )
在线值:
发帖
回帖
粉丝
6
fengyunabc 感谢分享。2.2和2.3的问题我以前都遇见过,最终也是采取也类似作者使用的方法解决。
感谢支持。综合了提到的几个Hook库后考量,差不多是这情况了
2024-9-9 23:03
0
雪    币: 1796
活跃值: (2060)
能力值: ( LV4,RANK:50 )
在线值:
发帖
回帖
粉丝
7
blowfish 系统不支持,hook很难做到100%安全。 比如正在执行detour_thread_suspend()时,某个线程调用了CreateThread()又搞出来一个新线程
是的,做不到万无一失,但尽可能缩小特殊情况出现的窗口、降低出现稳定性问题的风险,还是可以争取的。
2024-9-9 23:05
0
游客
登录 | 注册 方可回帖
返回
//