允许用户在 Firefox 中阻止注入的第三方 DLL

在 Firefox 110 中,用户现在可以控制哪些第三方 DLL 允许加载到 Firefox 进程中

让我们讨论一下这意味着什么以及它可能在什么时候有用。

什么是第三方 DLL 注入?

在 Windows 上,第三方产品有多种方法可以将它们的代码注入到其他正在运行的进程中。这样做有许多原因;最常见的是用于防病毒软件,但其他用途包括硬件驱动程序、屏幕阅读器、银行业务(在某些国家)以及不幸的是,恶意软件。

将来自第三方产品的 DLL 注入 Firefox 进程非常常见 - 根据我们的遥测数据,超过 70% 的 Windows 用户至少有一个这样的 DLL!(明确地说,这意味着任何未由 Mozilla 数字签署或属于操作系统部分的 DLL)。

大多数用户在 DLL 被注入 Firefox 时并不知道,因为大多数情况下没有明显的迹象表明正在发生这种情况,除了检查about:third-party 页面

不幸的是,将 DLL 注入 Firefox 会导致性能、安全或稳定性问题。这是由于以下几个原因

  • DLL 通常会挂钩到 Firefox 内部函数,这些函数会随着版本的发布而发生变化。我们不会特别努力维护内部函数的行为(这些函数有数千个),因此第三方产品的发布者必须认真测试新版本的 Firefox,以避免稳定性问题。
  • Firefox 作为一款网页浏览器,会加载并运行来自不受信任且可能具有攻击性的网站的代码。考虑到这一点,我们付出了很多努力来确保 Firefox 的安全性;例如,请参见站点隔离安全架构改进的进程隔离。第三方产品可能并不像我们那样重视安全性。
  • 我们在 Firefox 上运行大量的测试,第三方产品可能不会进行那么多的测试,因为它们可能不是专门为与 Firefox 协同工作而设计的。

事实上,我们的数据显示,在 Windows 上,所有 Firefox 崩溃报告中,超过 2% 出现在第三方代码中。尽管 Firefox 已经阻止了许多已知会导致崩溃的特定第三方 DLL(有关详细信息,请参见下文)。

这也低估了由第三方 DLL 间接引起的崩溃,因为我们的指标只查找调用堆栈中的第三方 DLL。此外,第三方 DLL 更可能在启动时导致崩溃,这对用户来说更严重。

Firefox 有一个第三方注入策略,我们尽可能建议第三方使用扩展来与 Firefox 集成,因为这是官方支持的,并且更加稳定。

为什么不默认阻止所有 DLL 注入?

为了获得最大的稳定性和性能,Firefox 可以尝试阻止所有第三方 DLL 注入其进程。但是,这会破坏一些用户希望能够与 Firefox 一起使用的有用产品,例如屏幕阅读器。这也将是技术上的挑战,而且可能无法阻止每个第三方 DLL,尤其是那些以比 Firefox 更高的权限运行的第三方产品。

自 2010 年以来,Mozilla 能够为所有 Windows 用户阻止 Firefox 中的特定第三方 DLL。我们只在万不得已的情况下才会这样做,在尝试与供应商联系以修复根本问题之后,我们尽可能紧密地进行调整,使 Firefox 用户不再崩溃。(我们可以仅阻止 DLL 的特定版本,并且只在导致问题的特定 Firefox 进程中这样做)。这是一个有用的工具,但我们只在特定第三方 DLL 导致大量崩溃,以至于它出现在 Firefox 崩溃列表中时才考虑使用它。

即使我们知道某个第三方 DLL 会导致 Firefox 崩溃,有时 DLL 提供的功能对用户来说至关重要,用户不希望我们代表他们阻止 DLL。如果用户需要使用某些软件才能访问银行或政府帐户或申报税款,我们阻止它并不会对他们有任何帮助,即使阻止它会使 Firefox 更稳定。

赋予用户阻止注入的 DLL 的权力

