all posts

Sandbox ffmpeg: Per-Job microVMs for Transcoding

Ajay Kumar··9 min read

ffmpeg is one of the most useful and one of the most dangerous programs you can put behind a public upload form. It's millions of lines of decades-old, performance-tuned C that parses hundreds of container and codec formats, most of them written by people who did not have your threat model in mind. Feed it a file a stranger uploaded — or let an AI agent assemble the command line — and you've handed a memory-unsafe parser a stream of attacker-controlled bytes. A malformed .mkv is a remote code execution request wearing a home-video costume. The safe place to run that request is a disposable microVM, not your app host.

This post walks the concrete pattern: create a fresh Firecracker microVM per transcode job, write the input file in (or fetch it from scoped storage), run ffmpeg under a wall-clock timeout and a memory ceiling, read the output back out, and destroy the VM. I'm Ajay, I built PandaStack — I'll be honest about where the boundary is and where it isn't.

Why ffmpeg on untrusted input is a security problem

The attack surface is enormous and the input is fully attacker-controlled. That combination is the whole problem. A user uploads a "video"; an agent decides to "just re-encode it to mp4." Neither of you reviewed the bytes. Here's what those bytes can do:

  • Memory-corruption RCE — a crafted container or codec bitstream hits a parsing bug (heap overflow, use-after-free) in one of ffmpeg's demuxers or decoders. There is a long CVE history here; the demuxer surface is exactly where malformed files land. On a shared host, a successful exploit runs as your worker process.
  • SSRF via input URLs — ffmpeg happily treats `-i http://…` (or `rtmp://`, `ftp://`, playlist/concat protocols) as an instruction to fetch a remote resource. If an agent builds the command from model output, `-i` can point at your cloud metadata endpoint (169.254.169.254) or an internal service. The transcoder becomes a request-forgery proxy.
  • Decompression / resolution bombs — a tiny file that decodes to enormous frames, or a filtergraph that allocates gigantic buffers, exhausts host RAM and takes neighbors down with it (a classic DoS).
  • CPU blowups — pathological filtergraphs or codec settings peg every core indefinitely. No output, just a stuck job burning your fleet.
  • Local file read/write — file protocols and output paths can be steered to read `/etc/passwd`-class files or clobber paths the worker can write, if the process isn't confined.
Argument allowlisting and `-protocol_whitelist` help, but they're a hardening layer, not a boundary. The RCE class lives in the *parser*, which runs after argument parsing — a valid, allowlisted command feeding a malicious file still reaches the vulnerable code. You need containment, not just input validation.

A container shares the host kernel; a microVM doesn't

The instinct is to drop ffmpeg into a Docker container and call it isolated. A container is a good hygiene layer, but it is a Linux process with namespaces and cgroups running on your host's one shared kernel. If the ffmpeg RCE chains into a kernel bug or a container escape — both are real, recurring classes — the blast radius is the host and every neighbor on it. The container shrank the surface; it didn't change what a successful exploit reaches.

A Firecracker microVM boots its own guest kernel behind hardware virtualization (KVM). ffmpeg inside it can only touch a tiny set of emulated virtio devices; there is no shared host kernel to pivot into. An exploit would have to escape the hypervisor itself — a far smaller, far more heavily audited surface than the full Linux syscall interface a container sees. This is the same isolation model AWS Lambda uses to run untrusted code from millions of accounts on shared fleets.

Why per-job VMs, not one long-lived transcoder

The right unit is one throwaway VM per transcode job. Two reasons. First, cross-tenant isolation: if you funnel every user's uploads through a single warm ffmpeg box, a job that corrupts guest memory or drops a backdoor now sits in the same VM the next user's job runs in. A fresh VM per job means the worst case is one poisoned disposable machine that you destroy on completion. Second, blast-radius accounting: a per-job VM has its own memory, its own filesystem, its own network namespace, and a hard TTL. When the job ends — or the timeout fires — the whole machine and everything the attacker did to it evaporates.

The historical objection to per-request VMs was startup cost: nobody wants to cold-boot a full VM per upload. That's solved by snapshot-restore. On PandaStack every create restores a baked snapshot on demand rather than cold-booting, so a create is p50 179ms (the restore step itself is ~49ms), p99 ~203ms. The first-ever boot of a template is a ~3s cold boot that captures the snapshot; every create after that takes the fast path. At that cost, a disposable VM per transcode job is genuinely practical — the isolation is close to free relative to the transcode itself.

The rule of thumb: one VM per job, destroyed after. Never reuse a transcoder VM across two different users' files — the isolation boundary is the VM, so reuse across trust domains is exactly what defeats it.

The pattern: create, transcode, read back, destroy

The loop is four steps. Create a sandbox. Get the input bytes into the guest (write them in, or have the guest fetch from scoped storage you control — see egress below). Run ffmpeg with an explicit wall-clock timeout, and let the VM's memory ceiling cap the RAM blowups. Read the output artifact back through the filesystem API, then kill the VM. Here's a single transcode via the SDK's exec, showing the flags that matter for untrusted input:

