Medium chrome OOB 📄 Reporter bug report

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Embargoed
Fix Status
ImpactBuffer overflow in Fonts
DescriptionBuffer overflow in Fonts
ComponentFonts
Sub-componentBlink>Fonts
Bug ClassOOB
Tracker524587778
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-06

Fix not yet public

No public source fix for this bug has been identified on the main branch yet — it is embargoed or not yet disclosed. Root-cause analysis is withheld until the fix commit is available.

Original Bug Report

reported by [email protected]

Potential OOB heap write via unchecked -1 in link.objidx inside HarfBuzz repacker

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A missing error check in HarfBuzz’s MarkBasePosFormat1::clone_range() allows an unchecked error value of -1 to be stored as a link’s target object ID. During subsequent repacker calculations, this unchecked index triggers a raw array access without bounds checking, potentially resulting in an out-of-bounds heap write on 32-bit systems.

Affected files:

  • third_party/harfbuzz/src/src/graph/markbasepos-graph.hh
  • third_party/harfbuzz/src/src/graph/graph.hh
  • third_party/harfbuzz/src/src/hb-vector.hh

Estimated timestamp from git blame: Unknown (Google3 checkout)

1. Summary of the Issue (Meant for Human Triage)

A potential logic and memory corruption vulnerability exists in the HarfBuzz GPOS/GSUB table repacker. A missing error check allows an unchecked (unsigned)-1 (stored as 0xFFFFFFFF) to be saved as a link object index (link.objidx). This issue occurs inside MarkBasePosFormat1::clone_range() when memory-pressure or address-space exhaustion causes an internal multi-kilobyte allocation to fail within MarkArray::clone() or AnchorMatrix::clone(). Instead of propagating the -1 error, clone_range() continues, linking the failed node directly into the graph under a valid node ID, which is then successfully connected to the root.

During the Dijkstra shortest-path calculations in update_distances(), the repacker iterates through this poisoned link and directly accesses the raw backing array via pointer arithmetic (vertices_.arrayZ[0xFFFFFFFF]) without any bounds check. On 32-bit systems (such as Windows x86 or Android ARM32), this wraps around to point roughly 112 bytes before the start of the vertices_ backing allocation. This enables an out-of-bounds read and a subsequent conditional 8-byte int64_t out-of-bounds write to heap-adjacent preceding blocks (corrupting heap chunk headers or neighboring allocations). On 64-bit systems, this attempts to access unmapped memory, leading to a deterministic far out-of-bounds page fault and denial of service (DoS).

The vulnerable code is reachable from a compromised renderer by passing a maliciously crafted TrueType font inside an SkPicture to the printing.mojom.PrintCompositor service (a restricted utility process). Due to the complex trigger conditions (requiring an orchestrated Out-Of-Memory failure for a specific allocation) and the 32-bit architecture limitation, this is classified as a Medium severity (S2) vulnerability.


2. Proof-of-Concept & Detailed Execution Flow

Note: The following steps are a potential exploitation flow based on static analysis. Our tooling agent does not currently possess the ability to execute code or build a live working proof of concept to dynamically confirm the memory corruption, but the logic has been exhaustively traced.

Potential Trigger Steps

  1. An attacker compromises a renderer process and constructs a malicious SkPicture document containing text drawn with a carefully crafted TrueType font.
  2. The font is embedded with an extremely large GPOS table containing a MarkBasePosFormat1 subtable. It is constructed such that subsetting it with HB_SUBSET_FLAGS_RETAIN_GIDS results in a subtable larger than 64 KB, intentionally provoking 16-bit offset overflows that cannot be resolved via standard graph space isolation.
  3. The SkPicture is also embedded with large image resources or WebGL buffers to fragment the 32-bit address space of the target process, exhausting large contiguous blocks while leaving 12-byte fragments available.
  4. The user initiates a Print or Print Preview action, sending the SkPicture to the printing.mojom.PrintCompositor service via IPC.

Inside third_party/harfbuzz/src/src/graph/markbasepos-graph.hh at line 450:

450:     auto mark_array =
451:         graph.as_table <MarkArray> (sc.this_index, &markArray);
452:     if (!mark_array) return -1;
453:     unsigned new_mark_array =
454:         mark_array.table->clone (sc.c,
455:                                  mark_array.index,
456:                                  sc.mark_array_links,
457:                                  marks,
458:                                  start);
459:     graph.add_link (&(prime->markArray), prime_id, new_mark_array); // No new_mark_array == -1 check!

Under address-space exhaustion or high fragmentation, MarkArray::clone()’s internal multi-kilobyte hb_calloc fails, causing it to return (unsigned)-1.

