简体   繁体   English

钩函数我不知道参数

[英]Hooking a function I don't know the parameters to

Lets say there is a DLL A.DLL with a known entry point DoStuff that I have in some way hooked out with my own DLL fakeA.dll such that the system is calling my DoStuff instead. 可以说有一个具有已知入口点DoStuff的DLL A.DLL ,我以某种方式将其与自己的DLL fakeA.dll ,以便系统调用我的DoStuff How do I write such a function such that it can then call the same entry point of the hooked DLL ( A.DLL ) without knowing the arguments of the function? 如何编写这样的函数,以便它可以在不知道函数参数的情况下调用挂钩的DLL( A.DLL )的相同入口点? Ie My function in fakeA.DLL would look something like 即我的fakeA.DLL函数看起来像

LONG DoStuff(
// don't know what to put here
)
{
    FARPROC pfnHooked;
    HINSTANCE hHooked;
    LONG lRet;

    // get hooked library and desired function

    hHooked = LoadLibrary("A.DLL");
    pfnHooked = GetProcAddress(hHooked, "DoStuff");

    // how do I call the desired function without knowing the parameters?

    lRet = pfnHooked( ??? );

    return lRet;
}

My current thinking is that the arguments are on the stack so I'm guessing I would have to have a sufficiently large stack variable (a big ass struct for example) to capture whatever the arguments are and then just pass it along to pfnHooked ? 我目前的想法是参数在堆栈上,所以我猜我将必须有足够大的堆栈变量(例如大ass struct )来捕获任何参数,然后将其传递给pfnHooked吗? Ie

// actual arg stack limit is >1MB but we'll assume 1024 bytes is sufficient
typedef struct { char unknownData[1024]; } ARBITARY_ARG; 

ARBITARY_ARG DoStuff(ARBITARY_ARG args){
    ARBITARY_ARG aRet;
    ...
    aRet = pfnHooked(args);

    return aRet;
}

Would this work? 这行得通吗? If so, is there a better way? 如果是这样,有没有更好的方法?


UPDATE: After some rudimentary (and non-conclusive) testing passing in the arbitrary block as arguments DOES work (which is not surprising, as the program will just read what it needs off the stack). 更新:经过一些基本(非结论性)测试后,将任意块作为参数传递确实起作用(这并不奇怪,因为该程序将从堆栈中读取所需内容)。 However collecting the return value is harder as if it's too large it can cause an access violation. 但是,收集返回值比较困难,因为返回值太大,可能会导致访问冲突。 Setting the arbitrary return size to 8 bytes (or maybe 4 for x86) may be a solution to most cases (including void returns) however that's still guesswork. 将任意返回大小设置为8个字节(对于x86,可能为4个字节)可能是大多数情况(包括空返回)的解决方案,但这仍然是猜测。 If I had some way of knowing the return type from the DLL (not necessarily at runtime) that would be grand. 如果我有某种方法可以知道DLL的返回类型(不一定在运行时),那将是很大的事情。

This should be a comment but the meta answer is yes you can hook the function without knowing the calling convention and arguments, on an x64/x86 platform. 这应该是一个注释,但meta回答是,您可以在x64 / x86平台上挂接函数而无需知道调用约定和参数。 Can it be purely done in C? 可以纯粹用C完成吗? No, it also needs a good deal of understanding of various calling convention and Assembly programming. 不,它还需要对各种调用约定和Assembly编程有充分的了解。 The hooking framework will have some of it's bits written in Assembly. 挂钩框架将用汇编语言编写其中的一些内容。

Most hooking framework inherently do that by creating a trampoline that redirects the execution flow from the called function's preamble to stub code that is generally independent of the function it is hooking. 大多数挂钩框架通过创建一个蹦床来固有地做到这一点,该蹦床将执行流从被调用函数的前导重定向到存根代码,该存根代码通常独立于它所挂钩的函数。 In user mode you're guaranteed stack to be always present so you can push your own local variables too on the same stack as long as you can pop them and restore the stack to it's original state. 在用户模式下,可以确保堆栈始终存在,因此您可以将自己的局部变量也推送到同一堆栈上,只要可以弹出它们并将堆栈恢复到原始状态即可。

