Repository: harfbuzz/harfbuzz (2026-04-18T14:01:34Z)

Issue:
[Security] High: Rust fontations glyph-name callback underflows on size == 0
## Summary

**Severity:** High
**Type:** Out-of-bounds write via integer underflow (CWE-124, CWE-191, CWE-787)
**Status:** Likely — data flow traced line-by-line; PoC not executed locally due to MSRV mismatch (local `rustc 1.85.0` vs. required `>= 1.87.0`).

In the optional Rust `fontations` backend, `_hb_fontations_glyph_name()` computes the destination copy length as `glyph_name.len().min(size as usize - 1)`. When the caller passes `size == 0`, the `usize` subtraction underflows to `usize::MAX`, `min()` resolves to `glyph_name.len()`, and the callback copies the full glyph-name bytes into a zero-capacity output buffer.

## Affected file/line

- `src/rust/font.rs:973-996` — the Rust callback with the underflowing arithmetic.
- `src/hb-font.cc:1536-1541` — C entry point dispatches without a `size == 0` guard.
- `src/hb-font.hh:668-675` — C++ wrapper; only checks `size != 0` to skip the NUL-write branch, not to reject the call.

Tested against commit `edc8c18ef88262fc467a30ab74f05080d6aea6cf` (branch `main`).

## Root cause

```rust
// src/rust/font.rs:973-996
extern "C" fn _hb_fontations_glyph_name(
    _font: *mut hb_font_t,
    font_data: *mut ::std::os::raw::c_void,
    glyph: hb_codepoint_t,
    name: *mut ::std::os::raw::c_char,
    size: ::std::os::raw::c_uint,
    _user_data: *mut ::std::os::raw::c_void,
) -> hb_bool_t {
    // ...
    let copy_len = glyph_name.len().min(size as usize - 1); // UNDERFLOWS when size == 0
    unsafe {
        std::ptr::copy_nonoverlapping(
            glyph_name.as_ptr() as *const c_char,
            name,
            copy_len,
        );
        *name.add(copy_len) = 0;
    }
    true as hb_bool_t
}
```

`size as usize - 1` on `size == 0` wraps to `usize::MAX`, defeating the `min()` clamp. The C wrapper `hb_font_t::get_glyph_name()` does not normalize `size == 0` before dispatch, so the underflow is reachable from public API. Unlike the sibling `get_glyph_from_name()` path, there is no length normalization in the wrapper layer.

## Impact

Heap or stack buffer overflow in the caller's destination buffer. The overflow length is bounded by the longest glyph name in the font — **attacker-influenced**, because the font file supplies both the glyph set and the glyph names. Exact memory impact depends on where the caller allocated the buffer (local `char buf[N]`, heap, WASM linear memory, etc.).

Reachability is gated by two conditions that keep severity below Critical:

- The build must enable the opt-in `fontations` feature (`-Dfontations=enabled`; default is disabled in `meson_options.txt:22-23`).
- The application must call `hb_fontations_font_set_funcs(font)` and then invoke `hb_font_get_glyph_name(..., size=0)` on attacker-influenced font data.

Downstream distributions that enable `-Dfontations=enabled` are directly exposed.

## Reproduction steps

```c
#include <hb.h>

int main(void) {
    hb_blob_t *blob = hb_blob_create_from_file("attacker-font.ttf");
    hb_face_t *face = hb_face_create(blob, 0);
    hb_font_t *font = hb_font_create(face);
    hb_fontations_font_set_funcs(font); /* requires -Dfontations=enabled */

    char buf[1];
    hb_font_get_glyph_name(font, 0, buf, 0); /* size == 0 -> underflow -> OOB write */
    return 0;
}
```

Build prerequisite: `meson setup build -Dfontations=enabled` with Rust `>= 1.87.0`. Under ASan, the OOB write into `buf` should trip a `stack-buffer-overflow` report sized by the glyph name length.

## Suggested fix

Guard `size == 0` in the Rust callback before the subtraction:

```rust
if size == 0 {
    return false as hb_bool_t;
}
let copy_len = glyph_name.len().min((size as usize) - 1);
```

Additionally, normalize `size == 0` in `hb_font_t::get_glyph_name()` so any future glyph-name backend (native or Rust) inherits the same protection rather than each backend re-validating its own arguments.

Add a regression test in `test/api/` (e.g., extend `test-font.c`) that calls `hb_font_get_glyph_name(font, gid, buf, 0)` and asserts the call returns false without writing.

## References

