技術約13分で読めます

RailsのCVE-2026-66066はサムネイルを作っていなくても対象になる

いけさん目次

TL;DR

影響 Active Storageの画像処理にlibvipsを使い、信頼できないユーザーからの画像アップロードを受けるRailsアプリ。未認証の攻撃者によるサーバー上の任意ファイル読み取りと、プロセス環境にある secret_key_base や外部サービス資格情報の露出

対応 activestorageの7.2.3.2、8.0.5.1、8.1.3.1への更新。あわせてlibvipsを8.13以上、ruby-vipsを2.2.1以上へ(どちらか足りなければ起動時に RuntimeError

注意 変換画像を作っているかどうかは条件外。アップロード時のメタデータ抽出だけでも同じローダーへ到達

更新後 BMP、ICO、PSDの変換画像生成が Vips::Error に。SVG、JPEG XL、JPEG 2000、Netpbmは widthheight の記録なし

入れ替え secret_key_base、マスターキーと credentials.yml.enc の中身、S3・GCS・Azureの鍵、データベース資格情報、外部APIのトークン


Ruby on Railsは2026年7月30日未明、Active Storageの脆弱性CVE-2026-66066を修正した7.2.3.2、8.0.5.1、8.1.3.1を公開した。
GitHub Advisoryの深刻度はCriticalで、CVSS 4.0(脆弱性の深刻度を数値化する共通指標)は9.5、ベクターは CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H となっている。

報告したのはEthiackの0xacb氏、s3np41k1r1t0氏、castilho氏と、GMO Flatt SecurityのRyotaK氏。
Ethiack側はこの問題をKindaRails2Shellと呼んでいる。7月に出たWordPress Coreのwp2shellをなぞった名前で、あちらは WordPress CoreのREST APIバッチ経由の認証前RCE として書いた。wp2shellの起点はCore内部のREST APIディスパッチのずれだったが、今回は画像処理ライブラリに何を読み込ませるかが起点になっている。

アドバイザリ公開時は攻撃チェーンの詳細が伏せられていたが、7月31日にRailsのフォレンジック資料とEthiackの技術解説が出て、経路をたどれるようになった。公開された条件と修正差分は、ローカルで再現できる範囲まで検証した。libvipsのローダー選択、Rails 8.1.3と8.1.3.1の解析結果の差、更新後に壊れる箇所、起動しなくなる条件を確認した。攻撃チェーンそのものは再現していない。

検証環境

項目
マシンM4 Mac mini
OSmacOS 26.5.2
libvips8.18.4(Homebrew)
ImageMagick7.1.2-29(libvipsの委譲先)
Ruby4.0.6
Rails8.1.3(修正前)と8.1.3.1(修正後)
ruby-vips2.3.0、起動検査の再現時のみ2.1.4
Active Storagevariant_processor = :vipsrails new の既定のまま)

対象条件に変換画像の生成は含まれない

公式アドバイザリが挙げる条件は2つだけだ。Active Storageの画像処理にlibvipsを使っていること、つまり config.active_storage.variant_processor = :vips であること。そして、信頼できないユーザーからの画像アップロードを受け付けていること。

前者は load_defaults 7.0 が設定する値で、以降のどのデフォルトでも変わっていない。今回 rails new で作ったアプリでも既定は :vips のままだった。

アドバイザリはこの2条件に続けて「Generating variants is not a separate requirement」と書いている。variant(サムネイル等の変換画像)の生成は条件に含まれないので、サムネイルを一枚も作っていないアプリも対象になる。

理由は公開コードで追える。Active Storageには変換対象フォーマットの許可リスト config.active_storage.variable_content_types があり、変換画像の生成はここを通る。一方でアップロード時にメタデータを抽出するアナライザー側は、ActiveStorage::Analyzer::ImageAnalyzer の判定が blob.image? だけになっている。

def self.accept?(blob)
  blob.image?
end

宣言されたContent-Typeが image/ で始まればアナライザーは受け付け、そこから中身をlibvipsへ読み込ませる。許可リストによる絞り込みはこの経路に存在しない。修正コミットのメッセージも「both the Vips analyzer and the Vips transformer passed untrusted attachments to them」と、アナライザーとトランスフォーマーの両方を挙げている。

影響を受けるgem修正版
activestorage < 7.2.3.27.2.3.2
activestorage >= 8.0, < 8.0.5.18.0.5.1
activestorage >= 8.1, < 8.1.3.18.1.3.1

Rails 6系はサポート対象外だが、:vips を明示的に設定していた構成には同じ問題がある。6系の既定は :mini_magick だったため、デフォルト構成では該当しない。

libvipsが印を付けたローダーには画像と無関係な形式も含まれる

libvipsはファイル形式ごとに「ローダー」と「セーバー」を持ち、その多くは外部ライブラリが実体になっている。libvipsは、悪意あるファイルに対する検証が十分でないものに「unfuzzed」「untrusted」という印を付けている。信頼できる入力にだけ使う想定のものだ。8.13以降はこの印が付いた操作を実行時にまとめて止められるようになっており、Active Storageはその仕組みを使っていなかった。

印の付いた操作を一覧させた。印は vips -l の出力に文字列として出る。

$ vips --version
vips-8.18.4
$ vips -l | grep -c untrusted
56

読み込み側で印が付いていたのは次の形式だった。ビルド構成によって含まれるものは変わるので、この内訳はあくまでHomebrewビルドの場合だ。

ローダー扱う形式実体
magickloadBMP、ICO、PSDなどImageMagickへ委譲する形式ImageMagick 7
svgloadSVGlibrsvg
pdfloadPDFpoppler
jxlloadJPEG XLlibjxl
jp2kloadJPEG 2000OpenJPEG
dcrawload各社のカメラRAWdcraw系
openexrloadOpenEXROpenEXR
ppmloadNetpbm(PBM、PGM、PPM、PFM、PNM)libvips内蔵
radloadRadiance HDRlibvips内蔵
openslideload病理画像のスライド形式OpenSlide
matloadMATLABの .matmatio
fitsload天文用FITScfitsio
analyzeload医用画像のAnalyze6libvips内蔵
csvloadCSVlibvips内蔵
rawloadヘッダなしの生データlibvips内蔵
vipsloadlibvips独自の .vlibvips内蔵

書き込み側では magicksavejxlsavefitssave に印が付いていた。

一般的なWeb画像はこの表の上の数行で、残りはカメラRAW、OpenEXR、Radiance HDR、Netpbmといった制作・科学寄りの画像形式と、MATLABの行列ファイル、天文台のFITS、医用画像、病理スライド、CSVが占めている。Webアプリが受け取る想定にない形式まで、同じ入口を共有している。

15形式をpngという名前で読ませる

拡張子やContent-Typeで弾けば済むのかを、形式ごとに確かめた。libvipsで作れる形式はlibvipsで、ICOとPSDはImageMagickで、SVG・CSV・PDFはテキストで用意し、中身はそのままファイル名だけ .png に付け替えた。

$ cp src.svg fake_svg.png
$ vips copy fake_svg.png out.v
$ vipsheader -a out.v | grep vips-loader
vips-loader: svgload

15形式で同じことをやり、どのローダーが動いたかと、VIPS_BLOCK_UNTRUSTED を立てたときの結果を並べた。

中身動いたローダーVIPS_BLOCK_UNTRUSTED=1
PNGpngloadなし読めた
JPEGjpegloadなし読めた
WebPwebploadなし読めた
TIFFtiffloadなし読めた
BMPmagickloadありブロック
ICOmagickloadありブロック
PSDmagickloadありブロック
SVGsvgloadありブロック
PDFpdfloadありブロック
JPEG XLjxlloadありブロック
JPEG 2000jp2kloadありブロック
Netpbmppmloadありブロック
Radiance HDRradloadありブロック
FITSfitsloadありブロック
CSV読み込み自体が失敗ありブロック

名前が全部 .png でも、動いたローダーは中身のとおりに分かれた。libvipsが判定に使っているのは先頭バイトで、SVGなら 3c 73 76 67<svg)、PDFなら 25 50 44 46%PDF)、BMPなら 42 4dBM)がそれにあたる。ファイル名は当然として、宣言Content-TypeもActive Storageのdirect uploadではクライアントが指定した値がそのまま記録される。この2つを条件にした絞り込みでは中身までは判定できない。

例外はCSVだった。.png という名前では読み込み自体が失敗し、src.csv という正しい名前を付けたときだけ csvload が処理した。CSVには判定に使える先頭バイトがないので、中身推測の経路では到達しないローダーもあった。

印のない4形式は VIPS_BLOCK_UNTRUSTED を立てても通った。修正が止めているのは印の付いた操作だけで、画像処理そのものは動いたままになる。

攻撃チェーンの起点はMATLABローダーのmatload

7月31日、Railsがフォレンジック用のリポジトリを、Ethiackが攻撃チェーンの技術解説を公開した。起点になっていたのは、上の表で唯一、画像形式ではないMATLABのローダーだった。

Railsの説明によると、まずActive Storageが、direct uploadでクライアントが申告した content_type を中身を見ずに記録する。次にlibvipsが先頭バイトを読み、MATLAB 5.0 で始まるファイルをMATLABローダーへ回す。ところがその先のlibmatioは、先頭ではなくオフセット124のバージョン語を見て形式を決める。ここに MAT v7.3、つまりHDF5だと書いてあると、libmatioはHDF5として扱う。

HDF5にはExternal File Listという機能があり、データセットの実体を別ファイルのパスとオフセットで指定できる。libmatioはこれを検証せずに開くため、画像として描画する処理が、そのままサーバー上の任意のファイルを読み出す動作になる。読み出された中身は変換画像のピクセル値として返る。Railsは自分のREADMEでこれを「同じ取り違えが、互いのフィールドを見られない2つの層で2回」起きたと書いている。

手元でもローダーの選択までは無害に確認できる。先頭に MATLAB 5.0 と書いただけの128バイトのヘッダを .png という名前で置いて、libvipsに読ませた。外部参照もデータも入れていない。

$ file probe_mat.png
probe_mat.png: Matlab v5 mat-file (little endian) version 0x100
$ vips copy probe_mat.png out.v
mat2vips: no matrix variables in "probe_mat.png"
$ VIPS_BLOCK_UNTRUSTED=1 vips copy probe_mat.png out.v
VipsForeignLoad: "probe_mat.png" is not a known file format

mat2vips が変数を探しに行った時点で、名前が .png でもMATLABローダーに到達したことが分かる。ブロックした側では形式そのものが未知になり、libmatioまで到達しなかった。Rails 8.1.3.1が入れた Vips.block_untrusted(true) は、このチェーンの1段目を無効化している。

任意ファイル読み取りからRCEへの段は、Ethiackによると secret_key_base を読み出したあと、別のCVEであるActive Storageのvariantメソッド注入と組み合わせる形になる。ここから先は確認していない。

flowchart TD
    A["攻撃者が細工したファイルを<br/>image/pngとしてアップロード"] --> B["ActiveStorageがblobを保存"]
    B --> C["ImageAnalyzer.accept?は<br/>blob.image?だけを判定"]
    C --> D["libvipsが先頭バイトを読んで<br/>ローダーを選ぶ"]
    D --> E["印の付いたローダーが動く"]
    E --> F["任意ファイル読み取り"]
    F --> G["プロセス環境の<br/>secret_key_baseや資格情報"]
    G --> H["RCE・他システムへの侵入"]

許可リストで足りるという判断が2026年5月に下されていた

修正コミットのメッセージには、image_processing 2.0がロード時に印付きの操作を止めるのに対し、Active Storageがそれを再度有効化していた経緯が書かれている。該当コミットは2026年5月20日の 1fc6efcd で、メッセージはこんな感じ。

Allow unfuzzed libvips loaders

This keeps backwards compatibility, since Active Storage already has an
allowlist of "variable" formats.

変換対象フォーマットの許可リストがあるから後方互換を優先してよい、という判断だ。ただしこの許可リストは、アナライザー側の経路には掛かっていない。前述の blob.image? だけの判定がそれだ。

許可リストの中身にも同じ問題が出る。activestorage/lib/active_storage/engine.rb の既定値には image/bmpimage/vnd.adobe.photoshopimage/vnd.microsoft.icon が含まれる。上の表のとおり、この3つはいずれも magickload に割り当てられる。許可リストに載せた形式のうち3つを、Active Storageは印の付いたローダーへそのまま送っていた。

なおこの 1fc6efcd は8.2の開発版(edge Rails)側の変更で、リリース済みの7.2、8.0、8.1系は image_processing 2.0を使っていない。これらのブランチでは最初から誰も印付きの操作を止めていなかった。

修正はブート時にblock_untrustedを呼ぶ

修正は3系統とも同じ内容で、Mike Dalessio氏が書いている。新規ファイル activestorage/lib/active_storage/vips.rb が追加され、ruby-vipsが使える環境ならブート時に印付きの操作を止める。

unless Vips.respond_to?(:block_untrusted)
  raise <<~ERROR.squish
    libvips's unfuzzed operations are not safe to use with untrusted content, and Active Storage
    cannot disable them. Disabling them requires libvips 8.13 or later and ruby-vips 2.2.1 or
    later. Please upgrade libvips and ruby-vips, or remove the ruby-vips gem from your Gemfile.
  ERROR
end

Vips.block_untrusted(true)

同じ呼び出しをruby-vipsから直接実行して、修正の前後を再現した。

require "vips"
Vips::Image.new_from_file("fake_svg.png").width   #=> 123(svgloadが動く)
Vips.block_untrusted(true)
Vips::Image.new_from_file("fake_svg.png")         #=> Vips::Error
Vips::Image.new_from_file("base.png").width       #=> 40(pngloadは通る)

Vips.block_untrusted が実装されていない環境、つまりlibvips 8.13未満またはruby-vips 2.2.1未満では、この raise が起動を止める。塞げない状態のままアプリが動き続けることを防ぐための例外だ。

Rails 8.1.3と8.1.3.1で同じ添付を解析させる

ここまではlibvips単体の挙動なので、実際のRailsアプリで同じことが起きるかを確かめた。rails new したアプリにActive Storageを入れ、中身がSVGで名前が fake_svg.png、宣言Content-Typeが image/png のファイルを添付した。variantは一切要求せず、blob.analyze だけを呼んだ。

blob = ActiveStorage::Blob.create_and_upload!(
  io: File.open("fake_svg.png"), filename: "fake_svg.png", content_type: "image/png")
blob.analyze
blob.reload.metadata

Gemfileのrailsだけを差し替えて両方で走らせた結果はこんな感じ。

添付の中身blob.image?ImageAnalyzer.accept?8.1.3の metadata8.1.3.1の metadata
SVGtruetruewidth 123, height 45記録なし
BMPtruetruewidth 40, height 30記録なし
PNGtruetruewidth 40, height 30width 40, height 30

8.1.3では、中身がSVGのファイルから 123x45 が記録された。PNG素材の寸法は 40x30 なので、この値はSVG自身のものだ。つまり svgload が実際に走った。variantを一度も要求していないアプリで、アップロードされたファイルをlibvipsの印付きローダーがそのまま処理していた。

blob.image?ImageAnalyzer.accept? は3件とも、どちらのバージョンでも true を返した。アナライザーがフォーマットで絞り込んでいないという公開コードの内容は、実アプリの挙動としても同じだった。

8.1.3.1では同じ添付から寸法が記録されなくなり、PNGだけが通った。攻撃チェーンそのものは追えていないが、入口が閉じたことはこの差で確認できた。

更新すると起動しなくなる環境がある

libvips 8.13という下限は、主要なディストリビューションが配っているバージョンより新しい。パッケージ管理のlibvipsをそのまま使っている環境には、要件を満たさない組み合わせがある。

環境libvips更新後
Debian 11 bullseye8.10.5起動時に RuntimeError
Ubuntu 20.04 focal8.9.1起動時に RuntimeError
Ubuntu 22.04 jammy8.12.1起動時に RuntimeError
Debian 12 bookworm8.14.1条件を満たす
Ubuntu 24.04 noble8.15.1条件を満たす
Debian 13 trixie8.16.1条件を満たす

ローカルのlibvipsは8.18.4で下限を満たしているので、代わりにruby-vips側を2.1.4へ落として同じ検査に掛けた。Vips.block_untrusted が定義されていない点はlibvips 8.13未満と同じ扱いになる。

$ bin/rails runner 'puts "起動できた"'
.../activestorage-8.1.3.1/lib/active_storage/vips.rb:36:in '<top (required)>':
libvips's unfuzzed operations are not safe to use with untrusted content, and Active Storage
cannot disable them. Disabling them requires libvips 8.13 or later and ruby-vips 2.2.1 or later.
Please upgrade libvips and ruby-vips, or remove the ruby-vips gem from your Gemfile. (RuntimeError)

rails runner が起動時点で止まった。警告どまりではなく例外なので、この構成では更新した瞬間にアプリが上がらなくなる。Ubuntu 22.04 LTSの標準サポートは2027年4月まで続く。該当構成で動いているアプリがどれだけあるかの公開データはなく、Rails Security Teamも合理的な推計は持たないと答えている。Railsの更新でlibvipsのバージョンは上がらないので、libvipsのバックポート、自前ビルドへの切り替え、コンテナのベースイメージ更新のいずれかを別途行う。

しかも8.13未満の環境には回避策もない。アドバイザリは、libvipsが8.13未満の場合はlibvips依存そのものをアプリから外す以外に手がないと書いている。画像処理には使わず、画像解析のためだけにruby-vipsを入れているアプリなら、Gemfileから ruby-vips を落とせばよい。

:mini_magick を使っているアプリにも影響がある。添付ファイルの処理内容は変わらないが、ruby-vipsがインストールされている限り印付き操作の停止はプロセス全体に掛かり、libvipsとruby-vipsのバージョン下限も適用される。両方を避けたいならGemfileからruby-vipsを外す。

BMP、ICO、PSDの変換画像は更新後に壊れる

修正は破壊的変更を含む。CHANGELOGによると、BMP、ICO、PSDの変換画像生成は Vips::Error になる。SVG、JPEG XL、JPEG 2000、Netpbmなどは、解析しても widthheight を記録しない。出力形式としてFITS、JXL、ImageMagickへ委譲される形式を要求した場合も Vips::Error が上がる。添付、保存、ダウンロードは変わらない。

8.1.3.1のアプリでBMPを添付して変換を要求すると、そのとおりになった。

b.variable?                                  #=> true
b.variant(resize_to_limit: [20,20]).processed #=> Vips::Error
# 同じ操作をPNGでやると成功する

variable?true のままだった。Active Storageの許可リストにBMPが残っているので変換対象とは見なされ、実行段階でlibvipsに止められた。分岐を variable? で書いているコードは、この変更を反映しないまま実行して失敗する。

libvips側では、止まったローダーが担当する形式は未知の形式として扱われる。

$ vips copy colors.bmp out1.png
$ VIPS_BLOCK_UNTRUSTED=1 vips copy colors.bmp out2.png
VipsForeignLoad: "colors.bmp" is not a known file format

エラーメッセージは対応形式が足りないビルドを使ったときと同じ文面になる。更新後にBMPが読めなくなったという報告と、libvipsのビルド不足による報告は、この文面だけでは区別が付かない。

変換画像の生成をリクエスト中に同期でやっているアプリでは、この Vips::Error がジョブの失敗ではなくエラー応答として表に出る。CHANGELOGは、その場合に許可リストから該当Content-Typeを外す方法を挙げている。

Rails.application.config.active_storage.variable_content_types -=
  %w[ image/bmp image/vnd.microsoft.icon image/vnd.adobe.photoshop ]

この設定でActive Storageはその3形式を変換対象から外し、変換自体を実行しなくなる。

すぐ更新できない場合の一時策

libvipsが8.13以上ある環境なら、Railsを上げずに印付きの操作を止められる。libvipsは初期化時に VIPS_BLOCK_UNTRUSTED 環境変数を読むため、この変数の設定で塞げる。ruby-vipsが2.2.1以上あるなら、イニシャライザーから Vips.block_untrusted(true) を呼ぶ形でもよい。どちらの場合も、上に書いたBMP・ICO・PSDの破壊的変更が同時に適用される。

特定のローダーがどうしても必要な場合は、イニシャライザーで個別に戻せる。全部止めたあとにSVGだけ許可すると、SVGは読めてBMPは止まったままになった。

Vips.block_untrusted(true)
Vips.block("VipsForeignLoadSvg", false)
Vips::Image.new_from_file("fake_svg.png").width  #=> 123
Vips::Image.new_from_file("fake_bmp.png")        #=> Vips::Error

粒度としては使えるが、戻したローダーは信頼できない入力に晒される場所からは外して使うしかない。libvipsのImageMagickローダーはビルド時のメジャーバージョンで名前が変わるため、Rails側のテストも VipsForeignLoadMagickVipsForeignLoadMagick7 の両方を指定している。片方だけを書いた場合、もう片方の名前でビルドされた環境では解除が効かない。

WAF(HTTPリクエストを検査して攻撃パターンを遮断する防御層)での検知・遮断の可否は、Active Storageの保存先やアップロード方式といったアプリケーションの構成に大きく依存する。GMO Flatt Securityは有効性が極めて限定的だとして、修正版への更新の代替にしないよう求めている。

秘密情報の入れ替えは更新とは別に残る

アドバイザリは、アプリケーションプロセスから読める秘密情報はすべて露出した可能性があるものとして扱い、入れ替えるよう書いている。更新で塞がるのは経路で、露出済みの値は別途入れ替える。

対象は secret_key_baseconfig/master.key または RAILS_MASTER_KEY のマスターキーとそれが復号する credentials.yml.enc の中身、S3・GCS・Azureといったストレージサービスの鍵、データベース資格情報、アプリが呼ぶ外部サービスのトークンと鍵になる。

secret_key_base を変えると、有効なセッションが失効して利用者は再ログインになる。暗号化クッキー、署名付きクッキー、署名付きGlobal ID、Active StorageのURLも影響を受ける。この副作用があるためローテーション機構で古い値をフォールバックとして残したくなるが、アドバイザリは必要な場合の中間段階としてのみ使い、露出した秘密をフォールバックに残さないよう明記している。

侵害確認の材料は、7月31日のフォレンジックリポジトリで公開された。アドバイザリ本体にIoC(攻撃を受けた痕跡として探す指標)は載っていないが、こちらには痕跡の探し方と実際に動くスキャナーが入っている。

痕跡が残る理由も同じリポジトリが説明している。読み出されたファイルの中身は変換画像のピクセル値として返るので、生成された変換画像が自分のオブジェクトストレージに残る。読み出された中身がそのまま自分の保管庫に置かれている状態で、Railsは残る痕跡のうちこれが最も有力だとしている。

検出側は、細工されたMATファイルをオブジェクトの先頭128バイトから2つのヘッダ項目だけで判定する。ファイル全体を落とさずレンジ読みで分類できる作りなので、本番のバケットに対しても回せる。リポジトリには「脆弱だった期間」を出すものと「悪用されたか」を出すものの2つの手順があり、後者は前者の出力を前提にしている。攻撃ファイルの生成器は意図的に含まれていない。

secret_key_base の入れ替えは、この確認結果を待たずに進める判断もある。読み出されていた場合に露出する範囲が広く、確認そのものに時間がかかるためだ。

参考: