diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml new file mode 100644 index 0000000..1dd0d79 --- /dev/null +++ b/.github/workflows/php.yml @@ -0,0 +1,42 @@ +name: PHP + +on: + pull_request: + branches: ["master", "main"] + push: + branches: ["master", "main"] + +permissions: + contents: read + +jobs: + test: + name: lint + phpunit + runs-on: ubuntu-latest + # 7.4 rather than 8.x: lib/ sets properties dynamically, which PHP 8.2 + # deprecates, and the library's own floor is php >= 5.3.19. phpunit 9.6 + # supports 7.3+. + container: php:7.4-cli + steps: + - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + + # Runs first and on its own: a syntax error must fail the build even if the + # suite cannot boot. + - name: Syntax check + run: | + for f in lib/*.php tests/*.php tests/manual/*.php; do + [ -e "$f" ] || continue + php -l "$f" + done + + # git + unzip are not in the official php image, and Composer needs one of + # them to unpack downloaded packages (ext-zip is not built in either). + - name: Install dependencies + run: | + apt-get update -qq && apt-get install -y -qq --no-install-recommends git unzip >/dev/null + curl -sS https://getcomposer.org/installer | php + php composer.phar install --no-interaction --no-progress + + # Excludes @group network — those tests reach badssl.com and the real S3 host. + - name: PHPUnit + run: ./vendor/bin/phpunit --exclude-group network diff --git a/.gitignore b/.gitignore index f01fad7..886dd88 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ composer.phar vendor/** composer.lock local.log +.phpunit.result.cache diff --git a/composer.json b/composer.json index 165a2e2..0c7172c 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,7 @@ "php": ">=5.3.19" }, "require-dev": { - "phpunit/phpunit": "4.6.*" + "phpunit/phpunit": "^9.6" }, "suggest": { "phpdocumentor/phpdocumentor": "2.*" diff --git a/lib/Local.php b/lib/Local.php index 69a9973..02b24ff 100644 --- a/lib/Local.php +++ b/lib/Local.php @@ -11,7 +11,36 @@ class Local { public $pid = NULL; - + + /** + * Quote a value so the shell treats it as exactly one literal argument. + * escapeshellarg() has been available since PHP 4, so this keeps the + * library's declared floor of PHP >= 5.3.19. + */ + private static function esc($value) { + return escapeshellarg((string) $value); + } + + /** + * Join pre-escaped command fragments, dropping the empty ones. + * + * This replaces the old `preg_replace('/\s+/S', " ", $command)` collapse. + * That collapse existed only to squeeze out the gaps left by unset flags, + * but it rewrote whitespace *inside* quoted values too — which would now + * corrupt legitimately escaped arguments (a value of "a b" would arrive at + * the binary as "a b"). Filtering the parts achieves the same tidy command + * line without touching the arguments themselves. + */ + private static function join_parts($parts) { + $out = array(); + foreach ($parts as $part) { + $part = trim((string) $part); + if ($part !== "") + $out[] = $part; + } + return implode(" ", $out); + } + public function __construct() { $this->key = getenv("BROWSERSTACK_ACCESS_KEY"); $this->logfile = getcwd() . "/local.log"; @@ -54,7 +83,18 @@ public function isRunning() { return False; } else { - $return_message = shell_exec("ps -" . "$this->pid " . "| wc -l"); + // $pid is public and is also populated from the spawned process's stdout + // (see start()), so it must never be concatenated into a shell string + // raw. A PID is an integer by definition — cast, and treat anything that + // is not a positive integer as "not running" rather than asking ps about + // it. + $pid = intval($this->pid); + if ($pid <= 0) + return False; + // $pid is the intval() above, guarded > 0, so the only bytes that can + // reach the shell here are digits. + // nosemgrep: php.lang.security.exec-use.exec-use + $return_message = shell_exec("ps -" . $pid . " | wc -l"); if (intval($return_message) > 1) { return True; @@ -81,15 +121,15 @@ public function add_args($arg_key, $value = NULL) { elseif ($arg_key == "forcelocal") $this->force_local_flag = "-forcelocal"; elseif ($arg_key == "localIdentifier") - $this->local_identifier_flag = "-localIdentifier $value"; + $this->local_identifier_flag = "-localIdentifier " . self::esc($value); elseif ($arg_key == "proxyHost") - $this->proxy_host = "-proxyHost $value"; + $this->proxy_host = "-proxyHost " . self::esc($value); elseif ($arg_key == "proxyPort") - $this->proxy_port = "-proxyPort $value"; + $this->proxy_port = "-proxyPort " . self::esc($value); elseif ($arg_key == "proxyUser") - $this->proxy_user = "-proxyUser $value"; + $this->proxy_user = "-proxyUser " . self::esc($value); elseif ($arg_key == "proxyPass") - $this->proxy_pass = "-proxyPass $value"; + $this->proxy_pass = "-proxyPass " . self::esc($value); elseif ($arg_key == "forceproxy") $this->force_proxy_flag = "-forceproxy"; elseif ($arg_key == "hosts") @@ -98,11 +138,18 @@ public function add_args($arg_key, $value = NULL) { $this->folder_flag = "-f"; $this->folder_path = $value; } - elseif (strtolower($value) == "true"){ - array_push($this->user_args, "-$arg_key"); + elseif ($value !== NULL && strtolower((string) $value) == "true"){ + // The argument NAME is interpolated into the command line too, so it is a + // shell sink in its own right. Quoting it closes that without changing + // what the binary receives: the shell strips the quotes, so `-myFlag` + // still arrives as the argv element `-myFlag`. An unknown name keeps + // being forwarded to the binary exactly as before -- whether the binary + // should accept unknown flags at all is a separate question (CWE-88) and + // not this change's business. + array_push($this->user_args, self::esc("-$arg_key")); } else { - array_push($this->user_args, "-$arg_key '$value'"); + array_push($this->user_args, self::esc("-$arg_key") . " " . self::esc($value)); } } @@ -114,11 +161,18 @@ public function start($arguments) { $this->binary_path = $this->binary->binary_path(); $call = $this->start_command(); - if(strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') - system('echo "" > '. '$this->logfile'); - else - system("echo \"\" > '$this->logfile' "); - $call = $call . "2>&1"; + // The logfile path is caller-supplied (add_args('logfile', ...)), so it is + // quoted here too — the old single-quote wrapper was escapable. The Windows + // branch additionally used a single-quoted PHP string, so it truncated a + // file literally named '$this->logfile' instead of the configured one. + // nosemgrep: php.lang.security.exec-use.exec-use + system("echo \"\" > " . self::esc($this->logfile)); + $call = $call . " 2>&1"; + // $call comes from start_command(), where every caller-supplied part is + // escapeshellarg()'d and every unquoted token is a fixed flag name. This is + // the sink the whole change exists to make safe; tests/LocalTest.php pins + // that with payloads that fail on the pre-fix code. + // nosemgrep: php.lang.security.exec-use.exec-use $return_message = shell_exec($call); $data = json_decode($return_message,true); if ($data["state"] != "connected") { @@ -140,10 +194,38 @@ public function start_command() { if(strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') $exec = "call"; - $user_args = join(' ', $this->user_args); - $command = "$exec $this->binary_path -d start -logFile '$this->logfile' $this->folder_flag $this->key $this->folder_path $this->force_local_flag $this->local_identifier_flag $this->only_flag $this->only_automate_flag $this->proxy_host $this->proxy_port $this->proxy_user $this->proxy_pass $this->force_proxy_flag $this->force_flag $this->verbose_flag $this->hosts $user_args"; - $command = preg_replace('/\s+/S', " ", $command); - return $command; + // Every caller-supplied value is quoted so the shell sees it as one literal + // argument. The fixed flag names and the $exec builtin are the only tokens + // that stay unquoted, and none of them is caller-controlled. The flag + // fragments built in add_args() are already escaped there. + $parts = array($exec); + if ((string) $this->binary_path !== "") + $parts[] = self::esc($this->binary_path); + $parts[] = "-d"; + $parts[] = "start"; + $parts[] = "-logFile"; + $parts[] = self::esc($this->logfile); + $parts[] = $this->folder_flag; + if ((string) $this->key !== "") + $parts[] = self::esc($this->key); + if ((string) $this->folder_path !== "") + $parts[] = self::esc($this->folder_path); + $parts[] = $this->force_local_flag; + $parts[] = $this->local_identifier_flag; + $parts[] = $this->only_flag; + $parts[] = $this->only_automate_flag; + $parts[] = $this->proxy_host; + $parts[] = $this->proxy_port; + $parts[] = $this->proxy_user; + $parts[] = $this->proxy_pass; + $parts[] = $this->force_proxy_flag; + $parts[] = $this->force_flag; + $parts[] = $this->verbose_flag; + if ((string) $this->hosts !== "") + $parts[] = self::esc($this->hosts); + $parts = array_merge($parts, $this->user_args); + + return self::join_parts($parts); } public function stop_command() { @@ -152,10 +234,14 @@ public function stop_command() { if(strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') $exec = "call"; - $user_args = join(' ', $this->user_args); - $command = "$exec $this->binary_path -d stop $this->local_identifier_flag"; - $command = preg_replace('/\s+/S', " ", $command); - return $command; + $parts = array($exec); + if ((string) $this->binary_path !== "") + $parts[] = self::esc($this->binary_path); + $parts[] = "-d"; + $parts[] = "stop"; + $parts[] = $this->local_identifier_flag; + + return self::join_parts($parts); } } diff --git a/phpunit.xml b/phpunit.xml index c5544cc..1abbab8 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,8 +1,11 @@ - + + - tests + tests - diff --git a/tests/LocalTest.php b/tests/LocalTest.php index bee33df..3034070 100644 --- a/tests/LocalTest.php +++ b/tests/LocalTest.php @@ -8,27 +8,27 @@ require_once __DIR__ . '/../vendor/autoload.php'; -class LocalTest extends \PHPUnit_Framework_TestCase { +class LocalTest extends \PHPUnit\Framework\TestCase { private $bs_local; - public function setUp(){ + protected function setUp(): void { $this->bs_local = new Local(); } - public function tearDown(){ + protected function tearDown(): void { $this->bs_local->stop(); } public function test_verbose() { $this->bs_local->add_args('v'); - $this->assertContains('-v',$this->bs_local->start_command()); + $this->assertStringContainsString('-v',$this->bs_local->start_command()); } public function test_set_folder() { $this->bs_local->add_args('f', "/"); - $this->assertContains('-f',$this->bs_local->start_command()); - $this->assertContains('/',$this->bs_local->start_command()); + $this->assertStringContainsString('-f',$this->bs_local->start_command()); + $this->assertStringContainsString('/',$this->bs_local->start_command()); } public function test_enable_force() { @@ -37,36 +37,36 @@ public function test_enable_force() { public function test_set_local_identifier() { $this->bs_local->add_args("localIdentifier", "randomString"); - $this->assertContains('-localIdentifier randomString',$this->bs_local->start_command()); + $this->assertStringContainsString("-localIdentifier 'randomString'",$this->bs_local->start_command()); } public function test_enable_only() { $this->bs_local->add_args("only"); - $this->assertContains('-only',$this->bs_local->start_command()); + $this->assertStringContainsString('-only',$this->bs_local->start_command()); } public function test_enable_only_automate() { $this->bs_local->add_args("onlyAutomate"); - $this->assertContains('-onlyAutomate', $this->bs_local->start_command()); + $this->assertStringContainsString('-onlyAutomate', $this->bs_local->start_command()); } public function test_enable_force_local() { $this->bs_local->add_args("forcelocal"); - $this->assertContains('-forcelocal',$this->bs_local->start_command()); + $this->assertStringContainsString('-forcelocal',$this->bs_local->start_command()); } public function test_custom_boolean_argument() { $this->bs_local->add_args("boolArg1", true); $this->bs_local->add_args("boolArg2", true); - $this->assertContains('-boolArg1',$this->bs_local->start_command()); - $this->assertContains('-boolArg2',$this->bs_local->start_command()); + $this->assertStringContainsString('-boolArg1',$this->bs_local->start_command()); + $this->assertStringContainsString('-boolArg2',$this->bs_local->start_command()); } public function test_custom_keyval() { $this->bs_local->add_args("customKey1", "custom value1"); $this->bs_local->add_args("customKey2", "custom value2"); - $this->assertContains('-customKey1 \'custom value1\'',$this->bs_local->start_command()); - $this->assertContains('-customKey2 \'custom value2\'',$this->bs_local->start_command()); + $this->assertStringContainsString("'-customKey1' 'custom value1'",$this->bs_local->start_command()); + $this->assertStringContainsString("'-customKey2' 'custom value2'",$this->bs_local->start_command()); } public function test_set_proxy() { @@ -74,19 +74,169 @@ public function test_set_proxy() { $this->bs_local->add_args("proxyPort", 8080); $this->bs_local->add_args("proxyUser", "user"); $this->bs_local->add_args("proxyPass", "pass"); - $this->assertContains('-proxyHost localhost -proxyPort 8080 -proxyUser user -proxyPass pass',$this->bs_local->start_command()); + $this->assertStringContainsString("-proxyHost 'localhost' -proxyPort '8080' -proxyUser 'user' -proxyPass 'pass'",$this->bs_local->start_command()); } public function test_enable_force_proxy() { $this->bs_local->add_args("-forceproxy"); - $this->assertContains('-forceproxy',$this->bs_local->start_command()); + $this->assertStringContainsString('-forceproxy',$this->bs_local->start_command()); } public function test_hosts() { $this->bs_local->add_args("-hosts", "localhost,8080,0"); - $this->assertContains('localhost,8080,0',$this->bs_local->start_command()); + $this->assertStringContainsString('localhost,8080,0',$this->bs_local->start_command()); } + // --------------------------------------------------------------------------- + // Command-injection regression tests (CWE-78 / CWE-88). + // + // These execute the assembled command line for real, but never the + // BrowserStackLocal binary: binary_path is pointed at /bin/echo, so the only + // thing that can run besides echo is an injected payload. Each test drops a + // marker file; the marker existing means the shell executed attacker bytes. + // + // The payloads use command substitution rather than a `; cmd` chain on + // purpose: the command line starts with the `exec` builtin, which replaces + // the shell, so a trailing chain never gets its turn -- while $(...) is + // expanded before exec runs. Every one of these fails on the pre-fix code. + // --------------------------------------------------------------------------- + + /** @return string a marker path that does not exist yet */ + private function marker($name) { + $path = rtrim(sys_get_temp_dir(), '/') . '/bsl_test_' . $name . '_' . getmypid(); + if (file_exists($path)) { unlink($path); } + return $path; + } + + private function assertNotExecuted($marker, $command) { + $fired = file_exists($marker); + if ($fired) { unlink($marker); } + $this->assertFalse($fired, "payload executed -- command injection is live. Command was: " . $command); + } + + public function test_no_injection_via_local_identifier() { + $marker = $this->marker('lid'); + $this->bs_local->binary_path = '/bin/echo'; + $this->bs_local->add_args('key', 'dummykey'); + $this->bs_local->add_args('localIdentifier', 'x$(touch ' . $marker . ')'); + $command = $this->bs_local->start_command(); + shell_exec($command . ' 2>&1'); + $this->assertNotExecuted($marker, $command); + } + + public function test_no_injection_via_proxy_host_backticks() { + $marker = $this->marker('proxy'); + $this->bs_local->binary_path = '/bin/echo'; + $this->bs_local->add_args('key', 'dummykey'); + $this->bs_local->add_args('proxyHost', 'x`touch ' . $marker . '`'); + $command = $this->bs_local->start_command(); + shell_exec($command . ' 2>&1'); + $this->assertNotExecuted($marker, $command); + } + + public function test_no_injection_via_hosts() { + $marker = $this->marker('hosts'); + $this->bs_local->binary_path = '/bin/echo'; + $this->bs_local->add_args('key', 'dummykey'); + $this->bs_local->add_args('hosts', 'x$(touch ' . $marker . ')'); + $command = $this->bs_local->start_command(); + shell_exec($command . ' 2>&1'); + $this->assertNotExecuted($marker, $command); + } + + public function test_no_injection_via_logfile() { + $marker = $this->marker('logfile'); + $this->bs_local->binary_path = '/bin/echo'; + $this->bs_local->add_args('key', 'dummykey'); + $this->bs_local->add_args('logfile', "/tmp/bsl_test.log'\$(touch " . $marker . ")'"); + $command = $this->bs_local->start_command(); + shell_exec($command . ' 2>&1'); + $this->assertNotExecuted($marker, $command); + } + + public function test_no_injection_via_custom_flag_value() { + $marker = $this->marker('customval'); + $this->bs_local->binary_path = '/bin/echo'; + $this->bs_local->add_args('key', 'dummykey'); + $this->bs_local->add_args('customFlag', "' \$(touch " . $marker . ") '"); + $command = $this->bs_local->start_command(); + shell_exec($command . ' 2>&1'); + $this->assertNotExecuted($marker, $command); + } + + public function test_no_injection_via_stop_command() { + $marker = $this->marker('stop'); + $this->bs_local->binary_path = '/bin/echo'; + $this->bs_local->add_args('localIdentifier', 'x$(touch ' . $marker . ')'); + $command = $this->bs_local->stop_command(); + shell_exec($command . ' 2>&1'); + $this->assertNotExecuted($marker, $command); + } + + public function test_no_injection_via_pid_in_is_running() { + $marker = $this->marker('pid'); + $this->bs_local->pid = 'aux$(touch ' . $marker . ')'; + $running = $this->bs_local->isRunning(); + $this->bs_local->pid = NULL; // keep tearDown()'s stop() a no-op + $this->assertNotExecuted($marker, 'isRunning() with a non-numeric $pid'); + $this->assertFalse($running, 'a non-numeric pid must not be reported as running'); + } + + public function test_no_injection_via_argument_name() { + // The argument NAME is interpolated into the command line as well, so it is + // its own shell sink. Quoting it is enough — the name is still forwarded to + // the binary, it just cannot reach the shell as code. + $marker = $this->marker('argname'); + $this->bs_local->binary_path = '/bin/echo'; + $this->bs_local->add_args('key', 'dummykey'); + $this->bs_local->add_args('x$(touch ' . $marker . ')', 'v'); + $command = $this->bs_local->start_command(); + shell_exec($command . ' 2>&1'); + $this->assertNotExecuted($marker, $command); + } + + public function test_unknown_argument_names_are_still_forwarded() { + // Deliberately NOT an allowlist: quoting closes the shell sink without + // changing behaviour, so every name the library already forwarded keeps + // working — dashes included — and nothing throws. Whether the binary should + // accept unknown flags is a separate (CWE-88) question. + foreach (array('customKey1', 'custom_key_2', 'a1', '-forceproxy', '-hosts', + 'weird.name', 'name with space') as $name) { + $local = new Local(); + $local->binary_path = '/bin/echo'; + $local->add_args($name, 'somevalue'); + $command = $local->start_command(); + // The name reaches the binary as one argv element, quoted, never as code. + $this->assertStringContainsString(escapeshellarg("-$name"), $command, + "argument name '$name' must still be forwarded"); + $argv = shell_exec($command . ' 2>&1'); + $this->assertStringContainsString("-$name", $argv, + "the binary must still receive '-$name' verbatim"); + } + } + + public function test_unset_options_leave_no_empty_arguments() { + // start_command() used to squeeze out the gaps left by unset flags with a + // whitespace collapse. That collapse also rewrote whitespace inside quoted + // values, so it was replaced by dropping the empty parts -- this asserts the + // command line stays free of stray empty tokens. + $this->bs_local->add_args('key', 'dummykey'); + $command = $this->bs_local->start_command(); + $this->assertStringNotContainsString(" ", $command); + $this->assertStringNotContainsString(" '' ", $command); + } + + public function test_values_keep_their_internal_whitespace() { + $this->bs_local->add_args('key', 'dummykey'); + $this->bs_local->add_args('localIdentifier', "two spaces"); + $this->assertStringContainsString("-localIdentifier 'two spaces'", $this->bs_local->start_command()); + } + + /** + * Starts the real binary — needs BROWSERSTACK_ACCESS_KEY and outbound network. + * + * @group network + */ public function test_isRunning() { $this->assertFalse($this->bs_local->isRunning()); $this->bs_local->start(array('v' => true)); @@ -97,12 +247,22 @@ public function test_isRunning() { $this->assertTrue($this->bs_local->isRunning()); } + /** + * Starts the real binary — needs BROWSERSTACK_ACCESS_KEY and outbound network. + * + * @group network + */ public function test_checkPid() { $this->assertFalse($this->bs_local->isRunning()); $this->bs_local->start(array('v' => true)); $this->assertTrue($this->bs_local->pid > 0); } + /** + * Starts the real binary twice — needs BROWSERSTACK_ACCESS_KEY and outbound network. + * + * @group network + */ public function test_multiple_binary() { $this->bs_local->start(array('v' => true)); $bs_local_2 = new Local(); diff --git a/tests/manual/injection-poc.php b/tests/manual/injection-poc.php new file mode 100644 index 0000000..84b3221 --- /dev/null +++ b/tests/manual/injection-poc.php @@ -0,0 +1,163 @@ +getMessage(); + } + if ($command !== null) { + shell_exec($command . ' 2>&1'); + } + $fired = file_exists($marker); + printf("ARM %d %-44s payload_executed=%s\n", $arm, $label, $fired ? 'YES <-- VULNERABLE' : 'no'); + if ($note !== '') { + printf(" %s\n", $note); + } + if ($command !== null) { + printf(" command: %s\n", $command); + } + if ($fired) { $vulnerable++; unlink($marker); } +} + +function fresh_local() { + $local = new BrowserStack\Local(); + $local->binary_path = '/bin/echo'; + $local->add_args('key', 'dummykey'); + return $local; +} + +// -- F-002: known argument fields interpolated into the command string -------- +$m = marker(1); +run_arm('localIdentifier, command substitution', $m, function () use ($m) { + $local = fresh_local(); + $local->add_args('localIdentifier', 'x$(touch ' . $m . ')'); + return $local->start_command(); +}); + +$m = marker(2); +run_arm('proxyHost, backticks', $m, function () use ($m) { + $local = fresh_local(); + $local->add_args('proxyHost', 'x`touch ' . $m . '`'); + return $local->start_command(); +}); + +$m = marker(3); +run_arm('hosts, command substitution', $m, function () use ($m) { + $local = fresh_local(); + $local->add_args('hosts', 'x$(touch ' . $m . ')'); + return $local->start_command(); +}); + +$m = marker(4); +run_arm('localIdentifier, semicolon chain', $m, function () use ($m) { + $local = fresh_local(); + $local->add_args('localIdentifier', 'x; touch ' . $m . '; echo'); + return $local->start_command(); +}); + +// -- F-002: the logfile path reaches both shell sinks ------------------------- +$m = marker(5); +run_arm('logfile, single-quote break-out', $m, function () use ($m) { + $local = fresh_local(); + $local->add_args('logfile', "/tmp/bsl_poc.log'\$(touch " . $m . ")'"); + return $local->start_command(); +}); + +// (start()'s own `system("echo \"\" > ")` truncation call takes the same +// caller-supplied logfile and is quoted the same way; it is not exercised here +// because reaching start() would download and launch the real binary.) + +// -- F-003: the add_args() else-branch, via the KEY and via the VALUE --------- +$m = marker(7); +run_arm('arbitrary arg_key (injection via the name)', $m, function () use ($m) { + // The name is quoted rather than rejected: it is still forwarded to the + // binary, it just cannot reach the shell as code. + $local = fresh_local(); + $local->add_args('x$(touch ' . $m . ')', 'v'); + return $local->start_command(); +}); + +$m = marker(8); +run_arm('custom flag value, quote break-out', $m, function () use ($m) { + $local = fresh_local(); + $local->add_args('customFlag', "' \$(touch " . $m . ") '"); + return $local->start_command(); +}); + +// -- F-008: attacker-controlled $pid reaching the isRunning() shell call ------ +$arm++; +$m = marker(9); +$local = new BrowserStack\Local(); +$local->pid = 'aux$(touch ' . $m . ')'; +$local->isRunning(); +$fired = file_exists($m); +printf("ARM %d %-44s payload_executed=%s\n", $arm, 'public $pid -> isRunning()', $fired ? 'YES <-- VULNERABLE' : 'no'); +if ($fired) { $vulnerable++; unlink($m); } + +// -- stop_command() inherits the localIdentifier fragment --------------------- +$arm++; +$m = marker(10); +$local = fresh_local(); +$local->add_args('localIdentifier', 'x$(touch ' . $m . ')'); +shell_exec($local->stop_command() . ' 2>&1'); +$fired = file_exists($m); +printf("ARM %d %-44s payload_executed=%s\n", $arm, 'localIdentifier -> stop_command()', $fired ? 'YES <-- VULNERABLE' : 'no'); +printf(" command: %s\n", $local->stop_command()); +if ($fired) { $vulnerable++; unlink($m); } + +echo "\n"; +if ($vulnerable > 0) { + echo "RESULT: VULNERABLE - $vulnerable of $arm arms executed attacker-controlled commands.\n"; + exit(1); +} +echo "RESULT: OK - all $arm payloads were passed through as inert argv data.\n"; +exit(0);