Read OSS

WTF、メモリ管理、そしてコアプリミティブ

上級

前提知識

  • 第1回: アーキテクチャ概要とWebKitコードベースのナビゲーション
  • C++の深い知識: ムーブセマンティクス、RAII、テンプレート、CRTP
  • 参照カウントとガベージコレクションの違いの理解

WTF、メモリ管理、そしてコアプリミティブ

WebKitのコードはすべて、bmalloc(メモリアロケータ)とWTF(Web Template Framework)という2つの基盤レイヤーの上に成り立っています。これらはオプションのライブラリではなく、コードベースの語彙そのものです。Refを理解しなければWebCoreのクラスは読めませんし、protectedThisを知らなければDOMミューテーションのデバッグはできません。IsoHeapを理解しなければ、WebKitのセキュリティ設計を語ることもできないのです。

この記事では、これらの基盤レイヤーを解剖していきます。まずWebKitがなぜSTLを置き換えるのかから始め、所有権モデルを歩んで、最後に型混同エクスプロイトを大幅に困難にするセキュリティ強化済みアロケータで締めくくります。

WTF: WebKitがSTLを置き換える理由

Web Template Frameworkという名前は気まぐれでなく、Webエンジンのワークロードに特化したライブラリを意図的に構築した結果です。WebKitではstd::vectorWTF::Vectorで、std::unordered_mapWTF::HashMapで、std::stringWTF::Stringで、std::shared_ptrWTF::Ref/WTF::RefPtrでそれぞれ置き換えています。

その動機は3つあります。

  1. パフォーマンス — WebKitのコンテナはWebワークロード向けに最適化されている。Vectorはコンパイル時に制御されるインラインストレージ(スモールバッファ最適化)をサポートする。HashMapはRobin Hoodハッシュを用いたオープンアドレス法を採用している。Stringは可能な限り8ビットのLatin-1データとして保持し、不要な16ビットストレージを避ける。

  2. セキュリティ — スマートポインタ型はコンパイル時に所有権の不変条件を強制する。Ref<T>はnull非許容であり、nullポインタ参照バグを根本から排除する。アロケータは型を分離してエクスプロイトを防ぐ。

  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と互換性のある代替品ではありません。API、イテレータのセマンティクス、パフォーマンス特性がすべて異なります。STLの挙動を仮定せず、ヘッダファイルを直接参照してください。

参照カウント: Ref、RefPtr、RefCounted

WebKitの主要な所有権モデルは侵入的参照カウント(intrusive reference counting)です。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"

RefCountedBaseuint32_t m_refCount(初期値1)を保持する非テンプレートの基底クラスです。ref()メソッドがカウントをインクリメントし、derefBase()メソッドがデクリメントして、カウントがゼロに達したときにtrueを返します。これがオブジェクト削除のシグナルです。

RefCounted<T>はテンプレートパラメータ化されたderef()を追加します。カウントがゼロになるとdelete const_cast<T*>(static_cast<const T*>(this))を呼び出し、実際のオブジェクト破棄が行われます。

2種類のスマートポインタ型はそれぞれ異なる保証を提供します。

  • Ref<T>null非許容。常に有効な参照を保持します。デフォルト構築はできません。T&からの構築時に即座にref()が呼ばれます。これが推奨される所有権型です。

  • RefPtr<T>null許容nullptrを保持できます。オプショナルな参照、コンテナの値、遅延初期化フィールドなどに有用です。

侵入的アプローチのstd::shared_ptrに対する主な利点は、コントロールブロックの独立した確保が不要な点です。既存のDOMノードに対してRef<Node>を作成する際、必要なのは単一のアトミックなインクリメントだけです。ヒープ確保もキャッシュラインの追加消費もありません。1セッションで数百万の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ミューテーションを引き起こす可能性があるメソッドの冒頭で、*thisへの一時的なRefを作成します。

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ラッパーが、メソッドが返るまでオブジェクトの生存を保証します。

