Read OSS

WTF、内存管理与核心原语

高级

前置知识

  • 第 1 篇:架构概览与 WebKit 代码库导航
  • 扎实的 C++ 基础:移动语义、RAII、模板、CRTP
  • 理解引用计数与垃圾回收的区别

WTF、内存管理与核心原语

WebKit 的每一行代码都构建在两个基础层之上:bmalloc(内存分配器)和 WTF(Web Template Framework)。它们不是可选的工具库,而是整个代码库的基础词汇。不理解 Ref,就读不懂 WebCore 的类;不了解 protectedThis,就无法调试 DOM 变更;不搞清楚 IsoHeap,就无法评估 WebKit 的安全架构。

本文将系统拆解这两个基础层:先从 WebKit 为何要替换 STL 说起,再梳理其所有权模型,最后介绍那个让类型混淆漏洞利用难度大幅提升的安全加固分配器。

WTF:WebKit 为何替换 STL

Web Template Framework 这个名字并非随意为之——它是一个专为 Web 引擎工作负载设计的库的有意选择。WebKit 用 WTF::Vector 替换 std::vector,用 WTF::HashMap 替换 std::unordered_map,用 WTF::String 替换 std::string,用 WTF::Ref/WTF::RefPtr 替换 std::shared_ptr

这背后有三个核心动机:

  1. 性能 — WTF 的容器针对 Web 工作负载进行了专项优化。Vector 支持内联存储(在编译期控制的小缓冲区优化);HashMap 采用 Robin Hood 哈希的开放地址法;String 在可能的情况下以 8-bit Latin-1 格式存储数据,避免不必要的 16-bit 内存占用。

  2. 安全 — 智能指针类型在编译期强制保证所有权的正确性。Ref<T> 不可为空,从根本上消除了一整类空指针解引用 bug。内存分配器按类型隔离,以防止漏洞利用。

  3. 平台抽象 — WTF 提供了一致的线程原语(RunLoopWorkQueueLock)、时间工具函数以及针对 macOS、Linux、Windows 的平台辅助功能。

graph LR
    subgraph STL["C++ STL"]
        SV["std::vector"]
        SM["std::unordered_map"]
        SS["std::shared_ptr"]
        STR["std::string"]
    end
    subgraph WTF_LIB["WTF"]
        WV["Vector<T, N>"]
        WM["HashMap<K,V>"]
        WR["Ref<T> / RefPtr<T>"]
        WSTR["String (8/16-bit)"]
    end
    SV -.->|"replaced by"| WV
    SM -.->|"replaced by"| WM
    SS -.->|"replaced by"| WR
    STR -.->|"replaced by"| WSTR

提示: 阅读 WebKit 代码时,应将 WTF 类型视为一等公民,而非对 STL 的简单封装。它们与 STL 并不兼容——API 不同,迭代器语义不同,性能特性也不同。请直接查阅头文件,不要预设它们与 STL 行为一致。

引用计数:Ref、RefPtr 与 RefCounted

WebKit 的主要所有权模型是侵入式引用计数。与 std::shared_ptr 需要单独分配控制块不同,WebKit 通过 RefCounted<T> 基类将引用计数直接内嵌到对象自身中。

类层次结构如下:

classDiagram
    class RefCountedBase {
        -uint32_t m_refCount = 1
        +ref()
        +hasOneRef() bool
        +refCount() uint32_t
        #derefBase() bool
    }
    class RefCounted~T~ {
        +deref()
    }
    class Ref~T~ {
        -T* m_ptr
        +get() T&
        +operator->() T*
    }
    class RefPtr~T~ {
        -T* m_ptr
        +get() T*
        +operator->() T*
        +operator bool()
    }
    RefCountedBase <|-- RefCounted
    RefCounted <.. Ref : "points to"
    RefCounted <.. RefPtr : "points to"

RefCountedBase 是持有 uint32_t m_refCount(初始值为 1)的非模板基类。ref() 方法递增计数,derefBase() 方法递减计数并在计数归零时返回 true,表示该对象应当被销毁。