In graph_t::add_link() (defined in third_party/harfbuzz/src/src/graph/graph.hh):

  void add_link (T* offset, unsigned parent_id, unsigned child_id) {
    auto& v = vertices_[parent_id];
    auto* link = v.obj.real_links.push ();
    link->width = 2;
    link->objidx = child_id;                       // Stores (unsigned)-1 (0xFFFFFFFF)
    ...
    vertices_[child_id].add_parent (parent_id, false); // Bounds-checked via operator[], returns Crap(vertex_t)
  }

clone_range() successfully returns prime_id, which is a valid node index. Lookup::add_sub_tables() links prime_id under the Lookup vertex, making it a root-reachable real_link of the graph. The repacker then calls update_distances().

The Sink: Dereference and Out-Of-Bounds Write

In third_party/harfbuzz/src/src/graph/graph.hh inside update_distances():

1599:       for (const auto& link : next.obj.all_links ())
1600:       {
1601:         if (visited[link.objidx]) continue;
1602: 
1603:         auto& child_v = vertices_.arrayZ[link.objidx]; // Pointer arithmetic: arrayZ + 0xFFFFFFFF * sizeof(vertex_t)
1604:         const auto& child = child_v.obj;
1605:         unsigned link_width = link.width ? link.width : 4;
1606:         int64_t child_weight = (child.tail - child.head) +
1607:                                ((int64_t) 1 << (link_width * 8)) * (child_v.space + 1);
1608:         int64_t child_distance = next_distance + child_weight;
1609: 
1610:         if (child_distance < child_v.distance)
1611:         {
1612:           child_v.distance = child_distance;          // Out-of-bounds 8-byte write!
1613:           queue.insert (child_distance, link.objidx);
1614:         }
1615:       }
  • Why visited[0xFFFFFFFF] does not prevent execution: visited is an hb_vector_t<bool>. The bounds check inside hb_vector_t::operator[] fails, invoking Crap(bool) (third_party/harfbuzz/src/src/hb-null.hh:176). Crap<Type>() uses memcpy to zero out the global _hb_CrapPool and returns a reference to it on every call. Thus, visited[0xFFFFFFFF] always evaluates to false, bypassing the short-circuit check.
  • The OOB Write Offset: On a 32-bit platform, the pointer arithmetic arrayZ + 0xFFFFFFFF * sizeof(vertex_t) wraps around the 32-bit memory space, computing an offset equivalent to arrayZ - sizeof(vertex_t). On 32-bit architectures, sizeof(vertex_t) is 112 bytes. The assignment child_v.distance = child_distance; accesses an int64_t residing at offsetof(vertex_t, distance) (40 bytes into the struct). This effectively writes 8 bytes to (arrayZ - 112) + 40, which equates to arrayZ - 72. This corrupts the heap metadata or user data preceding the vertices_ array.

Suggested Fix

In third_party/harfbuzz/src/src/graph/markbasepos-graph.hh, add a validation check inside clone_range() directly after the subtable clone attempts:

    unsigned new_mark_array =
        mark_array.table->clone (sc.c,
                                 mark_array.index,
                                 sc.mark_array_links,
                                 marks,
                                 start);
    if (new_mark_array == (unsigned)-1) return -1; // ADD THIS CHECK
    graph.add_link (&(prime->markArray), prime_id, new_mark_array);

    unsigned class_count = classCount;
    auto base_array =
        graph.as_table<AnchorMatrix> (sc.this_index, &baseArray, class_count);
    if (!base_array) return -1;
    unsigned new_base_array =
        base_array.table->clone (sc.c,
                                 base_array.index,
                                 start, end, this->classCount);
    if (new_base_array == (unsigned)-1) return -1; // ADD THIS CHECK
    graph.add_link (&(prime->baseArray), prime_id, new_base_array);

3. Technical Verification Details (Automated Audit Logs - Reviewers may skip this section)

> Critic model verdict (2026-06-16 00:34:24): > * Severity: Medium (S2) > * Brief Notes / Reasoning: > The vulnerability is accurate and verified. The root cause is a missing check on the return value of MarkArray::clone() and AnchorMatrix::clone() in MarkBasePosFormat1::clone_range(). If the multi-KB allocation fails under memory pressure (returning (unsigned)-1), add_link() writes 0xFFFFFFFF into link.objidx without validation. Later, update_distances() iterates this link and accesses vertices_.arrayZ[0xFFFFFFFF]. Due to visited[(unsigned)-1] failing to short-circuit (it returns false via the Crap pool), the function performs an OOB read of child_v properties and a conditional 8-byte int64_t OOB write to child_v.distance. >
> On 32-bit platforms, pointer arithmetic wraps around causing the write to land ~68 bytes before the vertices_.arrayZ allocation, corrupting the user data of the adjacent preceding heap chunk. On 64-bit platforms, this causes a deterministic far-OOB crash (DoS). >
> Severity Justification: The baseline severity for an exploitable OOB write in a sandboxed utility process (like kPrintCompositor) reached from a renderer is High (S1). However, we downgrade this to Medium (S2) due to significant mitigating factors: > 1. Architecture Limitation: The heap corruption only manifests on 32-bit architectures (Win32, Android arm32); on 64-bit it is unmapped memory and causes a DoS. > 2. Trigger Complexity: The attacker must carefully groom and fragment the 32-bit address space to cause an OOM exactly on the larger clone() hb_calloc while allowing the preceding 12-byte and subsequent small allocations to succeed. > 3. Primitive Limitation: The 64-bit payload written is derived indirectly from OOB-read values rather than directly attacker-chosen. > This combination of extreme trigger complexity and architectural limitations firmly justifies a downgrade to Medium (S2).

