#1710·JUCE

[Bug]: SimpleShapedText::getTextRange indexes a Span with a negative index (UB) via addFittedText into a narrow box

Author: VibehunterCreated Aug 12, 2026Updated Aug 20, 2026

JUCE 8.0.13, macOS 13.7.8, Intel. Found with an ASan+UBSan build of a standalone shaping harness, no JUCE modifications.

SUMMARY

GlyphArrangement::addFittedText can drive SimpleShapedText::getTextRange into indexing a Span with a negative index, cast to size_t. UBSan reports it as an unsigned-offset overflow; in practice it reads one or more ShapedGlyph elements BEFORE the start of the run.

MINIMAL REPRODUCER, four codepoints

U+0639 U+0615 U+000D U+0061
ARABIC LETTER AIN | ARABIC SMALL HIGH TAH (Mn) | CARRIAGE RETURN | LATIN SMALL LETTER A

Geometry: Graphics::drawFittedText (or GlyphArrangement::addFittedText directly) into a box 1 to 5 px wide, height 24, at a 12 pt font. Delete-one is at a fixpoint: all four three-codepoint subsets are clean.

A second, independent minimal from a different corpus entry, at an 8 px box:

U+0639 U+D800 U+0301 U+000A U+0061
ARABIC AIN | LONE HIGH SURROGATE | COMBINING ACUTE | LINE FEED | 'a'

THE FAULTING SITE

juce_SimpleShapedText.cpp:1417-1419

const auto indexInRun = glyphIndex - glyphRange.getStart();
const auto cluster    = glyphRun[(size_t) indexInRun].cluster;   // <-- 1419

glyphRun is a Span built at :1414 from glyphsInVisualOrder.data() + glyphRange.getStart(). When glyphRange.getStart() > glyphIndex, indexInRun is negative, the (size_t) cast wraps, and Span::operator[] (juce_Span.h:141, a bare ptr[index]) reads before the run.

Observed indexInRun values: -1, -2, -5, -7. sizeof(ShapedGlyph) is 32 on this build, so the pointer deltas are -32, -64, -160 and -224 bytes.

UBSan, verbatim:

juce_Span.h:141:62: runtime error: addition of unsigned offset to 0x... overflowed to 0x...
  #0  juce::Span<juce::detail::ShapedGlyph const, SIZE_MAX>::operator[]  juce_Span.h:141
  #1  juce::detail::SimpleShapedText::getTextRange(long long) const      juce_SimpleShapedText.cpp:1419
  #2  juce::detail::ShapedText::Impl::getTextRange(long long) const      juce_ShapedText.cpp:79
  ...
  #12 juce::addGlyphsFromShapedText(...)                                 juce_GlyphArrangement.cpp:190
  #13 juce::GlyphArrangement::addFittedText(...)                         juce_GlyphArrangement.cpp:533

WHAT EACH POSITION OF THE INPUT REQUIRES

Each was probed independently, one subprocess per candidate:

[0] a base the RESOLVED FONT CANNOT RENDER. Arabic, Hebrew, Devanagari, Syriac, Thaana, NKo, Cyrillic or a lone surrogate all fire. Latin, Greek and CJK do NOT. [1] a COMBINING MARK on that base. [2] CR or LF, and nothing else. TAB, VT, ESC, NEL, U+2028 and U+2029 do NOT fire it. [3] any further content. There must be text on BOTH sides of the break.

SCOPE

Only GlyphArrangement::addFittedText and Graphics::drawFittedText reach it. getStringWidthInt, addJustifiedText and the ellipsis drawText path were each swept over the same 900-string corpus and came back clean, exit 0.

It fires at every justification, at both maxLines 1 and 2, at every minimumHorizontalScale from 0.0 to 1.0, and on every font tried including a genuinely installed Lucida Grande. The narrow box matters: the rate falls off sharply as the box widens.

WHAT THIS IS NOT

I am not reporting a crash. Every hit I have observed is a small negative index that lands inside the live allocation and reads a wrong cluster value, so the visible symptom is a wrong glyph range rather than a fault. I have not produced a SIGSEGV from it. A larger negative index presumably would, but I have not demonstrated that and I am not going to imply it.

One adjacent thing I did NOT test, mentioned so it is not mistaken for cleared: at :1412 the std::optional returned by getItemWithEnclosingRange (juce_Ranges.h:519-523) is dereferenced with no guard at all. UBSan does not check optional dereference, so that path is untested rather than proven safe.

WHY IT MATTERS IN PRACTICE

Our own reachable path is a CoreAudio device/port name drawn into a fixed-width label at minimumHorizontalScale 1.0. Device names are vendor-controlled and can contain anything, including a newline. A user with an Arabic, Hebrew or Devanagari named interface is a rare but entirely real combination. We have guarded our side by stripping C0/C1 controls before the draw, which breaks position [2], but that only protects our own call sites.

HARNESS

The harness is a standalone JUCE console app, no framework modifications, ASan+UBSan behind a build flag so the plain build stays comparable. Happy to share it, or to run any patch you want tested on this rig.