- CWE-124: Buffer Underwrite ('Buffer Underflow')
- CWE-191: Integer Underflow (Wrap or Wraparound)
- CWE-787: Out-of-bounds Write

---

Reported via an internal security audit of the HarfBuzz tree at commit `edc8c18ef`. Happy to supply additional detail or coordinate disclosure timing if preferred.


Discussion:
PR created: https://github.com/harfbuzz/harfbuzz/pull/5947



Patch:
diff --git a/src/hb-coretext-font.cc b/src/hb-coretext-font.cc
index 939e620f3b6..9f46928275e 100644
--- a/src/hb-coretext-font.cc
+++ b/src/hb-coretext-font.cc
@@ -409,6 +409,12 @@ hb_coretext_get_glyph_name (hb_font_t *font,
   if (!cf_name)
     return false;
 
+  if (!size)
+  {
+    CFRelease (cf_name);
+    return true;
+  }
+
   CFIndex len = CFStringGetLength (cf_name);
   if (len > (CFIndex)size - 1)
     len = (CFIndex)size - 1;
diff --git a/src/hb-ft.cc b/src/hb-ft.cc
index 5fef168c8d0..8ac5db77462 100644
--- a/src/hb-ft.cc
+++ b/src/hb-ft.cc
@@ -766,6 +766,12 @@ hb_ft_get_glyph_name (hb_font_t *font HB_UNUSED,
   hb_lock_t lock (ft_font->lock);
   FT_Face ft_face = ft_font->ft_face;
 
+  if (!size)
+  {
+    char buf[128];
+    return !FT_Get_Glyph_Name (ft_face, glyph, buf, sizeof (buf)) && *buf;
+  }
+
   hb_bool_t ret = !FT_Get_Glyph_Name (ft_face, glyph, name, size);
   if (ret && (size && !*name))
     ret = false;
diff --git a/src/rust/font.rs b/src/rust/font.rs
index 8e6b526b0dd..efe0c7c44cf 100644
--- a/src/rust/font.rs
+++ b/src/rust/font.rs
@@ -981,9 +981,12 @@ extern "C" fn _hb_fontations_glyph_name(
     let data = unsafe { &mut *(font_data as *mut FontationsData) };
 
     if let Some(glyph_name) = data.glyph_names.get(GlyphId::new(glyph)) {
+        if size == 0 {
+            return true as hb_bool_t;
+        }
         let glyph_name = glyph_name.as_str();
         // Copy the glyph name into the buffer, up to size-1 bytes
-        let len = glyph_name.len().min(size as usize - 1);
+        let len = glyph_name.len().min((size as usize) - 1);
         unsafe {
             std::slice::from_raw_parts_mut(name as *mut u8, len)
                 .copy_from_slice(&glyph_name.as_bytes()[..len]);


Test patch:
diff --git a/test/api/test-ft.c b/test/api/test-ft.c
index ae792cff017..e32e0b6a04a 100644
--- a/test/api/test-ft.c
+++ b/test/api/test-ft.c
@@ -80,7 +80,7 @@ test_native_ft_basic (void)
 
   init_freetype ();
 
-  ft_face = get_ft_face ("fonts/Cantarell.A.otf");
+  ft_face = get_ft_face ("fonts/adwaita.ttf");
 
   g_assert_nonnull (ft_face);
   g_assert_nonnull (FT_Get_Font_Format (ft_face));
@@ -135,6 +135,77 @@ test_native_ft_set_funcs_preserves_load_flags (void)
   cleanup_freetype ();
 }
 
+static void
+test_native_ft_glyph_name_zero_size_probe (void)
+{
+  static const char *files[] = {
+    "fonts/adwaita.ttf",
+    "fonts/SourceSansPro-Regular.otf",
+    "fonts/Cantarell.A.otf",
+  };
+  FT_Face ft_face = NULL;
+  hb_font_t *font = NULL;
+  hb_bool_t ret;
+  hb_codepoint_t glyph = 0;
+  char name[64];
+  char guard[4] = { 0x7f, 0x7f, 0x7f, 0x7f };
+  unsigned int i;
+
+  init_freetype ();
+
+  for (i = 0; i < G_N_ELEMENTS (files); i++)
+  {
+    unsigned int gid;
+
+    ft_face = get_ft_face (files[i]);
+    g_assert_nonnull (ft_face);
+
+    font = hb_ft_font_create_referenced (ft_face);
+    g_assert_nonnull (font);
+    hb_ft_font_set_funcs (font);
+
+    for (gid = 0; gid < (unsigned int) ft_face->num_glyphs; gid++)
+      if (hb_font_get_glyph_name (font, gid, name, sizeof (name)))
+      {
+        glyph = gid;
+        goto found;
+      }
+
+    hb_font_destroy (font);
+    font = NULL;
+    FT_Done_Face (ft_face);
+    ft_face = NULL;
+  }
+
+  g_test_skip ("No FreeType glyph-name test font available");
+  cleanup_freetype ();
+  return;
+
+found:
+
+  ret = hb_font_get_glyph_name (font, glyph, name, sizeof (name));
+  g_assert_true (ret);
+  g_assert_true (*name);
+
+  ret = hb_font_get_glyph_name (font, glyph, NULL, 0);
+  g_assert_true (ret);
+
+  ret = hb_font_get_glyph_name (font, glyph, guard, 0);
+  g_assert_true (ret);
+  g_assert_cmpint (guard[0], ==, 0x7f);
+  g_assert_cmpint (guard[1], ==, 0x7f);
+  g_assert_cmpint (guard[2], ==, 0x7f);
+  g_assert_cmpint (guard[3], ==, 0x7f);
+
+  ret = hb_font_get_glyph_name (font, 0xFFFFFF, NULL, 0);
+  g_assert_false (ret);
+
+  hb_font_destroy (font);
+  FT_Done_Face (ft_face);
+
+  cleanup_freetype ();
+}
+
 int
 main (int argc, char **argv)
 {
@@ -142,6 +213,7 @@ main (int argc, char **argv)
 
   hb_test_add (test_native_ft_basic);
   hb_test_add (test_native_ft_set_funcs_preserves_load_flags);
+  hb_test_add (test_native_ft_glyph_name_zero_size_probe);
 
   return hb_test_run ();
 }
diff --git a/test/api/test-glyph-names.c b/test/api/test-glyph-names.c
index 48ca06b8a94..50dd104d35c 100644
--- a/test/api/test-glyph-names.c
+++ b/test/api/test-glyph-names.c
@@ -100,6 +100,277 @@ test_glyph_names_cff (void)
   hb_face_destroy (face);
 }
 
+/* Regression tests for issue #5946: hb_font_get_glyph_name (..., size=0)
+ * must not underflow and write past a zero-capacity buffer, while preserving
+ * the long-standing "return value only" probe behavior. */
+
+static void
+test_glyph_names_zero_size (void)
+{
+  hb_face_t *face;
+  hb_font_t *font;
+  char guard[4] = { 0x7f, 0x7f, 0x7f, 0x7f };
+
+  face = hb_test_open_font_file ("fonts/adwaita.ttf");
+  font = hb_font_create (face);
+
+  /* size == 0 must not write to the buffer. */
+  g_assert_true (hb_font_get_glyph_name (font, 1, guard, 0));
+  g_assert_cmpint (guard[0], ==, 0x7f);
+  g_assert_cmpint (guard[1], ==, 0x7f);
+  g_assert_cmpint (guard[2], ==, 0x7f);
+  g_assert_cmpint (guard[3], ==, 0x7f);
+
+  hb_font_destroy (font);
+  hb_face_destroy (face);
+}
+
+static void
+test_glyph_names_zero_size_post (void)
+{
+  hb_face_t *face;
+  hb_font_t *font;
+  hb_bool_t ret;
+  char guard[8] = { 0x7f, 0x7f, 0x7f, 0x7f, 0x7f, 0x7f, 0x7f, 0x7f };
+  unsigned int i;
+
+  face = hb_test_open_font_file ("fonts/adwaita.ttf");
+  font = hb_font_create (face);
+
+  /* Named glyph, size == 0: keep the old return-value-only probe behavior
+   * and leave the buffer untouched. */
+  ret = hb_font_get_glyph_name (font, 1, guard, 0);
+  g_assert_true (ret);
+  for (i = 0; i < 8; i++)
+    g_assert_cmpint (guard[i], ==, 0x7f);
+
+  /* .notdef, size == 0: same invariant. Confirms it is not glyph-specific. */
+  ret = hb_font_get_glyph_name (font, 0, guard, 0);
+  g_assert_true (ret);
+  for (i = 0; i < 8; i++)
+    g_assert_cmpint (guard[i], ==, 0x7f);
+
+  /* Perform a successful sized lookup first, then retry with size == 0.
+   * Confirms no residual state corrupts the zero-size path. */
+  {
+    char name[64];
+    ret = hb_font_get_glyph_name (font, 2, name, 64);
+    g_assert_true (ret);
+    g_assert_cmpstr (name, ==, "icon0");
+  }
+  ret = hb_font_get_glyph_name (font, 2, guard, 0);
+  g_assert_true (ret);
+  for (i = 0; i < 8; i++)
+    g_assert_cmpint (guard[i], ==, 0x7f);
+
+  hb_font_destroy (font);
+  hb_face_destroy (face);
+}
+
+static void
+test_glyph_names_zero_size_cff (void)
+{
+  hb_face_t *face;
+  hb_font_t *font;
+  hb_bool_t ret;
+  char guard[8] = { 0x7f, 0x7f, 0x7f, 0x7f, 0x7f, 0x7f, 0x7f, 0x7f };
+  unsigned int i;
+
+  face = hb_test_open_font_file ("fonts/SourceSansPro-Regular.otf");
+  font = hb_font_create (face);
+
+  /* CFF-backed named glyph, size == 0: preserve the return-value-only probe
+   * behavior without writing into the buffer. */
+  ret = hb_font_get_glyph_name (font, 2, guard, 0);
+  g_assert_true (ret);
+  for (i = 0; i < 8; i++)
+    g_assert_cmpint (guard[i], ==, 0x7f);
+
+  /* .notdef on CFF. */
+  ret = hb_font_get_glyph_name (font, 0, guard, 0);
+  g_assert_true (ret);
+  for (i = 0; i < 8; i++)
+    g_assert_cmpint (guard[i], ==, 0x7f);
+
+  hb_font_destroy (font);
+  hb_face_destroy (face);
+}
+
+static void
+test_glyph_names_zero_size_unnamed_glyph (void)
+{
+  /* size == 0 on a glyph beyond the font's range must also return false
+   * and must not write. */
+  hb_face_t *face;
+  hb_font_t *font;
+  hb_bool_t ret;
+  char guard[4] = { 0x7f, 0x7f, 0x7f, 0x7f };
+  unsigned int i;
+
+  face = hb_test_open_font_file ("fonts/adwaita.ttf");
+  font = hb_font_create (face);
+
+  ret = hb_font_get_glyph_name (font, 100, guard, 0);
+  g_assert_false (ret);
+  for (i = 0; i < 4; i++)
+    g_assert_cmpint (guard[i], ==, 0x7f);
+
+  hb_font_destroy (font);
+  hb_face_destroy (face);
+}
+
+static void
+test_glyph_names_zero_size_null_buffer (void)
+{
+  /* name == NULL with size == 0 is a valid return-value-only probe. */
+  hb_face_t *face;
+  hb_font_t *font;
+  hb_bool_t ret;
+
+  face = hb_test_open_font_file ("fonts/adwaita.ttf");
+  font = hb_font_create (face);
+
+  /* Glyph with a name. */
+  ret = hb_font_get_glyph_name (font, 1, NULL, 0);
+  g_assert_true (ret);
+
+  /* Glyph beyond the font's range. */
+  ret = hb_font_get_glyph_name (font, 100, NULL, 0);
+  g_assert_false (ret);
+
+  hb_font_destroy (font);
+  hb_face_destroy (face);
+}
+
+static void
+test_glyph_names_size_one (void)
+{
+  /* Boundary: size == 1. The backend must produce exactly a trailing NUL
+   * at name[0] (len = min(|name|, size-1) = 0 bytes of name copied) and
+   * must not touch any byte past name[0]. */
+  hb_face_t *face;
+  hb_font_t *font;
+  hb_bool_t ret;
+  char buf[4] = { 0x7f, 0x7f, 0x7f, 0x7f };
+
+  face = hb_test_open_font_file ("fonts/adwaita.ttf");
+  font = hb_font_create (face);
+
+  ret = hb_font_get_glyph_name (font, 1, buf, 1);
+  g_assert_true (ret);
+  g_assert_cmpint (buf[0], ==, 0);
+  /* Bytes beyond the caller-declared capacity must remain intact. */
+  g_assert_cmpint (buf[1], ==, 0x7f);
+  g_assert_cmpint (buf[2], ==, 0x7f);
+  g_assert_cmpint (buf[3], ==, 0x7f);
+
+  hb_font_destroy (font);
+  hb_face_destroy (face);
+}
+
+static void
+test_glyph_names_small_buffer_truncation (void)
+{
+  /* Non-regression sanity: a small-but-nonzero buffer must truncate to
+   * size-1 bytes of the name plus a trailing NUL. */
+  hb_face_t *face;
+  hb_font_t *font;
+  hb_bool_t ret;
+  char buf[4] = { 0x7f, 0x7f, 0x7f, 0x7f };
+
+  face = hb_test_open_font_file ("fonts/adwaita.ttf");
+  font = hb_font_create (face);
+
+  /* ".notdef" truncated to capacity 4 -> ".no\0". */
+  ret = hb_font_get_glyph_name (font, 0, buf, 4);
+  g_assert_true (ret);
+  g_assert_cmpint (buf[0], ==, '.');
+  g_assert_cmpint (buf[1], ==, 'n');
+  g_assert_cmpint (buf[2], ==, 'o');
+  g_assert_cmpint (buf[3], ==, 0);
+
+  hb_font_destroy (font);
+  hb_face_destroy (face);
+}
+
+static void
+test_glyph_names_exact_fit_buffer (void)
+{
+  /* Non-regression sanity: a buffer exactly sized to fit the full name
+   * (including the trailing NUL) must hold the full name. For ".notdef"
+   * that is 8 bytes. */
+  hb_face_t *face;
+  hb_font_t *font;
+  hb_bool_t ret;
+  char buf[8] = { 0x7f, 0x7f, 0x7f, 0x7f, 0x7f, 0x7f, 0x7f, 0x7f };
+
+  face = hb_test_open_font_file ("fonts/adwaita.ttf");
+  font = hb_font_create (face);
+
+  ret = hb_font_get_glyph_name (font, 0, buf, 8);
+  g_assert_true (ret);
+  g_assert_cmpstr (buf, ==, ".notdef");
+  g_assert_cmpint (buf[7], ==, 0);
+
+  hb_font_destroy (font);
+  hb_face_destroy (face);
+}
+
+static void
+test_glyph_names_nonzero_sizes_unchanged (void)
+{
+  /* Non-regression sanity: the existing behaviour at typical sizes must
+   * be preserved by the fix. */
+  hb_face_t *face;
+  hb_font_t *font;
+  hb_bool_t ret;
+  char name[64];
+
+  face = hb_test_open_font_file ("fonts/adwaita.ttf");
+  font = hb_font_create (face);
+
+  ret = hb_font_get_glyph_name (font, 0, name, 64);
+  g_assert_true (ret);
+  g_assert_cmpstr (name, ==, ".notdef");
+
+  ret = hb_font_get_glyph_name (font, 1, name, 64);
+  g_assert_true (ret);
+  g_assert_cmpstr (name, ==, ".space");
+
+  /* Beyond last glyph still returns false. */
+  ret = hb_font_get_glyph_name (font, 100, name, 64);
+  g_assert_false (ret);
+
+  hb_font_destroy (font);
+  hb_face_destroy (face);
+}
+
+static void
+test_glyph_names_zero_size_sub_font (void)
+{
+  /* Sub-fonts delegate to the parent's glyph-name funcs. Preserve the
+   * return-value-only probe behavior through the parent chain as well. */
+  hb_face_t *face;
+  hb_font_t *parent;
+  hb_font_t *font;
+  hb_bool_t ret;
+  char guard[4] = { 0x7f, 0x7f, 0x7f, 0x7f };
+  unsigned int i;
+
+  face = hb_test_open_font_file ("fonts/adwaita.ttf");
+  parent = hb_font_create (face);
+  font = hb_font_create_sub_font (parent);
+
+  ret = hb_font_get_glyph_name (font, 1, guard, 0);
+  g_assert_true (ret);
+  for (i = 0; i < 4; i++)
+    g_assert_cmpint (guard[i], ==, 0x7f);
+
+  hb_font_destroy (font);
+  hb_font_destroy (parent);
+  hb_face_destroy (face);
+}
+
 int
 main (int argc, char **argv)
 {
@@ -107,6 +378,16 @@ main (int argc, char **argv)
 
   hb_test_add (test_glyph_names_post);
   hb_test_add (test_glyph_names_cff);
+  hb_test_add (test_glyph_names_zero_size);
+  hb_test_add (test_glyph_names_zero_size_post);
+  hb_test_add (test_glyph_names_zero_size_cff);
+  hb_test_add (test_glyph_names_zero_size_unnamed_glyph);
+  hb_test_add (test_glyph_names_zero_size_null_buffer);
+  hb_test_add (test_glyph_names_size_one);
+  hb_test_add (test_glyph_names_small_buffer_truncation);
+  hb_test_add (test_glyph_names_exact_fit_buffer);
+  hb_test_add (test_glyph_names_nonzero_sizes_unchanged);
+  hb_test_add (test_glyph_names_zero_size_sub_font);
 
   return hb_test_run();
 }