jf0x3f

$ cat writeup.md / 2026.10.03

GrumPHP option injection: from a staged filename to code execution

GHSA-fmpp-rfr9-jgpg · CWE-88 · phpro/grumphp before 2.25.0 · published October 3, 2026

A filename is usually data. It becomes dangerous when one program passes it to another program at a position where the receiving command still accepts options.

That boundary was missing in GrumPHP's PHP CS Fixer task. During a normal pre-commit run, GrumPHP appended repository-controlled staged paths directly after PHP CS Fixer's fix command. A contributor could therefore stage a PHP file named --config=evil.php. PHP CS Fixer interpreted the pathname as another configuration option, loaded evil.php, and executed repository-controlled PHP in the developer's or CI worker's security context.

The issue was published as GHSA-fmpp-rfr9-jgpg. It affects phpro/grumphp versions before 2.25.0 and is fixed in 2.25.0. GitHub lists the advisory as Moderate and maps it to CWE-88: Improper Neutralization of Argument Delimiters in a Command.

Finding at a glance

PropertyValue
Componentphpro/grumphp PHP CS Fixer task
Affected versions< 2.25.0
Patched version2.25.0
Confirmed environmentGrumPHP 2.24.0 at 8abcadb; PHP CS Fixer 3.95.2
WeaknessCWE-88 argument or option injection
PrimitiveRepository-controlled staged filename
ImpactArbitrary PHP execution as the developer or CI worker
Reporterjf0x3a

The trust boundary

GrumPHP obtains the staged paths for the commit and stores them in a GitPreCommitContext. The PHP CS Fixer task then constructs a process argument list, adds its configured options, adds the fix command, and finally appends those paths.

Simplified, the vulnerable construction looked like this:

$arguments->add('--config=trusted.php');
$arguments->add('fix');
$arguments->addFiles($files);

There was no end-of-options delimiter between trusted arguments and untrusted file operands. Given a staged filename of --config=evil.php, the resulting argument sequence was effectively:

php-cs-fixer --config=trusted.php fix --config=evil.php

PHP CS Fixer accepted the later --config value and evaluated that PHP configuration file. The attack did not require changing the project's trusted GrumPHP configuration.

Why this is not shell injection

Symfony Process preserved the argument boundaries correctly. No shell metacharacters, quoting trick, or command substitution was needed. The problem appeared one layer later: PHP CS Fixer parsed a valid, separately delivered argument as an option rather than as a pathname.

This distinction matters during review. Escaping every argument protects a shell boundary, but it does not stop option injection. When a downstream CLI accepts operands that an attacker can influence, the caller must use that utility's supported option terminator or another API that unambiguously separates options from data.

Exploitation conditions

The demonstrated path requires all of the following:

  1. The project uses an affected GrumPHP release and enables the supported phpcsfixer task.
  2. PHP CS Fixer is installed and discoverable.
  3. An untrusted contributor can introduce files that will become staged changes.
  4. The staged set contains --config=evil.php and the referenced evil.php payload.
  5. A developer or automation process runs GrumPHP's pre-commit path over those files.

The option-like filename is conspicuous and exploitation requires the victim or automation to process it, which reduces likelihood. Once triggered, however, the loaded PHP executes with the full privileges and environment of that process.

Safe local proof

The validation used a disposable Git repository and a fixed local marker. It performed no network access and read no secrets. The payload configuration only demonstrated that PHP CS Fixer evaluated the untrusted file:

<?php
file_put_contents(
    __DIR__ . '/POC_MARKER',
    "UNTRUSTED_CONFIG_EXECUTED\n"
);

return (new PhpCsFixer\Config())
    ->setRules(['@PSR12' => true])
    ->setFinder(PhpCsFixer\Finder::create()->in(__DIR__));

A second file supplied the option-looking pathname:

./--config=evil.php

After both files were staged, the normal GrumPHP pre-commit command created the marker:

git add -- .
test ! -e POC_MARKER
php /path/to/grumphp/bin/grumphp git:pre-commit --no-ansi
test "$(sed -n '1p' POC_MARKER)" = "UNTRUSTED_CONFIG_EXECUTED"

Running task 1/1: phpcsfixer... ✔
VULNERABLE: repository-controlled evil.php executed

The decisive result was not a crash or parser error. It was the fixed marker written by the repository-controlled configuration while GrumPHP reported that the supported task ran.

Negative control

The same filename was then passed directly to PHP CS Fixer with -- separating options from operands:

rm -f POC_MARKER
php-cs-fixer \
  --format=json --dry-run --config=trusted.php \
  fix -- '--config=evil.php'

test ! -e POC_MARKER
echo "CONTROL: marker absent"

PHP CS Fixer retained trusted.php as its configuration, treated --config=evil.php as a file, and did not create the marker. This control isolated the missing delimiter as the security-relevant difference.

Impact

A malicious repository contributor can turn a staged filename into code execution when a maintainer or CI job processes the change with the vulnerable task. The code runs in the same security context as GrumPHP and can therefore read source code, uncommitted workspace data, environment variables, and CI credentials available to that process. It can also modify or delete anything that identity can access.

This is especially important in automated review or integration workflows. A repository contribution should be treated as untrusted data even when it changes only filenames. Running developer tooling over that contribution may cross from parsing untrusted repository state into executing code with workstation or runner privileges.

The fix

The direct remediation is to insert PHP CS Fixer's supported end-of-options delimiter immediately before the file list:

$arguments->add('fix');

if ($context instanceof GitPreCommitContext ||
    !$config['config_contains_finder']) {
    $arguments->add('--');
    $arguments->addFiles($files);
}

Users should upgrade to GrumPHP 2.25.0 or later and reinitialize generated hooks if required by their installation. Maintainers should keep a regression test with an option-shaped staged filename such as --config=evil.php.

Broader lessons for CLI integrations

  • Argument arrays are necessary but not sufficient. They prevent shell tokenization bugs, not downstream option parsing.
  • Filenames are attacker-controlled input. Git permits names that begin with hyphens, contain spaces, and exercise parser edge cases.
  • Use utility-specific terminators. Insert -- only where the receiving program documents and supports it.
  • Test hostile operands. Regression suites should include leading-hyphen names and verify both the selected configuration and the absence of side effects.
  • Audit sibling adapters. Any task runner that appends repository paths to an external command deserves the same review.
  • Reduce CI authority. Pull-request jobs should not expose unnecessary secrets or use write-capable tokens when processing untrusted changes.

References