Hook Heaps and Live Free

admin 2023年5月25日02:24:19评论18 views字数 12976阅读43分15秒阅读模式

更新

最终决定添加一个至少 EXE 的小演示。在这里:https://github.com/waldo-irc/LockdExeDemo

介绍

我想写这篇博文来谈谈Cobalt Strike、函数Hook和Windows堆的问题。我们将针对BeaconEye(https://github.com/CCob/BeaconEye)作为我们的检测工具进行绕过。

最近,我看到MDSec Labs发布了许多关于NightHawk及其一些魔法的推文。这激发了我尝试在自己的Dropper中重新创造出其中一些魔法的想法,以便更好地理解它并尝试在自己的红队工具包中创建一个有竞争力的Dropper。我决定从对堆分配进行加密开始,这是最好的起点。

让我们稍微谈谈为什么我们想要加密堆分配。我不打算深入讨论堆栈和堆之间的区别。堆栈是有局部作用域的,并且通常在函数执行完成时超出作用域。这意味着在函数运行期间设置在堆栈上的项目会在函数返回和完成时从堆栈上移除,这对于您想要长期保留在内存中的变量显然并不理想。这就是堆的用途。堆更适用于长期存储解决方案。在堆上分配的内存会一直保留在堆上,直到您的代码手动释放这些分配。如果您不断地将数据分配到堆上而从未释放任何东西,这也可能导致内存泄漏的问题。

根据这个描述,让我们考虑一些堆可能包含的数据。堆可能包含长期的配置信息,比如Cobalt Strike的配置,例如牺牲进程、睡眠时间、回调路径等。了解到这一点,显然我们希望保护这些数据。然后你可能会说:“但等等,有睡眠和混淆!”但是(除非我做错了什么),它似乎并没有真正加密堆字符串。这意味着只要您的Cobalt Strike代理在内存中运行,任何防御者都可以基本上以明文形式在进程的堆空间中看到您的配置。作为防御者,我们甚至不需要识别您注入的线程,我们可以轻松使用HeapWalk()(https://docs.microsoft.com/en-us/windows/win32/api/heapapi/nf-heapapi-heapwalk)遍历所有分配,然后尝试识别诸如"%windir%"这样简单的内容,以找出您的牺牲进程(显然,这可以更改,不是一个很好的硬指标,但您可以得到一般的想法 - 下面是示例代码):

static PROCESS_HEAP_ENTRY entry;
BOOL IdentifyStringInHeap() {
    SecureZeroMemory(&entry, sizeof(entry));
    while (HeapWalk(GetProcessHeap(), &entry)) {
        if ((entry.wFlags & PROCESS_HEAP_ENTRY_BUSY) != 0) {
            // Find str in the allocated space by iterating over its whole size
            // lpData is the pointer and cbData is the size
            findStr("%windir%", entry.lpData, entry.cbData);
        }
    }
}

正如您所看到的,这是一个相当令人担忧的问题。既然我们知道了这个问题,现在我们必须着手解决它。这引出了一个问题,如何解决呢?

所以我们有几种潜在的解决方案,每种解决方案都会遇到一些问题。让我们从独立的可执行文件开始,因为这个比较简单。这个二进制文件就是您的Cobalt Strike有效载荷,除此之外没有其他内容。在这种情况下,我们可以非常容易地实现我们的目标,因为唯一使用堆的就是我们邪恶的有效载荷。使用之前提到的HeapWalk()函数,我们可以迭代遍历堆中的每个分配并对其进行加密!为了防止错误,我们可以在加密堆之前暂停所有线程,然后在加密完成后恢复所有线程。

重要提示!即使您认为自己的程序是单线程的,Windows似乎会在后台提供用于垃圾回收和其他类型功能的线程,供类似RPC和WININET的工具使用。如果您不暂停这些线程,它们在尝试引用加密的分配时会导致您的进程崩溃。以下是一个示例崩溃情况:

Hook Heaps and Live Free


Hook Heaps and Live Free

理论上,这是一个相当简单的实现!我们需要完成的最后一部分拼图是如何在Cobalt Strike休眠时调用所有这些功能。解决方案很简单!

Hooking

如果我们查看Cobalt Strike二进制文件的IAT(导入地址表),我们会发现它利用Kernel32.dll的Sleep函数来实现休眠功能。Hook Heaps and Live Free                    

我们只需要在kernel32.dll中hook Sleep函数,然后在我们hook的Sleep函数中修改行为,如下所示:

void WINAPI HookedSleep(DWORD dwMiliseconds) {
        DoSuspendThreads(GetCurrentProcessId(), GetCurrentThreadId());
        HeapEncryptDecrypt();

        OldSleep(dwMiliseconds);

        HeapEncryptDecrypt();
        DoResumeThreads(GetCurrentProcessId(), GetCurrentThreadId());
}

基本上,我们暂停所有线程,运行以下类似的加密程序例程:

static PROCESS_HEAP_ENTRY entry;
VOID HeapEncryptDecrypt() {
    SecureZeroMemory(&entry, sizeof(entry));
    while (HeapWalk(currentHeap, &entry)) {
        if ((entry.wFlags & PROCESS_HEAP_ENTRY_BUSY) != 0) {
            XORFunction(key, keySize, (char*)(entry.lpData), entry.cbData);
        }
    }
}

这段代码创建了一个PROCESS_HEAP_ENTRY结构体,并在每次调用时将其清零,然后遍历堆并将数据放入结构体中。然后,我们检查当前堆条目的标志位,并验证是否已分配内存,以便只对已分配的内存进行加密。

然后我们运行原始/旧的睡眠函数,该函数将被创建为我们hooking功能的一部分,然后在恢复线程之前解密,这样我们就可以防止在再次引用分配的时候崩溃。总的来说,这是一个相当简单的过程。我们还没有触及的是hooking的能力。

首先,什么是hooking函数?意味着我们重新定向对进程空间内的函数(例如Sleep( ))的调用,以便在内存中运行我们的自定义函数。通过这样做,我们可以改变函数的行为,观察调用时传递的参数(由于我们现在调用的是自定义函数,例如,我们可以打印传递给它的参数),甚至可以完全阻止函数的正常工作。在许多情况下,这是EDR(终端检测和响应)工作的方式,用于监视和警报可疑行为。它们hook他们认为有趣的函数,例如CreateRemoteThread,并记录所有参数,以便以后警报可疑调用。

因此,让我们来谈谈如何hook一个函数,对我来说,这是整个经历中最有趣和最有意义的部分。有很多方法可以实现这一点,但我只想提到两种,并深入探讨一种。我将提到的两种技术是IAT  hooking 和Trampoline Patching(这可能不是正确的术语,我不太确定什么是正确的)。

IAT Hooking

IAT hooking的思想很简单。每个进程空间都有一个称为导入地址表的表格。该表格包含了被二进制文件导入并用于使用的DLL和相关函数指针的列表。hook的推荐和最稳定的方式是遍历导入地址表,识别您要hook的DLL,找到您要hook的函数,并将其函数指针覆盖为您的自定义hook函数。每当进程调用该函数时,它将定位到该指针并调用您的函数。如果您希望在hook函数中调用原始函数,可以存储旧的指针。在ired.team网站上已经有一个示例,我将在这里提供链接:https://www.ired.team/offensive-security/code-injection-process-injection/import-adress-table-iat-hooking。

现在,这种方法有其优点和缺点。明显的两个优点是实现简单和非常稳定。您只是改变了要调用的函数,没有任何破坏性风险。现在让我们来谈谈缺点。

如果有任何使用GetProcAddress()来解析函数的情况,它不会在IAT中(尽管我相信您可以执行EAT hooking 来解决这个问题,但这是另一个话题)。这是一种非常有针对性的hook方法,这可能是一个优点,但如果您想要监视更广泛的调用(例如,能够hook NtCreateThreadEx而不仅仅是CreateRemoteThread,如果调用更底层,您可能会错过许多调用),则具有双重效果。从理论上讲,它也更容易被检测到。

这个过程很简单,我就不详细介绍了。这里还有另一篇相关文章:https://guidedhacking.com/threads/how-to-hook-import-address-table-iat-hooking.13555/

Trampoline Hooking

现在让我们来谈谈Trampoline Patching。Trampoline Patching要比IAT Hooking困难得多,要更难以稳定运行,并且由于需要解析很多相对地址,因此在x64上普遍执行起来可能需要很长时间。幸运的是,有人已经花时间编写了一个开源库,以非常稳定的方式完成了所有必要的操作:https://github.com/TsudaKageyu/minhook。

但为了学习的目的,我们还是来看一下这种hook方法的工作原理,以便在需要时可以重新实现自己的hook。起初,我考虑分享我的实现,但我决定将其留给读者作为一项练习(尤其是因为已经存在其他用于学习的实现)。相反,我们将调试我的实现,以更好地理解这种修补机制的工作方式。

总体思路如下,下面是一堆文字!我们将使用GetProcAddress和LoadLibrary来解析函数的基址。然后,我们将解析前X个有效汇编指令,这些指令的总长度至少为5个字节。具体来说,我们将使用一种非常常见的技术,利用5字节的相对跳转操作码(E9)来跳转到函数基址加减2GB的位置,然后跳转到我们的任意函数。显然,为了使其工作,我们需要覆盖函数的前5个字节,但是如果我们这样做,如果我们需要再次调用它,就会破坏原始函数。为了确保我们可以在需要时还原旧功能,我们需要保存第一条指令,然后将其写入作为跳板的代码洞中,跳板会执行它,并跳回函数的下一条指令。但是,如果第一条指令只有4个字节,如果我们写入5个字节,就会破坏第二条指令的第一个操作码!所以我们需要将前两条指令存储在我们的跳板中,此时跳板将执行前两条指令,并跳回第三条指令以继续执行。跳板所在的位置将成为被hook的原始函数的新指针。因此,原始函数指针现在的运行方式如下所示 ->

OldFunction = Trampoline -> JMP to original location of function + size of trampoline

这个代码洞也会有一个跳转到我们的任意函数的某个位置,写在原函数底部的相对的5字节跳转到这个位置,然后跳转到任意函数,就像这样---->>。

Base of old function jmps -> cave that contains the following assembly 
FF 25 00 00 00 00 [PUSH QWORD PTR]
00 00 00 00 00 00 00 00 [This is an arbitrary pointer to your functionin your C it would be &ArbitraryFunction]

有了这个,我们现在可以在调用旧函数时运行我们的任意函数,并在需要时调用旧/原始函数。

现在让我们在调试过程中看一下这个。我们将hook MessageBoxA函数。首先,我们来看看干净的MessageBoxA和hook 后的对比。

首先,我们hook MessageBoxA函数,代码如下所示:


        Hook("user32.dll""MessageBoxA", (LPVOID)NewMessageBoxA, (FARPROC*)&OldMessageBoxA);
    

MessageBoxA住在user32.dll中,所以如果我们想得到它的基址,就必须在那里找到它。有了这个,我们找到了基地址,修补了所有的东西,在一个山洞里添加了一些代码,解决了相对跳转的问题,并把蹦蹦跳跳的东西储存在OldMessageBoxA()中。

我们的任意hook的MessageBoxA函数将看起来像这样:

int WINAPI HookedMB(HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType) {
    return OldMB(hWnd, "HOOKED", lpCaption, uType);
}

我们需要匹配返回类型和参数,并在此处运行原始的MessageBoxA函数,但无论如何,我们将修改文本始终为"HOOKED"

现在让我们看一下在进行hook之前和之后的情况。

之前Hook Heaps and Live Free
Hook Heaps and Live Free

之后Hook Heaps and Live Free
Hook Heaps and Live Free

因此,MessageBoxA函数是之前提到问题的一个相当完美的例子。如您在“修改前”屏幕截图中所见,第一条指令只有4个字节,这意味着我们需要存储前两条指令,然后我们的相对跳转继续覆盖前5个字节。我们无需修改剩余的字节,我们的 trampoline 会执行我们存储的前两条指令,然后跳回到0x00007FF8EF70AC27的位置。让我们继续在调试器中查看新的hook 功能是什么样子的。我们将从运行JMP指令后开始:


Hook Heaps and Live Free

在这里,我们首先看到了两个00,我这样做是为了确保如果我们在代码洞中写入多个跳板函数,不会覆盖函数指针的00 00结尾部分。接下来,我们看到了FF 25 00 00 00 00,这是JMP QWORD PTR指令。紧接着,您会看到指向我们hook函数的8个字节指针!如果我们执行这条指令,将会看到:


Hook Heaps and Live Free


最后Hook Heaps and Live Free



在这里,我们可以看到我们正在执行我们的hook函数。hook函数只运行并返回旧函数,所以让我们继续执行,进入旧函数:


Hook Heaps and Live Free


让我们看看这将导致什么:

Hook Heaps and Live Free

如果您观察这个图像,您会看到我们正在执行我们覆盖的那两条指令!紧接着复制的字节,我们进行了第二个JMP QWORD PTR指令,直接跳转到OriginalFunction+7的位置(因为在这种情况下跳板函数的大小为7个字节)。这将使我们正好位于第三条指令的开始处。让我们看看:Hook Heaps and Live Free

在这里,您可以看到我们现在正在执行CMP指令,继续从我们离开的地方执行!通过这个过程,您可以看到类似minhook这样的工具是如何工作的。现在您可以选择像我一样自己实现它,或者直接使用稳定的工具,比如minhook。如果您感到冒险,我会给您一个免费的非优化代码示例,用于查找前面2 GB的任何代码洞(您需要自己解决一些问题,毕竟,生活中没有完全免费的东西!):

    for (i = 0; i < 2147483652; i ++) {
        currentByte = (LPBYTE)funcAddress + i;
        if (memcmp(currentByte, "x00", 1) == 0) {
            caveLength += 1;
            LPBYTE newByteForward = currentByte + 1;
            if (memcmp(newByteForward, "x00", 1) == 0) {
                while (memcmp(newByteForward, "x00", 1) == 0) {
                    caveLength++;
                    newByteForward++;
                }
            }
            if (caveLength >= totalSize) {
                while (memcmp(currentByte - 1, "x00", 1) != 0 || memcmp(currentByte - 2, "x00", 1) != 0) {
                    currentByte++;
                }
                // Make sure the section is executable or try again
                MEMORY_BASIC_INFORMATION info;
                VirtualQuery(currentByte, &info, totalSize);
                if (info.AllocationProtect == 0x80 || info.AllocationProtect == 0x20 || info.AllocationProtect == 0x40) {
                    break;
                }
                else {
                    i += caveLength;
                    caveLength = 0;
                    continue;
                }
            }
            else {
                i += caveLength;
                caveLength = 0;
                continue;
            }
        }
    }

将EXE组合在一起

是时候把一切都整合在一起,看看最终的效果如何。让我们逐步了解以下步骤:

  1. hook Sleep( )函数。

  2. hook 函数中暂停所有线程。

  3. 使用HeapWalk( )对所有分配进行加密。

  4. 通过trampoline函数运行原始的Sleep( )函数。

  5. 使用HeapWalk( )对所有分配进行解密。

  6. 恢复所有线程。

我假设你已经具备了自己的加密、hook和完整线程暂停功能。代码应该类似于下面的样子:


static PROCESS_HEAP_ENTRY entry;
VOID HeapEncryptDecrypt() {
    SecureZeroMemory(&entry, sizeof(entry));
    while (HeapWalk(currentHeap, &entry)) {
        if ((entry.wFlags & PROCESS_HEAP_ENTRY_BUSY) != 0) {
            XORFunction(key, keySize, (char*)(entry.lpData), entry.cbData);
        }
    }
}

static void(WINAPI* OrigianlSleepFunction)(DWORD dwMiliseconds);
void WINAPI HookedSleepFunction(DWORD dwMiliseconds) {
    DoSuspendThreads(GetCurrentProcessId(), GetCurrentThreadId());
    HeapEncryptDecrypt();

    OriginalSleepFunction(dwMiliseconds);

    HeapEncryptDecrypt();
    DoResumeThreads(GetCurrentProcessId(), GetCurrentThreadId());
}
    
void main()
{
    DoSuspendThreads(GetCurrentProcessId(), GetCurrentThreadId());
    Hook("kernel32.dll""Sleep", (LPVOID)HookedSleepFunction, (FARPROC*)&OriginalSleepFunction, true);
    if (!OldAlloc) {
        MessageBoxA(NULL, "Hooking RtlAllocateHeap failed.""Status", NULL);
    }
    DoResumeThreads(GetCurrentProcessId(), GetCurrentThreadId());
    // Sleep is now hooked
}
    

总的来说,这段代码非常直接,显然不包括你的植入物。你可以通过某种方式在同一进程空间中执行植入物的shell代码,或者将其转化为一个DLL并将其注入到beacon执行之后!由于它使用了HeapWalk(),它可以对过去、现在和未来的所有分配进行加密,而只需要hook Sleep()函数开始调用即可。

演示时间!为了演示目的,我们对睡眠时间小于等于1的内容不进行加密。Hook Heaps and Live Free


正如您所看到的,首先我们睡眠1秒,BeaconEye捕获到了我们的配置。然后我们将睡眠时间更改为5,加密开始,我们成功关闭了BeaconEye。

请记住,由于这会加密所有的堆分配,因此这不适用于作为注入线程的情况,因为注入线程所在的进程在Cobalt Strike睡眠时将无法正常工作。想象一下,注入到explorer.exe中,每次beacon睡眠时,整个explorer都会冻结。所以,当涉及到注入线程时,这种解决方案显然并不理想。如果我们想要一个可以作为线程运行的解决方案,我们将需要做更多的工作。

针对线程的堆加密:考虑因素

所以我们的新设计将需要与一个单独的线程一起工作。我们将无法暂停其他线程,无法锁定堆,主进程必须继续运行。这意味着当我们注入一个beacon线程时,我们必须确保所有加密的分配都来自于该线程。如果我们正确地针对线程进行定位,我们就可以成功避免问题。那么我们该如何做到这一点呢?

我们现在在我们的dropper中具备了hook的能力。为了操纵堆,有一组被称为Windows内部函数的函数。这些函数在Windows中如下所示:

  1. HeapCreate( )

  2. HeapAllocate( )

  3. HeapReAllocate( )

  4. HeapFree( )

  5. HeapDestroy( )

在Windows内部,Malloc和free实际上是HeapAllocate( )和HeapFree( )的高级包装器,而HeapAllocate( )和HeapFree( )则是RtlAllocateHeap( )和RtlFreeHeap( )的高级包装器。而RtlAllocateHeap( )和RtlFreeHeap( )是在Windows中直接管理堆的最底层函数。


Hook Heaps and Live Free

这意味着如果我们hook RtlAllocateHeap( )、RtlReAllocateHeap( )和RtlFreeHeap( )这三个函数,我们就可以跟踪Cobalt Strike中的所有分配和释放堆空间的操作。这很好,因为通过hook这三个函数,我们可以在一个映射中插入分配和重新分配的地址,并在释放操作时从映射中移除它们。然而,这仍然没有解决我们的线程目标问题。

很简单!事实证明,如果你从一个被hook的函数中调用GetCurrentThreadId(),你实际上可以获得调用线程的线程ID!利用这一点,你可以注入你的beacon,获取它的线程ID,然后执行类似下面的操作:

GlobalThreadId = GetCurrentThreadId(); We get the thread Id of our dropper!
HookedHeapAlloc () {
    if (GlobalThreadId == GetCurrentThreadId()) { // If the calling ThreadId matches our initial thread id then continue
        // Insert allocation into a list
    }
}

对于重新分配操作,也要进行相应的处理,并在释放操作时进行移除操作。现在你就可以针对一个线程了!到目前为止,一切都很简单。但还记得之前提到的问题吗?为什么我们需要暂停其他线程?WININET和RPC调用仍然会在我们及时解密之前尝试访问加密内存。这里有几个选择,但我个人使用了一个我认为非常有趣的方法。由于加载的shell代码既不是有效的EXE文件,也不是DLL文件,我能够针对任何调用源自无名称模块的调用进行目标分配。

为了使这个机制起作用,我们需要能够解析进行函数调用的模块。可以使用以下代码来实现:

#include <intrin.h>
#pragma intrinsic(_ReturnAddress)

GlobalThreadId = GetCurrentThreadId(); We get the thread Id of our dropper!

HookedHeapAlloc (Arg1, Arg2, Arg3) {
    LPVOID pointerToEncrypt = OldHeapAlloc(Arg1, Arg2, Arg3);
    if (GlobalThreadId == GetCurrentThreadId()) { // If the calling ThreadId matches our initial thread id then continue
    
     HMODULE hModule;
     char lpBaseName[256];

  if (::GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, (LPCSTR)_ReturnAddress(), &hModule) == 1) {
          ::GetModuleBaseNameA(GetCurrentProcess(), hModule, lpBaseName, sizeof(lpBaseName));
         }

        std::string modName = lpBaseName;
        std::transform(modName.begin(), modName.end(), modName.begin(),
                [](unsigned char c "") { return std::tolower(c); });
        if (modName.find("dll") == std::string::npos && modName.find("exe") == std::string::npos) {
                     // Insert pointerToEncrypt variable into a list
        }
    }
}

这将获取_ReturnAddress内部函数,并将其与GetModuleHandleEx和标志GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS一起使用,以确定是哪个模块进行了此调用。然后,我们可以将其转换为小写字符串,并且如果该字符串不包含DLL或EXE,我们就可以插入它。有了这个,你就有了一个稳定的加密睡眠时要加密的分配列表!对于hook的重新分配,你需要重复这个过程。

为了运行加密操作,你需要迭代该列表并加密每个分配的地址,而不是使用HeapWalk( )!这将取决于你决定使用的是映射、向量、链表还是其他数据结构。关键是你要存储由真实的HeapAlloc或ReAlloc返回的指针,并遍历数组,按大小加密数据。上面示例中的第三个参数是大小(https://docs.microsoft.com/en-us/windows/win32/api/heapapi/nf-heapapi-heapalloc)。

所以现在我们hook了4个不同的函数,在一个向量中基于线程ID插入分配的地址,在睡眠时迭代该向量并加密每个地址,如果成功,我们应该能够绕过BeaconEye。

演示时间!同样地,为了演示目的,我们对睡眠时间小于等于1的内容不进行加密。

Hook Heaps and Live Free


Hook Heaps and Live Free



成功!我们可以注入到任何进程中,只加密我们自己线程的堆,而进程不会因为我们睡眠而崩溃!

旅程中的其他观察

在这个稳定的堆加密的过程中,我还发现了3个有趣的发现。让我们逐个来看一下。

首先,还有两种可以完全绕过BeaconEye的方法。偶然间我发现,将beacon注入到explorer.exe中似乎完全绕过了BeaconEye的检测。演示如下:

Hook Heaps and Live Free


如您所见,注入到cmd.exe中被捕获,但注入到explorer.exe中似乎没有被有效地扫描。

此外,对二进制文件进行符号初始化也完全绕过了BeaconEye的检测,只需使用以下代码即可:

#include <dbghelp.h>
#pragma comment(lib, "dbghelp.lib")

SymInitialize(GetCurrentProcess(), NULL, TRUE);

一如既往的演示:Hook Heaps and Live Free


最后,我注意到一个有趣的事实……我不确定大家是否知道,Cobalt Strike在退出时根本不会清理堆分配的内存。这意味着如果你退出了注入的Cobalt Strike线程,并且进程没有重新启动,你的配置信息现在会作为可提取的遗留物留在内存中。

最后的演示:

Hook Heaps and Live Free



也许通过本文教给你的一切,你可以构建一些方法来解决这个问题?

Hook Heaps and Live Free


至于蓝队,现在你知道退出并不是结束!我可能在这篇文章中犯了一些错误,请随时告诉我,我很乐意进行更正,因为教育是主要目标。我在Discord上,你会找到我的联系方式。

感谢

SecIdiot - 帮助我思考和解决许多问题,并教我关于HeapWalk( )的知识。

Mr.Un1k0d3r - 教给我们如何使用C编写恶意软件,并通过他的hooking课程激发了这篇文章。

ForrestOrr - 帮助我学习hooking 和 trampolines的知识,并引导我理解堆加密的逻辑。

在我的下一篇文章中,我希望探讨一些其他的hooking能力和想法,这些能力和想法可以帮助我们实现更多有趣的功能。

文章来源于:https://www.arashparsa.com/hook-heaps-and-live-free/

 若有侵权请联系删除

加下方wx,拉你一起进群学习


Hook Heaps and Live Free

原文始发于微信公众号(红队蓝军):Hook Heaps and Live Free

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2023年5月25日02:24:19
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   Hook Heaps and Live Freehttp://cn-sec.com/archives/1757164.html

发表评论

匿名网友 填写信息