Fix OS command injection in Local::start()/stop()/isRunning() (CWE-78, CWE-88) - #30
Draft
07souravkunda wants to merge 3 commits into
Draft
Fix OS command injection in Local::start()/stop()/isRunning() (CWE-78, CWE-88)#3007souravkunda wants to merge 3 commits into
07souravkunda wants to merge 3 commits into
Conversation
…, CWE-88)
Every caller-supplied value reached shell_exec()/system() by way of raw string
interpolation, so any consumer that forwards untrusted input into
Local::start() -- a wrapping HTTP service, a CI orchestrator splicing a
repo-scoped variable into localIdentifier or proxyHost, a multi-tenant test
runner -- handed the caller arbitrary command execution with the privileges of
the PHP process.
Confirmed reachable on eight distinct sinks: localIdentifier, proxyHost/Port/
User/Pass, hosts, logfile (both the -logFile argument and the truncating
system() call in start()), an arbitrary argument NAME through the add_args()
else-branch, the value side of that same branch, the public $pid property in
isRunning(), and the localIdentifier fragment reused by stop_command(). The
assembled line starts with the `exec` builtin, so a trailing `; cmd` chain does
not detonate -- but command substitution is expanded before exec runs, and
$(...) fires on all of them.
- add_args() rejects any argument name outside [A-Za-z0-9_-]+ with a
LocalException. A name is emitted as a `-<name>` flag, so it cannot be
quoted without ceasing to be a flag; it has to be validated instead. The
charset keeps every documented custom flag working, dashes included.
- Every caller-supplied value is wrapped in escapeshellarg() -- available
since PHP 4, so the declared php >= 5.3.19 floor is untouched.
- isRunning() casts $pid to int and reports a non-integer pid as not running
instead of asking ps about it.
- start_command()/stop_command() assemble a filtered list of parts rather
than interpolating one string and collapsing whitespace afterwards. That
collapse only existed to squeeze out the gaps left by unset flags, and it
rewrote whitespace inside quoted values too, which would now corrupt
legitimately escaped arguments.
- start()'s logfile truncation is quoted as well; its Windows branch used a
single-quoted PHP string, so it had been truncating a file literally named
'$this->logfile' rather than the configured one.
- `$call . "2>&1"` was missing its separating space; it only worked because
the old whitespace collapse left a trailing one.
Values now reach the binary as single quoted argv elements, which is what the
binary already received for benign input -- no behavioural change there. The
emitted command line does change shape (values are quoted), so the tests that
assert on it are updated.
Tests: eight injection regression tests that execute the assembled command line
for real against /bin/echo and assert the payload never runs. All eight fail on
the pre-fix code. tests/manual/injection-poc.php is the same proof as a
standalone script (8 of 9 arms vulnerable before, 0 after).
The test harness is modernised to phpunit ^9.6 with a CI workflow, matching the
open TLS-verification PR, because phpunit 4.6 cannot boot on a supported PHP and
the regression tests would otherwise never execute.
Residual, deliberately not in scope: the access key is still a positional
argument and so is still visible in `ps`/`/proc/<pid>/cmdline`. It can no longer
inject, and moving it off the command line needs binary-side support -- tracked
separately.
| $pid = intval($this->pid); | ||
| if ($pid <= 0) | ||
| return False; | ||
| $return_message = shell_exec("ps -" . $pid . " | wc -l"); |
07souravkunda
force-pushed
the
locsec/WI-97804311
branch
from
August 12, 2026 12:53
66ca340 to
9eafbe9
Compare
| // 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. | ||
| system("echo \"\" > " . self::esc($this->logfile)); |
Semgrep's diff scan re-reports php.lang.security.exec-use on both lines,
because the lines changed — the constructs themselves are pre-existing and are
already among master's open findings. Both are now the mitigated versions, so
they are annotated with the reason rather than left to fail the check:
- isRunning(): the interpolated value is the intval() directly above, guarded
> 0, so only digits can reach the shell.
- start(): $call comes from start_command(), where every caller-supplied part
is escapeshellarg()'d and every unquoted token is a fixed flag name. This is
precisely the sink this change exists to make safe, and the regression tests
pin it with payloads that fail on the pre-fix code.
07souravkunda
force-pushed
the
locsec/WI-97804311
branch
from
August 12, 2026 12:54
9eafbe9 to
80d297b
Compare
The previous commit rejected any argument name outside [A-Za-z0-9_-]+ with a
LocalException. That conflated two different findings:
- the NAME reaching the shell as code (CWE-78) — a real sink, and the one this
change exists to close;
- the binding forwarding unknown names to the binary at all (CWE-88) — not a
shell issue, and a deliberate, documented feature of this library.
escapeshellarg() on the name closes the first without touching the second. The
shell strips the quotes, so `-myFlag` still arrives at the binary as the argv
element `-myFlag` — verified for every name the library already forwarded,
dashes included. Unknown names keep being forwarded exactly as before.
That removes the only caller-visible behaviour change in this PR: nothing throws
that did not throw before, and no name that worked stops working.
Tests: test_rejects_an_injectable_argument_name becomes
test_no_injection_via_argument_name (asserts the payload stays inert), plus
test_unknown_argument_names_are_still_forwarded, which asserts the binary still
receives `-<name>` verbatim for names an allowlist would have rejected —
including 'weird.name' and 'name with space'. 9 injection regression tests now,
all 9 red on the pre-fix code.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
BrowserStack\Localbuilt its command line by interpolating caller-supplied values straight into a string and handing it toshell_exec()/system(). Any consumer that forwards untrusted input intoLocal::start()— a service exposing a "configure tunnel" endpoint, a CI orchestrator splicing a repo-scoped variable intolocalIdentifierorproxyHost, a multi-tenant test runner — therefore handed that input arbitrary command execution with the privileges of the PHP process. CWE-78 (OS command injection) and CWE-88 (argument injection).Confirmed reachable on eight distinct sinks:
localIdentifierstart_command()proxyHost/proxyPort/proxyUser/proxyPassstart_command()hostsstart_command()logfile-logFileargument (the old'...'wrapper was escapable)logfilesystem("echo \"\" > ...")call instart()add_args()else-branch emits it as-<name>'$value'was escapablepublic $pidOne nuance worth recording: the assembled line begins with the
execbuiltin, which replaces the shell with the binary, so a trailing; cmdchain never gets its turn. Command substitution is expanded beforeexecruns, so$(...)and backticks detonate on every sink above. The regression tests use that primitive rather than the;form.How
escapeshellarg()on the argument NAME as well as the value. The name is interpolated as-<name>, so it is its own shell sink. An earlier revision of this branch rejected names outside[A-Za-z0-9_-]— that was wrong, and is removed. Quoting the name closes the sink with no behaviour change: the shell strips the quotes, so-myFlagstill arrives at the binary as the argv element-myFlag. Whether the binary should accept unknown flags at all is a separate argument-injection (CWE-88) question, and not this change's business.escapeshellarg(). Available since PHP 4, so the declaredphp >= 5.3.19floor is untouched.isRunning()casts$pidtointand reports a non-integer pid as not running rather than askingpsabout it.start_command()/stop_command()assemble a filtered list of parts instead of interpolating one string and collapsing whitespace afterwards. Thatpreg_replace('/\s+/S', " ", ...)collapse existed only to squeeze out the gaps left by unset flags, and it rewrote whitespace inside quoted values too — which would now corrupt legitimately escaped arguments ("a b"would reach the binary as"a b").start()'s Windows branch used a single-quoted PHP string, so it had been truncating a file literally named$this->logfilerather than the configured one; and$call . "2>&1"was missing its separating space, working only because the old collapse left a trailing one.proc_open()with an array argument list was considered and not taken — the array form needs PHP 7.4+, well above this library's floor, and it would be a much larger rewrite of the daemon-stdout handling for no additional safety over full escaping.Compatibility
Values now reach the binary as single quoted argv elements, which is exactly what the binary already received for benign input — no behavioural change at the binary boundary. The emitted command string does change shape (values are quoted), so the tests that assert on it are updated.
No caller-visible behaviour change. Nothing throws that did not throw before, and no argument name or value that worked stops working —
test_unknown_argument_names_are_still_forwardedpins that, asserting the binary still receives-<name>verbatim for names an allowlist would have rejected (weird.name,name with spaceincluded).The one behaviour change is a bug fix on Windows:
start()used a single-quoted PHP string, so it had been truncating a file literally named$this->logfilerather than the configured logfile. The intended behaviour never worked there; it does now. Untested on Windows (no host) — see below.Testing
tests/LocalTest.phpthat execute the assembled command line for real against/bin/echoand assert the payload never runs. All 9 fail on the pre-fix code (verified: 13 failures pre-fix, 0 post-fix).tests/manual/injection-poc.php— the same proof as a standalone script. 8 of 9 arms vulnerable before the fix, 0 of 9 after.23 tests, 40 assertionsgreen (1 pre-existing risky test,test_enable_force, asserts nothing — unrelated).start()→isRunning() === true→ a BrowserStack Automate session driven over the tunnel to a local site viabs-local.com→stop()→isRunning() === false,pid === NULL, no stray processes.The test harness is migrated to phpunit ^9.6 with a CI workflow, because phpunit
4.6.*cannot boot on any supported PHP and the regression tests would otherwise never execute. That migration is intentionally byte-identical to the one in the open TLS-verification PR (#29) so the two branches converge rather than conflict; the only divergence is the syntax-check step, which globslib/*.php tests/*.phpinstead of naming files, so it works on either branch.Not in scope
The access key is still passed as a positional argument, so it remains visible in
ps//proc/<pid>/cmdline(CWE-214). It can no longer inject, but moving it off the command line requires binary-side support for an environment variable or a mode-0600 key file, so it is tracked separately.CI status — read before reacting to the Semgrep red
lint + phpunitphp:7.4-clisemgrep/ci(the gating workflow)CodeQL/Analyze (actions)Semgrep OSSSemgrep OSSis the GitHub code-scanning view of the uploaded SARIF, not the gating scan. It lists thephp.lang.security.exec-usefindings at the lines carrying anosemgrepannotation: theshell_execinisRunning(), thesystem()instart(), andshell_exec($call). The semgrep run itself honours those suppressions (hencesemgrep/cipassing with 0 blocking), but the SARIF still enumerates them, so code-scanning surfaces them as "new alerts in code changed by this pull request" purely because the diff touched those lines.For context,
masteralready carries four open alerts of the exact same rule onlib/Local.php(lines 57, 120, 122, 133) — the identical constructs, in their pre-fix, genuinely-injectable form. This PR does not add a new class of finding; it makes those very sinks safe and annotates the three it touches with the reason.The annotations are deliberately narrow — one rule id, one line each, with the argument-safety reasoning in the comment directly above:
isRunning()— the interpolated value is theintval()two lines up, guarded> 0, so only digits reach the shell.start()'ssystem()— the only interpolated value isescapeshellarg()'d.start()'sshell_exec($call)—$callcomes fromstart_command(), where every caller-supplied part is escaped and every unquoted token is a fixed flag name.No code-scanning alert was dismissed, and no rule was disabled repo-wide.
Sibling bindings — this bug is not php-only
The bindings mirror each other, so for whoever picks this up next:
os.systemremoved,Popenis list-formsystem("echo > #{@logfile}")andsystem("echo '' > '#{@logfile}'")atlib/browserstack/local.rb:77,79, plus string-formIO.popen(start_command)/IO.popen(stop_command)at:85,:118binaryAbsolutein a chmod commandThe escaping approach here transfers to ruby directly. Worth doing as its own change rather than folding it in.