You don't really need to copy the existing arguments to your own stack variable. 您实际上不需要将现有参数复制到自己的堆栈变量中。 You can just inspect the stack, definitely read a bit about calling convention and how stacks are constructed on different architectures for various types of invocation in assembly before you attempt anything. 您可以检查堆栈,一定要阅读一些有关调用约定的知识,以及在尝试进行任何操作之前,如何针对汇编中的各种类型的调用在不同体系结构上构建堆栈。

yes, this is possible do generic hooking 100% correct - one common for multiple functions with different arguments count and calling conventions. 是的,通用钩子可以做到100%正确-对于具有不同参数计数和调用约定的多个函数来说,这是常见的。 for both x86/x64 (amd64) platforms. 适用于两个x86 / x64(amd64)平台。

but for this need use little asm stubs - of course it will be different for x86/x64 - but it will be very small - several lines of code only - 2 small stub procedures - one for filter pre-call and one for post-call. 但是为此需要使用很少的asm存根-当然,对于x86 / x64来说会有所不同-但它会非常小-仅几行代码-2个小存根过程-一个用于过滤器调用,而一个用于调用后。 but most code implementation (95%+) will be platform independent and in c++ (of course this possible do and on c but compare c++ - c source code will be larger, ugly and harder to implement) 但是大多数代码实现(95%+)将与平台无关并且在c ++中实现 (当然这可以在c上实现,但是可以与c ++相比-c源代码更大,更难看并且更难实现)

in my solution need allocate small executable blocks of code for every hooking api (one block per hooked api). 在我的解决方案中,需要为每个挂钩API分配小的可执行代码块(每个挂钩api一个块)。 in this block - store function name, original address (or to where transfer control after pre-call - this is depended from hooking method) and one relative call instruction to common asm pre-call stub. 在此块中-存储函数名称,原始地址(或预调用后的传输控制的位置-这取决于挂钩方法)和一条到公共asm预调用存根的相对调用指令。 magic of this call not only that it transfer control to common stub, but that return address in stack will be point to block itself (ok , with some offset, but if we will use c++ and inheritance - it will be exactly point to some base class, from which we derive our executable block class). 这种调用的神奇之处不仅在于它可以将控制权转移到公共存根,而且堆栈中的返回地址将指向自身本身(确定,带有一些偏移量,但是如果我们将使用c ++和继承,它将恰好指向某个基础类,从中派生可执行块类)。 as result in common precall stub we will be have information - which api call we hook here and then pass this info to c++ common handler. 作为公共precall存根的结果,我们将获得信息-我们在此处挂接到哪个api调用,然后将此信息传递给c ++公共处理程序。

one note, because in x64 relative call can be only in range [rip-0x80000000, rip+0x7fffffff] need declare (allocate) this code blocks inside our PE in separate bss section and mark this section as RWE . 请注意,因为在x64中相对调用只能在[rip-0x80000000, rip+0x7fffffff]范围内[rip-0x80000000, rip+0x7fffffff]需要在单独的bss部分中的PE中声明(分配)此代码块,并将此部分标记为RWE we can not simply use VirtualAlloc for allocate storage, because returned address can be too far from our common precall stub. 我们不能简单地使用VirtualAlloc来分配存储,因为返回的地址可能与我们的通用预调用存根相距太远。