Automated Codebase Investigator Output:

{
  "query": "Find the definition of `vertex_t` in `third_party/harfbuzz/src/src/graph/graph.hh`. What fields does it have? What is the `sizeof(vertex_t)` conceptually? Also check `offsetof(vertex_t, distance)`.",
  "conclusions": "### Conceptual Layout and Size of `vertex_t`\n\n#### **On 64-bit Platforms (LP64):**\n- `obj` (starts at **offset 0**): **56 bytes**\n- `distance` (starts at **offset 56**): **8 bytes**\n- `space` (starts at **offset 64**): **4 bytes**\n- `start` (starts at **offset 68**): **4 bytes**\n- `end` (starts at **offset 72**): **4 bytes**\n- `priority` (starts at **offset 76**): **4 bytes**\n- `incoming_edges_` (starts at **offset 80**): **4 bytes**\n- `single_parent` (starts at **offset 84**): **4 bytes**\n- `has_incoming_virtual_edges_` (starts at **offset 88**): **1 byte**\n- *Padding* (offsets 89-95 to align `parents` to 8 bytes): **7 bytes**\n- `parents` (starts at **offset 96**): **48 bytes**\n- **`sizeof(vertex_t)`** = **144 bytes**\n\n#### **On 32-bit Platforms:**\n- `obj` (starts at **offset 0**): **36 bytes**\n- *Padding* (offsets 36-39 to align `distance` to 8 bytes, depending on platform alignment rules): **4 bytes**\n- `distance` (starts at **offset 40**): **8 bytes**\n- `space` (starts at **offset 48**): **4 bytes**\n- `start` (starts at **offset 52**): **4 bytes**\n- `end` (starts at **offset 56**): **4 bytes**\n- `priority` (starts at **offset 60**): **4 bytes**\n- `incoming_edges_` (starts at **offset 64**): **4 bytes**\n- `single_parent` (starts at **offset 68**): **4 bytes**\n- `has_incoming_virtual_edges_` (starts at **offset 72**): **1 byte**\n- *Padding* (offsets 73-75 to align `parents` to 4 bytes): **3 bytes**\n- `parents` (starts at **offset 76**): **36 bytes**\n- **`sizeof(vertex_t)`** = **112 bytes**...\n\n### **`offsetof(vertex_t, distance)`**\nOn a 32-bit platform, it is **40**..."
}
{
  "query": "Find the definition of `add_parent` in `vertex_t` in `third_party/harfbuzz/src/src/graph/graph.hh`. What does it do? If it is called on the `Crap(vertex_t)` singleton, is it memory safe?",
  "conclusions": "Calling `add_parent` on the `Crap(vertex_t)` singleton is **not memory safe** for several reasons... `Crap(Type)` (defined in `hb-null.hh:176-181`) returns a reference to a global memory area `_hb_CrapPool`... a `memcpy` of `Null(vertex_t)` is done into `_hb_CrapPool`, resetting its contents to default values. If `add_parent` is called multiple times on `Crap(vertex_t)`, it transitions to using the `parents` hashmap, which allocates dynamic memory using `hb_malloc` (via `parents.set`). On any subsequent call to `Crap(vertex_t)`, the `_hb_CrapPool` is overwritten by a `memcpy` of `Null(vertex_t)`... resulting in a **memory leak**."
}

Sink Checks (Confirmed):

  • Target pointer is unprotected by MiraclePtr. HarfBuzz manages memory natively (Type *arrayZ).
  • visited[(unsigned)-1] is validated to explicitly NOT short-circuit because the fallback Crap(bool) implementation calls memcpy(..., &Null(bool), ...) to zero the static CrapPool on every invocation.
  • Error propagation in split-helpers.hh and gsubgpos-graph.hh fails to detect poisoned links already assigned to valid parent subtrees.

Evaluated with Chrome root at commit: 8c517fbcbb533e59ec9cedac868c8a9bdc30beb2


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker