Cyber Security

Safe Subprocesses in Small PHP Applications - Pass Arguments, Not Shell Programs

Safe Subprocesses in Small PHP Applications - Pass Arguments, Not Shell Programs

A small PHP application sometimes needs a tool that PHP itself does not provide: an image converter, a document renderer, a media probe, or a narrowly scoped maintenance command. Calling an executable may be reasonable. The dangerous step is treating a command line as ordinary text and inserting request data into it.

The question is not merely whether suspicious punctuation can be removed. It is whether the application can keep three different things separate: the executable it chose, the options it chose, and the data supplied for one argument. Once a shell receives one assembled string, that distinction depends on another language parser.

This article develops a defensive pattern for PHP 7.4 or later on Linux. It does not claim that proc_open() makes every child process safe, and it does not offer a general-purpose process supervisor. The narrower goal is to preserve the boundary between control and data, then identify the controls that still remain.

The shell is a language interpreter

MITRE's CWE-78 describes OS command injection as externally influenced input modifying the command an application intends to send to the operating system. The key word is command. A shell does more than start a program: it interprets quoting, variable expansion, redirection, pipelines, command separators, and other syntax.

Consider the shape of this code, without needing an attack payload:

$command = '/usr/bin/tool --mode fixed ' . $requestValue;
exec($command, $output, $exitCode);

The application intends $requestValue to be data. The shell receives one program containing both fixed syntax and variable text. Correctness now depends on quoting rules, the operating system, the locale, and every future edit to the string. The PHP manual for exec() explicitly warns that user-supplied data must be escaped before it is passed to the function.

A better first question is whether a subprocess is needed at all. The OWASP OS Command Injection Defense Cheat Sheet and CWE-78 both prefer language APIs or libraries when they can perform the same operation. PHP's mkdir(), for example, is clearer than invoking a system mkdir. A library removes a parser and usually gives the application more structured errors.

Pass an argument vector, not a command string

When an external program is genuinely required, PHP provides a stronger structural choice. Since PHP 7.4, the proc_open() manual permits the command to be an array. PHP documents that the process is then opened directly, without going through a shell, and that PHP handles the necessary argument escaping.

The distinction is visible in the data structure:

$process = proc_open(
    [
        '/usr/bin/tool',
        '--mode',
        'fixed',
        '--',
        $requestValue,
    ],
    $descriptors,
    $pipes
);

Each array element is one argument. Shell punctuation inside $requestValue is not reinterpreted as a pipeline or a second command because no shell is parsing the array as a shell program. This is similar in spirit to using a prepared database statement: control structure and data occupy different channels.

The analogy has a limit. A prepared SQL value cannot become an SQL keyword, but an argument is still interpreted by the executable. If variable data starts with a hyphen, a program may treat it as an option. If the program accepts an option that reads another configuration, writes a chosen file, or launches a helper, the shell was never needed for harmful behavior. OWASP calls this argument injection.

Executable, options, and operands need separate policies

A useful review labels every element before the process starts.

Keep the executable fixed

Do not let a request choose a binary name or path. Use an absolute, application-owned path such as /usr/bin/grep rather than relying on PATH. The PHP manual notes that a simple filename in the first array element is searched through PATH; an absolute path avoids that search decision.

If the application supports several operations, map a small internal identifier to a fixed executable and fixed argument template. Reject unknown identifiers. Do not accept an arbitrary “command” field and try to clean it afterward.

Keep options fixed where possible

Options define what the program is allowed to do. They belong in application code, not in a form field. A user may choose a business-level mode from an allowlist, but the application should translate that choice to a reviewed set of options.

Many Unix utilities recognize -- as the end of options. Placing it before variable operands can stop a hyphen-prefixed value from becoming another option. This is not a universal promise: the invoked program's own documentation must confirm its argument grammar.

Validate operands by meaning

Structured invocation does not answer whether an operand is appropriate. A page number should be parsed as an integer within a useful range. A format should come from a small allowlist. A path should usually be created or resolved by the server rather than accepted directly from the browser. Free text needs a length limit and any domain-specific constraints.

Positive validation is more durable than maintaining a list of characters that looked dangerous in yesterday's shell. It also catches ordinary mistakes before they reach the child process.

Escaping is a constrained fallback

Some work genuinely requires shell features such as a pipeline or redirection. In that case, the shell is an intentional dependency, not an invisible convenience. The command should be small, fixed as far as possible, reviewed for the actual platform, and supplied only with individually escaped arguments.

PHP's escapeshellarg() is designed to turn one string into one shell argument. It should not be confused with escapeshellcmd(), which escapes a whole command string but, according to its own manual, still allows an arbitrary number of arguments.

Even escapeshellarg() is not a reason to prefer a shell. PHP documents different behavior on Windows, where some characters are replaced, and notes that multibyte behavior depends on the current LC_CTYPE locale. Escaping addresses shell parsing. It does not validate the executable's options, permissions, resource use, or business rules.

A small Linux example

The following example searches an application-owned text file for a literal string. The executable and options are fixed, -- ends option parsing for GNU grep, and the variable value occupies one array element.

<?php

declare(strict_types=1);

$needle = trim((string) ($_POST['needle'] ?? ''));

if ($needle === '' || strlen($needle) > 80 || str_contains($needle, "\0")) {
    http_response_code(422);
    exit('Invalid search text');
}

$descriptors = [
    0 => ['file', '/dev/null', 'r'],
    1 => ['pipe', 'w'],
    2 => ['pipe', 'w'],
];

$process = proc_open(
    [
        '/usr/bin/grep',
        '--fixed-strings',
        '--line-number',
        '--',
        $needle,
        '/srv/example/data/public-notes.txt',
    ],
    $descriptors,
    $pipes,
    '/srv/example',
    ['PATH' => '/usr/bin:/bin', 'LANG' => 'C']
);

if (!is_resource($process)) {
    throw new RuntimeException('Could not start search process');
}

$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);

$exitCode = proc_close($process);

if ($exitCode > 1) {
    error_log('grep failed: ' . substr($stderr, 0, 500));
    http_response_code(500);
    exit('Search failed');
}

echo htmlspecialchars($stdout, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

This example is deliberately narrow. GNU grep defines exit status 0 for a match, 1 for no selected lines, and greater than 1 for an error, so “non-zero means failure” would be incorrect here. The output is HTML-encoded because safe process invocation does not make child output safe for an HTML response.

The snippet is also not suitable for arbitrary commands or large output. Reading stdout fully before stderr can deadlock when a child fills one pipe while the parent is waiting on the other. A production wrapper for potentially large streams needs concurrent draining, byte limits, and careful cleanup. The proc_open() documentation specifically warns about closing pipes and deadlocks.

Bound what the child can do

Separating arguments prevents one important parsing mistake. It does not turn the child into trusted code. Several independent limits still matter:

  • Identity: run PHP and the child with only the permissions required for the task. Least privilege reduces the consequence of a mistake; it does not repair one.
  • Working directory: pass an explicit absolute directory instead of inheriting an accidental one.
  • Environment: provide a minimal environment when practical. Do not pass secrets the child does not need, and do not trust variables such as PATH to select code.
  • Input: prefer pipes or server-managed files for substantial data. Command-line arguments may be visible to other local processes on some systems and should not carry secrets.
  • Output: cap stdout and stderr, treat both as untrusted, and avoid returning internal diagnostics to a visitor.
  • Time: define a deadline. A child can hang without any injection vulnerability.
  • Concurrency: limit how many expensive children one request or account can create.

Timeout handling deserves caution. The proc_terminate() manual says it signals a process and returns immediately; it does not wait for termination. A command may also create descendants. Robust cancellation depends on the operating system and process model, so a web request should not improvise a universal process-tree manager.

Often the cleaner architecture is to enqueue a narrowly defined job and let a dedicated worker execute it under a restricted service account. The web process then validates and records intent rather than holding an HTTP connection open while managing an external program.

Observe the result without leaking internals

The parent should record whether process creation succeeded, whether the deadline was reached, the documented exit status, duration, and a bounded diagnostic. It should not log request secrets, entire documents, or an assembled command string containing sensitive values.

proc_close() waits for the process to terminate and returns its exit code. That code is meaningful only under the invoked program's documentation. Standard error is not automatically a failure, and an empty standard output is not automatically success. Treat the executable's interface as an API contract.

A review checklist

  1. Can a PHP API or maintained library replace the external command?
  2. Is the executable an absolute, fixed path controlled by the application?
  3. Are arguments passed as an array to proc_open() rather than assembled as shell text?
  4. Are options fixed or mapped from a small allowlist?
  5. Does the program support an end-of-options marker before variable operands?
  6. Is each operand validated according to its business meaning, type, and length?
  7. Are working directory, environment, identity, filesystem access, and network access constrained?
  8. Are stdin, stdout, stderr, runtime, output size, and concurrency bounded?
  9. Are exit codes interpreted using the executable's documentation?
  10. Are logs useful without exposing secrets or child-process details to the visitor?

Conclusion

The strongest subprocess defense is often not to start one. When PHP must call an external tool, the next strongest move is structural: choose the executable in code, keep options under application control, and pass each operand as a separate array element without a shell.

That design prevents data from becoming shell syntax, but it is not the end of the review. The executable still interprets arguments, and the operating system still grants the child time, memory, files, network access, and privileges. Validation and containment remain necessary.

A useful final question is therefore not “Did every dangerous character get escaped?” It is “Which parser receives this value next, and what authority does that parser have?”

References