RefCounted<T> 在此基础上增加了模板参数化的 deref() 方法,当计数归零时调用 delete const_cast<T*>(static_cast<const T*>(this)) 完成实际的析构。

两种智能指针提供不同的保证:

  • Ref<T>不可为空。始终持有有效引用,不能默认构造。从 T& 构造时会立即调用 ref()。这是首选的所有权类型。

  • RefPtr<T>可为空。可以持有 nullptr,适用于可选引用、容器成员值以及延迟初始化的字段。

侵入式方案相比 std::shared_ptr 有一个关键优势:无需额外分配控制块。这意味着对已有 DOM 节点创建 Ref<Node> 只需一次原子自增操作——没有堆分配,没有额外的 cache line 占用。对于一个每次会话都要创建和销毁数百万个 DOM 节点的系统来说,这一点至关重要。

protectedThis 模式与 Use-After-Free 防护

WebKit 中最重要的编程模式之一——也是你将反复看到的模式——就是 protectedThis

Ref<PendingScript> protectedThis(*this);

这行代码出现在 Source/WebCore/dom/PendingScript.cpp 第 68 行。一个对象为什么要持有对自身的引用?

答案在于 DOM 树变更的工作方式。考虑以下场景:

sequenceDiagram
    participant JS as JavaScript
    participant N as Node::dispatchEvent()
    participant DOM as DOM Tree
    
    JS->>N: element.click()
    N->>N: Begin dispatching event
    Note over N: Handler may modify DOM
    JS->>DOM: element.remove()
    Note over DOM: Node ref count drops to 0
    DOM-->>N: Node deleted!
    N->>N: 💥 Use-after-free

当 DOM 节点派发事件时,事件处理器(JavaScript 代码)可能以某种方式修改 DOM 树,将正在派发事件的节点本身从树中移除。如果该节点的引用计数在派发过程中降至零,节点就会在其方法仍处于调用栈上时被销毁。

解决方案很简单,但必须一致地应用:在任何可能触发回调或 DOM 变更的方法起始处,创建一个临时的 Ref 指向 *this

void Node::someMethod() {
    Ref protectedThis { *this }; // bumps ref count
    // Safe to call code that might remove us from the tree
    dispatchEvent(...);
    // protectedThis destructor decrements count when we return
}

这个模式遍布整个 WebCore,出现在事件派发、定时器回调、Promise 决议,以及所有对象的方法可能触发代码进而导致自身被销毁的场景中。栈上的 Ref 确保对象在方法返回之前始终存活。

提示: 如果你在 WebCore 中新增了调用 JavaScript 或派发事件的代码,务必追问自己:"这个回调有没有可能销毁我当前所在的对象?"如果答案是肯定的,就在方法开头加上 Ref protectedThis { *this };

WeakPtr 与 CanMakeWeakPtr

非拥有性引用同样不可或缺。WeakPtr<T> 会在被引用对象销毁时自动变为空——无悬空指针,无需手动失效。

要使用 WeakPtr,类必须继承自 CanMakeWeakPtr

classDiagram
    class CanMakeWeakPtr~T~ {
        -WeakPtrFactory m_weakPtrFactory
        +weakImpl() WeakPtrImpl&
    }
    class WeakPtr~T~ {
        -WeakPtrImpl* m_impl
        +get() T*
        +operator bool()
    }
    class WeakPtrImpl {
        -void* m_ptr
        +get() void*
        +clear()
    }
    CanMakeWeakPtr --> WeakPtrImpl : "owns factory"
    WeakPtr --> WeakPtrImpl : "observes"

其机制通过一个中间对象 WeakPtrImpl 实现。目标对象被销毁时,CanMakeWeakPtr 的析构函数会调用 impl 的 clear(),将内部指针置为空。所有现存的 WeakPtr 都指向同一个 impl,因此会同时变为空。

正如文档所指出的,WeakPtr 涉及一层额外的间接寻址(本质上是指向指针的指针),可能影响编译器优化。在性能敏感的代码中,RefPtrCheckedPtr 往往是更好的选择。

WeakPtr 的常见使用场景包括:不希望延长目标对象生命周期的事件监听器、在被缓存对象销毁后应自动失效的缓存条目,以及观察者不应维持主体对象存活的观察者模式。

bmalloc 与 IsoHeap:安全加固的内存分配

WTF 之下是 bmalloc——WebKit 的自定义内存分配器。其最重要的安全特性是 IsoHeap(隔离堆),它按类型对内存分配进行隔离。

在传统分配器中,一个 Node 对象和一个 CSSStyleSheet 对象可能被分配在相邻的内存位置。如果攻击者在 Node 上触发了 use-after-free,就可以在同一内存槽中分配一个 CSSStyleSheet,进而实施类型混淆攻击——用 CSSStyleSheet 的数据去调用 Node 的方法。

IsoHeap 通过为每种类型分配独立的内存页来防止这一点:

flowchart TD
    subgraph TRADITIONAL["Traditional Allocator"]
        T1["Page: [Node][CSS][Node][Event]"]
    end
    
    subgraph ISO["IsoHeap (bmalloc)"]
        I1["Page A: [Node][Node][Node]"]
        I2["Page B: [CSS][CSS][CSS]"]
        I3["Page C: [Event][Event]"]
    end
    
    TRADITIONAL -->|"type confusion possible"| VULN["🔓 Attacker can reuse Node slot with CSS object"]
    ISO -->|"type confusion blocked"| SAFE["🔒 Node slot can only be reused by Node"]

在代码库中,可以通过 WTF_MAKE_TZONE_ALLOCATED 宏让类型选择加入隔离分配。以 Node.h 为例:

class Node : public EventTarget, public CanMakeCheckedPtr<Node> {
    WTF_MAKE_PREFERABLY_COMPACT_TZONE_ALLOCATED(Node);

TZoneMalloc 系统构建于 bmalloc 的 IsoHeap 之上,提供按类型隔离的内存分配。WebContent 进程的入口点甚至会在初始化阶段配置 TZone 的 bucket 参数,如 WebContentServiceEntryPoint.mm 所示:

#if USE(TZONE_MALLOC)
    bmalloc::api::TZoneHeapManager::setBucketParams(4);
#endif

这是纵深防御的体现:即便攻击者找到了 use-after-free 漏洞,类型隔离也能大幅限制其可利用的空间。

String 与线程原语

WebKit 的 String 类型针对 Web 工作负载进行了深度优化。其核心洞察是:Web 上的大多数文本都是 ASCII——因此 String 默认以 8-bit Latin-1 格式存储字符,只有当字符串包含非 Latin-1 字符时才升级为 16-bit UChar 存储。

字符串类型体系包含:

  • String — 主要的可空字符串类型,底层由引用计数的 StringImpl 支撑。
  • AtomString — 经过内部化的字符串,用于低成本的相等性比较(指针比较,而非逐字符比较)。用于标签名、属性名和 CSS 属性名等场景。
  • StringView — 对字符串数据的非拥有视图,类似于 std::string_view

线程方面,WTF 提供:

  • RunLoop — 平台抽象的事件循环(在 Apple 平台封装 CFRunLoop,在 GTK 平台封装 GMainLoop)。
  • WorkQueue — 用于后台任务的并发派发队列。
  • Lock / Condition — 轻量级同步原语。
  • ThreadSafeRefCounted<T>RefCounted 的线程安全变体,使用原子操作进行引用计数。

注意,普通的 RefCounted 不是线程安全的——它使用非原子自增。如果需要跨线程共享对象,必须改为继承 ThreadSafeRefCounted。引用计数调试器会在违反此规则时触发断言。

下一步

理解了 WTF 和 bmalloc 的基础之后,我们就可以正式进入 WebCore——WebKit 中规模最大、也最为复杂的组件。第 3 篇将追踪 HTML 如何变成像素:DOM 树、渲染管线、CSS 样式解析(包括独特的 CSS 选择器 JIT 编译器),以及连接 JSC 垃圾回收器与 WebCore 引用计数世界的 Web IDL 绑定系统。本篇介绍的 Ref/RefPtr 模式将出现在几乎每一行代码中。