在 Firefox 110 中,用户可以阻止第三方 DLL 加载到 Firefox 中。这可以在about:third-party 页面上完成,该页面已经列出了所有已加载的第三方模块。about:third-party 页面还会显示哪些第三方 DLL 参与了之前的 Firefox 崩溃;除了 DLL 发布者的名称之外,希望这将使用户能够做出明智的决定,即是否阻止 DLL。这是一个最近导致 Firefox 崩溃的 DLL 的示例;单击带有破折号的按钮将阻止它

Screenshot of the about:third-party page showing a module named "CrashingInjectibleDll.dll" with a yellow triangle indicating it has recently caused a crash, and a button with a dash on it that can be used to block it from loading into Firefox.

以下是阻止 DLL 并重新启动 Firefox 后的外观

 Screenshot of the about:third-party page showing a module named "CrashingInjectibleDll.dll" with a yellow triangle indicating it has recently caused a crash, and a red button with an X on it indicating that it is blocked from loading into Firefox.

如果阻止 DLL 导致问题,则在疑难解答模式下启动 Firefox 将禁用对该 Firefox 运行的所有第三方 DLL 阻止,并且可以在 about:third-party 页面上像往常一样阻止或取消阻止 DLL。

工作原理

阻止 DLL 加载到进程中是一项棘手的工作。为了检测所有加载到 Firefox 进程中的 DLL,必须在启动过程中非常早地设置阻止列表。为此,我们有启动程序进程,它在挂起状态下创建主浏览器进程。然后,它设置任何沙盒策略,从磁盘加载阻止列表文件,并将条目复制到浏览器进程中,然后再启动该进程。

复制是通过一种有趣的方式完成的:启动程序进程使用CreateFileMapping()创建了一个由操作系统支持的文件映射对象,并且在用阻止列表条目填充它之后,它会复制句柄并使用WriteProcessMemory()将该句柄值写入浏览器进程。具有讽刺意味的是,WriteProcessMemory() 通常被第三方 DLL 用作将自身注入其他进程的一种方式;在这里,我们使用它来设置一个位于已知位置的变量,因为启动程序进程和浏览器进程是从同一个 .exe 文件中运行的!

由于一切都发生在启动过程中的早期,早于加载 Firefox 配置文件,因此被阻止的 DLL 列表存储在每个 Windows 用户而不是每个 Firefox 配置文件中。具体来说,该文件位于 %AppData%\Mozilla\Firefox 中,文件名格式为 blocklist-{install hash},其中安装哈希是 Firefox 磁盘上位置的哈希。这是一种将阻止列表与不同的 Firefox 安装分开保存的简单方法。

检测和阻止 DLL 加载

为了检测何时尝试加载 DLL,Firefox 使用一种称为函数拦截或挂钩的技术。这会修改内存中的现有函数,以便在现有函数开始执行之前调用另一个函数。这对于许多原因都很有用;它允许更改函数的行为,即使该函数不是为了允许更改而设计的。Microsoft Detours 是一个通常用于拦截函数的工具。

在 Firefox 的情况下,我们感兴趣的函数是NtMapViewOfSection(),每当 DLL 加载时都会调用它。目标是在发生这种情况时收到通知,以便我们检查阻止列表,如果 DLL 在阻止列表中,则禁止它加载。