ヒント: JavaScriptを呼び出したりイベントをディスパッチしたりするWebCoreのコードを追加するときは、常に「このコールバックは、現在実行中のオブジェクトを削除しうるか?」と自問しましょう。もしそうなら、メソッドの先頭にRef protectedThis { *this };を追加してください。

WeakPtrとCanMakeWeakPtr

非所有参照も同様に重要です。WeakPtr<T>は参照先のオブジェクトが破棄されると自動的にnullになります。ダングリングポインタも手動の無効化も不要です。

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()を呼び出し、内部ポインタをnullに設定します。すべてのWeakPtrは同じimplを参照しているため、一斉にnullになります。

ドキュメントにも記載の通り、WeakPtrはポインタへのポインタという間接参照レイヤーを持つため、コンパイラの最適化に影響することがあります。パフォーマンスが重要なコードではRefPtrCheckedPtrが選ばれることが多いです。

WeakPtrの典型的なユースケースとしては、オブジェクトのライフタイムを延長せずに観察するイベントリスナー、キャッシュ対象が削除されたら期限切れになるキャッシュエントリ、サブジェクトを生かし続けてはならないオブザーバーパターンなどが挙げられます。

bmallocとIsoHeap: セキュリティ強化済みアロケーション

WTFの下にあるのがbmalloc、WebKit独自のメモリアロケータです。セキュリティ上の最重要機能はIsoHeap(Isolated Heap)で、型ごとにアロケーションを分離します。

従来のアロケータでは、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プロセスのエントリポイントでは、WebContentServiceEntryPoint.mmにあるように、初期化時にTZoneバケットのパラメータも設定されています。

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

これは多層防御の一環です。攻撃者がuse-after-free脆弱性を発見したとしても、型の分離によってその悪用範囲が制限されます。

Stringとスレッドプリミティブ

WebKitのString型はWebワークロードに向けて徹底的に最適化されています。重要な洞察はWeb上のテキストの大半がASCIIだという点で、Stringはデフォルトで文字を8ビットのLatin-1として保持し、Latin-1で表現できない文字が含まれる場合のみ16ビットのUChar表現にアップグレードします。

文字列型の階層には以下が含まれます。

  • String — 主要なnull許容文字列型。内部ではRef-カウント済みのStringImplを使用。
  • AtomString — インターン済み文字列で、等値比較を低コストにします(文字単位ではなくポインタで比較)。タグ名、属性名、CSSプロパティ名などに使用されます。
  • StringView — 文字列データへの非所有ビュー。std::string_viewに相当します。

スレッド処理については、WTFが以下を提供します。

  • RunLoop — プラットフォーム抽象化されたイベントループ(Apple環境ではCFRunLoop、GTK環境ではGMainLoopをラップ)。
  • WorkQueue — バックグラウンド処理のための並行ディスパッチキュー。
  • Lock / Condition — 軽量な同期プリミティブ。
  • ThreadSafeRefCounted<T> — スレッドセーフな参照カウントのためにアトミック操作を使用するRefCountedの変種。

注意すべき点として、通常のRefCountedはスレッドセーフではありません。非アトミックなインクリメントを使用するためです。スレッド間でオブジェクトを共有する場合は、ThreadSafeRefCountedを継承する必要があります。この規則に違反すると、参照カウントデバッガがアサートします。

次のステップ

WTFとbmallocの基盤を理解したところで、WebCoreへと進む準備が整いました。第3回では、HTMLがピクセルになるまでの過程を追います。DOMツリー、レンダリングパイプライン、CSSスタイル解決(ユニークなCSSセレクタJITコンパイラも含む)、そしてJSCのガベージコレクタとWebCoreの参照カウント世界を橋渡しするWeb IDLバインディングシステムを取り上げます。この記事で登場したRef/RefPtrのパターンは、ほぼすべての行に現れることでしょう。