all posts

Sandbox Your Agent's Computer-Vision Pipeline in MicroVMs

Ajay Kumar··8 min read

Give an AI agent a camera and it will happily point it at anything. Ingest a user-uploaded image, extract text from a scanned PDF, pull frames from a video, run object detection, generate a thumbnail. Underneath every one of those verbs is a native decoder: OpenCV, ffmpeg, ImageMagick, Pillow, libjpeg, libwebp, Tesseract. Those libraries are decades of C and C++ written to parse hostile file formats fast, and "parse a hostile file format fast" is the exact sentence under which most of the industry's memory-corruption bugs were born.

The agent doesn't know or care that decoding an image is a security-sensitive operation. It just calls `cv2.imread(user_file)` in the same Python process that holds your API keys, and if that file is a crafted WebP or a malicious PNG chunk, the heap overflow inside the decoder is now running with your agent's full trust. The image isn't the payload; the parser is the vulnerability, and the attacker gets to choose the input.

I'm Ajay; I built PandaStack. This post is about treating every decode of an attacker-controlled file as untrusted execution and running it where it can't hurt you: a throwaway Firecracker microVM, one per job, that holds none of your secrets and that you destroy when the frame is processed.

The decoder is the attack surface, not the image

Image and video libraries have a long, specific, well-documented history of remote code execution through malformed inputs. You don't need to invent a threat model; you need to read the CVE feeds you already ignore.

  • ImageMagick's "ImageTragick" — delegate handling let a crafted image run shell commands during conversion. A file named like an image, processed by a normal thumbnail pipeline, executed attacker code.
  • libwebp — a heap buffer overflow in WebP decoding was widely exploited in the wild through image rendering. Anything that decodes WebP inherited it, which in practice was most of the software you use.
  • Malicious metadata — EXIF, ICC profiles, and embedded thumbnails are their own parsers. "Read the image's dimensions" can trip a bug in code you didn't know ran.
  • Video containers and codecs — ffmpeg parses an enormous surface of container and codec formats; malformed streams have produced out-of-bounds reads and writes for years.
  • Decompression bombs — a tiny file that decodes to gigabytes (a 10KB PNG that expands to 40,000 x 40,000 pixels) is a denial-of-service with no exploit at all. Pillow ships a `DecompressionBombError` because this is a routine, expected attack.
The common defense — "validate the file extension and magic bytes first" — is theater. The decoder that overflows is the same code that would parse the header you're checking. You cannot safely inspect an attacker-controlled image with the library that has the bug. The only durable move is to decode it somewhere you're willing to lose.

And a native heap overflow is worse than a Python exception. A memory-corruption bug in a C decoder can hand an attacker code execution in the process, and from there the next target is the host kernel. Which brings us to the container objection.

Why a container isn't the boundary

The reflex is to run the vision worker in a container and call it isolated. But a container shares the host kernel — it's a namespaced, cgroup-limited view of the same kernel your host runs on. A container is a polite suggestion to the kernel about what a process should see, and a native decoder that has been driven into arbitrary code execution is no longer in a mood to be polite. From code execution inside the container, the attacker's next move is a kernel bug, and a kernel bug reaches the host and every neighbor sharing it.

A Firecracker microVM changes the target. The guest has its own kernel behind hardware virtualization (KVM). An overflow in ffmpeg that pivots to a kernel exploit exploits the guest's kernel — a kernel that boots, does one image job, and is destroyed. There is no shared host kernel on the other side of the boundary to reach. This is the isolation model AWS Lambda uses to run untrusted code from strangers, and it's the right one for "decode a file I did not create."

The model: one microVM per vision job

The pattern is the same one that makes any untrusted-input pipeline safe: keep the trusted part on your host and push the dangerous decode into a disposable guest. The agent — the part with your credentials and your storage tokens — stays on a trusted machine. For each incoming file, create a microVM, write the file into the guest with `filesystem.write`, run the decode-and-process step inside the guest, read back only the sanitized result (extracted text, a re-encoded thumbnail, detection boxes as JSON), and destroy the VM.

This is only practical because the isolation is cheap. A cold Firecracker boot is a couple of seconds, but PandaStack creates sandboxes by restoring a baked snapshot on demand — the restore step is around 49ms, and end-to-end create is p50 179ms (p99 ~203ms). A hardware-isolated machine per image costs about a fifth of a second. With 16,384 pre-allocated network slots per agent, fanning out a batch of images across many guests at once is bounded by CPU and memory, not slots.

from pandastack import Sandbox

def process_image(image_bytes: bytes) -> dict:
    # Fresh, hardware-isolated guest for THIS file. ~179ms p50 to create.
    sbx = Sandbox.create(
        template="code-interpreter",
        ttl_seconds=60,          # decompression bomb / hung decode -> reclaimed
    )
    try:
        # The untrusted image goes INTO the guest. Your host never decodes it.
        sbx.filesystem.write("/work/upload.bin", image_bytes)

        # Decode + process inside the guest. If libwebp overflows here, it
        # overflows a disposable VM's own kernel, not your host.
        out = sbx.exec(
            "cd /work && python3 vision.py upload.bin",   # OpenCV / Pillow / OCR
            timeout_seconds=45,
        )
        if out.exit_code != 0:
            return {"ok": False, "reason": "decode failed or was killed"}

        # Read back ONLY the sanitized result -- text/boxes/thumbnail bytes.
        thumb = sbx.filesystem.read("/work/thumb.jpg")
        return {"ok": True, "text": out.stdout, "thumb": thumb}
    finally:
        sbx.delete()   # the whole blast radius goes with the VM

Two details make this robust in practice. First, `ttl_seconds` is your decompression-bomb defense of last resort: a file that decodes into gigabytes or a codec loop that never returns hits a wall — the guest is reclaimed, and no host resource was ever at risk. Second, you re-encode on the way out. The thumbnail you return is a fresh JPEG the guest produced, not the attacker's bytes passed through; sanitization is a side effect of decode-then-re-encode inside the sandbox.

For a batch — say, 500 uploaded images to thumbnail — you don't serialize them through one guest. Create many microVMs and process in parallel; each file gets its own kernel, its own memory budget, and its own ttl. One poisoned image can't corrupt the run of the other 499 because they never shared an address space.

In-process vs container worker vs microVM-per-job

  • Isolation strength — In-process library call: none; a decoder overflow runs with your agent's full trust and reaches your keys directly. Container worker: shares the host kernel, so code execution inside pivots to a kernel bug that reaches the host. MicroVM per job: own guest kernel behind hardware virtualization — an overflow stays in a disposable VM.
  • Decompression bombs — In-process: a bomb OOMs or hangs your agent process. Container: cgroup memory limits help but a shared kernel is still shared. MicroVM: fixed guest RAM plus ttl_seconds; the bomb saturates one guest that then gets reclaimed.
  • Output sanitization — In-process: you're tempted to pass the attacker's bytes through. Container: same. MicroVM: decode-and-re-encode inside the guest, return fresh bytes — sanitization comes for free.
  • Cleanup — In-process: leaked native buffers and mapped files persist in your long-lived process. Container: docker rm, if nothing escaped. MicroVM: the guest is destroyed on delete/ttl — no next job for a compromise to persist into.

The agent's vision code doesn't get dumber for running in a guest — OpenCV is still OpenCV, Tesseract still reads the text. What changes is that the one operation you cannot make safe by inspection — decoding a file a stranger chose — happens in a machine you were always going to throw away. At ~179ms per create, that safety is close to free, and it's a lot cheaper than explaining to a customer why their upload read another customer's data.

For the document side of the same problem, /blog/microvm-ai-agent-pdf-document-processing covers PDF parsers (another rich RCE surface). The general model is in /blog/how-to-sandbox-untrusted-code, and if you're still reaching for a container here, /blog/why-docker-is-not-a-sandbox is the argument in full.

Frequently asked questions

Why isn't validating the file type before decoding enough?

Because the code that overflows is the same code that parses the header you'd validate. Reading an image's magic bytes, dimensions, or EXIF means running the decoder's parsing logic — the exact surface where the memory-corruption bugs live. You cannot safely inspect an attacker-controlled image with the library that has the vulnerability. The durable defense is to decode it inside a disposable microVM you're willing to lose, not to pre-screen it on your trusted host.

Isn't a container enough to isolate image and video processing?

A container shares the host kernel, so a native heap overflow in a decoder that reaches code execution can pivot to a kernel vulnerability and escape to the host and every neighbor. Image and video libraries (ImageMagick, libwebp, ffmpeg) have a long history of exactly these memory-corruption bugs. A Firecracker microVM runs its own guest kernel behind hardware virtualization, so an overflow is contained to one disposable VM — the isolation model used to run untrusted code from many tenants.

How do microVMs protect against decompression bombs?

A decompression bomb is a tiny file that decodes to gigabytes, aiming to exhaust memory. In a microVM each guest has a fixed RAM budget, so the bomb saturates only its own guest, and a `ttl_seconds` cap means a hung or ballooning decode is killed and the guest reclaimed. No host memory is ever at risk. Pair that with a hard timeout on the exec call and a bomb becomes a failed job rather than an outage.

Does putting the pipeline in a microVM slow down image processing?

The decode itself runs at native speed inside the guest — OpenCV, ffmpeg, and Tesseract are unchanged. The only added cost is creating the sandbox, which on PandaStack is p50 179ms (p99 ~203ms) because every create restores a baked snapshot on demand rather than cold-booting. For batches you process files in parallel across many guests (16,384 network slots per agent), so throughput is bounded by CPU and memory, not by the isolation.

How do I safely return a processed image from the sandbox?

Re-encode inside the guest and return the fresh output, not the attacker's original bytes. Decoding and then re-encoding an image inside the sandbox produces a clean file whose structure your own code wrote — malicious chunks, embedded payloads, and hostile metadata don't survive the round trip. Read back only the sanitized result (the re-encoded thumbnail, extracted text as a string, or detections as JSON) and destroy the guest.

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.