为此,Firefox 使用一个自己开发的函数拦截器来拦截对 NtMapViewOfSection() 的调用,如果 DLL 在阻止列表中,则返回映射失败。为此,拦截器尝试两种不同的技术

  • 在 32 位 x86 平台上,一些从 DLL 导出的函数 会以两字节的无用指令(`mov edi, edi`)开始,并在其之前有 5 个字节的未使用指令(`nop` 或 `int 3`)。例如:
                  nop
                  nop
                  nop
                  nop
                  nop
    DLLFunction:  mov edi, edi
                  (actual function code starts here)
    

    如果拦截器 检测到这种情况,它可以将 5 个字节的未使用指令替换为一个跳转指令 (`jmp`),跳转到要调用的函数的地址。(由于我们是在 32 位平台上,只需要一个字节来指示跳转,四个字节来指示地址)所以,它看起来像这样:

                 jmp <address of Firefox patched function>
    DLLFunction: jmp $-5 # encodes in two bytes: EB F9
                 (actual function code starts here)
    

    当被修补的函数想要调用 `DLLFunction()` 的未修补版本时,它只需跳过 `DLLFunction()` 地址后面的 2 个字节,来开始实际的函数代码。

  • 否则,事情会变得更复杂。让我们考虑 x64 的情况。跳转到我们的修补函数的指令需要 13 个字节:10 个字节用于将地址加载到寄存器,3 个字节用于跳转到该寄存器的地址。因此,拦截器需要移动至少前 13 个字节的指令,以及在需要时完成最后一条指令所需的足够字节,到一个 trampoline 函数。(它被称为 trampoline,因为通常代码会跳转到这里,这会导致一些指令运行,然后跳出到目标函数的其余部分)。让我们看一个真实的例子。这里有一个简单的函数,我们将要拦截它,首先是 C 源代码 (Godbolt 编译器资源管理器链接)
    int fn(int aX, int aY) {
        if (aX + 1 >= aY) {
            return aX * 3;
        }
        return aY + 5 - aX;
    }
    

    以及汇编代码,以及相应的原始指令。请注意,这是使用 `-O3` 编译的,所以它有点密集。

    fn(int,int):
       lea    eax,[rdi+0x1]   # 8d 47 01
       mov    ecx,esi         # 89 f1
       sub    ecx,edi         # 29 f9
       add    ecx,0x5         # 83 c1 05
       cmp    eax,esi         # 39 f0
       lea    eax,[rdi+rdi*2] # 8d 04 7f
       cmovl  eax,ecx         # 0f 4c c1
       ret                    # c3

    现在,从 `fn()` 的开头算起 13 个字节,我们就在 `lea eax,[rdi+rdi*2]` 指令的中间,所以我们必须将所有东西复制到 trampoline 中。

    最终结果如下:

    fn(int,int) (address 0x100000000):
       # overwritten code
       mov     r11, 0x600000000 # 49 bb 00 00 00 00 06 00 00 00
       jmp     r11              # 41 ff e3
       # leftover bytes from the last instruction
       # so the addresses of everything stays the same
       # We could also fill these with nop’s or int 3’s,
       # since they won’t be executed
       .byte 04
       .byte 7f
       # rest of fn() starts here
       cmovl  eax,ecx         # 0f 4c c1
       ret                    # c3
       
    
    Trampoline (address 0x300000000):
       # First 13 bytes worth of instructions from fn()
       lea    eax,[rdi+0x1]   # 8d 47 01
       mov    ecx,esi         # 89 f1
       sub    ecx,edi         # 29 f9
       add    ecx,0x5         # 83 c1 05
       cmp    eax,esi         # 39 f0
       lea    eax,[rdi+rdi*2] # 8d 04 7f
       # Now jump past first 13 bytes of fn()
       jmp    [RIP+0x0]       # ff 25 00 00 00 00 
                              # implemented as jmp [RIP+0x0], then storing
                              # address to jump to directly after this
                              # instruction
       .qword 0x10000000f
    
    
    Firefox patched function (address 0x600000000):
            <whatever the patched function wants to do>

    如果 Firefox 修补后的函数想要调用未修补的 `fn()`,修补器会存储 trampoline 的地址(在这个例子中是 0x300000000)。在 C++ 代码中,我们将其封装在 `FuncHook` 类中,修补后的函数可以使用与普通函数调用相同的语法来调用 trampoline。

    整个设置比第一个情况要复杂得多。你可以看到,第一个情况的修补器 只有大约 200 行代码,而 处理这种情况的修补器 却超过 1700 行!一些额外的注意事项和复杂情况。

    • 并非所有被移动到 trampoline 的指令都能完全保持不变。例如,跳转到一个没有被移动到 trampoline 的相对地址 - 由于指令在内存中移动了,修补器需要将其替换为一个绝对跳转。修补器并不处理所有类型的 x64 指令(否则它将不得不更长!),但我们有 自动化测试 来确保我们能够成功拦截我们知道 Firefox 需要使用的 Windows 函数。
    • 我们专门使用 r11 将修补函数的地址加载到其中,因为根据 x64 调用约定,r11 是一个易失寄存器,调用者不需要保留它。
    • 由于我们使用 `jmp` 从 `fn()` 跳转到修补函数,而不是 `ret`,并且类似地从 trampoline 跳转回 `fn()` 的主代码,这使得代码保持栈中立。因此,调用其他函数并从 `fn()` 返回,所有操作在栈的位置方面都能正常工作。
    • 如果 `fn()` 中后面的部分有跳转到前 13 个字节的指令,那么这些跳转现在将跳转到跳转到修补函数的指令的中间,并且几乎肯定会出现问题。幸运的是,这种情况非常少见。大多数函数在开头都进行函数序言操作,所以这对 Firefox 拦截的函数来说不是问题。
    • 同样,在某些情况下,`fn()` 在前 13 个字节中存储了一些数据,这些数据被后面的指令使用,将这些数据移动到 trampoline 会导致后面的指令获得错误的数据。我们 遇到过这种情况,可以通过使用更短的 `mov` 指令来解决,如果我们能够为一个 trampoline 分配空间,使其位于前 2 GB 的地址空间内。这会导致一个 10 字节的补丁,而不是 13 字节的补丁,在很多情况下,这足以避免问题。
    • 还有一些其他需要快速提到的复杂情况(并非详尽无遗!):
      • Firefox 也有一个跨进程执行这种拦截的方法。有趣吧!
      • 对于 控制流保护 安全措施来说,trampoline 很棘手:由于它们是合法的间接调用目标,在编译时不存在,因此需要特别注意,才能允许 Firefox 修补后的函数调用它们。
      • trampoline 还涉及到一些针对异常处理的修正,因为我们必须为它们提供 展开信息

