Your Browser's Hyphenation Dictionary Names the OS It Runs On
CSS hyphens:auto uses a per-language dictionary, and macOS and Windows/Linux Chrome ship disjoint dictionary sets from two different engines. Which languages a browser will hyphenate, and where it breaks them, classifies the OS with one DOM read. Why closing it for a macOS profile means reverse-engineering Apple's CFBurstTrie format and reconstructing its hyphenation bit-for-bit.
Fingerprinting looks at canvas, fonts, WebGL. There is a text-layout signal that is just as sharp and almost never spoofed: which words the browser is willing to hyphenate.
CSS hyphens: auto1 breaks a long word across lines using a per-language dictionary. Put a word in a box too narrow to hold it, with word-break: normal, and it wraps only if the browser has a hyphenation dictionary for that language. No dictionary, no break, the word overflows on one line.
Here is the probe:
function hyphenates(lang, word) {
const el = document.createElement('div');
el.setAttribute('lang', lang);
el.style.cssText =
'width:6ch;font:20px serif;hyphens:auto;' +
'overflow-wrap:normal;word-break:normal';
el.textContent = word;
document.body.appendChild(el);
const wrapped = el.getBoundingClientRect().height > 30; // taller than 1 line
el.remove();
return wrapped;
}
Run it for Finnish and for Latin on genuine Chrome 150, three real machines:
| Probe | macOS | Windows | Linux |
|---|---|---|---|
hyphenates('fi', 'kansainvälistyminen') | true | false | false |
hyphenates('la', 'constitutionalibus') | false | true | true |
Genuine macOS Chrome hyphenates Finnish and refuses Latin. Genuine Windows and Linux Chrome do the opposite. One lang attribute and one height read, and the layout has told you the operating system.
Two engines, two dictionaries
Chrome does not carry one hyphenator. It carries two, and the platform picks one at build time.
macOS and iOS call CoreFoundation2. The function is CFStringGetHyphenationLocationBeforeIndex, and it reads Apple’s compiled dictionaries at /System/Library/LinguisticData/<locale>/hyphenation.dat. Windows, Linux, Android, and ChromeOS call Minikin3, the AOSP hyphenator, which reads hyph-<locale>.hyb pattern files that Chrome’s component updater4 delivers after startup.
Two libraries, two dictionary sets, and the sets do not match.
- Apple ships hyphenation for 21 locales:
ca cs da de el en(US/GB) es fi fr hr hu it nb nl pl pt ro ru sk sv uk. - Minikin ships 52, the AOSP set. It includes
af be bg bn cy et eu ga gl hi hy ka kn la lt lv ml mr nn or pa sl sq ta te tkand more, and it does not include Catalan, Finnish, Polish, or Romanian.
They overlap on the common European languages and split at the edges. Finnish, Polish, Catalan, Romanian are macOS-only. Latin, Welsh, Norwegian Nynorsk, Slovenian, Basque, Estonian are Minikin-only. A probe that walks ten of these locales reads the engine, and the engine is the OS family.
The coverage is only half the tell. Where both engines do hyphenate the same language, they still disagree on some words. Apple’s patterns and the AOSP TeX-derived patterns break internacionalizace at different points in Czech, Slovak, and Ukrainian. A detector that records the break positions for a fixed word, not just whether it wrapped, gets a second axis for free.
The bot version of this fails two ways
A custom Chromium build inherits the leak and usually makes it worse.
The first failure was documented by Joe Rutkowski (joe12387)5 in a public proof of concept, hyphenation-dictionary-poc. Most non-official builds never run the component updater, so Minikin’s dictionary directory stays empty and hyphens: auto never fires for any language. Real Chrome on the same OS hyphenates fine, because the updater delivered the dictionaries on first run. The custom build hyphenates nothing. That single delta, wraps versus does-not-wrap on English, separates a build that shipped no dictionaries from a real browser, and a probe that iterates locales defeats any half fix.
Second, once you do ship the AOSP .hyb set so English works, a profile claiming macOS is now hyphenating through Minikin. It hyphenates Latin and Nynorsk, which genuine macOS never does, and it fails Finnish and Polish, which genuine macOS always does. You have traded “hyphenates nothing” for “hyphenates like the wrong OS.”
Closing it for macOS means reproducing CoreFoundation. That library does not exist on Linux, so there is nothing to call.
Apple’s dictionary is a compiled trie, not a pattern list
hyphenation.dat is not TeX patterns. It is a serialized CFBurstTrie6, the same container CoreFoundation uses for its tokenizers. The header gives it away:
struct TrieHeader {
uint32_t signature; // 0x0ddba11
uint32_t rootOffset;
uint32_t count; // pattern count
uint32_t size; // == file size
uint32_t flags; // ReadOnly | BitmapCompression
uint64_t reserved[16];
};
The reserved[16] field is left uninitialized on disk, so it carries whatever stack bytes Apple’s serializer had lying around, including live pointers that look like 0x00007fff…. That garbage made the format look like a memory image with baked pointers. It is not. It is padding.
The body is a trie with three node kinds, selected by the low two bits of any child value:
DiskTrieLevel: a flatuint32_t slots[256]indexed by the next key byte, with the terminal payload at offset0x400.CompactDiskTrieLevel: auint64_t bitmap[4]plus apopcount-indexed slot array, for sparse levels.StringPage: a terminal bucket of{uint16 len, uint32 payload, bytes}entries, matched by exact length.
Every genuine .dat uses the legacy 0x0ddba11 layout, which the macOS 26 CoreFoundation binary reads through traverseCFBurstTrieWithCursor. The 2016 open-source CFBurstTrie.c reads a newer variant and returns nothing on these files, so the format was recovered from the shipping macOS disassembly, not from the published source.
The payload for a matched substring packs Liang7 hyphenation levels, three bits per position. A value is a leaf, the actual levels, when its low two bits are zero, and a child pointer otherwise. Genuine macOS returns hyphens where the accumulated level is odd, with a two-character minimum on each side. The rest is standard Liang once the container is decoded.
The reconstruction
The reader is a few hundred lines of portable C: parse the header, walk the trie by key bytes across DiskTrieLevel and CompactDiskTrieLevel nodes, scan a StringPage when a slot leads into one, max-accumulate the three-bit levels, emit a break where the level is odd. The lookup does one trie walk per starting position, so a word costs about a microsecond, not the quadratic substring rescan a naive port would do.
Two details decide correctness. The Liang levels for a matched pattern apply only within that pattern’s own span, so a long pattern’s high-order level groups must not leak onto positions outside it. Miss that bound and Norwegian gains an extra break next to a real one. And the trie stores UTF-8 keys while the break positions are code points, so Cyrillic and Greek need the byte walk and the code-point accounting kept separate.
We ship Apple’s real .dat files next to the binary and gate the whole path on the claimed OS. A profile presenting as macOS reads Apple’s dictionaries through the reconstruction; a Linux or Windows profile keeps Minikin untouched. The dictionary server hands out a .dat only for the locales genuine macOS actually supports, so a macOS profile hyphenates exactly Apple’s 21-locale set and stops hyphenating the Minikin-only languages. Coverage matches in both directions, not just the ones the target has.
Validation
The reconstruction runs against genuine macOS Chrome over the DevTools protocol, computing the break positions for every dictionary at full precision. It is byte-exact for all 21 locales, break-for-break, including the Czech, Slovak, and Ukrainian words where AOSP Minikin diverges from Apple. The end-to-end check is the probe from the top of this post: a macOS profile returns true for Finnish and Polish and false for Latin and Nynorsk, which is what a real Mac returns.
Why it matters
Hyphenation is deterministic, one DOM read, and it sits in text layout where a spoofing stack rarely looks. The coverage split alone classifies the OS family with a handful of lang probes, and the break positions add a second axis inside the languages both engines share. Getting it right for a macOS profile takes reverse-engineering a CoreFoundation container format, reconstructing Liang hyphenation over it bit-for-bit, and matching Apple’s exact locale coverage so the browser refuses the same languages a real Mac refuses.
Send a request through Scrapfly’s API and ask to present as macOS, and the text wraps where a Mac wraps, in the languages a Mac wraps them.
Scrapium is Scrapfly’s scraping browser, built to stay indistinguishable from real traffic. Our engineering blog goes deep on the rest of the stack.
CSS
hyphens: auto: lets the layout engine insert hyphens at dictionary-defined break points when a word does not fit its container. Requires alangand a hyphenation dictionary for that language. Reference: MDN. ↩︎CoreFoundation: Apple’s low-level C framework. Its
CFStringGetHyphenationLocationBeforeIndexprovides system hyphenation on macOS and iOS and has no equivalent on Linux or Windows, which is why a spoofed macOS profile on a Linux build has nothing to call. Reference: Apple Developer. ↩︎Minikin: the Android (AOSP) text-layout library. Chrome uses its hyphenator on Windows, Linux, Android, and ChromeOS, reading compiled
.hybpattern files. Reference: AOSP Minikin. ↩︎component updater: Chrome’s mechanism for delivering data such as hyphenation dictionaries after the browser starts, separate from the installer. A custom build that never contacts it ships no dictionaries. Reference: Chromium components. ↩︎
Joe Rutkowski (joe12387): the security researcher who published the hyphenation-dictionary proof of concept this section draws on. Profile: LinkedIn. ↩︎
CFBurstTrie: a compact trie container inside CoreFoundation used to serialize string data such as hyphenation patterns. On disk it is a header followed by disk-trie, compact-bitmap, and string-page nodes, with node kind encoded in the low two bits of each child value. Reference: Apple open source (CF). ↩︎
Liang’s algorithm: Franklin Liang’s 1983 hyphenation method. Patterns carry odd and even level digits between letters, levels are accumulated across every matching substring, and a hyphen is allowed where the accumulated level is odd. Reference: Hyphenation algorithm. ↩︎