Summary
The local storage driver's thumbnail helpers — GetSnapshot() and resizeImageToBufferWithFFmpegGo() — handed user-controlled paths to ffmpeg.Input() and ffmpeg.Probe() with no restriction on which FFmpeg protocols those child processes could open. A file that passed the existing sanitizeFilePath check (absolute, cleaned, regular file) could still be an HLS playlist or concat script pointing at http://, subfile: or file: targets, turning thumbnail generation into SSRF and arbitrary file read. The fix adds -protocol_whitelist file to every FFmpeg invocation and passes ffmpeg.KwArgs{"protocol_whitelist": "file"} to ffmpeg.Probe().
Introduction
ffmpeg.Input("/srv/storage/user42/clip.mp4") looks like a filesystem operation. It isn't. FFmpeg's -i argument is a URL, and FFmpeg resolves it through a long list of protocol handlers: file:, http:, https:, tcp:, rtmp:, pipe:, concat:, subfile:, data:, and more. The path you think you're opening is really a string that a very capable network client is about to parse.
That mismatch is the whole bug here. The local storage driver generates thumbnails for media it finds on disk. Two entry points do the work:
resizeImageToBufferWithFFmpegGo(inputFile string, width int, outputFormat string)— buildsffmpeg.Input(inputFile).Output("pipe:", outputArgs)and streams the result into abytes.Buffer.GetSnapshot(videoPath string)— callsffmpeg.Probe(videoPath)to read the container duration, computes a seek offset, then runsffmpeg.Input(videoPath, ffmpeg.KwArgs{"ss": ss, "noaccurate_seek": ""})with animage2/mjpegoutput to grab one frame.
GetSnapshot does run its argument through a sanitizeFilePath helper, which checks that the path is absolute, cleaned, and refers to a regular file. That stops ../../etc/shadow and it stops a literal http://evil.test/x string from being accepted as a path. What it cannot do is inspect what's inside the file — and FFmpeg's container formats are full of indirection that points back out to the network. If you write code that spawns FFmpeg against anything a user can influence, this section is about your code too.
Affected Versions
| Affected | not applicable (first-party code) — the local storage driver's FFmpeg thumbnail helpers (GetSnapshot, resizeImageToBufferWithFFmpegGo) |
| Fixed in | not applicable (first-party code) — fixed by the referenced hardening commit that adds -protocol_whitelist file |
| Ecosystem | go |
| CVE / GHSA | not assigned |
| CWE | CWE-78 (OS command/argument injection into a subprocess) |
The Vulnerability Explained
The vulnerable pattern
Before the fix, the image resize path built its command like this:
err = ffmpeg.Input(inputFile).
Output("pipe:", outputArgs). // Output to pipe (stdout)
GlobalArgs("-loglevel", "error").
Silent(true).
WithOutput(outBuffer, os.Stderr).
Run()
And the snapshot path probed the container like this:
jsonOutput, err := ffmpeg.Probe(videoPath)
Two global arguments are present (-loglevel error) and one crucial one is absent: there is nothing telling FFmpeg which protocol handlers it may use. The same omission appears in the frame-extraction stream that follows the probe.
Note that this is not classic shell metacharacter injection — ffmpeg-go builds an argv slice and does not go through /bin/sh, so a ; or $(…) in a filename is inert. The injection happens one layer in: the attacker controls a value that the callee re-interprets as a URL and as a reference to further URLs. The security boundary everyone assumes exists ("it's just a path") was never enforced.
Attacking this code path
The attacker needs only the ability to put a file somewhere the driver will thumbnail, plus the ability to request that thumbnail. Two concrete variants:
1. SSRF via an HLS playlist. Upload a file named clip.m3u8 (or any extension the thumbnailer accepts — FFmpeg probes by content, not suffix) containing:
#EXTM3U
#EXT-X-TARGETDURATION:10
#EXTINF:10,
http://169.254.169.254/latest/meta-data/iam/security-credentials/
#EXT-X-ENDLIST
sanitizeFilePath passes it: absolute, clean, regular file. ffmpeg.Probe() then has ffprobe resolve the segment over HTTP from inside your network. The request originates from the application server, with its security groups and its instance identity. Even when the response isn't valid media, timing and the error text returned up the err path from ffmpeg.Probe make a serviceable oracle for internal port and host scanning.
2. Local file disclosure rendered into the thumbnail. The subfile: and concat: protocols, and the concat demuxer, let an input stitch together byte ranges of other files. Point one at a credentials file or a private key, let the image2/mjpeg output encode the bytes as a frame, and download the thumbnail your own API cheerfully returns. The exfiltration channel is the product feature itself.
Because GetSnapshot runs ffmpeg.Probe before the frame extraction, the first attempt alone is enough to trigger an outbound fetch — an attacker gets SSRF even if the subsequent decode fails.
Real-world impact
For a self-hosted file manager or media service, this means any user with write access to a watched directory can reach cloud metadata endpoints, unauthenticated internal admin panels, and container-network services, and can read server-side files into an image they're authorized to download. On a cloud instance with an attached role, step one is usually credential theft from the metadata service. That's why this is rated critical despite needing a low-privilege foothold.
The Fix
The change is small and surgical: constrain what FFmpeg and ffprobe are permitted to open.
Before — the probe inherits no protocol restrictions:
jsonOutput, err := ffmpeg.Probe(videoPath)
After — the probe is pinned to the file protocol:
// Restrict ffprobe to the file protocol only, so a crafted path cannot
// make it fetch remote resources or read arbitrary local files via
// other protocols (http, concat, subfile, pipe, etc.).
jsonOutput, err := ffmpeg.Probe(videoPath, ffmpeg.KwArgs{"protocol_whitelist": "file"})
The same restriction is added to both encoding pipelines as a global argument. In the resize helper:
GlobalArgs("-loglevel", "error").
GlobalArgs("-protocol_whitelist", "file"). // Restrict ffmpeg to the file protocol only
Silent(true).
And in the snapshot stream that carries ffmpeg.KwArgs{"ss": ss, "noaccurate_seek": ""}:
GlobalArgs("-loglevel", "error").
GlobalArgs("-protocol_whitelist", "file").Silent(true).
Why all three edits were necessary
Each FFmpeg invocation is a separate operating-system process with its own argv. ffmpeg.Probe() spawns ffprobe; the Input(...).Output("pipe:") chains spawn ffmpeg. Hardening one does nothing for the others — and in GetSnapshot the probe runs first, so leaving it unrestricted would have preserved the SSRF primitive even with the frame extractor locked down. Three call sites, three identical restrictions.
With -protocol_whitelist file in effect, a playlist segment of http://169.254.169.254/... is rejected by FFmpeg's protocol layer before a socket is opened, and concat:, subfile:, pipe: and data: inputs are refused outright. The existing sanitizeFilePath call still earns its keep — it keeps the top-level path absolute and cleaned — but it is now backed by a control that operates at the layer where the actual decision is made.
One honest caveat, and the reason this is hardening rather than a complete seal: the concat demuxer (-f concat) still operates over the file protocol. It depends on FFmpeg's default -safe 1 to reject absolute and otherwise unsafe entries, so that default must not be overridden, and the process should continue to run with the narrowest filesystem permissions the deployment allows. The upstream change was submitted as a reviewed suggestion with no automated verification run against the repository, so maintainers should confirm the thumbnail paths still behave as expected for legitimate local media.
Key Takeaways
ffmpeg.Input()andffmpeg.Probe()take URLs, not paths. Any string you pass is eligible for FFmpeg'shttp:,concat:,subfile:,pipe:anddata:handlers unless you explicitly narrow them with-protocol_whitelist.- A
sanitizeFilePaththat checks "absolute, cleaned, regular file" validates the wrong thing. It inspects the path string, while the attack lives in the file's contents — a legitimate-looking.m3u8or concat script is a regular file that points somewhere else. - Protocol restrictions must be applied per process.
ffmpeg.Probe()forksffprobewith its own argv, soGlobalArgs("-protocol_whitelist", "file")on anInput(...).Output(...)chain never reaches it; the probe needsffmpeg.KwArgs{"protocol_whitelist": "file"}of its own. - Thumbnails are an exfiltration channel. An
image2/mjpegoutput encodes whatever bytes the demuxer fed it, so arbitrary-read primitives surface as a downloadable image through your normal API. - A probe that runs before decoding is a full SSRF primitive by itself. In
GetSnapshot, the duration lookup fires first — hardening only the frame-extraction stream would have left the hole open.
How Orbis AppSec Detected This
- Source: user-influenced filesystem paths flowing into the
inputFileparameter ofresizeImageToBufferWithFFmpegGo()and thevideoPathparameter of the local driver'sGetSnapshot()— values derived from directory traversal over storage that users can write to. - Sink: the FFmpeg subprocess invocations
ffmpeg.Input()(built intoffmpeg/ffprobeargv and executed) andffmpeg.Probe(). - Missing control: no restriction on FFmp