镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

BITMAPCOREHEADER DIB resources decoded incorrectly (wrong color table stride, wrong row stride for 1-bit) #9466

Closed

Description

@elemental-admin-dev

Bug

When loading raw DIB (device-independent bitmap) data embedded in Windows PE resource sections (e.g. cards.dll), Pillow misreads bitmaps that use the old OS/2 BITMAPCOREHEADER format (12-byte header, bcSize=12).

Two bugs

1. 4-bit paletted images

Color table is read as RGBQUAD (4 bytes/entry) instead of RGBTRIPLE (3 bytes/entry), shifting the pixel data offset by 16 bytes and producing wrong colors.

2. 1-bit monochrome images

Row stride calculation is wrong, producing a solid black stripe ~17px wide on the left of every decoded image.

Repro

Extract any bitmap resource from Windows XP cards.dll using pefile, write the raw bytes (without a BITMAPFILEHEADER) and open with:

from PIL import Image, ImageFile
ImageFile.LOAD_TRUNCATED_IMAGES = True
img = Image.open("raw_dib_resource.bmp")

Workaround

Manually parse the BITMAPCOREHEADER with struct and decode pixel data directly:

import struct
from PIL import Image

def decode_bitmapcoreheader(raw):
    bc_size, = struct.unpack_from("<I", raw, 0)
    assert bc_size == 12
    width, height, planes, bitcount = struct.unpack_from("<HHHH", raw, 4)
    num_colors = 2 ** bitcount if bitcount <= 8 else 0
    # RGBTRIPLE: 3 bytes each (NOT RGBQUAD)
    colors = []
    for i in range(num_colors):
        b, g, r = struct.unpack_from("<BBB", raw, 12 + i * 3)
        colors.append((r, g, b))
    px_offset = 12 + num_colors * 3
    row_stride = ((width * bitcount + 31) // 32) * 4
    px_data = raw[px_offset:]
    img = Image.new("RGB", (width, abs(height)))
    pixels = img.load()
    for row_idx in range(abs(height)):
        y = abs(height) - 1 - row_idx if height > 0 else row_idx
        for col in range(width):
            if bitcount == 1:
                byte_idx = row_idx * row_stride + col // 8
                bit = (px_data[byte_idx] >> (7 - col % 8)) & 1
                pixels[col, y] = colors[bit]
            elif bitcount == 4:
                byte_idx = row_idx * row_stride + col // 2
                nibble = (px_data[byte_idx] >> 4) & 0xF if col % 2 == 0 else px_data[byte_idx] & 0xF
                pixels[col, y] = colors[nibble]
    return img

Environment

  • Pillow 11.x
  • Python 3.14
  • Linux x86_64
  • Source: Windows XP cards.dll PE bitmap resources

Activity

  1. radarhere commented on Mar 13, 2026

    @radarhere
    Member

    Would you be able to link to an image that demonstrates the problem for those of us who aren't on Windows XP?

    In our test suite, we check https://github.com/python-pillow/Pillow/raw/refs/heads/main/Tests/images/bmp/g/pal8os2.bmp, which looks correct when I save it as a PNG using Pillow.

    Image
  2. elemental-admin-dev commented on Mar 15, 2026

    @elemental-admin-dev
    Author

    Thanks for the quick response, @radarhere! I've been able to isolate the exact bug.


    Root Cause

    The issue is in BmpImagePlugin.py at the offset correction block:

    if offset == 14 + file_info["header_size"] and file_info["bits"] <= 8:
        offset += 4 * file_info["colors"]  # ← BUG

    This runs when the BITMAPFILEHEADER pixel offset doesn't account for the palette (i.e., offset == 14 + header_size, just past the DIB header). It then tries to add the palette byte size — but uses a hardcoded 4 bytes/entry (RGBQUAD), which is correct for BITMAPINFOHEADER but wrong for BITMAPCOREHEADER, which uses RGBTRIPLE (3 bytes/entry).

    For a 4bpp image (16 colors): the correction adds 4×16 = 64 bytes instead of the correct 3×16 = 48 bytes, causing Pillow to seek 16 bytes too far, reading wrong pixel data.

    The fix is to use file_info["palette_padding"] (already correctly set to 3 for BITMAPCOREHEADER at line 103):

    if offset == 14 + file_info["header_size"] and file_info["bits"] <= 8:
        offset += file_info["palette_padding"] * file_info["colors"]

    Minimal Reproducer

    This script creates a 16×16 BITMAPCOREHEADER BMP where each row should be a distinct color, demonstrating the wrong colors Pillow reads:

    from PIL import Image, ImageFile
    import io, struct
    
    ImageFile.LOAD_TRUNCATED_IMAGES = True  # needed because buggy offset reads past EOF
    
    palette = [
        (255,0,0), (0,255,0), (0,0,255), (255,255,0),
        (255,0,255), (0,255,255), (128,0,0), (0,128,0),
        (0,0,128), (128,128,0), (128,0,128), (0,128,128),
        (192,192,192), (128,128,128), (0,0,0), (255,255,255),
    ]
    
    # Build BITMAPCOREHEADER DIB with RGBTRIPLE color table (3 bytes/entry)
    color_table = b"".join(struct.pack("BBB", b, g, r) for r, g, b in palette)
    row_stride = 8  # ((16*4+31)//32)*4
    pixel_data = b""
    for row in range(15, -1, -1):  # bottom-up
        row_bytes = bytearray(row_stride)
        for col in range(16):
            px = row  # row i → all pixels are color index i
            if col % 2 == 0:
                row_bytes[col // 2] = (px & 0xF) << 4
            else:
                row_bytes[col // 2] |= px & 0xF
        pixel_data += bytes(row_bytes)
    
    dib = struct.pack("<IHHHH", 12, 16, 16, 1, 4)  # BITMAPCOREHEADER, 4bpp
    
    # BITMAPFILEHEADER with offset that skips the palette
    # This value (14 + 12 = 26) triggers the buggy correction code path
    wrong_offset = 14 + 12  # Pillow then computes: 26 + 4*16 = 90 (correct would be 74)
    file_size = 14 + 12 + len(color_table) + len(pixel_data)
    fhdr = struct.pack("<2sIHHI", b"BM", file_size, 0, 0, wrong_offset)
    bmp_data = fhdr + dib + color_table + pixel_data
    
    img = Image.open(io.BytesIO(bmp_data)).convert("RGB")
    for row in range(4):
        got = img.getpixel((0, row))
        exp = palette[row]
        print(f"row {row}: expected {exp}, got {got}  {'OK' if got == exp else 'WRONG'}")

    Output on Pillow 12.1.1:

    row 0: expected (255, 0, 0), got (255, 0, 0)  OK
    row 1: expected (0, 255, 0), got (255, 0, 0)  WRONG
    row 2: expected (0, 0, 255), got (255, 0, 0)  WRONG
    row 3: expected (255, 255, 0), got (0, 255, 0)  WRONG
    

    Why pal8os2.bmp passes

    pal8os2.bmp has a correct BITMAPFILEHEADER pixel offset (properly accounting for the full 256-color RGBTRIPLE table), so offset != 14 + header_size and the buggy correction code path is never reached.

    The bug only triggers when the file header's pixel offset equals exactly 14 + header_size — a value written by software that omits the color table size from the offset calculation.

  3. radarhere commented on Mar 18, 2026

    @radarhere
    Member

    Thanks for the explanation.

    The original code was added in #5899. I was taking advice from a blog post that apparently didn't consider BITMAPCOREHEADER. I've created #9472 to resolve this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions