What happens when your PHP application processes an untrusted PDF with an external binary?
Most of us never think about it. A user uploads a file, a controller stores it, a queued job runs a command-line tool on it, and the result goes back to the user.
The tool might be a PDF engine, an image converter, an office-document converter, an OCR utility, or another document-processing binary. The code is often only a few lines long. It works, so it goes to production.
This article is about the part that is easy to overlook: the security boundary around that one command.
It applies whether or not you ever use the library I describe at the end.
1. The innocent-looking command
Here is the shape of code that exists in a lot of applications:
$path = storage_path('app/uploads/' . $upload->stored_name);
$output = shell_exec(
'pdftotext ' . escapeshellarg($path) . ' -'
);
Or perhaps you use Symfony Process:
use Symfony\Component\Process\Process;
$process = new Process([
'/usr/local/bin/some-pdf-tool',
'input.pdf',
]);
$process->run();
The second version is already better from a command-injection perspective because the arguments are separated rather than concatenated into a shell command.
But there is another question:
What can that child process see if something goes wrong inside it?
That question matters because document parsers are complicated pieces of software processing files that an attacker may completely control.
A PDF can contain compressed streams, fonts, images, object graphs, metadata, embedded files, malformed structures, unusual encodings and many other things.
The parser has to deal with all of them.
2. The hidden security boundary
Imagine your Laravel queue worker runs as:
www-data
Then it starts a PDF processor under the same Unix identity.
Unless you deliberately restrict it, that child process may inherit access to whatever www-data can access.
Depending on the server, that may include:
- application source code
- configuration files
- environment variables
- credentials available to the process
- other files owned by the same account
- other job directories
- network access
- writable temporary directories
- CPU, file descriptors and disk resources
The PDF processor probably does not need any of that.
It may need only:
/work/input.pdf
and somewhere to write:
/work/output.pdf
That difference became the design question for me:
Can the document-processing process see only the files required for one job, instead of everything the application account can reach?
That is the problem I wanted to solve.
3. Why input validation alone is not enough
Input validation still matters.
You should validate things such as:
- file type
- extension
- MIME type
- file size
- page count where appropriate
- command options
- filenames and paths
But validation and sandboxing solve different problems.
Validation asks:
Should I accept this input?
Sandboxing asks:
If the processor is compromised anyway, what can it reach?
A perfectly valid PDF can still trigger a bug in a parser.
Likewise, a newly discovered vulnerability may affect a file that passes every validation rule you currently have.
So I treat the sandbox as defence-in-depth, not as a substitute for:
- validation
- patching
- least-privilege Unix accounts
- sensible server configuration
- process timeouts
- monitoring
The goal is not to prove that a document processor is safe.
The goal is to reduce the attack surface and limit the blast radius if it is not.
4. Building a smaller execution boundary
For Linux, I chose Bubblewrap.
Bubblewrap is a small sandboxing tool that uses Linux namespaces and mount controls to construct a restricted environment for a process.
The model I wanted was simple:
PHP / Laravel application
|
| starts job
v
Secure Runner
|
| creates Bubblewrap sandbox
v
Document-processing binary
|
+-- sees /work
+-- sees its executable
+-- no inherited environment
+-- no network by default
+-- limited resources
The PHP application itself is not inside the sandbox.
Only the spawned document-processing process is.
That distinction is important.
One workspace per job
Each processing job gets a private directory.
Inside the sandbox it is mounted as:
/work
The processor does not need the application's source tree.
It does not need the parent jobs directory.
It does not need neighbouring users' files.
It only gets the workspace for that job.
No network by default
The sandbox uses separate namespaces, and network access is disabled by default.
For most PDF operations there is no legitimate reason for the binary processing a local file to connect to the Internet.
If network access is actually required, it should be an explicit decision rather than an accidental inheritance.
Clear the environment
A child process normally inherits environment variables from its parent.
That may include information the document processor has no reason to know.
The sandbox therefore starts with a cleared environment and only passes explicitly allowed variables.
This changes the model from:
inherit everything except what we remember to remove
to:
inherit nothing except what we deliberately add.
No shell
Executable and arguments are passed as an argv array through proc_open().
Conceptually:
executable
argument 1
argument 2
argument 3
rather than:
"executable argument1 argument2 argument3"
There is no shell command string to interpret.
This does not replace input validation, but it removes an unnecessary command-parsing layer.
Time and resource limits
A hostile document does not need remote code execution to cause trouble.
It might simply make a parser consume resources.
The runner therefore supports controls including:
- wall-clock timeout
- CPU limit through
prlimit - file-size limit
- open-file limit
- output-size cap
- disabled core dumps
If the process exceeds its deadline, the sandbox and its descendants are terminated.
This is useful for accidental runaway processing as well as deliberately hostile input.
Fail closed
One requirement mattered more than convenience:
If Bubblewrap is unavailable, do not quietly run the command without it.
The runner throws an exception instead.
That means a deployment problem becomes a failed processing job, rather than an invisible downgrade from sandboxed execution to normal execution.
For security controls, I strongly prefer that behaviour.
5. The sandbox has limits
This part is just as important as the feature list.
The sandbox does not automatically protect the entire PHP application.
It does not sandbox:
- the parent PHP process
- the Laravel queue worker
- unrelated PHP code
- PHP work performed in-process before or after the runner
- the host kernel
- Bubblewrap itself
For example, if you decode a hostile image using an in-process PHP extension before invoking the sandbox, that decoding step is outside this boundary.
The sandbox also cannot protect you from vulnerabilities in the Linux kernel or Bubblewrap that defeat the isolation mechanism itself.
And the output file should still be treated as untrusted.
If a tool creates:
output.pdf
that does not magically mean the file is safe for every other parser that may later open it.
Another current limitation is memory control.
Without using cgroups or another external mechanism, memory is not capped by default.
There is also no seccomp syscall filter applied by default.
This is why I describe the project as a tool that reduces the attack surface, not a universal security boundary.
6. A PHP example
The reusable part of this work became PDF-X Secure Runner.
It is installable through Composer:
composer require pdf-x/secure-runner
A basic example looks like this:
<?php
use PdfX\SecureRunner\{
JobWorkspace,
ResourceLimits,
SandboxConfig,
SecureRunner
};
$workspace = JobWorkspace::create('/var/lib/myapp/jobs');
try {
$workspace->write(
'in.pdf',
$uploadedBytes
);
$runner = new SecureRunner(
SandboxConfig::strict()
);
$result = $runner->run(
executable: '/usr/local/bin/pdfcpu',
arguments: [
'info',
$workspace->sandboxPath('in.pdf'),
],
workspace: $workspace,
limits: new ResourceLimits(
timeoutSeconds: 60
),
);
if ($result->isSuccessful()) {
echo $result->stdout;
}
} finally {
$workspace->cleanup();
}
The application writes the input into the job workspace.
Inside the sandbox, the processor sees that workspace as /work.
So:
$workspace->sandboxPath('in.pdf')
provides the path from the sandbox's point of view.
The binary does not need access to the real host path.
For produced files, the application can resolve outputs back through the workspace rather than blindly trusting arbitrary paths returned by the child process.
The workspace logic also rejects symlinks and paths escaping the job directory.
Static and dynamic binaries
A statically linked binary may need almost nothing except its executable and the job workspace.
A dynamically linked binary needs its runtime libraries.
For that case the configuration can explicitly expose the required system libraries:
$config = SandboxConfig::strict()
->withSystemLibraries();
There is a security trade-off here.
Every additional read-only path you expose becomes visible to the sandboxed process.
So the default should remain as narrow as practical.
Passwords and secrets
Another small but important point: avoid putting secrets in command arguments where possible.
Arguments may be visible in process listings on the host.
If a tool accepts a password or secret over standard input, prefer that mechanism.
The runner supports passing stdin separately from the argv array.
7. Testing the boundary
Sandbox code is easy to write incorrectly.
So I wanted more than unit tests around PHP objects.
The package includes:
vendor/bin/pdfx-sandbox-check
The self-check uses generated canary files to test properties such as:
- Bubblewrap availability
- workspace visibility
- parent-directory isolation
- sibling-job isolation
- environment leakage
- network access
- writes outside the workspace
- symlink escape behaviour
- timeout and process cleanup
It reports PASS or FAIL.
It does not inspect real secrets.
It does not try to prove the system is universally secure.
Its job is narrower:
Does this host appear to enforce the containment behaviour the application expects?
That matters because Linux distributions and hosting environments can differ in their user-namespace policies.
A configuration that works on one server may fail on another.
For that reason, the self-test should be run on every host where the package is deployed.
The project also runs its Linux containment suite in CI with Bubblewrap installed.
On platforms without Bubblewrap, such as my macOS development machine, the sandbox itself cannot run. The library is designed to fail closed in that situation.
8. Why I open-sourced it
This started while I was building PDF-X, an online PDF-processing service.
The original question was specific:
How should I run document-processing binaries against files uploaded by strangers?
But the answer was not really specific to PDF-X.
Any PHP application may eventually need to execute an external tool against un
Top comments (0)