【问题标题】:How to speed up my memory scan program?如何加快我的内存扫描程序?
【发布时间】:2016-06-03 09:09:29
【问题描述】:

我目前正在编写一个内存扫描程序,用于扫描 ANOTHER 进程中的 AOB。 aob 包含通配符,由类似39 35 ?? ?? ?? ?? 75 10 6A 01 E8 的字符串表示

这是我目前所拥有的:

  1. 我只需要扫描与特定保护常数匹配的内存区域。例如 PAGE_READWRITE。
  2. 但是,由于我必须扫描大范围的内存,不可能一次将整个部分读入我的地址空间。我必须用缓冲区来做;每次我读入一个块并处理那个小块。在我的程序中,我持有一个currentAddress 变量,它存储了我现在正在查看的地址。
  3. #2 中的方法的问题是,aob 可能位于两个块之间。我解决这个问题的方法是:每当搜索因缓冲区结束而结束但到目前为止字节匹配时,退一步。(其中 N 是匹配的字节数。)
  4. 我的算法采取了幼稚的方式;它蛮力解决问题并搜索所有可能的位置。代码如下:

    char *haystack = .....
    short *needle = .... //"39 35 ?? ?? ?? ?? 75 10 6A 01 E8"
    outer:for(int i = 0; i < lengthOfHayStack - lengthOfNeedle; i ++)
    {
        for(int j = 0; j < lengthOfNeedle; j ++)
        {
            if(buffer[i+j] != needle[j] && needle[j] != WILDCARD)
                 continue outer;
        }
        //found one?
    }
    
  5. 这是算法明智的。在实现方面,我首先使用repne scasb 在大海捞针中寻找针的第一个字节。这个过程是通过内联汇编完成的。找到索引后,我用c代码比较其余部分,因为我需要照顾通配符。

我的 Memory Scanner 的性能还可以,但我仍然希望改进它。有哪些方法可以加快我的内存扫描器的速度?

PS:AOB 的模块未知。因此我必须扫描整个内存区域。

【问题讨论】:

    标签: c++ windows algorithm memory assembly


    【解决方案1】:

    理论答案

    将您的搜索模式视为正则表达式,并将其转换为Deterministic Finite AutomatonDFA。除了这个 Wikipedia 条目之外,您还应该找到很多 Google 食物可供调查。

    基本上,搜索模式被转换为状态机。状态机的输入是您正在搜索的内存中的字节流,自动机的最终状态是遇到搜索模式后达到的状态。

    在数学上想出一个逻辑上更快的算法应该是不可能的,因为状态机的输入将只是对内存范围的线性扫描,而不是当前代码中的嵌套循环方法。搜索复杂度应该是 O(n),与正在搜索的内存大小成线性关系。不要认为理论上可以实现更好的复杂性,在这里。

    正则表达式基本上是nondeterministic finite automatonNFA(如引用的维基百科条目中所示),使用最方便的可用算法将其转换为确定性有限自动机。然后,要扫描的内存范围成为 DFA 状态机的输入,一旦达到 DFA 的最终状态,就已经找到了模式。

    实用答案

    std::regex_search 采用一对双向迭代器,用于定义使用正则表达式搜索的序列。

    定义并实现一个满足双向迭代器要求的迭代器类,并在您希望搜索的内存区域上进行迭代。将搜索模式转换为std::regex,并使用std::regex_search进行搜索。

    简要浏览正则表达式库的正式定义似乎并不能表明std::regex_search 保证某种类型的最大复杂性(我在这里可能错了,我没有对整个库规范进行详尽的搜索) ;此外,它需要双向迭代器,而不是输入或转发迭代器,这表明该实现可能不如标准 DFA 高效,但实际上,可能需要最少的工作量,以获得相当快的结果。

    【讨论】:

    • 但是我该如何使用std::regex_search 处理#3 中所述的问题?
    • @Kelvin Zhang 您必须实现自定义双向运算符,以便在迭代其序列时,它在内部处理所有内存块,作为同一序列的一部分。自定义迭代器的 begin() 将迭代器指向第一个块的第一个字节。 end() 将迭代器指向最后一个块的末尾,并且递增/递减迭代器会在需要时自动将迭代器的内部胆量从一个块推进到另一个块,以便正则表达式引擎看到一个包含所有块的单个序列,挤压在一起作为单字节流。
    【解决方案2】:

    1) 这里的其他答案建议构建一个 DFA,即线性时间。 您可以改为构建Knuth-Morris-Pratt search,并在许多情况下实现次线性次。它会根据跳过的块之前已经看到的位跳过不能包含该模式的内存块。如果你想让这个速度真的很快,我想你会发现核心算法必须用汇编程序编码。

    2) 与其从目标进程空间读取块(需要通过内核进行复制),不如将虚拟页面从目标空间映射到搜索者的空间。您可以使这些页面变得非常大(16Mb?),从而摊销映射成本;复制成本为零。

    【讨论】:

    • 线性,即使是小数,常数仍然是线性的。
    • @BenVoigt:是的。 OP想要“更快”。这意味着如果你有一个线性算法,你想处理常数因子。 “次线性”是指“较小的常数因子”。特别是,DFA 搜索将检查每个字节; KMP 搜索实际上不会查看某些字节。
    • 我的观点是 KMP 不是“次线性”的,它仍然是线性的,但比例常数较小。
    • @BenVoigt:是的,我们同意。
    • 我认为你应该更加努力。我的建议基于这样一个事实,即操作系统无法应另一个进程的请求将页面从一个进程映射到另一个进程,这并没有明显的原因;这对调试器特别有用。 shmget 暗示该机器已经存在于操作系统中。也许它不存在。但是,如果您仔细观察一下,您会在现代操作系统中发现哪些功能令人惊讶。
    【解决方案3】:

    repne scasbisn't faster than a plain byte-at-a-time loop, unfortunately.

    最好用向量指令扫描起始字节:

    使用pcmpeqb 一次检查整个向量是否有匹配的起始字节。使用匹配的位位置作为偏移量来加载完整的匹配候选。 (未对齐的负载比尝试进行数据相关的移位或随机播放更容易,因为palignr 仅可用于立即计数。索引pshufb 随机播放掩码表是可能的,但无济于事,因为无论如何您都需要加载更多内容。

    # load your search pattern into xmm4
    #broadcast the first byte to every byte of xmm5
    # then
    .loop:
        ...
        vpcmpeqb   xmm0, xmm5, [rsi]
        vpmovmskb  ecx, xmm0
        test       ecx,ecx
        jnz    .found_a_0x39_byte
    .resume_search:
        add        rsi, 16
        cmp        rsi, rdi  # end pointer
        jb     .loop
    ...
        .found_a_0x39_byte
        bsf        edx, ecx
        vpcmpeqb   xmm0, xmm4, [rsi+rdx]    ; check against the full pattern (unaligned load, use movdqu if implementing without avx)
        vpmovmskb  eax, xmm0
    
        ; eax has a one bit for every matching byte
        ; "39 35 ?? ?? ?? ?? 75 10 6A 01 E8"
        ;0b 1  1  0  0  0  0  1  1  1  1  1   reversed because little endian
        not        eax                 ; 0 bits are matching bytes
        test       eax, 0b11111000011  ; check that all bits we care about are zero
        jnz .try_again_with_next_set_bit_in_ecx  ; TODO implement this loop
        # .found_match:
        add        rdx, rsi    ; pointer to the start of the match
    

    您需要遍历 ecx 中设置的位位置,以检查所有候选起点。或者可以通过检查模式的第二个字节,将该位掩码左移一位,然后将其与第一个位掩码进行“与”来细化。然后你会得到一个只有 0x39 后跟 0x35 的位置的掩码。

    循环设置位:BMI1 的BLSR 将清除源中的最低设置位,如果结果为零,则设置ZF。这可能会有所帮助。 (它还设置CF 如果源是零开始,但这在这里没有用)。如果你不能使用 BMI1,there are other ways to clear the lowest bit

    请注意,bsf 如果输入为零,则设置 ZF,即使在这种情况下未定义输出寄存器。 (在这种情况下,使用 BMI1 的 tzcnt 获得 3264 的保证结果。在 C 语言中更有用(其中函数不能返回值和布尔值),但并不总是对 asm 的改进.)


    你可能很容易在内存带宽上遇到瓶颈,所以可以做类似的事情

    vpcmpeqw    xmm0, xmm5, [rsi]
    vpcmpeqw    xmm1, xmm5, [rsi+1]
    

    仅在找到候选的两字节序列时才退出主搜索循环。不过,这将导致 Sandybridge 的 L1 中的缓存库冲突。它只能从 128B 块的相同 1/8(2 个高速缓存行)中为每个时钟提供一个负载。英特尔 Haswell 及更高版本没有缓存库冲突。理论上,SnB 可能通过仅使用对齐负载并使用palignr 获得未对齐负载进行第二次检查而获胜。这很可能。在只有一个加载端口的 pre-SnB 上表现出色,并且您还希望将数据用于对齐检查。


    为了利用库函数来完成繁重的工作,GNU libc 提供了memmem。它类似于strstr,但采用显式大小而不是对以空字符结尾的字符串进行操作。您在 Windows 上,但也许有一个类似的函数具有矢量优化实现。在75 10 6A 01 E8 序列上使用它来寻找潜在的最终候选者。


    在块之间的边界,也许只是做一些手动的一次字节检查?或者使用palignr 以两种可能的方式将一个块的最后 16B 与下一个块的前 16B 结合起来?

    如果从块的末尾有一个小于 11B 的 0x39,也许只做palignr

    【讨论】:

    • 但是在我用 repne scasb 替换典型的 c 字节到字节代码之后,我的程序确实变得更快了。 (比以前快两倍)
    • @KelvinZhang:您是否在禁用优化的情况下进行编译? repne scas 并不可怕,但在 Haswell 上,Agner Fog 的表说它每 2 个时钟大约 1 个字节。一个编写良好的循环应该很容易每个时钟执行一个字节,并且可能每个时钟 2B 并进行一些展开。
    • 我在 Windows 8.1 x64 上使用 Visual Studio 2013 优化 Maximize Speed /O2@PeterCordes
    • 如果您的 for 循环当时比较 4 个字节,则 for 循环可能比 repne scasb 快。这仍然会比 repne scasd 慢。但是当时比较 4 个字节对我来说是不切实际的。因为我的模式包含通配符。另外,您不能假设该模式不会出现在那些不是 4 的倍数的地址中。@PeterCordes
    • @KelvinZhang:一次一个字节的循环至少可以和repne scasb 一样快。根据编译器做了什么,循环中有什么,以及分支预测,它可能不是。 repne scasb 确实具有在找到候选人时没有分支错误预测的优势。显然,我不是在谈论仅进行 4B 比较的循环。 (尽管有比特黑客you can check for any byte in a word being a specific value。这显然远没有 SSE2 循环那么快。)
    猜你喜欢
    • 2011-05-08
    • 1970-01-01
    • 2013-09-13
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2012-11-19
    • 1970-01-01
    相关资源
    最近更新 更多