Decoding in a box¶
The check removes the files a decoder should never see: the wrong format, the lying name, the size that would exhaust memory, the codings a scanner never writes, the PDF features a book never has. What it cannot remove is a flaw in the reader you do allow, reached by a file of a sensible size in a format you meant to read. The README says so under what it does not do. This page is the recipe for the next line of defence: run the decoder where a flaw costs one file and not the process that opened it.
Written for Linux, where the operating system will hold a process to a memory allowance. macOS will not, and the page says what that leaves. Nothing here is a dependency of sillybos; it is a pattern, in two short files, that we ran before writing it up.
The shape¶
Two processes. The parent checks the file with sillybos, then starts a worker in a fresh Python with no environment, a scratch directory as its working directory, nothing on its standard input and a wall-clock limit. The worker's first act is to set limits on itself: how much memory it may address, how much processor time it may use, how large a file it may write, and no core dump. Then it opens the file with only the allowed readers, decodes it, and writes the pixels out as a PNG of its own making.
Whatever happens in the worker, the parent gets one of two things: the PNG, or a sentence saying why not. A decoder that reads past a buffer, or allocates until the machine swaps, or loops, does so inside an allowance and dies alone.
The worker¶
"""The worker: set the limits on itself, then open, decode and write. Nothing else.
Run only by boxed.py. The limits are set here, in the child, rather than
with `preexec_fn` in the parent, which is unsafe in a program with threads.
On Linux `RLIMIT_AS` bounds what the process may address, so an allocation
beyond it fails instead of growing; macOS does not enforce it, and there
the wall-clock limit is the one that holds.
"""
from __future__ import annotations
import argparse
import resource
import sys
def limit(kind: int, value: int) -> None:
soft, hard = resource.getrlimit(kind)
if hard != resource.RLIM_INFINITY:
value = min(value, hard)
resource.setrlimit(kind, (value, value))
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("source")
parser.add_argument("destination")
parser.add_argument("--memory", type=int, required=True)
parser.add_argument("--cpu-seconds", type=int, required=True)
parser.add_argument("--max-output", type=int, required=True)
parser.add_argument("--formats", required=True)
args = parser.parse_args()
limit(resource.RLIMIT_AS, args.memory)
limit(resource.RLIMIT_CPU, args.cpu_seconds)
limit(resource.RLIMIT_FSIZE, args.max_output)
limit(resource.RLIMIT_CORE, 0)
from PIL import Image
Image.MAX_IMAGE_PIXELS = None # the check already judged the size; the box is what holds it
with Image.open(args.source, formats=args.formats.split(",")) as image:
image.load() # the decode: this is the line the box exists for
image.save(args.destination, format="PNG")
return 0
if __name__ == "__main__":
sys.exit(main())
The limits are set in the child, not with preexec_fn in the parent,
which is unsafe in any program with threads. RLIMIT_AS is what makes the
box a box on Linux: an allocation beyond it fails, so a decoder that wants
more than the allowance gets a MemoryError or a kill instead of the
machine's memory. RLIMIT_FSIZE stops a worker writing an output the size
of a disk; RLIMIT_CPU is a second clock in case the parent's is
defeated. Image.MAX_IMAGE_PIXELS is lifted because the check has already
judged the size against your policy; inside the box, the allowance is what
holds.
The parent¶
"""Decode an image in a boxed subprocess, after the check, so a flaw in the decoder costs one file.
The parent checks the file with sillybos and then runs the worker below in
a fresh process with the memory, CPU and output limits set inside it,
a wall-clock limit from outside, no environment, a scratch directory as
its working directory and nothing on standard input. The worker opens the
file with only the allowed readers, decodes it, and writes the pixels out
as a PNG of its own making. Whatever happens in the worker, the parent
gets either that PNG or a sentence saying why not.
"""
from __future__ import annotations
import os
import signal
import subprocess
import sys
import tempfile
from pathlib import Path
from sillybos import DEFAULT, Policy, Refused, require
from sillybos.pillow import pillow_formats
WORKER = Path(__file__).with_name("boxed_worker.py")
BASELINE_BYTES = 256 * 2**20 # the interpreter, Pillow and its codecs, with room
BYTES_PER_PIXEL = 8 # the decoded image and one working copy, four channels each
SECONDS_PER_MEGAPIXEL = 1.0
class DecodeRefused(Exception):
"""The box refused the file: the limit it hit and, where there is one, the decoder's own words."""
def memory_limit(policy: Policy) -> int:
"""What the worker may address, from the policy's pixel ceiling. Measure yours and adjust."""
return BASELINE_BYTES + BYTES_PER_PIXEL * policy.max_pixels
def decode_in_box(
path: Path, out: Path, policy: Policy = DEFAULT, *, memory: int | None = None
) -> Path:
"""Check `path`, then decode it in a limited subprocess, writing a PNG to `out`.
`memory` is the worker's address-space limit in bytes; by default it is
sized from the policy with `memory_limit`.
"""
verdict = require(path, policy) # Refused, with the reasons, before anything decodes
assert verdict.facts is not None
wall_seconds = (
5 + SECONDS_PER_MEGAPIXEL * verdict.facts.max_pixels / 1_000_000
if verdict.facts.max_pixels
else 30
)
with tempfile.TemporaryDirectory() as scratch:
try:
done = subprocess.run(
[
sys.executable,
"-I",
str(WORKER),
str(path.resolve()),
str(out.resolve()),
"--memory",
str(memory if memory is not None else memory_limit(policy)),
"--cpu-seconds",
str(int(wall_seconds) + 1),
"--max-output",
str(64 * 2**20),
"--formats",
",".join(pillow_formats(policy)),
],
cwd=scratch,
env={"PATH": os.defpath},
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
errors="replace",
timeout=wall_seconds,
check=False,
)
except subprocess.TimeoutExpired:
raise DecodeRefused(
f"the decoder did not finish within {wall_seconds:.0f} seconds"
) from None
if done.returncode == 0:
return out
if done.returncode < 0:
name = signal.Signals(-done.returncode).name
why = {
"SIGKILL": "was killed, which is what running out of memory looks like",
"SIGXCPU": "used more processor time than it was allowed",
"SIGXFSZ": "tried to write more output than it was allowed",
"SIGSEGV": "crashed",
}.get(name, f"ended with {name}")
raise DecodeRefused(f"the decoder {why}")
last = (done.stderr.strip().splitlines() or ["no message"])[-1]
raise DecodeRefused(f"the decoder failed: {last}")
Three things to notice. The check runs first, so the box only ever sees files that passed; its job is the flaws the check cannot know about. The allowance is sized from the policy: a baseline for the interpreter and its libraries, plus so many bytes a pixel for the decoded image and a working copy. Measure yours with a few real files and adjust the two constants; they are starting points, not a specification. And a dead worker is turned into a sentence: the signal that ended it says what happened, and a Python exception's last line is passed through.
What it looks like¶
We ran the two files on Linux (a python:3.12-slim container with Pillow
and sillybos installed) against a normal scan and a small PNG that decodes
to 12,000 by 12,000 pixels in RGB: 445 KB on disk, 432 MB decoded.
1. a normal scan: /tmp/box/page.png (1200, 1600)
2. the bomb under the default policy: refused before decoding:
('a page of 144,000,000 pixels; the limit is 89,478,485',)
3. the bomb under a policy that allows it, so only the box stands in the
way (the policy's own allowance would be 1782 MiB; the box is given
512 MiB): refused by the box: the decoder failed: MemoryError after 0.1s
4. a normal scan under the same 512 MiB box: page2.png
The second line is the check doing its job. The third is the box doing its own: the policy was loosened to let the file through, as a flaw in a decoder effectively does, and the worker failed in a tenth of a second inside its allowance, with the parent, and the machine, untouched. The fourth shows the same box is no obstacle to a real scan.
Going further on Linux¶
The two files use what Python's standard library offers. The system has more, and each is one line in the parent's command:
systemd-runputs the worker in its own control group, whereMemoryMax=is enforced against everything the process and its children allocate, not only the address space, andCPUQuota=bounds the processor:systemd-run --user --scope -p MemoryMax=512M -p CPUQuota=100% python boxed_worker.py ….bwrap(bubblewrap) gives the worker a filesystem with only what it needs and no network:bwrap --unshare-all --ro-bind /usr /usr --ro-bind /lib /lib --bind "$scratch" /work --chdir /work python …. A decoder that is made to run a command finds nothing to run it on.- A container does both, at the cost of starting one per file or
keeping one running. If the pipeline is already in a container, the
worker can be too, with
--memory,--cpus,--network noneand--read-only.
Pick the first that fits where the pipeline runs. All of them keep the same two-process shape and the same parent; only the command changes.
What macOS leaves you¶
RLIMIT_AS is accepted and ignored on macOS, and RLIMIT_CPU is honoured
only loosely. What remains is the wall-clock limit, the output limit, the
scrubbed environment and the separate process, which still means a crash
is one file's failure rather than the pipeline's. A pipeline that runs on
a laptop for development and on Linux in production gets the full box
where it matters. If the files are from outside and the machine is a Mac,
run the worker in a Linux container.
What the box is not¶
It is not a sandbox against a determined attacker: a process with the
same user can still read what that user can read, and RLIMIT_AS is a
memory allowance, not a security boundary. For that, the tools above
(bwrap, a container, or a dedicated sandbox such as firejail or a
virtual machine) are the ones to reach for. And it is no reason to skip
updating the decoders: the first line of every entry in the security
watch is the fixed version.