in common asm precall stub code must save rcx,rdx,r8,r9 registers for x64 (this is absolute mandatory) and ecx,edx registers for x86. 在常见的asm 预调用存根代码中,必须将rcx,rdx,r8,r9寄存器保存为x64 (这是绝对必要的 ),而将ecx,edx寄存器保存为x86。 this is need for case if function use __fastcall calling conventions. 如果函数使用__fastcall调用约定,则需要这样做 however windows api for example almost not using __fastcall - only several __fastcall functions exist from thousands of win api (for ensure this and found this functions - go to LIB folder and search for __imp_@ string (this is __fastcall common prefix) and then call c++ common handler, which must return address of original function(to where transfer control) to stub. stub restore rcx,rdx,r8,r9 (or ecx,edx ) registers and jump (but not call !) to this address 但是,例如,Windows api几乎不使用__fastcall-数千个win api中仅存在几个__fastcall函数(为确保找到并找到此函数,请转到LIB文件夹并搜索__imp_@字符串(这是__fastcall的通用前缀),然后调用c ++通用处理程序,必须将原始函数的地址(传递控制到该地址)返回到存根。存根恢复rcx,rdx,r8,r9 (或ecx,edx )寄存器并跳转 (但不调用 !)到该地址

if we want filter only pre-call this is all what we need. 如果我们只想过滤预调用,这就是我们所需要的。 however in most case need filter (hook) and post-call - for view/modify function return value and out parameters. 但是在大多数情况下,需要过滤器(挂接)和调用后-用于查看/修改函数的返回值和out参数。 and this is also possible do, but need little more coding. 这也是可行的,但只需要更多的编码。

for hook post-call obviously we must replace the return address for hooked api. 对于钩子调用,显然我们必须替换钩子api的返回地址。 but on what we must change return address ? 但是我们必须更改寄信人地址吗? and where save original return address ? 在哪里保存原始寄信人地址? for this we can not use global variable. 为此,我们不能使用全局变量。 even can not use thread local ( __declspec( thread ) or thread_local ) because call can be reqursive. 甚至不能使用本地线程( __declspec( thread )thread_local ),因为调用可能是必需的。 can not use volatile register (because it changed during api call) and can not use non-volatile register - because in this case we will be save it,for restore later - but got some question - where ? 不能使用易失性寄存器(因为在api调用期间已更改),也不能使用非易失性寄存器-因为在这种情况下,我们将其保存,以便稍后还原-但出现了一个问题-哪里?

only one (and nice) solution here - allocate small block of executable memory ( RWE ) which containing one relative call instruction to common post-call asm stub. 这里只有一个(也是不错的)解决方案-将一小块可执行内存( RWE )分配给一个普通的调用后asm存根,其中包含一个相对调用指令。 and some data - saved original return address, function parameters(for check out parameters in post handler) and function name 和一些数据-保存的原始返回地址,函数参数(用于在后处理程序中签出参数)和函数名称

here again, some issuer for x64 - this block must be not too far from common post stub (+/- 2GB) - so the best also allocate this stubs in separate .bss section (with the pre-call stubs). 在这里,还是x64的某个发行者-该块必须与普通的后期存根(+/- 2GB)相距不远-因此最好也将此存根分配到单独的.bss节中(带有预调用存根)。

how many need this ret-stubs ? 有多少需要这个存根? one per api call (if we want control post call). 每个api调用一个(如果我们要控制post调用)。 so not more than api calls active at any time. 因此在任何时候都不会超过激活的api调用。 usually say 256 pre-allocated blocks - more than enough. 通常说256个预分配的块-绰绰有余。 and even if we fail allocate this block in pre-call - we only not control it post call, but not crash. 即使我们在调用前分配该块失败-我们也不会在调用后控制它,而不会崩溃。 and we can not for all hooked api want control post-call but only for some. 而且我们不能为所有挂钩的api都希望控制调用后调用,而仅对某些调用。

for very fast and interlocked alloc/free this blocks - need build stack semantic over it. 对于非常快速且互锁的分配/释放,此块-需要在其上构建堆栈语义。 allocate by interlocked pop and free by interlocked push. 通过联锁弹出分配,并通过联锁推释放。 and pre initialize (call instruction) this blocks at begin (while push all it to stack, for not reinitialize it every time in pre-call) 并在开始时预初始化(调用指令)此块(同时将其全部压入堆栈,以免每次调用前都不重新初始化)

common post-call stub in asm is very simply - here we not need save any registers. asm中常见的调用后存根非常简单-在这里我们不需要保存任何寄存器。 we simply call c++ post handler with address of block (we pop it from stack - result of call instruction from block) and with original return value ( rax or eax ). 我们只需调用C ++后处理程序的块地址(我们弹出它从栈- 调用指令的结果),并与原来的返回值(RAXEAX)。 strictly said - api function can return pair rax+rdx or eax+edx but 99.9%+ of windows api return value in single register and i assume that we will be hooking only this api. 严格地说-api函数可以返回成对的rax + rdxeax + edx,但Windows api的99.9%+会在单个寄存器中返回值,我假设我们只会钩住此api。 however if want, can little adjust code for handle this too (simply in most case this not need) 但是,如果需要的话,也几乎不能调整代码来处理此问题(在大多数情况下根本不需要)

c++ post call handler restore original return address (by using _AddressOfReturnAddress() ), can log call and/or modify out parameters and finally return to.. original caller of api. C ++调用处理程序恢复原始返回地址(通过使用_AddressOfReturnAddress() ),可以记录调用和/或修改参数,最后返回到api的原始调用者。 what our handler return - this and will be final return value of api call. 我们的处理程序返回的内容-这将是api调用的最终返回值。 usually we mast return original value. 通常我们会返回原始值。

c++ code C ++代码

#if 0 
#define __ASM_FUNCTION __pragma(message(__FUNCDNAME__" proc\r\n" __FUNCDNAME__ " endp"))
#define _ASM_FUNCTION {__ASM_FUNCTION;}
#define ASM_FUNCTION {__ASM_FUNCTION;return 0;}
#define CPP_FUNCTION __pragma(message("extern " __FUNCDNAME__ " : PROC ; "  __FUNCTION__))
#else
#define _ASM_FUNCTION
#define ASM_FUNCTION
#define CPP_FUNCTION
#endif

class CODE_STUB
{
#ifdef _WIN64
    PVOID pad;
#endif
    union
    {
        DWORD code;
        struct  
        {
            BYTE cc[3];
            BYTE call;
        };
    };
    int offset;

public:

    void Init(PVOID stub)
    {
        // int3; int3; int3; call stub
        code = 0xe8cccccc;
        offset = RtlPointerToOffset(&offset + 1, stub);

        C_ASSERT(sizeof(CODE_STUB) == RTL_SIZEOF_THROUGH_FIELD(CODE_STUB, offset));
    }

    PVOID Function()
    {
        return &call;
    }

    // implemented in .asm
    static void __cdecl retstub()  _ASM_FUNCTION;
    static void __cdecl callstub() _ASM_FUNCTION;
};

struct FUNC_INFO
{
    PVOID OriginalFunc;
    PCSTR Name;

    void* __fastcall OnCall(void** stack);
};

struct CALL_FUNC : CODE_STUB, FUNC_INFO
{
};  

C_ASSERT(FIELD_OFFSET(CALL_FUNC,OriginalFunc) == sizeof(CODE_STUB));

struct RET_INFO 
{
    union
    {
        struct  
        {
            PCSTR Name;
            PVOID params[7];
        };

        SLIST_ENTRY Entry;
    };

    INT_PTR __fastcall OnCall(INT_PTR r);
};

struct RET_FUNC : CODE_STUB, RET_INFO 
{
};

C_ASSERT(FIELD_OFFSET(RET_FUNC, Entry) == sizeof(CODE_STUB));

#pragma bss_seg(".HOOKS")

RET_FUNC g_rf[1024];//max call count
CALL_FUNC g_cf[16];//max hooks count

#pragma bss_seg() 

#pragma comment(linker, "/SECTION:.HOOKS,RWE")

class RET_FUNC_Manager 
{
    SLIST_HEADER _head;

public:

    RET_FUNC_Manager()
    {
        PSLIST_HEADER head = &_head;

        InitializeSListHead(head);

        RET_FUNC* p = g_rf;
        DWORD n = RTL_NUMBER_OF(g_rf);

        do 
        {
            p->Init(CODE_STUB::retstub);
            InterlockedPushEntrySList(head, &p++->Entry);
        } while (--n);
    }

    RET_FUNC* alloc()
    {
        return static_cast<RET_FUNC*>(CONTAINING_RECORD(InterlockedPopEntrySList(&_head), RET_INFO, Entry));
    }

    void free(RET_INFO* p)
    {
        InterlockedPushEntrySList(&_head, &p->Entry);
    }
} g_rfm;

void* __fastcall FUNC_INFO::OnCall(void** stack)
{
    CPP_FUNCTION;

    // in case __fastcall function in x86 - param#1 at stack[-1] and param#2 at stack[-2]

    // this need for filter post call only
    if (RET_FUNC* p = g_rfm.alloc())
    {
        p->Name = Name;
        memcpy(p->params, stack, sizeof(p->params));
        *stack = p->Function();
    }

    return OriginalFunc;
}

INT_PTR __fastcall RET_INFO::OnCall(INT_PTR r)
{
    CPP_FUNCTION;

    *(void**)_AddressOfReturnAddress() = *params;

    PCSTR name = Name;
    char buf[8];
    if (IS_INTRESOURCE(name))
    {
        sprintf(buf, "#%04x", (ULONG)(ULONG_PTR)name), name = buf;
    }

    DbgPrint("%p %s(%p, %p, %p ..)=%p\r\n", *params, name, params[1], params[2], params[3], r);
    g_rfm.free(this);
    return r;
}

struct DLL_TO_HOOK 
{
    PCWSTR szDllName;
    PCSTR szFuncNames[];
};

void DoHook(DLL_TO_HOOK** pp)
{
    PCSTR* ppsz, psz;
    DLL_TO_HOOK *p;
    ULONG n = RTL_NUMBER_OF(g_cf);

    CALL_FUNC* pcf = g_cf;

    while (p = *pp++)
    {
        if (HMODULE hmod = LoadLibraryW(p->szDllName))
        {
            ppsz = p->szFuncNames;

            while (psz = *ppsz++)
            {
                if (pcf->OriginalFunc = GetProcAddress(hmod, psz))
                {
                    pcf->Name = psz;
                    pcf->Init(CODE_STUB::callstub);

                    // do hook: pcf->OriginalFunc -> pcf->Function() - code for this skiped
                    DbgPrint("hook: (%p) <- (%p)%s\n", pcf->Function(), pcf->OriginalFunc, psz);

                    if (!--n)
                    {
                        return;
                    }

                    pcf++;
                }
            }
        }
    }
}

asm x64 code: asm x64代码:

extern ?OnCall@FUNC_INFO@@QEAAPEAXPEAPEAX@Z : PROC ; FUNC_INFO::OnCall
extern ?OnCall@RET_INFO@@QEAA_J_J@Z : PROC ; RET_INFO::OnCall

?retstub@CODE_STUB@@SAXXZ proc
    pop rcx
    mov rdx,rax
    call ?OnCall@RET_INFO@@QEAA_J_J@Z
?retstub@CODE_STUB@@SAXXZ endp

?callstub@CODE_STUB@@SAXXZ proc
    mov [rsp+10h],rcx
    mov [rsp+18h],rdx
    mov [rsp+20h],r8
    mov [rsp+28h],r9
    pop rcx
    mov rdx,rsp
    sub rsp,18h
    call ?OnCall@FUNC_INFO@@QEAAPEAXPEAPEAX@Z
    add rsp,18h
    mov rcx,[rsp+8]
    mov rdx,[rsp+10h]
    mov r8,[rsp+18h]
    mov r9,[rsp+20h]
    jmp rax
?callstub@CODE_STUB@@SAXXZ endp

asm x86 code asm x86代码

extern ?OnCall@FUNC_INFO@@QAIPAXPAPAX@Z : PROC ; FUNC_INFO::OnCall
extern ?OnCall@RET_INFO@@QAIHH@Z : PROC ; RET_INFO::OnCall

?retstub@CODE_STUB@@SAXXZ proc
    pop ecx
    mov edx,eax
    call ?OnCall@RET_INFO@@QAIHH@Z
?retstub@CODE_STUB@@SAXXZ endp

?callstub@CODE_STUB@@SAXXZ proc
    xchg [esp],ecx
    push edx
    lea edx,[esp + 8]
    call ?OnCall@FUNC_INFO@@QAIPAXPAPAX@Z
    pop edx
    pop ecx
    jmp eax
?callstub@CODE_STUB@@SAXXZ endp

you can ask from where i know this decorated names like ?OnCall@FUNC_INFO@@QAIPAXPAPAX@Z ? 您可以从哪里知道我装饰过的名字,例如?OnCall@FUNC_INFO@@QAIPAXPAPAX@Z look for very begin of c++ code - for several macros - and first time compile with #if 1 and look in output window. 寻找C ++代码的最开始-用于多个宏-并首次使用#if 1进行编译,然后在输出窗口中查找。 hope you understand (and you will be probably need use this names, but not my names - decoration can be different) 希望您能理解(您可能需要使用这个名称,但不能使用我的名字-装饰可以有所不同)

and how call void DoHook(DLL_TO_HOOK** pp) ? 以及如何调用void DoHook(DLL_TO_HOOK** pp) like that: 像那样:

DLL_TO_HOOK dth_kernel32 = { L"kernel32", { "VirtualAlloc", "VirtualFree", "HeapAlloc", 0 } };
DLL_TO_HOOK dth_ntdll = { L"ntdll", { "NtOpenEvent", 0 } };

DLL_TO_HOOK* ghd[] = { &dth_ntdll, &dth_kernel32, 0 };
DoHook(ghd);

Lets say there is a DLL A.DLL with a known entry point DoStuff 可以说有一个带有已知入口点DoStuff的DLL A.DLL

If the entry point DoStuff is known it ought to be documented somewhere, at the very least in some C header file. 如果知道入口点DoStuff ,则应将其记录在某个地方,至少要记录在某些C头文件中。 So a possible approach might be to parse that header to get its signature (ie the C declaration of DoStuff ). 因此,一种可行的方法可能是解析该标头以获取其签名(即DoStuff的C声明)。 Maybe you could fill some database with the signature of all functions declared in all system header files, etc... Or perhaps use debug information if you have it. 也许您可以用在所有系统头文件中声明的所有函数的签名填充某个数据库,等等。或者,如果有的话,可以使用调试信息。

If you call some function (in C) and don't give all the required parameters, the calling convention & ABI will still be used, and these (missing) parameters get garbage values (if the calling convention defines that parameter to be passed in a register, the garbage inside that register; if the convention defines that parameter to be passed on the call stack , the garbage inside that particular call stack slot). 如果您在C中调用某个函数但未提供所有必需的参数,则仍将使用调用约定ABI ,并且这些(丢失的)参数将获得垃圾值(如果调用约定定义了要传入的参数)一个寄存器,该寄存器内的垃圾;如果约定定义了要在调用堆栈上传递的参数,则为该特定调用堆栈插槽内的垃圾)。 So you are likely to crash and surely have some undefined behavior (which is scary , since your program might seem to work but still be very wrong). 因此,您很可能崩溃,并且肯定有一些未定义的行为 (这很可怕 ,因为您的程序似乎可以运行,但仍然非常错误)。

However, look also into libffi . 但是,也请查看libffi Once you know (at runtime) what to pass to some arbitrary function, you can construct a call to it passing the right number and types of arguments. 一旦知道(在运行时)将传递给某个任意函数的内容,就可以构造对它的调用,并传递正确的数目和参数类型。

My current thinking is that the arguments are on the stack 我目前的想法是参数在堆栈上

I think it is wrong (at least on many x86-64 systems). 我认为这是错误的(至少在许多x86-64系统上)。 Some arguments are passed thru registers. 一些参数通过寄存器传递。 Read about x86 calling conventions . 了解有关x86调用约定的信息

Would this work? 这行得通吗?

No, it won't work because some arguments are passed thru registers, and because the calling convention depends upon the signature of the called function (floating point values might be passed in different registers, or always on the stack; variadic functions have specific calling conventions; etc....) 不,它不起作用,因为某些参数是通过寄存器传递的,并且调用约定取决于被调用函数的签名(浮点值可能在不同的寄存器中传递,或者始终在堆栈中传递;可变参数具有特定的调用约定;等等...)

BTW, some recent C optimizing compilers are able to do tail call optimizations, which might complicate things. 顺便说一句,最近的一些C 优化编译器能够进行尾部调用优化,这可能会使事情复杂化。

There is no standard way of doing this because lot of things like calling conventions, pointer sizes etc matter when passing arguments. 没有标准的方法可以执行此操作,因为在传递参数时,许多事情(例如调用约定,指针大小等)都很重要。 You will have to read the ABI for your platform and write an implementation, which I fear again won't be possible in C. You will need some inline assembly. 您将必须阅读所用平台的ABI并编写一个实现,我担心再次用C语言无法实现。您将需要一些内联汇编。

One simple way to do it would be (for a platform like X86_64) - 一种简单的方法是(对于X86_64这样的平台)-

MyDoStuff:
    jmpq *__real_DoStuff

This hook does nothing but just calls the real function. 这个钩子什么也没做,只是调用了实函数。 If you want to do anything useful while hooking you will have to save restore some registers before the call (again what to save depends on the ABI) 如果要在挂机时执行任何有用的操作,则必须在调用之前保存恢复某些寄存器的内容(再次保存的内容取决于ABI)

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM