Pillow: TIFFエンコーダにおけるColorMapのプレーンサイズ不一致により、ヒープ領域外へのアクセスが発生する
## 概要
悪意のある攻撃者が提供した小さなTIFFファイルを開いて通常通り読み込むと、アプリケーションがそのファイルをメタデータを保持したままTIFF形式で保存する際に、ヒープ領域外へのアクセスが発生する。再現可能なケースは、1×1の、16ビットグレースケールで、PackBits圧縮されたTIFFファイルであり、タグ320(ColorMap)に**768個のSHORT値**が含まれています。これは、それぞれ256エントリを持つ、ゼロで埋められた3つのプレーンです。読み込み後、Pythonオブジェクトの偽装やメタデータの注入は行われません。
PillowのTIFFエンコーダは、**768 × 2 = 1536バイト**を割り当てた後、256個のSHORT値で区切られた3つのプレーンへのポインタをlibtiffに渡します。`BitsPerSample`が16の場合、libtiffは各プレーンに対して**65536個のSHORT値**を期待し、プレーンポインタからの**131072バイトの読み込み**を試みます。既存のASANの観測では、`PyImaging_LibTiffEncoderNew`で割り当てられた1536バイトの領域を越えたヒープバッファオーバーフローによる読み込みが発生していることが確認されています。
この不正なColorMapは、このグレースケール画像に対する有効なパレットである必要はありません。問題は、保存時に受け入れられたメタデータが、固定されたプレーンの範囲と有効なサンプル深度との整合性が取れないまま渡されることです。問題は、単にファイルを開いて読み込むだけでは再現されず、エンコーダのパスを経由する必要があります。
## 影響と前提条件
アプリケーションは、入力ファイルを受け入れ、関連するTIFFメタデータを保持し、libtiffをバックエンドとするTIFFの保存パスを呼び出す必要があります。観察された結果は、ヒープ領域外へのアクセスと、ASANによるプログラムの終了です。エンコード中にプロセスメモリが公開される可能性もありますが、**このレポートでは、任意のヒープの内容を抽出することによってそれを確認していません**。任意の読み込み操作、完全なメモリ公開チェーン、コード実行、アドレス空間の回避、またはプロセス間への影響は確認されていません。
131072バイトのコピー試行は、実際に公開されたバイト数ではありません。このレポートでは、シンセティックカナリアシリアライザーを実行したり、サニタイズされていないエンコードされた出力を収集したりしていません。
## テスト環境と証拠の限界
既存の観察結果は、2026年8月30日に、**macOS ARM64上でCPython 3.11.8**を使用し、改変されていない公式の**Pillow 12.3.0**のwheelファイルと、それに同梱されている**libtiff 4.7.1**を用いて記録されました。
- Wheelファイル: `pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl`
- Wheelファイルのサイズ: `4785266` バイト
- WheelファイルのSHA-256ハッシュ値: `37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6`
- Pillowのソースコードのバージョン: `bb1d8e8ab8d29048624d96e3ee53cecf7c13d13d`
- 同梱されているlibtiffのSHA-256ハッシュ値: `e8988717b7470394a47d5ccd343130796b24ee70b6d885d3818e0e0b53238be1`
AppleのASANランタイムが、プロセスの起動時に組み込まれました。これは、**ASANによって再構築されたPillowのwheelファイルではありません**。メモリ割り当て/memcpyのインターセプトによって、報告された境界違反が検出されますが、wheelファイルのネイティブな命令は完全に監視されていません。
ここに示されているのは、この特定の完全なパッケージ環境のみです。影響を受けるバージョン範囲、現在の開発ブランチの結果、または修正されたバージョンについては言及されていません。深刻度は**不明**であり、CVSSスコアは提案されていません。
以前の検証では、Pillow 8.0.1のエンコーダコンストラクタが、最新のPython/libtiff環境に対してコンポーネントとしてコンパイルされたものが使用されました。**しかし、それはPillow 8.0.1の完全なインストールではありません**。そのため、以前のパッケージの完全な動作を反映しているとは言えません。これは、今回の再現手順の一部でも、影響を受けるバージョン範囲の一部でもありません。
## 観察された証拠と制御
以前の統合テストでは、`Image.open(BytesIO(input))`, `image.load()`, その後 `image.save(BytesIO(), format="TIFF")`という自然なシーケンスが実行されました。この際、画像のメタデータは置き換えられませんでした。各ケースは3回記録されました。
| 元のファイル名 | バイト数 | 記録された結果 |
| --- | ---: | --- |
| `colormap16` | 1685 | `I;16` として読み込まれ、保存中に ASAN エラーコード 86 で終了。131072 バイトの読み込みが、1536 バイトのヒープ領域 (3/3) を越えています。 |
| `colormap8` | 1684 | 同じカラーマップを使用しますが、8ビットのサンプルです。保存は成功し、SHORT/count 768 (3/3) が保持されました。 |
| `colormap_absent` | 137 | カラーマップのない、同じ 16ビットのジオメトリです。保存は成功しました (3/3)。 |
記録されたネイティブ診断の、アドレス情報を含まない概要:
```text
AddressSanitizer: heap-buffer-overflow
READ of size 131072
1536 バイト領域の後に 0 バイト
コピーパス: memcpy -> _TIFFmemcpy -> setByteArray -> _TIFFsetShortArrayExt
-> _TIFFVSetField -> TIFFVSetField -> ImagingLibTiffSetField
-> PyImaging_LibTiffEncoderNew
割り当て: calloc -> PyImaging_LibTiffEncoderNew
```
入力された SHA-256 値 (スクリプト内でも検証されます):
- `colormap16`: `5f38e0ead90b97949fb0c98c63f9c68d00ba9181db6631ecb8927376dcc75f4d`
- `colormap8`: `fdb26d9135549ed44824901985086c5ee4f06515699ca3291e498c523935405c`
- `colormap_absent`: `bd54979624f3ffb713342e0458caff9e17c477f940cda2f0960feebfabbc9556`
## 根本原因と参照元
- [Pillow `src/encode.c`、870行~899行、特定バージョン](https://github.com/python-pillow/Pillow/blob/bb1d8e8ab8d29048624d96e3ee53cecf7c13d13d/src/encode.c#L870): `ColorMap`タプルは、正確に768個のエントリを含む必要があります。1536バイトの領域が、固定のストライド256で割り当てられます。このチェックは、その領域と`BitsPerSample`との整合性を確認していません。
- [Pillow `src/PIL/TiffImagePlugin.py`、1944行~1991行、特定バージョン](https://github.com/python-pillow/Pillow/blob/bb1d8e8ab8d29048624d96e3ee53cecf7c13d13d/src/PIL/TiffImagePlugin.py#L1944): `save`関数は、提供されたタグをlibtiffエンコーダに渡し、タグをソートします。そのため、`ColorMap`の前にサンプル深度が確立されます。
- [libtiff `tif_dir.c`、441行~448行、特定バージョン](https://gitlab.com/libtiff/libtiff/-/blob/5fe20d0e9aba49a6a350ed533459d1505203838f/libtiff/tif_dir.c#L441): `TIFFTAG_COLORMAP`は、各プレーンの数を`1L << td->td_bitspersample`として計算し、その数だけ`SHORT`型データを各ポインタからコピーします。
報告されている問題は、Pillowが、メタデータに依存する必要なサイズを持つネイティブAPIのために、固定サイズの配列を構築することによるものです。これは、正しくサイズが設定された呼び出し元バッファを持つ状況におけるlibtiff自体の欠陥を意味するものではありません。
## 関連技術と開示範囲
[Pillow PR #5148](https://github.com/python-pillow/Pillow/pull/5148)、2020年12月28日にマージされた、TIFFメタデータに関する関連技術ですが、アーカイブされた差分は、短期間しか存在しなかったスタックのフィールド名ポインタを、永続的なリテラル文字列 `"CustomField"` に置き換えています。これは、**異なるフィールド名の生存期間に関する修正**であり、今回のプレーンサイズの不一致とは異なります。レビューされた資料では、この根本原因に関する正確な過去の公開報告または修正は確認されておらず、**新規性があるとは断定できません**。この特定の報告は、依然として送信されていません。他の観察事項との関連性は、この報告自体の先行技術開示を意味するものではありません。
## 再現手順
上記のとおり、**既にレビュー済みの、既存の**インストール済み環境を使用します。具体的には、正確なホイールファイル、既存の互換性のあるmacOS ARM64 CPython 3.11インタプリタ、および既存の互換性のあるApple ASANランタイムが必要です。`--package-path`は、`PIL`ディレクトリ自体ではなく、`PIL`を含む絶対パスのディレクトリを指定します。このスクリプトは、何もインストールしたり、ダウンロードしたり、ビルドしたり、パッチを適用したり、送信したりしません。スクリプトは、選択された主要なパッケージファイルに対して、元のインストール時のハッシュ値をチェックし、ロードされたパッケージ/バージョンをチェックします。これらのチェックは、完全なインストール認証ではありません。
以下の完全なPythonブロックを`reproduce.py`として保存し、実行してください。
```sh
python3.11 -I -S -B reproduce.py \
--package-path /絶対パス/レビュー済みの/site-packages \
--asan-runtime /絶対パス/レビュー済みの/libclang_rt.asan_osx_dynamic.dylib
```
両方の引数は必須です。ランタイムは、子プロセスが起動する際にロードされる必要があり、後から`dlopen`でロードされるべきではありません。各子プロセスは、Pillowをインポートするか、フィクスチャを開く前に、自身の実際のプロセスシンボルテーブルで`__asan_init`、`__asan_address_is_poisoned`、および`__asan_region_is_poisoned`を解決する必要があります。単に`DYLD_INSERT_LIBRARIES`を設定するだけでは、ASANの利用を証明するものではありません。シンボルの欠落やパッケージの不一致があると、この操作は実行できません。
3つのケースはそれぞれ、別の子プロセスで1回ずつ実行されます。各子プロセスには、**30秒の実行時間制限、10秒のCPU使用量制限、コアダンプが無効、および1 MiBの通常ファイルサイズ制限**が設定されています。親プロセスは、クリーンな許可リスト環境、隔離されたPythonの起動 (`-I -S -B`)、空のテンポラリディレクトリ、標準入力なし、および128 KiBのパイプ出力制限を提供します。タイムアウトまたは出力制限を超えると、子プロセスのプロセスグループが終了します。これらの制限は、セキュリティサンドボックスではなく、隔離のためのものです。TIFFファイルはディスクに書き込まれません。テンポラリディレクトリは削除され、生の診断情報は転送または永続化されることはありません。
以下に示される圧縮/Base64エンコードされたデータは、**変更されていない、元の安全なTIFFファイル**であり、圧縮前のサイズとSHA-256チェックサムが記載されています。外部の依存関係はありません。この説明に付属するスタンドアロンスクリプトは、このブロックと完全に同じ内容です。
**2026年8月31日に追加されたインライン検証:** 正確な組み込みスタンドアロンスクリプトは、3つの固定ケースすべてに合格しました。1つのASan READテストで、131,072バイトのデータが1,536バイトの領域を越えて読み込まれ、2つのテストで制御が正常に通過しました。子プロセスガードと制限は問題なく通過し、タイムアウトは発生しませんでした。これは、以前の実行とは別に、追加の境界条件の検証結果です。新しいビルド、インストール、完全な履歴チェーン、または古いバージョンの統合テストは行われていません。
## スタンドアロンスクリプトからの期待される結果
テストケースは以下の順序で実行されます: `colormap8`, `colormap_absent`, `colormap16`. 印刷される各行には、`guard_passed: true`, `within_limits: true`, および `expected_observation: true` が含まれている必要があります。最初の2つのケースでは、正常な保存と、期待されるサンプル深度/ColorMapフィールドの保持が必要です。最後のケースでは、ASANのエラーコード86 **と**、特定のREAD/範囲/スタックのシグネチャが必要です。単なるクラッシュだけでは不十分です。2つの制御テストは、エンコードされたメタデータを検査しますが、危険なケースでは、エンコードされたTIFFファイルは出力されません。
親プロセスが0を返すのは、3つの期待される結果すべてが一致する場合のみです。1は、いずれかのケースが一致しなかったことを意味します(ガードの失敗やASAN診断の変化を含む)。2は、親プロセスのセットアップが失敗したことを意味します。ガードの失敗、タイムアウト、関連性のないクラッシュ、または診断情報の欠落は、**決定的な証拠ではなく、欠陥が存在しないことを示すものではありません**。ASANガードを回避してTIFFファイルを生成しないでください。
## 完全なスタンドアロン再現スクリプト
```python
import argparse
import base64
import ctypes
import hashlib
import io
import json
import os
from pathlib import Path
import platform
import resource
import selectors
import struct
import subprocess
import sys
import tempfile
import time
import zlib
```
FIXTURES = {'colormap8': [1684,
'fdb26d9135549ed44824901985086c5ee4f06515699ca3291e498c523935405c',
'eNrz9NRi4GBgYOBmYGBkYWAEssAYic3EyAymQaqYoWzWBgYGNigbhAWh6iexMTCIIomLIZkjDmUzAbEMkhoHIJsBiCYxjIJRMApGwSgY2QAAPu0DiA=='],
'colormap_absent': [137,
'bd54979624f3ffb713342e0458caff9e17c477f940cda2f0960feebfabbc9556',
'eNrz9NRi4GBgYOBiYGBkYWAEssAYic3EyAymBYCYGcpmbWBgYIOyQVgQqr4NiEWRxMWQzBGHspmBWAZJDQOEBgDj8wKm'],
'colormap16': [1685,
'5f38e0ead90b97949fb0c98c63f9c68d00ba9181db6631ecb8927376dcc75f4d',
'eNrz9NRi4GBgYOBmYGBkYWAEssAYic3EyAymBYCYGcpmbWBgYIOyQVgQqn4SGwODKJK4GJI54lA2MxDLIKlxALJBgpMYRsEoGAWjYBSMZACsEwB78wOS']}
PACKAGE_PINS = {'PIL/ImageFile.py': '983c4f35e0c51046d4c636998ec77000cd2da208870a03b205e104cd0b2df629',
'PIL/_version.py': 'fe7d75d4019326aca24ae57d4ab190cd62ba1225b175051119cd61cd87e42b1d',
'PIL/__init__.py': '7361b6ad3878589affe5956e4a4da24d71398b0891fd3a8c15e9362217b4ca01',
'PIL/features.py': '14f904863b416914aaa6480735876ec31885b7272ee0d8d92b010c5b1b5e98f5',
'PIL/TiffTags.py': '766dea350424b9a2caab3513be794689403c2b7222cd038006f69cff08edb45d',
'PIL/TiffImagePlugin.py': '44b2a3a80b280d18cda3fc70d9fd206e2fa44e9897c9550f70b51395047b273',
'PIL/_imaging.cpython-311-darwin.so': '6d09b1fb925779fff32b5b8a4c348a42c201d350d0e0fd8cdc00410b6779e596',
'PIL/Image.py': '04af3f2db7ffdd61e829fa7aa8f5481d394a0a4129a8fd8aa5b13908928fcd6d',
'PIL/.dylibs/libtiff.6.dylib': 'e8988717b7470394a47d5ccd343130796b24ee70b6d885d3818e0e0b53238be1',
'pillow-12.3.0.dist-info/WHEEL': 'de2586b77651a8bf1b7778c3609979713ebc901784a837fca80ed4969fc82e57',
'pillow-12.3.0.dist-info/METADATA': '8fc07146cab33ef7582361dab6eac596d340b03ec648f18217da7894f73838c8'}
CASES = tuple(FIXTURES)
WALL_SECONDS = 30
FILE_LIMIT = 1024 * 1024
PIPE_LIMIT = 128 * 1024
```python
def limits():
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
resource.setrlimit(resource.RLIMIT_CPU, (10, 10))
resource.setrlimit(resource.RLIMIT_FSIZE, (FILE_LIMIT, FILE_LIMIT))
def fixture(case):
size, expected_hash, encoded = FIXTURES[case]
decoder = zlib.decompressobj()
data = decoder.decompress(base64.b64decode(encoded, validate=True), size + 1)
if (len(data) != size or not decoder.eof or decoder.unused_data
or decoder.unconsumed_tail
or hashlib.sha256(data).hexdigest() != expected_hash):
raise ValueError("fixture integrity check failed")
return data
def field(data, wanted):
if len(data) < 8 or data[:4] not in (b"II*\0", b"MM\0*"):
raise ValueError("not classic TIFF")
endian = "<" if data[:2] == b"II" else ">"
directory = struct.unpack_from(endian + "I", data, 4)[0]
count = struct.unpack_from(endian + "H", data, directory)[0]
if directory + 2 + count * 12 + 4 > len(data):
raise ValueError("truncated directory")
for number in range(count):
offset = directory + 2 + number * 12
tag, kind, length = struct.unpack_from(endian + "HHI", data, offset)
if tag != wanted:
continue
size = {1: 1, 2: 1, 3: 2, 4: 4, 7: 1}[kind] * length
start = offset + 8 if size <= 4 else struct.unpack_from(endian + "I", data, offset + 8)[0]
if start + size > len(data):
raise ValueError("truncated field")
return kind, length, data[start:start + size], endian
return None
```
```
def checked_image_module(package):
if (sys.implementation.name != "cpython" or sys.version_info[:2] != (3, 11)
or sys.platform != "darwin" or platform.machine() != "arm64"):
raise RuntimeError("macOS ARM64 CPython 3.11 が必要です")
for relative, expected in PACKAGE_PINS.items():
path = (package / relative).resolve(strict=True)
if not path.is_relative_to(package) or hashlib.sha256(path.read_bytes()).hexdigest() != expected:
raise RuntimeError("レビュー済みの wheel ファイルの不一致")
process_symbols = ctypes.CDLL(None)
for symbol in ("__asan_init", "__asan_address_is_poisoned", "__asan_region_is_poisoned"):
getattr(process_symbols, symbol)
sys.path.insert(0, str(package))
from PIL import Image, features
if (Image.__version__ != "12.3.0" or features.version("libtiff") != "4.7.1"
or Path(Image.__file__).resolve() != package / "PIL/Image.py"
or Path(Image.core.__file__).resolve() != package / "PIL/_imaging.cpython-311-darwin.so"):
raise RuntimeError("予期しない Pillow または libtiff")
return Image
def emit(descriptor, message):
os.write(descriptor, (json.dumps(message, sort_keys=True) + "\n").encode("ascii"))
```
```python
def child(case, package):
descriptor = os.dup(1)
with open(os.devnull, "wb") as quiet:
os.dup2(quiet.fileno(), 1)
try:
image_module = checked_image_module(package)
data = fixture(case)
with image_module.open(io.BytesIO(data)) as image:
image.load()
if image.size != (1, 1) or image.info.get("compression") != "packbits":
raise ValueError("予期せぬ入力のジオメトリ")
bits = 8 if case == "colormap8" else 16
if image.tag_v2[258] != (bits,):
raise ValueError("予期せぬサンプル幅")
if case == "colormap_absent":
if 320 in image.tag_v2:
raise ValueError("予期せぬカラーマップ")
elif image.tag_v2[320] != (0,) * 768:
raise ValueError("予期せぬカラーマップのプレーン")
emit(descriptor, {"ready": True})
with io.BytesIO() as output:
image.save(output, format="TIFF")
if case == "colormap16":
emit(descriptor, {"unsafe_save_returned": True})
return 1
encoded = output.getvalue()
saved_bits = field(encoded, 258)
map_field = field(encoded, 320)
bits_preserved = (saved_bits is not None and saved_bits[:2] == (3, 1)
and struct.unpack(saved_bits[3] + "H", saved_bits[2])[0] == bits)
map_preserved = (map_field is None if case == "colormap_absent"
else map_field is not None and map_field[:2] == (3, 768)
and map_field[2] == bytes(1536))
emit(descriptor, {"control_passed": bool(bits_preserved and map_preserved)})
return 0
except Exception:
emit(descriptor, {"child_failed": True})
return 2
finally:
os.close(descriptor)
```
```python
def run_case(case, package, runtime):
with tempfile.TemporaryDirectory(prefix="pillow-check-") as temporary:
environment = {
"PATH": "/usr/bin:/bin:/usr/sbin:/sbin",
"HOME": temporary,
"TMPDIR": temporary,
"LC_ALL": "C",
"DYLD_INSERT_LIBRARIES": str(runtime),
"ASAN_OPTIONS": "detect_leaks=0:halt_on_error=1:abort_on_error=0:exitcode=86:"
"disable_coredump=1:symbolize=1:malloc_fill_byte=165:"
"max_malloc_fill_size=1048576:log_path=stderr",
}
command = [sys.executable, "-I", "-S", "-B", str(Path(__file__).resolve()),
"--package-path", str(package), "--asan-runtime", str(runtime), "--child", case]
output = bytearray()
diagnostics = bytearray()
deadline = time.monotonic() + WALL_SECONDS
with subprocess.Popen(command, cwd=temporary, env=environment, stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
preexec_fn=limits, start_new_session=True) as process:
bounded = True
try:
with selectors.DefaultSelector() as selector:
selector.register(process.stdout, selectors.EVENT_READ, output)
selector.register(process.stderr, selectors.EVENT_READ, diagnostics)
while selector.get_map():
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError
for key, events in selector.select(min(remaining, 0.25)):
chunk = os.read(key.fileobj.fileno(), 8192)
if not chunk:
selector.unregister(key.fileobj)
else:
key.data.extend(chunk)
```
```
if len(output) + len(diagnostics) > PIPE_LIMIT:
raise BufferError
process.wait(timeout=max(0.001, deadline - time.monotonic()))
except (TimeoutError, BufferError, subprocess.TimeoutExpired):
bounded = False
try:
os.killpg(process.pid, 9)
except ProcessLookupError:
pass
process.wait()
status = process.returncode
try:
messages = [json.loads(line) for line in output.splitlines()]
except (ValueError, UnicodeError):
messages = []
ready = bool(messages and messages[0] == {"ready": True})
if case == "colormap16":
observed = bool(bounded and ready and status == 86 and len(messages) == 1
and b"AddressSanitizer: heap-buffer-overflow" in diagnostics
and b"READ of size 131072" in diagnostics
and b"0 bytes after 1536-byte region" in diagnostics
and b"PyImaging_LibTiffEncoderNew" in diagnostics
and b"_TIFFmemcpy" in diagnostics)
else:
observed = bool(bounded and ready and status == 0
and messages == [{"ready": True}, {"control_passed": True}]
and b"AddressSanitizer:" not in diagnostics)
output.clear()
diagnostics.clear()
return {"case": case, "expected_observation": observed, "guard_passed": ready,
"within_limits": bounded}
```
```
def main():
parser = argparse.ArgumentParser(description='Fixed ColorMap の範囲不一致: 最初のテストケース、ASAN専用トリガー。')
parser.add_argument("--package-path", required=True, type=Path,
help="PIL を含む、既存のインストールルート。")
parser.add_argument("--asan-runtime", required=True, type=Path,
help="既存の互換性のある ASAN dylib。後からロードされることはありません。")
parser.add_argument("--child", choices=CASES, help=argparse.SUPPRESS)
arguments = parser.parse_args()
if arguments.child:
limits()
return child(arguments.child, arguments.package_path.resolve())
try:
package = arguments.package_path.resolve(strict=True)
runtime = arguments.asan_runtime.resolve(strict=True)
if not package.is_dir() or not runtime.is_file():
raise ValueError("無効な既存のパス")
for case in CASES:
fixture(case)
results = [run_case(case, package, runtime) for case in CASES]
except Exception:
print(json.dumps({"setup_failed": True}))
return 2
for result in results:
print(json.dumps(result, sort_keys=True))
return 0 if all(result["expected_observation"] for result in results) else 1
if __name__ == "__main__":
raise SystemExit(main())
```
影響を受けるパッケージ:
- pip (Python) Pillow: 影響あり <= 12.3.0、13.0.0 で修正済み。