
Jellyfin Server 12.2 - High - Remote - SSRF - gzip data:image/png leftover of GHSA-cf3c still fetches http and file
abraxaslabs.tech · github.com/abraxas · @abraxas_null · [email protected] · jellyfin-svg-gzip
Class: SSRF + file read + memex
Reach: Remote (any authenticated user POST /UserImage; rasterize is unauth GET /UserImage)
Jellyfin Server 12.2 (ca0f16eb) - Jellyfin. Svg.Skia 3.7.0.
GHSA-cf3c-8m59-2vhx closed outer http and file hrefs on profile SVGs. 12.2 still pins Svg.Skia 3.7.0. SvgDocument.ResolveExternalImages = ExternalType.None is ignored by 3.7.0 ImageDrawable. SvgSecurityValidator.ValidateDataUri returns allow for any data URI whose media type is not exactly image/svg+xml, before the gzip walk. Svg.Skia GetImageFromDataUri: mimeType.StartsWith("image/") plus gzip magic calls LoadSvgz with no Jellyfin re-scan. The inner SVG's http(s) / file hrefs go to WebRequest.Create. That is the original GHSA-cf3c sink.
Any authenticated user uploads a gzip-png wrapped SVG. An unauthenticated GET of that profile image makes the server fetch attacker http / https / file:// while rasterizing.
| ID | no CVE yet |
| Class | SSRF + file read + memex |
| Reach | Remote. Any user POST /UserImage. Rasterize is unauth GET /UserImage?userId= |
| CWE | CWE-918 / CWE-611 / CWE-400 |
| CVSS | High: 7.7 CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:L |
| Product | Jellyfin Server |
| Affected | 12.2 (ca0f16eb7c195f720a1493ed469a27c4657db0c6), image jellyfin/jellyfin:12.2, Svg.Skia 3.7.0 |
| Auth | Authenticated upload. Unauthenticated rasterize. |
| License | GNU Affero GPL v3.0 |
| Lab | 127.0.0.1 only |
Leftover #2 of the Jellyfin 12.2 leftovers series. Sibling #1 Suggestions IDOR. Same pin: playlist ItemIds, FFmpeg colon-slash.
Actor is any signed-in Jellyfin user. Profile image is not admin-only. Rasterize does not need a session.
| Axis | Method + path | Grants | Provenance |
|---|---|---|---|
| Recon | POST /UserImage (Content-Type: image/svg+xml, BASE64 body) then unauth GET /UserImage?userId= | Server GET of attacker http/https (loopback, RFC1918, link-local/IMDS) while Skia rasterizes | Lab-proven. Oracle hostname oracle:8080, hits=1, witness in path |
| Recon | same | file:// bytes painted into the PNG/WebP/JPEG the requester downloads, if they decode as an image | Inferred. Same WebRequest.Create sink as GHSA-cf3c |
| Availability | same, inner file:///dev/urandom | Unbounded read retained on the managed heap. Repeats on every later image GET without re-upload | Inferred. Measured in GHSA-cf3c |
| Integrity | none | No user-policy write, no plugin install, no session steal | Closed |
Stays closed: direct http/file hrefs on the outer SVG (lab: GET stayed image/svg+xml, oracle 0); nested data:image/svg+xml (re-scanned, including gzip); DTD SYSTEM; GHSA-vcmh inline XSS (Content-Disposition: attachment plus rasterize); plugin-image traversal; Magick/PDF. Not RCE. Not session XSS. SSRF body is what Skia paints, not the raw HTTP response.
12.2 is the tag that already shipped the GHSA-cf3c pre-scan. I sat on it anyway. Patches that add a validator in front of a library that still fetches are a gift: you read what the validator walks, then you ask what the library still parses.
Directory.Packages.props still pins Svg.Skia 3.7.0. Upstream wieslawsoltes/Svg.Skia#448 (fixed in #501): ImageDrawable never consults ResolveExternalImages. Jellyfin's three ExternalType.None assignments are a no-op on this pin. Renovate #17076 is not in the tag.
The pre-scan is then the only gate. ValidateReference rejects every non-fragment, non-data: href. Nested image/svg+xml is re-parsed, including gzip. The comment at SvgSecurityValidator.cs:169-173 says any other media type is raster and returns allow before the 0x1F 0x8B walk. Svg.Skia 3.7.0 GetImageFromDataUri disagrees: any image/* with gzip magic is LoadSvgz.
Wrong turns already recorded: treating GHSA-cf3c as closed because the negative control stays image/svg+xml; nested data:image/svg+xml gzip (re-scanned); DTD SYSTEM; GHSA-vcmh inline XSS; plugin-image traversal; Magick/PDF; admin RemoteImages/Download (intended artwork fetch); a pixel-bomb DoS that is not this leftover; a reverse shell. Theatre.
python3 jellyfin-svg-gzip-Abraxas-Labs.py
or:
cd lab
./run.sh
Loopback only: Jellyfin http://127.0.0.1:18780, oracle http://127.0.0.1:18781. Image jellyfin/jellyfin:12.2. Oracle returns a 1x1 PNG and logs the request path. Compose hostname oracle:8080 is what Skia fetches.
Negative: POST a profile SVG whose image href is http://oracle:8080/negative.png. GET stays image/svg+xml, oracle 0. Positive: gzip of an inner SVG whose href is http://oracle:8080/JELLYFIN-SVG-SVGSKIA-WITNESS.png, wrapped as data:image/png;base64 on the outer SVG. GET is image/png, oracle 1.
SUCCESS jellyfin-svg-gzip gzip-png-ssrf hits=1 negative=0 JELLYFIN-SVG-SVGSKIA-WITNESS
Witness-only. No exploit payload. No reverse shell. Authorized lab. Loopback.
Walk gzip and nested data URIs in SvgSecurityValidator even when the declared media type is not image/svg+xml. Or bump Svg.Skia past the ImageDrawable ExternalType.None ignore (upstream #448 / #501; renovate #17076 is not in this tag). Both. The library flag is the intended kill switch. The validator should not lie about what the library will parse.