如果 DLL 在黑名单上,我们修补后的 `NtMapViewOfSection()` 版本将返回映射失败,这会导致整个 DLL 加载失败。这并不能阻止所有类型的注入,但它确实阻止了大多数注入。

一个额外的复杂情况是,一些 DLL 会通过修改 firefox.exe 的 导入地址表 来注入自己,这是一个 firefox.exe 调用外部函数的列表。如果其中一个函数加载失败,Windows 将终止 Firefox 进程。所以,如果 Firefox 检测到这种类型的注入,并想要阻止 DLL,我们将改为将 DLL 的 `DllMain()` 重定向到一个不执行任何操作的函数。

最后的话

Mozilla 宣言 的第四条原则指出,“个人在互联网上的安全和隐私是根本的,不能被视为可选的”,我们希望这将赋予 Firefox 用户更多信心,让他们能够更加安全地访问互联网。用户不必再在卸载有用的第三方产品和 Firefox 出现稳定性问题之间做出选择,现在他们有了第三种选择:保留第三方产品并阻止它注入 Firefox!

由于这是一个新功能,如果您在阻止第三方 DLL 时遇到问题,请提交 错误。如果您遇到第三方产品导致 Firefox 出现问题,请不要忘记向该产品的供应商提交问题 - 由于您是该产品的用户,供应商收到的任何报告都比我们收到的报告更有意义!

更多信息

特别感谢 David Parks 和 Yannis Juglaret 阅读并为这篇博文的许多草稿提供了反馈,以及 Toshihito Kikuchi 提供了动态黑名单的初始原型。

关于 Greg Stoll

更多 Greg Stoll 的文章…


一条评论

  1. Hales

    感谢您采取了这种细致入微的方法,而不是采取极端的“禁止所有 DLL”或极端的“无所谓”的观点。

    以安全的名义禁用所有 DLL 注入,实际上可能会给许多用户带来相反的结果(他们可能会使用其他浏览器、旧版本的 Firefox 或特殊版本)。

    2023 年 3 月 31 日 下午 8:39

本文的评论已关闭。