# Runs INSIDE the per-job microVM (via sbx.exec), not on your host.
# -nostdin      : never block on terminal input
# -y            : overwrite output without prompting
# -protocol_whitelist file : refuse http/rtmp/etc. so a crafted input
#                 can't turn ffmpeg into an SSRF proxy
# -t 600        : cap output duration; pairs with the wall-clock timeout
ffmpeg -nostdin -y \
  -protocol_whitelist file \
  -i /work/input.bin \
  -c:v libx264 -preset veryfast -crf 23 \
  -c:a aac -b:a 128k \
  -t 600 \
  /work/output.mp4

Note what the flags do and don't buy you. `-protocol_whitelist file` closes the URL-fetch / SSRF door at the ffmpeg level, and it's worth setting. But it's belt-and-suspenders on top of the VM boundary — the real containment is that this process is inside a microVM whose network egress you also control, so even if a flag is wrong, ffmpeg can't reach your metadata endpoint or internal network.

Resource limits: timeouts, memory ceilings, CPU caps

Isolation stops an exploit from spreading; resource limits stop a job from being a denial-of-service. For untrusted transcoding you want three, layered:

Wall-clock timeout

Pass `timeout_seconds` on every exec. This is your circuit breaker against filtergraph blowups and pathological codec settings that would otherwise run forever. A stuck ffmpeg is far more common than a crashing one, and an unbounded exec is a leaked worker. Belt it further with a `ttl_seconds` on create so a VM you forget to kill reaps itself regardless.

Memory ceiling

The guest's RAM is fixed at the VM's configured size. A decompression bomb or a giant filtergraph buffer can, at worst, OOM inside its own guest — the guest kernel's OOM killer reaps ffmpeg, and the job fails cleanly. It cannot eat your host's memory or your neighbors', because the guest simply has no access to it. That's the difference from a shared-kernel container, where an over-allocating process contends for host RAM.

CPU caps and job concurrency

Size the VM's vCPU count to what a transcode legitimately needs, and cap how many jobs run at once. A per-job VM naturally bounds a single job's CPU to its guest; concurrency control at your scheduler bounds the fleet. Because each job is its own VM, a CPU-pathological input burns only its own allotment and then gets timed out — it doesn't starve unrelated jobs sharing a process pool.

Egress control: stop ffmpeg from fetching or exfiltrating

Two network risks matter here. Inbound-shaped: ffmpeg fetching a remote `-i` URL (SSRF, as above). Outbound-shaped: a successful RCE payload phoning home or exfiltrating whatever it found in the guest. The `-protocol_whitelist` flag addresses the first at the app layer, but the durable control is at the network layer, where a wrong flag or a fresh CVE can't undo it.

  • Default-deny egress — if the transcode is purely local (bytes in, bytes out), the guest needs no outbound network at all. Denying egress means an exploit has nowhere to send stolen data and no SSRF target to reach, full stop.
  • Scoped fetch instead of arbitrary URLs — if inputs must come from remote storage, don't pass a user/agent-supplied URL to `-i`. Fetch the object on the host (or via a broker you control) into scoped storage, then write the bytes into the guest. The guest never gets to choose a destination.
  • Block link-local + internal ranges — at minimum, deny the metadata endpoint (169.254.169.254) and RFC1918 ranges so even an allowed egress path can't be steered at your own infrastructure.
  • Per-VM network namespace — each PandaStack sandbox already runs in its own Linux network namespace (the platform pre-allocates 16,384 /30 subnets per agent), so egress rules are applied per sandbox, not shared across tenants.

End to end with the Python SDK

Here is the full loop for one untrusted transcode job: create a disposable VM, write the user's bytes in, run ffmpeg under a timeout, check the exit code, read the output back, and let the context manager destroy the VM. Set PANDASTACK_API_KEY in your environment; the SDK picks it up.

from pandastack import Sandbox

# `input_bytes` is the untrusted upload. Never trust its name, extension,
# or claimed content type — ffmpeg will probe the real container itself.
def transcode_to_mp4(input_bytes: bytes) -> bytes:
    # Fresh, throwaway VM for THIS job only. TTL is a backstop reaper.
    with Sandbox.create(template="base", ttl_seconds=900) as sbx:
        # Get the untrusted bytes into the guest filesystem.
        sbx.filesystem.write("/work/input.bin", input_bytes)

        cmd = (
            "ffmpeg -nostdin -y "
            "-protocol_whitelist file "          # no http/rtmp/ftp: kills SSRF
            "-i /work/input.bin "
            "-c:v libx264 -preset veryfast -crf 23 "
            "-c:a aac -b:a 128k "
            "-t 600 "                            # cap output duration
            "/work/output.mp4"
        )
        # Wall-clock timeout is the circuit breaker for filtergraph blowups.
        result = sbx.exec(cmd, timeout_seconds=600)

        if result.exit_code != 0:
            # A malformed/hostile file fails HERE, inside the disposable VM.
            raise RuntimeError(f"transcode failed: {result.stderr[-2000:]}")

        # Pull the finished artifact back out as raw bytes.
        return sbx.filesystem.read("/work/output.mp4")
    # VM (and anything a malicious file did to it) is destroyed on exit.


