Research/jellyfin-ffmpeg-colonslash
Series0-dayNo CVEHighPublic

Jellyfin, EncodingUtils -i leftover of GHSA-wwwm

Jellyfin Server 12.2 still wraps a non-File ffmpeg -i path in quotes without EscapeProcessArgument. A library .strm whose URL contains a quote splits ProcessStartInfo.Arguments. POSIX :// filenames do not hit this sink. Labbed. Not RCE. No CVE yet.

Name
Jellyfin, EncodingUtils -i leftover of GHSA-wwwm
Series
Jellyfin 12.2 leftovers
Phase
0
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
Jellyfin
Affected
Jellyfin Server v12.2 (ca0f16eb7c195f720a1493ed469a27c4657db0c6). Image jellyfin/jellyfin:12.2. Leftover of GHSA-wwwm / PR 17685 on EncodingUtils Http/rtsp -i.
Published
17 Oct 2026
Updated
17 Oct 2026
Tags
0-day, jellyfin, ffmpeg, leftover, cwe-88, remote

The Http branch still wraps the raw URL

I am @abraxas_null. The proof of concept is on GitHub: abraxas/jellyfin-ffmpeg-colonslash (loopback client). The lab stack is lab/: docker-compose.yml, run.sh, poc.py. Authorized lab only. It talks to loopback.

This is leftover #4 of the Jellyfin 12.2 leftovers series. The other three SUCCESS leftovers on this pin are jellyfin-suggestions-idor, jellyfin-svg-gzip, and jellyfin-playlist-itemids.

This is Jellyfin Server v12.2 (ca0f16eb7c195f720a1493ed469a27c4657db0c6). Image jellyfin/jellyfin:12.2. No CVE yet. CWE-88. 7.1 High (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:N). Class: FFmpeg argument injection. Reach: Remote after a library .strm plant (writer of a scanned media folder, then unauth stream).

GHSA-wwwm-px48-fpvq taught the tree to stop interpolating a subtitle path into ffmpeg argv without quoting. PR 17685 arrived late onto v12, replaced NormalizePath with EscapeProcessArgument, and applied it on conversion argv and the file: -i prefix. Tests cover "/media/a\"b.mkv" and a filename that looks like extra flags. A quote-only File path is closed.

The Http wrapper was left as "%s". ManagedFileSystem.IsPathFile is false for any :// that is not file://. TranscodeManager still assigns ProcessStartInfo.Arguments as one string. Unauth GET /Videos/{id}/stream with no static starts that process.

A scanned library .strm whose first line is http://127.0.0.1/a" JELLYFIN-FFMPEG-COLONSLASH-WITNESS "x is enough. Uri.TryCreate accepts that URL. The TranscodeManager log is:

Plain text
-i "http://127.0.0.1/a" JELLYFIN-FFMPEG-COLONSLASH-WITNESS "x"

Control LabControl.mkv stays one argument: -i file:"/media/movies/LabControl.mkv".

A POSIX filename with :// does not hit this sink. Slash cannot be a filename. The lab plant a://b"…"x.mkv is stored as /media/movies/a:/b"…"x.mkv and takes the escaped File branch. I am not filing a filename-with-colonslash variant. I am not claiming plugin-DLL RCE. The lab proves the argv split only.

Why Jellyfin

Jellyfin is the media server people actually self-host. Libraries are the product. A folder on a NAS is a library. A .strm in that folder is a one-line pointer at a remote URL. Stream-by-GUID has been unauthenticated for a long time. The interesting leftover after a year of ffmpeg GHSAs is whichever -i branch the last patch did not quote-escape.

I had just finished the 12.0 advisory wave on this tree. VirtualFolders Path.Combine. Svg.Skia outer hrefs. Playlist Items userId. HLS segment traversal. StreamOptions level. LiveTV M3U. Subtitle-upload .strm. ClientLog. Custom encoder path. Codec regex. That is a product that spent 2026 teaching itself not to concatenate attacker strings into ffmpeg. v12.2 is the GA that is supposed to have learned it.

I only wanted Critical or High. Remote preferred. Medium stays on the floor unless it chains. jellyfin-web XSS, Wizarr, Seerr, and jellyfin-ios are other trees. I did not file them.

Vendor intake is security@jellyfin.org, subject [Jellyfin Security]. Unpaid. Packs are AGPLv3 under github.com/abraxas. Labs talk to loopback and print a witness. No shells.

Pinning v12.2

Latest GA. Tag v12.2. Peel is ca0f16eb7c195f720a1493ed469a27c4657db0c6. Image jellyfin/jellyfin:12.2 reports 12.2.0. I did not chase master. A leftover on a GA tag is a leftover someone can reproduce.

The lab is that image on 127.0.0.1:18810. Wizard, one Movies folder at /media/movies, a tiny control .mkv, a .strm whose URL contains the witness, and a POSIX filename plant that is supposed to fail. Replay is ./run.sh.

The skip list is half the hunt

Before you look for leftovers, you write down what is already public so you do not "discover" a CVE with a press release. Twenty-three published GitHub advisories on this tree. On the ffmpeg / stream / plant surfaces I did not re-file:

  • GHSA-wwwm-px48-fpvq subtitle conversion argv. ConvertTextSubtitleToSrtInternal now calls EscapeProcessArgument on input and output. Unauth GET Videos/{id}/{ms}/Subtitles/{index}/Stream.{format} still has no [Authorize]. That reach is unchanged. The quote break on that conversion line is closed. This leftover is the -i URL wrapper, not a re-file of the advisory.
  • GHSA-jh22-fw8w-2v9x unauth StreamOptions -level. ParseStreamOptions still copies every lowercase query key. NormalizeTranscodingLevel double.TryParses and drops trailing tokens. Profile is allowlisted. Closed.
  • GHSA-866x-wj5j-2vf4 / GHSA-2c3c-r7gp-q32m codec-parameter injection. videoCodec / audioCodec / subtitleCodec / container still carry ContainerValidationRegexStr. GetVideoEncoder fails closed to copy. Space-injection via codec names is closed.
  • GHSA-rr9h-w522-cvmr admin custom FFmpeg binary. /System/MediaEncoder/Path is gone. EncodingConfigurationStore.Validate throws if EncoderAppPath changes. Path is CLI / env then $PATH.
  • GHSA-j2hf-x4q5-47j3 subtitle upload plus local .strm. Upload Format is an extension allowlist. Language rejects directory separators. .strm probe keeps http / https / rtsp / rtp only. Local-file .strm read is closed.

Also closed on this pin, so they are not this post: VirtualFolders Path.Combine (GHSA-6828), X-Forwarded-For (GHSA-qcmf, default KnownProxies empty), session remote-control (GHSA-4vx8), LiveTV M3U (GHSA-8fw7), HLS / MKV attachment (GHSA-hw73 / GHSA-f47c), ClientLog (GHSA-jg92 / GHSA-9p5f), branding splash (GHSA-rrr6), unauth library download (GHSA-wg4c), unauth remote image GET (GHSA-rgjw). POST /Items/{id}/RemoteImages/Download?imageUrl= is still a raw artwork fetch with RequiresElevation. Intended. Dropped.

Surfaces that returned nothing

I split the tree the way you split a media server: first-run wizard, path-combine, session remote-control, plugins, LiveTV M3U, subtitle / .strm local, QuickConnect, backup, DLNA (plugin-only), branding splash, SyncPlay, transcode / trickplay, WebSocket, system / config / encoder path, library download, ClientLog.

Most of that is Jellyfin doing what the 12.0 / 12.2 changelog said it would do.

POST /Startup/User forbids if the first user already has a password. EnsureStartupWizardIntegrity restores the completed flag when any admin has a password. Empty KnownProxies means ForwardedHeaders.None. HLS is class-level [Authorize]. Universal audio is [Authorize]. Progressive /Videos/{id}/stream and /Audio/{id}/stream are still anonymous. That last sentence is longstanding. It is not this leftover. This leftover is what you put in the library so that anonymous transcode runs ffmpeg with extra arguments.

The temperature changed on EncodingUtils.GetInputArgument sitting next to the File branch that already knew the rule.

PR 17685 closed file: and left Http

EncodingUtils.GetInputArgument is two functions. Protocol not File:

Plain text
if (protocol != MediaProtocol.File)
{
    return string.Format(CultureInfo.InvariantCulture, "\"{0}\"", inputFile);
}

That is a pair of quotes around the raw path. No EscapeProcessArgument. The concat overload does the same with inputFiles[0].

File protocol goes to GetFileInputArgument:

Plain text
if (path.Contains("://", StringComparison.Ordinal))
{
    return string.Format(CultureInfo.InvariantCulture, "\"{0}\"", path);
}

path = path.EscapeProcessArgument();
return string.Format(CultureInfo.InvariantCulture, "{1}:\"{0}\"", path, inputPrefix);

The second return is the PR 17685 close. The first return is the same raw wrapper as Http. It exists so a file:// URI, or a path that already looks like a URI, does not get the file: prefix twice. A real File path never contains :// as a filename, because slash is a directory separator. That branch is dead on POSIX. It is not the leftover.

ManagedFileSystem.IsPathFile is the classifier:

Plain text
if (path.Contains("://", StringComparison.OrdinalIgnoreCase)
    && !path.StartsWith("file://", StringComparison.OrdinalIgnoreCase))
{
    return false;
}

GetPathProtocol then falls through to MediaProtocol.Http for anything that is not File and does not start with Rtsp / Rtmp / Http / rtp / ftp / udp. An http://… shortcut is Http. GetInputArgument takes the non-File branch.

TranscodeManager.StartFfMpeg still uses one string:

Plain text
FileName = _mediaEncoder.EncoderPath,
Arguments = commandLineArguments,

It logs {Filename} {Arguments}. That log is the oracle.

VideosController.GetVideoStream has no [Authorize]. If static=true and the input protocol is Http, the handler fetches the remote URL itself and never starts ffmpeg. Omit static. Default falls through to GetProgressiveVideoFullCommandLine and the transcode helper. That is the trigger.

.strm probe is the plant. ProbeProvider.FetchShortcutInfo reads the first non-comment line and keeps it only when Uri.TryCreate succeeds and the scheme is http / https / rtsp / rtp. Local and file:// lines are logged and dropped. That is the GHSA-j2hf close. It is also why a quoted http URL is interesting: if TryCreate had rejected ", the leftover would be theory. It does not reject ".

Playback copies ShortcutPath onto the media source when the shortcut protocol is not File. Unauth stream then builds -i from that URL.

Wrong turn: a filename with ://

The hunt started on the path.Contains("://") shortcut inside GetFileInputArgument. On paper a POSIX filename may contain both :// and ". IsPathFile would return false. Protocol would become Http. GetInputArgument would wrap the raw path. The plant I wrote down was a://b" WITNESS "x.mkv.

The lab stored /media/movies/a:/b" WITNESS "x.mkv. Slash is a separator. Directory a:, file b" WITNESS "x.mkv. Canonical Path has no ://. IsPathFile is true. The File branch runs EscapeProcessArgument. TranscodeManager logs -i file:"/media/movies/a:/b\" …". Quote-only File paths were already closed. I am not dressing that FAIL up as this bug.

That is why the slug says colonslash and the SUCCESS plant is a .strm. The name is the first theory. The leftover that fires is the branch that theory was aiming at: non-File protocol, raw quotes.

The second wrong turn was the hunt note that Uri.TryCreate typically rejects ". I wrote that before I put a quoted URL in a .strm and watched the library keep it. .NET's Uri parser is more permissive than the note. The probe copies ShortcutPath. The stream uses it. The log splits.

A third wrong turn, smaller: static=true on this plant. Http remote stream. No ffmpeg. No argv. The trigger is the transcode path, which is the default when static is unset.

I am not going to print a walk of -vf drawtext=textfile=/etc/shadow aimed at a live host. The missing escape is the useful part. Historical gadgets from the old GHSA-866x chain (-vf drawtext=textfile=, -attach / -dump_attachment) are inferred from that split. They are not run. Plugin-DLL RCE is the old chain and is not claimed here.

The lab

Source of truth is lab/. Image jellyfin/jellyfin:12.2. Port 18810. Bind it to loopback.

docker-compose.yml publishes 127.0.0.1:18810:8096, mounts /config, /cache, and /media, and sets JELLYFIN_PublishedServerUrl to the loopback URL.

run.sh brings the image up, waits for GET /Startup/User, then poc.py. The client plants three things under /media/movies:

  • LabControl.mkv, a one-second black H.264 file. Control. Must stay one -i file:"…".
  • LabPlant.strm, one line: http://127.0.0.1/a" JELLYFIN-FFMPEG-COLONSLASH-WITNESS "x. The SUCCESS plant.
  • a://b" JELLYFIN-FFMPEG-COLONSLASH-WITNESS "x.mkv, the filename theory. Stored as a:/b"…"x.mkv. File branch. Negative.

Then the first-run wizard: POST /Startup/User, remote access, a Movies virtual folder on /media/movies, POST /Startup/Complete. Authenticate. POST /Library/Refresh. Wait until /Items has both the control GUID and the .strm GUID.

