Skip to content

Possible out-of-bounds read in JPEG encoding from unchecked width/height vs buffer size #27

Description

@OvOhao

Possible out-of-bounds read in JPEG encoding from unchecked width/height vs buffer size

I found a possible out-of-bounds read (heap over-read with information disclosure) in
JpegEncoder::encode. The Jpeg constructor (Jpeg::New) accepts a JS Buffer, a width,
and a height as independent arguments, validating only that the first is a Buffer and that
width/height are non-negative integers. It never checks that the buffer contains
width * height * channels bytes. The encoder then reads height scanlines of
width * channels bytes directly out of the buffer's backing store. A caller passing a small
buffer with large width/height causes reads far past the end of the buffer, and the over-read
bytes are compressed into the returned JPEG and handed back to JS — leaking adjacent heap memory.

File: src/jpeg_encoder.cpp (missing validation in src/jpeg.cpp, Jpeg::New)

Function: JpegEncoder::encode

// src/jpeg.cpp — Jpeg::New: the ONLY input checks, then raw pointer captured
if (!Buffer::HasInstance(args[0])) return VException("First argument must be Buffer.");
if (!args[1]->IsInt32())           return VException("Second argument must be integer width.");
if (!args[2]->IsInt32())           return VException("Third argument must be integer height.");
int w = args[1]->Int32Value();
int h = args[2]->Int32Value();
if (w < 0) return VException("Width can't be negative.");
if (h < 0) return VException("Height can't be negative.");
...
Local<Object> buffer = args[0]->ToObject();
Jpeg *jpeg = new Jpeg((unsigned char*) Buffer::Data(buffer), w, h, buf_type); // no length check
// src/jpeg_encoder.cpp — JpegEncoder::encode
switch (buf_type) {
case BUF_RGBA: rgb_data = rgba_to_rgb(data, width*height*4); ... break; // reads w*h*4
case BUF_BGRA: rgb_data = bgra_to_rgb(data, width*height*4); ... break; // reads w*h*4
case BUF_BGR:  rgb_data = bgr_to_rgb(data,  width*height*3); ... break; // reads w*h*3
case BUF_RGB:  rgb_data = data;                                  break;
}
...
while (cinfo.next_scanline < cinfo.image_height) {
    row_pointer = &rgb_data[start + cinfo.next_scanline*3*width];   // reads 3*width per scanline
    jpeg_write_scanlines(&cinfo, &row_pointer, 1);
}
  1. Jpeg::New captures Buffer::Data(buffer) with caller-supplied w/h, checking only that
    they are >= 0. No check ties the buffer's byte length to width * height * channels.
  2. For RGBA/BGRA/BGR, the *_to_rgb helpers are explicitly told to read width*height*4 or
    width*height*3 bytes from data. For RGB, encode reads 3*width bytes per scanline for
    height scanlines = width*height*3 bytes.
  3. When the JS buffer is smaller, these reads run out of bounds.
  4. The over-read bytes are compressed via libjpeg into jpeg, copied into a new Buffer, and
    returned to the JS callback (JpegEncodeSync and the async UV_JpegEncode), disclosing
    adjacent heap memory.

JS trigger (if applicable):

const jpeg = require('./build/Release/jpeg').Jpeg;
const j = new jpeg(Buffer.alloc(4), 1000, 1000, 'rgb'); // claims 1000x1000, buffer is 4 bytes
const leaked = j.encodeSync(); // returned JPEG contains adjacent heap memory

Suggested fix: in Jpeg::New, reject the call with a VException when
Buffer::Length(buffer) < channels(buf_type) * width * height (computed with overflow checks),
before constructing the Jpeg.

Additional defect: use-after-free of the input Buffer (no retention)

Unlike node-png — whose Png::New retains the input Buffer via
png->handle_->SetHiddenValue(String::New("buffer"), args[0])Jpeg::New stores only the raw
Buffer::Data(buffer) pointer and never keeps a reference to the input Buffer:

// src/jpeg.cpp — Jpeg::New: no SetHiddenValue / persistent handle on args[0]
Local<Object> buffer = args[0]->ToObject();
Jpeg *jpeg = new Jpeg((unsigned char*) Buffer::Data(buffer), w, h, buf_type); // raw pointer only
jpeg->Wrap(args.This());
return args.This();

JpegEncoder holds that bare pointer as unsigned char *data; (data(ddata) in its constructor).
Because nothing ties the input Buffer's lifetime to the Jpeg object, a caller can construct the
Jpeg, drop the last JS reference to the source Buffer, and let V8 free (or relocate) its backing
store before calling the asynchronous encode(). UV_JpegEncode then dereferences the freed
pointer on the libuv thread pool — a use-after-free (read of freed memory folded into the output
JPEG), in addition to the OOB read above. (The async path does jpeg->Ref(), which pins the Jpeg
wrapper itself, but not the input Buffer.)

Additional fix (UAF): retain the input Buffer for the object's lifetime — mirror node-png's
SetHiddenValue/persistent-handle approach on args[0], or copy the pixel bytes into
Jpeg-owned storage in the constructor.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions