ユーザーの画像をアプリサーバで変換しない理由と手順

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

ユーザーの画像をアプリサーバで変換しない理由と手順

本記事はプロモーションを含みます。

はじめに: 画像の変換は、攻撃者のファイルを読む処理だ

アプリサーバで画像を変換すると、中身で読み方が決まる・小さなファイルが巨大な画素を持つ・アプリと同じ権限で動く、の 3 つを抱える

プロフィール画像や投稿の添付で、ユーザーが上げた画像を縮小・変換する。アプリサーバの上で ImageMagick や sharp を呼べば数行で済むので、多くのサービスがそうしている。

しかし画像の変換は、知らない相手が作ったファイルを、多数の形式を読めるライブラリに渡す処理だ。結論から書くと、次の 3 つを分けて考えると守りやすい。

何が起きるかどうするか
拡張子が .jpg でも、ライブラリは中身を見て別の形式として読む先頭のバイトで形式を確かめ、読む形式を絞る
数百 KB のファイルが、展開すると数百 MB の画素になる寸法をヘッダで読み、展開する前に断る
変換で穴を突かれると、アプリと同じ権限・同じ通信先を取られる変換を、アプリサーバと別の環境で動かす

以下、元になった投稿の流れを紹介し、手元の ImageMagick 7.1.2 と libvips 8.18.7 で何が起きるかを確かめてから、各段の作り方を書く。

何の話か: 画像の処理を別サーバに分ける 8 段の流れ

ブラウザから S3 へ直接上げ、先頭のバイトを確かめてから、Lambda の libvips で変換して S3 に置く 8 段の流れ

catnose 氏の 2026-11-05 の投稿は、ImageMagick や sharp をアプリケーションサーバの上で動かすのは怖く、ユーザーから渡された画像は別サーバで処理している、という内容だ。引用元の投稿は、サーバ側で ImageMagick を使うのをやめ、次の流れにしたと書いている。

  1. ブラウザから S3 に直接上げる
  2. アップロードの完了をサーバで受け取る
  3. Range リクエストでファイルの先頭を取り、画像形式を簡易に確かめる
  4. 問題が無ければ、S3 のパスを Lambda に渡す
  5. Lambda が S3 から画像を取ってくる
  6. 最新の libvips で変換する
  7. 問題が無ければ、変換後の画像を S3 に上げる
  8. 変換後の S3 のパスか、エラーを返す

要点は、アプリサーバが画像の中身を一度も展開しないことだ。アプリサーバが触るのは、先頭の数バイトと S3 のパスだけになる。

なぜアプリサーバで動かさないのか

2016 年の ImageTragick (CVE-2016-3714) では、ユーザーが上げた画像を ImageMagick で処理するだけで、リモートでコードを実行された。告知のサイトは、実際に悪用されていると書いている。勧められた対策は、「対応する形式のマジックバイトで始まるかを確かめてから ImageMagick に渡す」「policy ファイルで危ない coder を止める」の 2 つと、サンドボックスでの実行だった。

10 年たった今も、この対策が要る理由は変わっていない。手元で 2 つのことを確かめた。

拡張子でなく、中身で読み方が決まる

中身が SVG のファイルを photo.jpg と名付けても、ImageMagick も libvips も SVG として読む

中身が 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・その他の多数の形式の読み込みを動かせる。読み込みの数だけ、攻撃者が突ける場所がある。

390KB の PNG が 4 億画素を持つ

20000x20000 の真っ黒な PNG は 389,648 バイトだが、展開すると 1 画素 1 バイトでも 400MB になる

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 アプリケーションの脆弱性が生まれる原理から説明した本が手元にあると引きやすい。

体系的に学ぶ 安全なWebアプリケーションの作り方 第3版 (徳丸浩 著、SBクリエイティブ) 画像: 楽天市場

体系的に学ぶ 安全なWebアプリケーションの作り方 第3版 (徳丸浩 著、SBクリエイティブ)

実際の作り方

8 段の流れを、アプリサーバが受け持つ部分 (1-4) と、変換の環境が受け持つ部分 (5-8) に分けて書く。

ブラウザから S3 へ直接上げる (1-2)

アプリサーバは署名付き URL を発行するだけで、画像の本体はブラウザから S3 へ直接届く

アプリサーバは画像の本体を受け取らず、S3 の署名付き URL (presigned URL) を発行するだけにする。URL には期限と、保存先のキーを決めて署名する。AWS のドキュメントの例では、ContentType を署名に含めて発行し、アップロードのときにも同じ Content-Type を付けて PUT する。

Content-Type はアップロードする側が名乗る値で、中身の証明にはならない。上の SVG のように、名乗りと中身は食い違える。保存先のキーは、ユーザーが送ったファイル名でなく、サーバ側で決めた名前にする。

アップロードの完了は、ブラウザからアプリサーバに知らせてもらうか、S3 のイベント通知 (s3:ObjectCreated:*) で受け取る。

Range リクエストで先頭のバイトだけ見る (3)

Range: bytes=0-15 で先頭の 16 バイトだけ取り、RIFF と WEBP の並びで形式を確かめる

形式の確かめには、ファイル全体は要らない。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 に書き出させた画像の先頭は次のとおりだった。

形式先頭のバイト
PNG89 50 4E 47 0D 0A 1A 0A
JPEGFF D8 FF
GIF47 49 46 38 (GIF8)
WebP52 49 46 46 (RIFF) と、9-12 バイト目の 57 45 42 50 (WEBP)

受け付ける形式の並びで始まらなければ、ここで断る。PNG なら、先頭 24 バイトの IHDR に幅と高さが 4 バイトずつ入っている。上の big.png では 00004e20 が 2 つ並び、20000 x 20000 と読める。寸法の上限もここで確かめられる。

ただし、これは簡易な検査だ。先頭が正しくても、その後ろに何が入っているかは分からない。だから、次の変換でも読む形式を絞る。

変換は別の環境で、読む形式を絞った libvips で (4-8)

VIPS_BLOCK_UNTRUSTED を付けると、svgload などの untrusted な読み込みが止まり、photo.jpg は読めなくなる

変換は、アプリサーバと別の環境で動かす。投稿の例では 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 で絞る

policy で読み込みを GIF・JPEG・PNG・WEBP に絞ると、photo.jpg は SVG として拒否され、big.png は幅の上限で拒否される

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 を見て確かめる。

まとめ

  • 画像の変換は、知らない相手のファイルを多数の形式を読めるライブラリに渡す処理だ。ImageMagick も libvips も、拡張子でなく中身で読み方を決める
  • 小さなファイルが巨大な画素を持てる。アップロードのサイズ上限では止まらないので、寸法をヘッダで読んで断る
  • アプリサーバは署名付き URL を出し、Range で先頭のバイトだけを見る。画像の中身を展開しない
  • 変換は、権限を絞った別の環境で動かす。libvips は VIPS_BLOCK_UNTRUSTED と形式ごとの読み込みで、ImageMagick は policy.xml の allowlist で、読む形式を絞る
  • 書き出しでメタデータを捨て、変換後の画像だけを配る

参考