Skip to content

Commit 978088a

Browse files
cmb69charmitro
authored andcommitted
Harden proc_open() against cmd.exe hijacking
As is, whenever `proc_open()` needs to invoke the shell, cmd.exe is looked up in the usual executable search path. That implies that any cmd.exe which is placed in the current working directory (which is not necessarily what is reported by `getcwd()` for ZTS builds), will be used. This is a known attack vector, and Microsoft recommends to always use the fully qualified path to cmd.exe. To prevent any cmd.exe in the current working directory to be used, but to still allow users to use a drop in replacement for cmd.exe, we search only the `PATH` for cmd.exe (and pass the fully qualified path to `CreateProcessW`), instead of relying on automatic executable search by passing the base name only. To be able to easily test this, we provide a minimalist C file which will be build as test_helper, and used by the new test case. [1] <https://msrc.microsoft.com/blog/2014/04/ms14-019-fixing-a-binary-hijacking-via-cmd-or-bat-file/> Closes phpGH-17043.
1 parent b68286c commit 978088a

File tree

6 files changed

+101
-3
lines changed

6 files changed

+101
-3
lines changed

NEWS

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ PHP NEWS
1515
. Fixed bug GH-17037 (UAF in user filter when adding existing filter name due
1616
to incorrect error handling). (nielsdos)
1717

18+
- Windows:
19+
. Hardened proc_open() against cmd.exe hijacking. (cmb)
20+
1821
19 Dec 2024, PHP 8.3.15
1922

2023
- Calendar:

ext/standard/Makefile.frag.w32

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,7 @@ ext\standard\url_scanner_ex.c: ext\standard\url_scanner_ex.re
77
$(RE2C) $(RE2C_FLAGS) -b -o ext/standard/url_scanner_ex.c ext/standard/url_scanner_ex.re
88

99
$(BUILD_DIR)\ext\standard\basic_functions.obj: $(PHP_SRC_DIR)\Zend\zend_language_parser.h
10+
11+
$(PHP_SRC_DIR)\ext\standard\tests\helpers\bad_cmd.exe: $(PHP_SRC_DIR)\ext\standard\tests\helpers\bad_cmd.c
12+
cd $(PHP_SRC_DIR)\ext\standard\tests\helpers
13+
$(PHP_CL) /nologo bad_cmd.c

ext/standard/proc_open.c

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -698,22 +698,77 @@ static void init_process_info(PROCESS_INFORMATION *pi)
698698
memset(&pi, 0, sizeof(pi));
699699
}
700700