if __name__ == "__main__":
    with open("user_upload.mkv", "rb") as f:
        raw = f.read()
    mp4 = transcode_to_mp4(raw)
    with open("safe_output.mp4", "wb") as f:
        f.write(mp4)
    print(f"transcoded {len(raw)} bytes in -> {len(mp4)} bytes out")

The shape generalizes to any media job — audio normalization, thumbnail extraction, format conversion, waveform generation. For an AI agent that constructs ffmpeg command lines from model output, the same loop applies, and the VM boundary is what makes it safe to let the model choose flags at all: the worst a bad command can do is fail (or misbehave) inside a machine you're about to throw away. Still, prefer to constrain what the agent can emit — an allowlist of codecs and flags plus the network default-deny means a prompt-injected command line has neither a dangerous protocol nor a place to send data.

Transcode on the app host vs a container vs a per-job microVM

The same untrusted-media job, run three ways. (Treat any competitor or tool characterization here qualitatively and verify against their current docs.)

  • On the app host (bare subprocess) — Isolation: none; ffmpeg runs as your worker, an RCE owns the app. DoS: a RAM/CPU bomb takes the host and every request with it. SSRF: `-i http://…` reaches your whole network. Verdict: never do this with untrusted input.
  • In a container — Isolation: namespaces + cgroups on the shared host kernel; a container escape or kernel bug reaches the host. DoS: cgroups cap CPU/RAM per container (good), but noisy-neighbor and kernel contention remain. SSRF: still possible unless you also lock down egress. Verdict: a real hardening layer, not a boundary against RCE-class exploits.
  • In a per-job microVM (PandaStack) — Isolation: hardware-virtualized guest kernel; an exploit is contained to a disposable VM, not the host. DoS: fixed guest RAM + per-exec timeout + per-VM CPU means a bomb OOMs/times-out itself, neighbors unaffected. SSRF: per-VM netns with default-deny egress closes the fetch/exfil paths. Cost: ~179ms p50 create via snapshot-restore makes a fresh VM per job practical. Verdict: the safe default for untrusted or agent-driven media processing.

Honest limits

A microVM isolates execution; it doesn't sanitize your output. If you serve the transcoded file straight back to other users, you still own the usual content-safety questions (is it actually the format you claim, does your CDN sniff it correctly). The VM guarantees the malicious *input* couldn't touch your host — it makes no claim about the *output* being wholesome. Second, a sandbox isolates code, not your secrets: never inject credentials the transcode doesn't need into the guest, and don't hand a per-job VM more network reach than the job requires. Within those bounds, per-job microVMs turn ffmpeg from a standing liability on your app host into a contained, disposable, timed-out job — which is exactly what running strangers' media through a decades-old C parser should feel like.

Frequently asked questions

Is it safe to run ffmpeg on user-uploaded video?

Not on your app host or in a plain subprocess — ffmpeg is a large, memory-unsafe C codebase parsing fully attacker-controlled bytes, which is a classic RCE and DoS surface. Run each job inside an isolated microVM instead: a fresh Firecracker VM per transcode, with a wall-clock timeout, a fixed memory ceiling, and default-deny network egress. A successful exploit is then contained to a disposable VM you destroy on completion, not your host.

Isn't a Docker container enough to sandbox ffmpeg?

A container is a useful hardening layer but not a boundary against RCE-class bugs. Containers share the host's single kernel via namespaces and cgroups, so an ffmpeg exploit that chains into a kernel bug or container escape can reach the host and every neighbor. A Firecracker microVM boots its own guest kernel behind hardware virtualization, so an exploit would have to escape the hypervisor itself — a far smaller, more audited surface. Use a microVM for untrusted input.

How do I stop ffmpeg from fetching remote URLs (SSRF)?

Two layers. At the ffmpeg level, pass -protocol_whitelist file so it refuses http/rtmp/ftp inputs, which blocks -i http://… turning the transcoder into a request-forgery proxy. More durably, control egress at the network layer: run the job in a per-VM network namespace with default-deny outbound, block the cloud metadata endpoint (169.254.169.254) and internal ranges, and fetch remote inputs on a broker you control rather than passing user/agent-supplied URLs to -i.

Won't a VM per transcode job be too slow or expensive?

That used to be the objection, but snapshot-restore removes the cold-boot tax. On PandaStack every create restores a baked snapshot on demand rather than cold-booting, so a create is p50 179ms (p99 ~203ms), with only the first-ever template boot paying the ~3s cold-boot cost. Relative to the transcode itself, a disposable per-job VM adds little latency, and the VM is destroyed the moment the job finishes.

How do I stop a malformed file from eating all my RAM or CPU?

Layer resource limits on top of the VM boundary. The guest's RAM is fixed at the VM's configured size, so a decompression bomb or oversized filtergraph OOMs inside its own guest and fails the job cleanly rather than touching host memory. A wall-clock timeout_seconds on every exec kills runaway or infinite filtergraphs, and a ttl_seconds on create reaps a forgotten VM. Because each job is its own VM, a CPU-pathological input burns only its own allotment before being timed out.

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.