Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions lib/types/heif.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { IImage, ISize } from './interface'
import { findBox, readUInt32BE, toUTF8String } from './utils'
import { advanceBox, findBox, readUInt32BE, toUTF8String } from './utils'

const brandMap = {
avif: 'avif',
Expand Down Expand Up @@ -57,7 +57,7 @@ export const HEIF: IImage = {

images.push({ height, width })

currentOffset = ispeBox.offset + ispeBox.size
currentOffset = advanceBox(ispeBox.offset, ispeBox.size)
}

if (images.length === 0) {
Expand Down
4 changes: 2 additions & 2 deletions lib/types/jxl.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { IImage, ISize } from './interface'
import { JXLStream } from './jxl-stream'
import { findBox, toUTF8String } from './utils'
import { advanceBox, findBox, toUTF8String } from './utils'

/** Extracts the codestream from a containerized JPEG XL image */
function extractCodestream(input: Uint8Array): Uint8Array | undefined {
Expand All @@ -27,7 +27,7 @@ function extractPartialStreams(input: Uint8Array): Uint8Array[] {
partialStreams.push(
input.slice(jxlpBox.offset + 12, jxlpBox.offset + jxlpBox.size),
)
offset = jxlpBox.offset + jxlpBox.size
offset = advanceBox(jxlpBox.offset, jxlpBox.size)
}
return partialStreams
}
Expand Down
11 changes: 8 additions & 3 deletions lib/types/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export function readUInt(
return methods[methodName](input, offset)
}

const BOX_HEADER_SIZE = 8

function readBox(input: Uint8Array, offset: number) {
if (input.length - offset < 4) return
const boxSize = readUInt32BE(input, offset)
Expand All @@ -74,6 +76,11 @@ function readBox(input: Uint8Array, offset: number) {
}
}

// Advance past a box, guaranteeing forward progress even when its declared
// size is smaller than its own 8-byte header (e.g. 0, from a crafted input)
export const advanceBox = (offset: number, size: number): number =>
offset + Math.max(size, BOX_HEADER_SIZE)

export function findBox(
input: Uint8Array,
boxName: string,
Expand All @@ -83,8 +90,6 @@ export function findBox(
const box = readBox(input, currentOffset)
if (!box) break
if (box.name === boxName) return box
// Fix the infinite loop by ensuring offset always increases
// If box.size is 0, advance by at least 8 bytes (the size of the box header)
currentOffset += box.size > 0 ? box.size : 8
currentOffset = advanceBox(currentOffset, box.size)
}
}
63 changes: 63 additions & 0 deletions specs/heif-jxl-dos.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as assert from 'node:assert'
import { describe, it } from 'node:test'
import { imageSize } from '../lib'

// Regression test for GHSA-5p2g-fcmc-qvqq: a crafted 'ispe'/'jxlp' box with a
// zero-valued size field used to leave the parser's offset unchanged,
// spinning the containing loop forever.

const u32be = (n: number): number[] => [
(n >>> 24) & 0xff,
(n >>> 16) & 0xff,
(n >>> 8) & 0xff,
n & 0xff,
]
const ascii = (s: string): number[] => [...s].map((c) => c.charCodeAt(0))

describe('Denial of Service via zero-size boxes', () => {
it('does not hang on a HEIF file with a zero-size ispe box', () => {
// ftyp: size(16) + 'ftyp' + 'heic' + minor_version(0)
const ftyp = [...u32be(16), ...ascii('ftyp'), ...ascii('heic'), ...u32be(0)]
// meta: size(36) + 'meta' + version/flags(0)
const meta = [...u32be(36), ...ascii('meta'), ...u32be(0)]
// iprp: size(24) + 'iprp'
const iprp = [...u32be(24), ...ascii('iprp')]
// ipco: size(16) + 'ipco'
const ipco = [...u32be(16), ...ascii('ipco')]
// ispe: size(0, malformed) + 'ispe' + padding(4) + width(100) + height(50)
const ispe = [
...u32be(0),
...ascii('ispe'),
...u32be(0),
...u32be(100),
...u32be(50),
]

const buffer = new Uint8Array([...ftyp, ...meta, ...iprp, ...ipco, ...ispe])

assert.deepStrictEqual(imageSize(buffer), {
width: 100,
height: 50,
type: 'heic',
})
})

it('does not hang on a JXL file with a zero-size jxlp box', () => {
// JXL signature box: size(12) + 'JXL ' + payload(4)
const sig = [...u32be(12), ...ascii('JXL '), 0x0d, 0x0a, 0x87, 0x0a]
// ftyp: size(20) + 'ftyp' + 'jxl ' + minor_version(0) + 'jxl '
const ftyp = [
...u32be(20),
...ascii('ftyp'),
...ascii('jxl '),
...u32be(0),
...ascii('jxl '),
]
// jxlp: size(0, malformed) + 'jxlp'
const jxlp = [...u32be(0), ...ascii('jxlp')]

const buffer = new Uint8Array([...sig, ...ftyp, ...jxlp])

assert.throws(() => imageSize(buffer))
})
})
17 changes: 17 additions & 0 deletions specs/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,5 +156,22 @@ describe('Utils', () => {
const result = findBox(input, 'test', 0)
assert.equal(result, undefined)
})

it('advances by the full header size when a non-matching box declares a smaller size', () => {
// A non-matching box declaring size 1 (smaller than its own 8-byte
// header), followed immediately by the box being searched for.
// Advancing by the declared size instead of the header size would
// scan the same bytes repeatedly instead of skipping past it.
const skippedBox = new Uint8Array([0, 0, 0, 1, 111, 116, 104, 114]) // size 1, "othr"
const targetBox = new Uint8Array([0, 0, 0, 8, 116, 101, 115, 116]) // size 8, "test"
const input = new Uint8Array([...skippedBox, ...targetBox])

const result = findBox(input, 'test', 0)
assert.deepEqual(result, {
name: 'test',
offset: 8,
size: 8,
})
})
})
})