701+
/* on success, returns length of *comspec, which then needs to be efree'd by caller */
702+
static size_t find_comspec_nt(wchar_t **comspec)
703+
{
704+
zend_string *path = NULL;
705+
wchar_t *pathw = NULL;
706+
wchar_t *bufp = NULL;
707+
DWORD buflen = MAX_PATH, len = 0;
708+
709+
path = php_getenv("PATH", 4);
710+
if (path == NULL) {
711+
goto out;
712+
}
713+
pathw = php_win32_cp_any_to_w(ZSTR_VAL(path));
714+
if (pathw == NULL) {
715+
goto out;
716+
}
717+
bufp = emalloc(buflen * sizeof(wchar_t));
718+
do {
719+
/* the first call to SearchPathW() fails if the buffer is too small,
720+
* what is unlikely but possible; to avoid an explicit second call to
721+
* SeachPathW() and the error handling, we're looping */
722+
len = SearchPathW(pathw, L"cmd.exe", NULL, buflen, bufp, NULL);
723+
if (len == 0) {
724+
goto out;
725+
}
726+
if (len < buflen) {
727+
break;
728+
}
729+
buflen = len;
730+
bufp = erealloc(bufp, buflen * sizeof(wchar_t));
731+
} while (1);
732+
*comspec = bufp;
733+
734+
out:
735+
if (path != NULL) {
736+
zend_string_release(path);
737+
}
738+
if (pathw != NULL) {
739+
free(pathw);
740+
}
741+
if (bufp != NULL && bufp != *comspec) {
742+
efree(bufp);
743+
}
744+
return len;
745+
}
746+
701747
static zend_result convert_command_to_use_shell(wchar_t **cmdw, size_t cmdw_len)
702748
{
703-
size_t len = sizeof(COMSPEC_NT) + sizeof(" /s /c ") + cmdw_len + 3;
749+
wchar_t *comspec;
750+
size_t len = find_comspec_nt(&comspec);
751+
if (len == 0) {
752+
php_error_docref(NULL, E_WARNING, "Command conversion failed");
753+
return FAILURE;
754+
}
755+
len += sizeof(" /s /c ") + cmdw_len + 3;
704756
wchar_t *cmdw_shell = (wchar_t *)malloc(len * sizeof(wchar_t));
705757

706758
if (cmdw_shell == NULL) {
759+
efree(comspec);
707760
php_error_docref(NULL, E_WARNING, "Command conversion failed");
708761
return FAILURE;
709762
}
710763

711-
if (_snwprintf(cmdw_shell, len, L"%hs /s /c \"%s\"", COMSPEC_NT, *cmdw) == -1) {
764+
if (_snwprintf(cmdw_shell, len, L"%s /s /c \"%s\"", comspec, *cmdw) == -1) {
765+
efree(comspec);
712766
free(cmdw_shell);
713767
php_error_docref(NULL, E_WARNING, "Command conversion failed");
714768
return FAILURE;
715769
}
716770

771+
efree(comspec);
717772
free(*cmdw);
718773
*cmdw = cmdw_shell;
719774

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
--TEST--
2+
Harden against cmd.exe hijacking
3+
--SKIPIF--
4+
<?php
5+
if (PHP_OS_FAMILY !== "Windows") die("skip only for Windows");
6+
?>
7+
--FILE--
8+
<?php
9+
copy(__DIR__ . "/../helpers/bad_cmd.exe", "cmd.exe");
10+
$spec = [["pipe", "r"], ["pipe", "w"], ["pipe", "w"]];
11+
var_dump($proc = proc_open("@echo hello", $spec, $pipes, null));
12+
$read = [$pipes[1], $pipes[2]];
13+
$write = $except = null;
14+
if (($num = stream_select($read, $write, $except, 1000)) === false) {
15+
echo "stream_select() failed\n";
16+
} elseif ($num > 0) {
17+
foreach ($read as $stream) {
18+
fpassthru($stream);
19+
}
20+
}
21+
?>
22+
--EXPECTF--
23+
resource(%d) of type (process)
24+
hello
25+
--CLEAN--
26+
<?php
27+
@unlink("cmd.exe");
28+
?>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#include <stdio.h>
2+
3+
int main()
4+
{
5+
printf("pwnd!\n");
6+
return 0;
7+
}

win32/build/Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ DEBUGGER_CMD=
5454
DEBUGGER_ARGS=
5555
!endif
5656

57-
all: generated_files $(EXT_TARGETS) $(PECL_TARGETS) $(SAPI_TARGETS)
57+
all: generated_files $(EXT_TARGETS) $(PECL_TARGETS) $(SAPI_TARGETS) test_helpers
5858

5959
build_dirs: $(BUILD_DIR) $(BUILD_DIRS_SUB) $(BUILD_DIR_DEV)
6060

@@ -184,6 +184,7 @@ clean-pgo: clean-all
184184
-del /f /q $(BUILD_DIR)\$(DIST_ZIP_PECL)
185185
-del /f /q $(BUILD_DIR)\$(DIST_ZIP_TEST_PACK)
186186

187+
test_helpers: $(PHP_SRC_DIR)\ext\standard\tests\helpers\bad_cmd.exe
187188

188189
!if $(PHP_TEST_INI_PATH) == ""
189190
test: set-tmp-env

0 commit comments

Comments
 (0)