all posts

Sandboxing untrusted uploads: ImageMagick, ffmpeg, LibreOffice

Ajay Kumar··9 min read

Almost every SaaS product eventually grows an upload button. Then it grows a feature behind that button: generate a thumbnail, transcode the video, extract the text from the contract, render a preview of the spreadsheet. And so a worker somewhere in your infrastructure — usually one with your database credentials in its environment — starts feeding bytes it has never seen, from people it has never met, into ImageMagick, ffmpeg, LibreOffice, Ghostscript, FreeType, or whatever your language's image library wraps. Those are enormous, decades-old C and C++ codebases whose entire job is to parse hostile input formats. The uncomfortable framing is the accurate one: a malicious PNG is not data, it is a program, and by calling the decoder you agreed to run it.

I'm Ajay — I build PandaStack, which runs Firecracker microVMs as a service, so I have an obvious interest in you sandboxing things. I'll try to pay that off by being specific: what the threat model actually is, why patching is a treadmill rather than a boundary, the exact shape of the per-upload disposable VM pattern (including the resource limits everyone forgets), and the cases where all of this is honestly overkill and you should just call the library.

The threat model: a file format is an instruction set

A container format is a little virtual machine specification wearing a trench coat. A TIFF has tags that direct the decoder to offsets and lengths. A PDF has an object graph, a compression stack, embedded fonts, and — via Ghostscript — an actual PostScript interpreter. A video container has a demuxer that trusts sample tables to point at real data. A font file has a bytecode-driven hinting VM. A modern office document is a zip archive full of XML with entity expansion, relationships, and embedded objects. Every one of those is a parser making decisions based on numbers the attacker chose.

The consequences fall into three buckets, and it's worth keeping them separate because they need different mitigations:

  • Memory-safety exploitation — heap overflows, use-after-free, type confusion in a decoder, leading to code execution as your worker process. This is the classic. The 2016 ImageTragick issue in ImageMagick's delegate handling turned image uploads into shell command execution on a large fraction of the web; the 2023 libwebp heap overflow shipped inside basically every browser and app that renders images; FreeType and various ffmpeg demuxers have long, decorated histories here. I'm deliberately not quoting CVE identifiers or scores — go read the vendor advisories for the versions you actually run, because that is the only version of this list that matters.
  • Confused-deputy and SSRF — the converter is a general-purpose tool that will happily fetch things. ImageMagick can be steered into a delegate that shells out; SVG can reference external resources; ffmpeg will follow protocols and playlists to files and URLs you did not intend; office documents can carry links and formulas that phone home. No memory corruption required — the parser does exactly what it was built to do, just on the attacker's behalf, from inside your network, with your metadata service one hop away.
  • Denial of service — a 40-byte zip that expands to 40 gigabytes, a PNG whose header claims 30,000 by 30,000 pixels (that's a multi-gigabyte allocation before a single pixel is decoded), an XML entity expansion bomb, a video whose stream copy never terminates. Nothing was exploited. Your worker fleet fell over anyway, which from the pager's perspective is the same outcome.
Note that bucket three is not a security bug in the parser and never will be. Decompression bombs and pixel bombs are the library doing exactly what you asked. That's why limits and isolation are separate controls: no amount of patching prevents a resource exhaustion attack, and no amount of resource limiting prevents a heap overflow.

"Just keep the library updated" is a treadmill, not a boundary

Dependabot is good. Keep doing it. But notice what patching gives you: protection against the bugs that have already been found, disclosed, fixed, released, and deployed by you. It gives you nothing against the next one, and the historical rate of memory-safety findings in media parsing code suggests there will be a next one. Meanwhile the property you actually want is not "my decoder has no bugs" — that's unfalsifiable — it's "when my decoder has a bug, the attacker gets a machine that contains nothing and can reach nothing."

Patching reduces the probability of an incident. Isolation reduces the cost of one. You need both, but only one of them is under your control on a Friday night.

There's a second reason the treadmill loses. Your transitive dependency on a C parser is usually much wider than you think. Call a friendly-looking Python or Node image library and you may be transitively linking libwebp, libpng, libjpeg-turbo, giflib, libtiff, lcms, freetype, and zlib. Ask for a PDF thumbnail and you may have quietly invited Ghostscript to the party. The audit surface is not "the library I imported"; it's the closure of every decoder reachable from an attacker-chosen magic byte.

The real problem is where the parser runs

Here is the shape most teams actually have. The upload lands in an API process or a Celery/Sidekiq/BullMQ worker. That process holds a database connection with write access, an S3 credential scoped generously because scoping it tightly was a Q3 ticket, a Stripe key, an internal service token, and — if it runs on a cloud VM or an EKS node — the ability to hit the instance metadata endpoint. Then it calls the thumbnailer. Your thumbnailer has the same privileges as your billing code, and only one of them was written on the assumption that it would be fed bytes chosen by an adversary.

Moving it to "a separate container" helps with blast radius on paper and much less in practice, because the container usually shares the host kernel with everything else on the node, often runs as root inside its namespace, frequently has a service account token mounted at a well-known path, and can almost always reach the cluster's internal network. Container escapes are a real and recurring bug class, and a kernel-facing attack surface of hundreds of syscalls is a large thing to defend with a seccomp profile you did not write.

Three options, honestly compared. Numbers below are PandaStack's measured platform figures, not generic claims — and for any competing product, verify the equivalents against their own docs rather than trusting my summary.

  • Blast radius of a decoder bug — In-process library call: the attacker gets your worker process, its heap, its environment variables, its open database handles, and its network position. Shared container worker: the attacker gets a namespaced process on a shared kernel, plus whatever the pod's service account and node network reach. Per-upload microVM: the attacker gets a disposable guest kernel with one file in it, which you delete seconds later.
  • Kernel attack surface — In-process: the full host kernel, same as your app. Shared container: the full host kernel, filtered by whatever seccomp/AppArmor profile is in effect, which is often the runtime default. Per-upload microVM: a separate guest kernel; escaping means defeating the guest kernel and then the VMM, and Firecracker's device model is deliberately tiny (virtio-net, virtio-blk, virtio-vsock, serial, RNG) with a jailer and seccomp filter around the VMM process.
  • Credential exposure — In-process: total; every secret the worker holds is one heap read away. Shared container: whatever you mounted, plus the service account token and node metadata endpoint if you didn't block them. Per-upload microVM: nothing, if you follow the rule that the VM receives bytes and returns bytes and is never handed a credential.
  • Resource exhaustion containment — In-process: a pixel bomb takes down the worker and its neighbours; you're relying on library-level limits you probably didn't configure. Shared container: cgroup limits help, but a memory hog can still trigger noisy-neighbour effects and node-level pressure. Per-upload microVM: RAM and vCPU are fixed at the hypervisor boundary, so the worst case is one VM dying with a bad exit code and your handler returning a 422.
  • Startup cost per job — In-process: effectively zero, which is exactly why it's the default. Shared container: near zero if the worker is long-lived; a fresh container per job is fast but shares the kernel anyway, so you paid latency without buying a boundary. Per-upload microVM: on PandaStack a create is p50 179ms and p99 around 203ms because every create restores a baked snapshot rather than cold booting (the restore step itself is roughly 49ms); a first-ever cold boot with no snapshot yet is around 3 seconds.
  • Operational complexity — In-process: none, it's a function call. Shared container: moderate; you own the image, the profiles, the network policy, and the autoscaling. Per-upload microVM: you own an extra hop and have to think about getting bytes in and out, which is real work and the honest cost of this pattern.

The per-upload disposable microVM pattern

The pattern is deliberately boring, which is the point. Five steps, no state, no reuse:

  1. Create a fresh microVM with a short TTL, so a VM you forget about reaps itself.
  2. Write the raw upload into the guest filesystem. Bytes only — no URLs, no credentials, no bucket names, nothing the converter could be tricked into fetching or leaking.
  3. Run the converter with hard limits: a wall-clock timeout, CPU and address-space rlimits, and the tool's own resource caps.
  4. Read the output artifact back out as bytes, only if the exit code was zero.
  5. Destroy the VM. Everything the attacker did — dropped files, spawned processes, patched binaries, planted cron entries — goes with it.

Here's that loop with the PandaStack Python SDK. Note what is absent: the sandbox never learns where the file came from, who uploaded it, or that a database exists.

from pandastack import Sandbox

MAX_UPLOAD = 25 * 1024 * 1024  # 25 MiB, enforced before we spend a VM

# Magic-byte sniff on the host. This is NOT the security boundary -- it just
# stops us from handing a PDF to the PNG path and pins the ImageMagick coder
# so the file's own header can't pick a delegate for us.
ALLOWED = {
    b"\x89PNG\r\n\x1a\n": "png",
    b"\xff\xd8\xff": "jpeg",
    b"GIF8": "gif",
    b"RIFF": "webp",
}


def sniff(data: bytes) -> str:
    for magic, coder in ALLOWED.items():
        if data.startswith(magic):
            return coder
    raise ValueError("unsupported upload type")


def thumbnail(upload: bytes) -> bytes:
    if len(upload) > MAX_UPLOAD:
        raise ValueError("upload too large")
    coder = sniff(upload)

    # One VM per upload. Nothing is reused, so nothing leaks between users.
    with Sandbox.create(template="base", ttl_seconds=300) as sbx:
        sbx.filesystem.write("/work/in.bin", upload)

        cmd = (
            "cd /work && "
            "ulimit -v 1048576 && "   # ~1 GiB address space
            "ulimit -t 25 && "        # 25s of CPU, then SIGKILL from the kernel
            "ulimit -f 262144 && "    # ~256 MiB max output file
            "timeout -s KILL 30 magick "
            "-limit memory 256MiB -limit map 512MiB -limit disk 1GiB "
            "-limit area 64MP -limit time 20 -limit thread 2 "
            f"{coder}:in.bin -strip -thumbnail 512x512 png:out.png"
        )
        r = sbx.exec(cmd, timeout_seconds=60)

        if r.exit_code != 0:
            # A hostile file looks exactly like a corrupt one from out here.
            # Reject the upload; do not retry it on a bigger machine.
            raise ValueError(f"conversion failed ({r.exit_code}): {r.stderr[-2000:]}")

        return sbx.filesystem.read("/work/out.png")  # bytes
    # VM destroyed on block exit, along with anything the file left behind.

Two details in there carry more weight than they look like they do. First, `{coder}:in.bin` explicitly pins the ImageMagick coder instead of letting the file's own contents choose one — coder selection by content sniffing is precisely the path by which "a PNG" becomes a Ghostscript invocation. Second, `-strip` throws away metadata, which is both a privacy win (EXIF GPS coordinates in a user avatar are a data-protection incident waiting for a journalist) and a way to avoid re-emitting attacker-controlled metadata blobs to your other consumers.

Actually limiting the converters

The VM boundary handles exploitation. Limits handle the bomb class. You want both belts and both braces, because the microVM's RAM ceiling turns a memory bomb into a dead VM rather than a dead node — but a dead VM is still a failed job and a wasted 30 seconds, and the tool-level limits fail faster and more cheaply.

# ---------------------------------------------------------------
# 0. Take away the network first. The converter needs bytes, not a
#    socket. Exact rules depend on how your platform reaches the
#    guest -- keep the control/exec channel open, drop the rest.
#    (This is the in-guest belt; enforce it at the network layer
#    too, because a compromised guest can undo its own firewall.)
# ---------------------------------------------------------------
nft add table inet fw
nft add chain inet fw out '{ type filter hook output priority 0; policy drop; }'
# ...then re-allow only the /30 peer your control plane talks over.

# ---------------------------------------------------------------
# 1. Images -- ImageMagick 7 (`magick`; it's `convert` on IM6).
#    -limit area is the pixel-bomb defence: a 30000x30000 "image"
#    is 900MP and gets refused before anything is allocated.
# ---------------------------------------------------------------
magick -limit memory 256MiB -limit map 512MiB -limit disk 1GiB \
       -limit area 64MP -limit time 20 -limit thread 2 \
       png:/work/in.bin -strip -thumbnail 512x512 png:/work/out.png

# Belt and braces: kill the dangerous coders and delegates outright.
# Path varies by distro/version -- check `magick -list policy`.
cat >/etc/ImageMagick-7/policy.xml <<'XML'
<policymap>
  <policy domain="delegate" rights="none" pattern="*"/>
  <policy domain="coder" rights="none" pattern="{PS,PS2,PS3,EPS,PDF,XPS,MSL,MVG,SVG,MSVG,TEXT,LABEL,URL,HTTP,HTTPS}"/>
  <policy domain="resource" name="memory" value="256MiB"/>
  <policy domain="resource" name="area" value="64MP"/>
  <policy domain="resource" name="time" value="20"/>
</policymap>
XML

# ---------------------------------------------------------------
# 2. Video -- ffmpeg. Pin the input format, close the protocol list
#    (this is what stops a crafted playlist reading local files or
#    calling out), and cap CPU time, duration, and output size.
# ---------------------------------------------------------------
ffmpeg -nostdin -hide_banner -loglevel error -timelimit 120 \
  -protocol_whitelist file -f mp4 -i /work/in.bin \
  -threads 2 -t 600 -fs 200M \
  -vf scale=1280:-2 -c:v libx264 -preset veryfast -crf 26 -c:a aac \
  -y /work/out.mp4

# ---------------------------------------------------------------
# 3. Documents -- LibreOffice headless. Give it a throwaway profile
#    and HOME, and a hard timeout; soffice is not shy about hanging.
# ---------------------------------------------------------------
export HOME=/work
timeout -s KILL 120 soffice --headless --norestore --nolockcheck \
  -env:UserInstallation=file:///work/loprofile \
  --convert-to pdf --outdir /work/out /work/in.docx
`-protocol_whitelist file` and a pinned `-f` are not optional decoration. ffmpeg's willingness to follow references inside container formats and playlists is a documented way to turn "transcode my video" into "read a file off your disk" or "make a request from inside your VPC." The microVM makes both of those far less interesting, since there is no interesting file and no interesting network — but close the door anyway.

Two more rules that live above the converter. Re-encode, never pass through: if a user uploads a PNG and you store their exact bytes and serve them back, you've made your CDN a malware distribution channel and every downstream client a victim of the same decoder bugs. Emit a freshly encoded artifact from your own pipeline. And be extremely careful about serving user-supplied SVG or HTML from your own origin — that's a stored-XSS problem no sandbox can fix, because the code runs in your users' browsers, not in your VM.

Keeping the latency sane without a warm pool

The historical objection to "a VM per request" is that VMs take seconds to boot, so everyone built warm pools: a fleet of idle machines kept alive to absorb bursts, which you pay for around the clock and which quietly reintroduce the reuse problem you were trying to avoid. If a pooled worker is recycled between two users' files, the isolation boundary now depends on your cleanup code being perfect, and cleanup code is never perfect.

Snapshot-restore removes the trade. Instead of booting a machine, you restore a snapshot of a machine that already booted: memory is mapped copy-on-write and paged in lazily, and the rootfs is a copy-on-write clone. On PandaStack every create takes that path — p50 179ms, p99 around 203ms, with the restore itself around 49ms. Only the first spawn on a template pays the ~3 second cold boot, after which the baked snapshot is what gets restored. At that point "fresh VM per upload" costs about as much as a couple of cross-region API calls, and there is no pool to pay for, drain, or accidentally reuse.

For an upload pipeline this puts the VM comfortably below the noise floor. Nobody is thumbnailing a 12 MB photograph in 180ms; the ImageMagick call itself, plus fetching the object from storage, dominates. The interesting design question isn't "is the VM fast enough" but "what is one unit of work." Which brings us to the thing people get wrong.

One VM per upload, not per file

The unit should be one trust domain, not one command. A 40-page PDF from one customer is one upload and one trust domain, so render all 40 pages in one VM. Two different customers' files must never share a VM, no matter how convenient the batching would be. That distinction keeps throughput reasonable without weakening anything — and it's also where the explicit lifecycle API beats the context manager, since you want a `try/finally` around a loop that can fail halfway.

from pandastack import Sandbox

def render_pdf_pages(pdf: bytes, max_pages: int = 50) -> list[bytes]:
    """One upload = one trust domain = one VM. All pages, then destroy it."""
    sbx = Sandbox.create(
        template="base",
        ttl_seconds=600,                      # backstop if this process dies
        metadata={"job": "pdf-preview"},      # no user data, no credentials
    )
    try:
        sbx.filesystem.write("/work/in.pdf", pdf)

        # pdftoppm (poppler) rather than ImageMagick -> Ghostscript.
        # -l caps pages so a 90,000-page PDF is a rejection, not an outage.
        render = sbx.exec(
            "cd /work && ulimit -v 2097152 && ulimit -t 90 && "
            f"timeout -s KILL 120 pdftoppm -png -r 96 -l {max_pages} "
            "in.pdf page",
            timeout_seconds=150,
        )
        if render.exit_code != 0:
            raise ValueError(f"render failed: {render.stderr[-1000:]}")

        listing = sbx.exec("ls /work/page-*.png 2>/dev/null | sort", timeout_seconds=15)
        paths = [p for p in listing.stdout.split() if p]

        # Pull artifacts out as bytes. Nothing else crosses the boundary.
        return [sbx.filesystem.read(p) for p in paths]
    finally:
        sbx.kill()   # unconditional. The VM is evidence, not infrastructure.

One operational note if you build this on PandaStack specifically: guest RAM and vCPU are properties of the baked template snapshot, not of the create call, because Firecracker cannot resize a guest at restore time. So "give the transcoder more memory" means baking a template with more memory and pointing the heavy jobs at it, rather than passing a bigger number at runtime. Capacity-wise, the per-host addressing ceiling is 16,384 pre-allocated /30 subnets; long before that, the binding constraint is host RAM.

When this is overkill

I'd rather you skip this than cargo-cult it, so here's the honest list of cases where a per-upload microVM buys you very little.

  • The files aren't attacker-controlled. An internal ETL job converting files your own systems generated is not the same risk as a public upload form. Trust the pipeline you built.
  • You can use a memory-safe decoder for a narrow format and reject everything else. If you only accept JPEG and PNG at modest sizes and decode them with a memory-safe implementation in Rust or Go, you've eliminated most of the bug class at the source. That's a better fix than isolating a C parser — right up until product asks for HEIC, PDF, and DOCX support next quarter, at which point you're back here.
  • You already have per-request hardware-isolated execution. If your converter runs in a serverless function backed by a per-request microVM, or in gVisor, or in a per-tenant VM, you have most of this property already. Add limits and drop egress; don't add a second sandbox.
  • The threat is not the parser. Path traversal in your storage keys, an SSRF in your own URL fetcher, stored XSS from serving user SVG off your origin, and missing authorization on the download endpoint are all real upload bugs that a sandbox does absolutely nothing about. Fix those first — they're more common and cheaper to exploit.
  • The economics don't work. Sub-second create makes this viable for most workloads, but if you process an enormous volume of tiny files where each is worth a fraction of a cent, per-file isolation overhead may not pay. Batch by trust domain, or accept the risk explicitly and write it down.

What I'd push back on is the middle position that most teams actually occupy without having chosen it: a long-lived worker with production credentials, calling a C parser on public uploads, protected by a dependency bot and a hope. That configuration is not a decision, it's a default. If you decide to keep it, at least set the converter limits and take away the credentials — most of the value in this post is available for an afternoon's work and no new infrastructure.

And if you do move the parsers out, keep the discipline that makes the boundary meaningful: bytes in, bytes out, no credentials, no egress, hard limits, one VM per trust domain, destroyed unconditionally. The whole point is that when a decoder bug does land — and it will, because it always does — the attacker's prize is a machine containing one file they already had, with a TTL measured in minutes.

Frequently asked questions

Is it actually dangerous to run ImageMagick on user uploads?

Yes, and it has been demonstrated repeatedly. ImageMagick is a large C codebase that parses dozens of formats and historically delegated some of them to external programs, which is how the 2016 ImageTragick issue turned image uploads into command execution on many sites. Even setting delegates aside, image and font decoders across the ecosystem — libwebp, libtiff, FreeType and others — have a long record of memory-safety findings. The practical mitigations are to pin the input coder explicitly rather than letting the file choose, lock down policy.xml to disable dangerous coders and delegates, set every resource limit, and run the whole thing somewhere disposable. Check the vendor advisories for the exact versions you deploy rather than relying on any blog's summary.

How do I sandbox ffmpeg when transcoding user-uploaded video?

Run it in a disposable environment that holds no credentials and has no outbound network, and pass it bytes rather than URLs. On the ffmpeg command line, pin the input format with -f, restrict protocols with -protocol_whitelist file so crafted containers and playlists cannot read local files or make network requests, and cap the job with -timelimit for CPU time, -t for output duration, and -fs for output size. Add an external wall-clock timeout and address-space rlimits, because a transcode that hangs is a much more common failure than one that gets exploited. With the PandaStack Python SDK the pattern is Sandbox.create, filesystem.write the upload, exec the ffmpeg command with timeout_seconds, filesystem.read the result, and kill the sandbox.

Isn't a container enough to isolate file conversion?

A container is meaningfully better than an in-process library call, but it shares the host kernel with everything else on the node, so a kernel bug reached through a syscall is an escape rather than a crash. Containers also tend to arrive with a mounted service account token, reachable internal networking, and a cloud metadata endpoint one request away — all of which are exactly what a confused-deputy attack wants. A microVM adds a separate guest kernel and a hypervisor boundary, which is a much larger obstacle. If you keep the container, at minimum drop all capabilities, run as a non-root user with a read-only filesystem, remove every credential from the environment, block egress, and set cgroup memory and CPU limits.

How do I stop decompression bombs and huge images from taking down my workers?

Treat resource exhaustion as a separate control from exploitation, because patching never fixes it — the library is doing exactly what the file asked. Enforce a maximum upload size before you spend any compute, then cap the decoder itself: ImageMagick's -limit area rejects a 30,000 by 30,000 pixel image before allocating, and -limit memory, -limit map, -limit disk, and -limit time cover the rest. Wrap the process with a wall-clock timeout plus rlimits for CPU time, address space, and maximum output file size. Finally, run it inside a VM with a fixed RAM ceiling so the worst realistic outcome is one sandbox dying with a non-zero exit code instead of node-level memory pressure.

Doesn't creating a VM per upload add too much latency?

Not if the platform restores a snapshot instead of booting. Cold-booting a VM per request would indeed be too slow, which is why the traditional workaround was a warm pool — and warm pools reintroduce reuse between tenants, which is the thing you were isolating against. On PandaStack every create restores a baked Firecracker snapshot: p50 179ms, p99 around 203ms, with the restore step itself about 49ms; only the very first spawn on a template pays a roughly 3 second cold boot. For upload processing that is well below the cost of fetching the object from storage and running the converter, so the sandbox is not the bottleneck.

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.