Unauth GET /Videos/{control}/stream.ts?videoCodec=h264&audioCodec=aac&maxWidth=64. Then the same request on the plant id. Read compose logs. The control line contains -i file:"/media/movies/LabControl.mkv" and does not contain the witness as an extra argument. The plant line contains -i "http://127.0.0.1/a" JELLYFIN-FFMPEG-COLONSLASH-WITNESS "x". argv-split=yes. plant-i=quote-break.

The plant stream itself 500s. ffmpeg cannot fetch that URL. That is expected. The leftover is the argv, not a returned video. The TranscodeManager log is the witness.

Plain text
git clone https://github.com/abraxas/jellyfin-ffmpeg-colonslash
cd jellyfin-ffmpeg-colonslash
python3 jellyfin-ffmpeg-colonslash-Abraxas-Labs.py

Or cd lab && ./run.sh. Target only http://127.0.0.1:18810. Last line:

Plain text
SUCCESS jellyfin-ffmpeg-colonslash argv-split=yes plant-i=quote-break control-id=f603a52f649bcf822b333c4d5f21cc86 plant-id=d7aabf0935bb524e1667b1ff9bb945d9 JELLYFIN-FFMPEG-COLONSLASH-WITNESS

What an attacker can do

Same plant model as GHSA-wwwm: write a .strm into a scanned media folder. NAS, Samba, guest share, anyone the operator already trusted to drop media. Then anyone who can open TCP to the server can GET /Videos/{id}/stream with no Authorization and no static. Item GUIDs are not a secret once a library is public, once Suggestions or playlist ItemIds leak them (leftovers #1 and #3 on this pin), or once a client has listed the library. That is the full privilege set. Nothing in this list is a shell.

Integrity of ffmpeg argv (lab-proven)

Unauth GET /Videos/{itemId}/stream after the .strm plant. Extra ffmpeg arguments outside the -i quotes. Lab oracle: TranscodeManager log contains -i "http://127.0.0.1/a" JELLYFIN-FFMPEG-COLONSLASH-WITNESS "x". Control stays -i file:"/media/movies/LabControl.mkv".

The same handler on /Audio/{id}/stream is the audio sibling. HLS is [Authorize] and not this trigger. static=true on an Http source skips ffmpeg.

Recon via historical filter gadget (inferred, not run)

Same stream plus -vf drawtext=textfile= (or subfile). Host file bytes painted into the returned transcode, if ffmpeg gets that far. Same class of gadget as GHSA-jh22. I did not run it. The argv split is the proven half.

Integrity of jellyfin-UID write (inferred, not run)

Same stream plus -attach / -dump_attachment. Write as the jellyfin UID. GHSA-866x used that shape to drop a plugin DLL. Plugin load after write is a separate chain. It is not shown here. I am not claiming RCE.

What this leftover does not grant

  • No shell. No ProcessBuilder. No plugin-DLL RCE.
  • Quote-only File paths stay escaped on the File branch.
  • A POSIX filename with :// becomes a directory. Canonical Path has no ://. Escaped file: -i.
  • GHSA-jh22 level TryParse is closed.
  • Codec query strings still match the regex.
  • .strm local / file:// paths are dropped. Probe keeps http / https / rtsp / rtp only.
  • EncoderAppPath cannot be set via the encoding config API.
  • Subtitle-upload .strm (GHSA-j2hf) stays closed.
  • Unauth item list is still [Authorize]. This leftover is not an enumerator. Suggestions and playlist ItemIds are.
Axis Method + path Grants Provenance
Integrity / argv unauth GET /Videos/{id}/stream after a library .strm plant Extra ffmpeg arguments outside the -i quotes Lab-proven. TranscodeManager log
Recon same plus -vf drawtext=textfile= Host file bytes in the transcode Inferred gadget; not run
Integrity -attach / -dump_attachment jellyfin-UID write Inferred; plugin load not shown
RCE not claimed - -

Fix

Call EscapeProcessArgument on every GetInputArgument branch, including Http and rtsp. Keep wrapping, but quote-escape the inner string the way the file: branch already does. ProcessStartInfo.ArgumentList would also remove the class. Either is enough. One wrapper that still interpolates raw quotes is not.

Contact

Abraxas Labs - abraxaslabs.tech - github.com/abraxas - @abraxas_null - abraxas.null@proton.me