ユーザーが上げた画像を、アプリサーバの ImageMagick や sharp で変換するのは危ない。拡張子が .jpg でも中身の SVG として読まれ、390KB の PNG が 4 億画素を持つ。S3 に直接上げ、先頭のバイトを確かめ、読む形式を絞った別環境で変換する手順を解説する。

本記事はプロモーションを含みます。
プロフィール画像や投稿の添付で、ユーザーが上げた画像を縮小・変換する。アプリサーバの上で ImageMagick や sharp を呼べば数行で済むので、多くのサービスがそうしている。
しかし画像の変換は、知らない相手が作ったファイルを、多数の形式を読めるライブラリに渡す処理だ。結論から書くと、次の 3 つを分けて考えると守りやすい。
| 何が起きるか | どうするか |
|---|---|
拡張子が .jpg でも、ライブラリは中身を見て別の形式として読む | 先頭のバイトで形式を確かめ、読む形式を絞る |
| 数百 KB のファイルが、展開すると数百 MB の画素になる | 寸法をヘッダで読み、展開する前に断る |
| 変換で穴を突かれると、アプリと同じ権限・同じ通信先を取られる | 変換を、アプリサーバと別の環境で動かす |
以下、元になった投稿の流れを紹介し、手元の ImageMagick 7.1.2 と libvips 8.18.7 で何が起きるかを確かめてから、各段の作り方を書く。
わかる
— catnose (@catnose99) 2026年11月5日
ImageMagickとかsharpとかをアプリケーションサーバー上で動かすのは今の大脆弱性時代は怖すぎる
面倒でもユーザーから渡された画像の処理は別サーバーでやるようにしてる https://t.co/cDGsdHMTJx
catnose 氏の 2026-11-05 の投稿は、ImageMagick や sharp をアプリケーションサーバの上で動かすのは怖く、ユーザーから渡された画像は別サーバで処理している、という内容だ。引用元の投稿は、サーバ側で ImageMagick を使うのをやめ、次の流れにしたと書いている。
要点は、アプリサーバが画像の中身を一度も展開しないことだ。アプリサーバが触るのは、先頭の数バイトと S3 のパスだけになる。
2016 年の ImageTragick (CVE-2016-3714) では、ユーザーが上げた画像を ImageMagick で処理するだけで、リモートでコードを実行された。告知のサイトは、実際に悪用されていると書いている。勧められた対策は、「対応する形式のマジックバイトで始まるかを確かめてから ImageMagick に渡す」「policy ファイルで危ない coder を止める」の 2 つと、サンドボックスでの実行だった。
10 年たった今も、この対策が要る理由は変わっていない。手元で 2 つのことを確かめた。
中身が SVG のファイルを photo.jpg という名前にして、ImageMagick と libvips に渡した。
cat > evil.svg <<'EOF'
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10"/></svg>
EOF
cp evil.svg photo.jpg
magick identify photo.jpg
# => photo.jpg SVG 10x10 10x10+0+0 16-bit sRGB 100B 0.000u 0:00.001
vipsheader photo.jpg
# => photo.jpg: 10x10 uchar, 4 bands, srgb, svgload
どちらも拡張子を無視し、中身を見て SVG の読み込み (svgload) を選んだ。アップロードの受け口で拡張子や Content-Type を image/jpeg に絞っても、変換するライブラリは SVG・PDF・その他の多数の形式の読み込みを動かせる。読み込みの数だけ、攻撃者が突ける場所がある。
20000 x 20000 画素の真っ黒な PNG を作ると、ファイルは 389,648 バイトだった。
vips black big.png 20000 20000
stat -c '%s' big.png
# => 389648
vipsheader big.png
# => big.png: 20000x20000 uchar, 1 band, b-w, pngload
ファイルは 400KB 弱でも、画素は 4 億ある。1 画素 1 バイトの白黒でも、展開すれば 400MB になり、RGB なら 3 倍だ。アップロードのサイズ上限ではこれを止められない。アプリサーバの上で展開すれば、同じプロセスで動く他のリクエストのメモリを奪う。
この 2 つは、どちらも「信頼できない相手のデータが、脆弱な場所に届くか」という問いになる。脆弱性の告知を受けたときの数え方は「脆弱性の告知から影響範囲を見積もる3つの問い」に書いた。
アップロードの検査や、入力を信頼しない設計を体系立てて押さえるなら、Web アプリケーションの脆弱性が生まれる原理から説明した本が手元にあると引きやすい。
8 段の流れを、アプリサーバが受け持つ部分 (1-4) と、変換の環境が受け持つ部分 (5-8) に分けて書く。
アプリサーバは画像の本体を受け取らず、S3 の署名付き URL (presigned URL) を発行するだけにする。URL には期限と、保存先のキーを決めて署名する。AWS のドキュメントの例では、ContentType を署名に含めて発行し、アップロードのときにも同じ Content-Type を付けて PUT する。
Content-Type はアップロードする側が名乗る値で、中身の証明にはならない。上の SVG のように、名乗りと中身は食い違える。保存先のキーは、ユーザーが送ったファイル名でなく、サーバ側で決めた名前にする。
アップロードの完了は、ブラウザからアプリサーバに知らせてもらうか、S3 のイベント通知 (s3:ObjectCreated:*) で受け取る。
形式の確かめには、ファイル全体は要らない。S3 の GetObject は HTTP の Range ヘッダに対応していて、指定した範囲のバイトだけを返す (1 回の GET で複数の範囲は取れない)。
このサイトの画像で試すと、16 バイトだけが 206 で返ってきた。
curl -s -r 0-15 -o /dev/null -w '%{http_code} %{size_download}\n' \
https://chillfixx.work/blog/go-json-v2-migration/img.webp
# => 206 16
curl -s -r 0-15 https://chillfixx.work/blog/go-json-v2-migration/img.webp | xxd -p
# => 524946469e5900005745425056503858
先頭の 52494646 は RIFF、9-12 バイト目の 57454250 は WEBP だ。手元で libvips に書き出させた画像の先頭は次のとおりだった。
| 形式 | 先頭のバイト |
|---|---|
| PNG | 89 50 4E 47 0D 0A 1A 0A |
| JPEG | FF D8 FF |
| GIF | 47 49 46 38 (GIF8) |
| WebP | 52 49 46 46 (RIFF) と、9-12 バイト目の 57 45 42 50 (WEBP) |
受け付ける形式の並びで始まらなければ、ここで断る。PNG なら、先頭 24 バイトの IHDR に幅と高さが 4 バイトずつ入っている。上の big.png では 00004e20 が 2 つ並び、20000 x 20000 と読める。寸法の上限もここで確かめられる。
ただし、これは簡易な検査だ。先頭が正しくても、その後ろに何が入っているかは分からない。だから、次の変換でも読む形式を絞る。
変換は、アプリサーバと別の環境で動かす。投稿の例では Lambda だ。変換の環境が持つ権限は、アップロード先からの読み込みと、変換後の置き場への書き込みだけにする。変換で穴を突かれても、取られるのはその権限だけになる。
libvips は 8.13 から、ファジングで十分に試されていない読み込みを、実行時に止められる。環境変数 VIPS_BLOCK_UNTRUSTED を付けると、untrusted と印の付いた処理が動かなくなる。libvips 8.18.7 で vips -l foreign を見ると、svgload・pdfload・magickload・jp2kload・jxlload などに印が付いていて、JPEG・PNG・WebP・GIF の読み込みには付いていなかった。
VIPS_BLOCK_UNTRUSTED=1 vips thumbnail a.jpg t.webp 400
# => 成功
VIPS_BLOCK_UNTRUSTED=1 vips thumbnail photo.jpg t2.webp 400
# => VipsForeignLoad: "photo.jpg" is not a known file format
さらに確実にするなら、汎用の読み込みでなく、形式ごとの読み込みを名指しで呼ぶ。
vips jpegload photo.jpg out.png
# => VipsJpeg: Not a JPEG file: starts with 0x3c 0x73
書き出しでは、メタデータを捨てる。手元の JPEG は vipsheader -a で exif の項目が 12 行出たが、keep=none を付けて書き出すと 0 行になった。撮影したスマホの位置情報を、そのまま公開しないためだ。
vips copy a.png 'b.jpg[keep=none]'
sharp を使う場合は、sharp が必要な形式だけで libvips を組んで同梱している (libvips 8.13 のリリースノート)。画素数の上限 limitInputPixels の既定は 268402689 で、これを超える入力は処理しない。
変換が済んだら、変換後の S3 のパスか、エラーを返す。アプリが以後に配るのは、変換後の画像だけにする。
ImageMagick を外せないなら、policy.xml で読める形式と寸法を絞る。ImageMagick の repo には、公開サービス向けの policy-websafe.xml がある。メモリ・時間・幅などの上限を置き、スクリプトやベクター画像、URL を扱う危ない module を止めるものだ。ただし、そのコメントに「特定の形式の allowlist にはしていない」とある。
受け付ける形式が決まっているなら、全部を止めてから必要な module だけを開ける。
<policymap>
<policy domain="delegate" rights="none" pattern="*"/>
<policy domain="module" rights="none" pattern="*"/>
<policy domain="module" rights="read|write" pattern="{GIF,JPEG,PNG,WEBP}"/>
<policy domain="resource" name="width" value="8KP"/>
<policy domain="resource" name="height" value="8KP"/>
</policymap>
この policy を MAGICK_CONFIGURE_PATH で読ませて、上の 2 つのファイルを渡した。
magick photo.jpg x.png
# => magick: attempt to perform an operation not authorized by the security policy `SVG' ...
magick big.png x.png
# => magick: Image width exceeds user limit in IHDR `big.png' ...
magick a.jpg x.png
# => 成功
SVG は policy で止まり、20000 画素の幅は IHDR を読んだ段階で断られた。普通の JPEG は変換できる。policy が効いているかは、本番と同じ環境で magick -list policy を見て確かめる。
VIPS_BLOCK_UNTRUSTED と形式ごとの読み込みで、ImageMagick は policy.xml の allowlist で、読む形式を絞るlimitInputPixels)