CVE-2024-4577漏洞分析

本篇文章将对CVE-2024-4577漏洞进行详细分析。

CVE-2024-4577漏洞叫做PHP CGI参数注入漏洞[1],该漏洞的利用条件为:

  1. Windows环境,并且系统默认语言为以下之一:
    855: 西里尔字母
    862: 希伯来语
    866: 俄语(西里尔字母)
    932: 日语(Shift-JIS)
    936: 简体中文(GBK)
    950: 繁体中文(Big5)
  2. apache + php cgi环境

漏洞成因分析

因为该漏洞必须要在cgi配置下才能触发,所以可以尝试从apache的mod_cgi模块开始入手,首先分析apache的mod_cgi.c文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static void register_hooks(apr_pool_t *p)
{
static const char * const aszPre[] = { "mod_include.c", NULL };
ap_hook_handler(cgi_handler, NULL, NULL, APR_HOOK_MIDDLE);
ap_hook_post_config(cgi_post_config, aszPre, NULL, APR_HOOK_REALLY_FIRST);
}

AP_DECLARE_MODULE(cgi) =
{
STANDARD20_MODULE_STUFF,
create_cgi_dirconf, /* dir config creater */
NULL, /* dir merger --- default is to override */
create_cgi_config, /* server config */
merge_cgi_config, /* merge server config */
cgi_cmds, /* command apr_table_t */
register_hooks /* register hooks */
};

要分析apache的模块,入口点为AP_DECLARE_MODULE宏定义设置的结构体,该结构的最后一个成员变量register_hooks指定的函数为该模块的入口点。

register_hooks函数中,通过ap_hook_handler函数设置的句柄函数为apache接受请求后会调用的函数,因此下一步跟进到cgi_handler函数中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static int cgi_handler(request_rec *r)
{
......
ap_add_cgi_vars(r);
......
/* build the command line */
if ((rv = cgi_build_command(&command, &argv, r, p, &e_info)) != APR_SUCCESS) {
ap_log_rerror(APLOG_MARK, APLOG_ERR, rv, r, APLOGNO(01222)
"don't know how to spawn child process: %s",
r->filename);
return HTTP_INTERNAL_SERVER_ERROR;
}

/* run the script in its own process */
if ((rv = run_cgi_child(&script_out, &script_in, &script_err,
command, argv, r, p, &e_info)) != APR_SUCCESS) {
ap_log_rerror(APLOG_MARK, APLOG_ERR, rv, r, APLOGNO(01223)
"couldn't spawn child process: %s", r->filename);
return HTTP_INTERNAL_SERVER_ERROR;
}

通过分析cgi_handler的代码,找到三个比较重要的函数ap_add_cgi_varscgi_build_commandrun_cgi_child

首先查看ap_add_cgi_vars函数,代码如下所示:

1
2
3
4
5
6
7
8
9
AP_DECLARE(void) ap_add_cgi_vars(request_rec *r)
{
......
apr_table_setn(e, "GATEWAY_INTERFACE", "CGI/1.1");
apr_table_setn(e, "SERVER_PROTOCOL", r->protocol);
apr_table_setn(e, "REQUEST_METHOD", r->method);
apr_table_setn(e, "QUERY_STRING", r->args ? r->args : "");
......
}

该函数对之后执行CGI的一些环境变量做了设置。

接着,对cgi_build_command函数进行分析,发现cgi_build_command为函数变量,在cgi_post_config函数中被定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
static int cgi_post_config(apr_pool_t *p, apr_pool_t *plog,
apr_pool_t *ptemp, server_rec *s)
{
......
/* This is the means by which unusual (non-unix) os's may find alternate
* means to run a given command (e.g. shebang/registry parsing on Win32)
*/
cgi_build_command = APR_RETRIEVE_OPTIONAL_FN(ap_cgi_build_command);
if (!cgi_build_command) {
cgi_build_command = default_build_command;
}
return OK;
}

通过分析cgi_post_config函数,发现cgi_build_command变量指向了ap_cgi_build_command函数,接着跟进到ap_cgi_build_command函数中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
static apr_status_t ap_cgi_build_command(const char **cmd, const char ***argv,
request_rec *r, apr_pool_t *p,
cgi_exec_info_t *e_info)
{
......
if (e_info->cmd_type) {
/* We have to consider that the client gets any QUERY_ARGS
* without any charset interpretation, use prep_string to
* create a string of the literal QUERY_ARGS bytes.
*/
*cmd = r->filename;
if (r->args && r->args[0] && !ap_strchr_c(r->args, '=')) {
args = r->args;
}
}
......
/* If the file has an extension and it is not .com and not .exe and
* we've been instructed to search the registry, then do so.
* Let apr_proc_create do all of the .bat/.cmd dirty work.
*/
if (ext && (!strcasecmp(ext,".exe") || !strcasecmp(ext,".com")
|| !strcasecmp(ext,".bat") || !strcasecmp(ext,".cmd"))) {
interpreter = "";
}
......
*argv = (const char **)(split_argv(p, interpreter, *cmd,
args)->elts);
*cmd = (*argv)[0];
......
}

关键代码如上所示,其中r->args为GET请求参数,当请求参数不存在=时,将会被直接传递给args变量,最后args变量将会经过split_argv函数处理,传递给argv变量,argv变量为最后执行CGI命令的参数变量。

接着我们跟进到split_argv函数中,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
static apr_array_header_t *split_argv(apr_pool_t *p, const char *interp,
const char *cgiprg, const char *cgiargs)
{
apr_array_header_t *args = apr_array_make(p, 8, sizeof(char*));
char *d = apr_palloc(p, strlen(interp)+1);
const char *ch = interp;
const char **arg;
int prgtaken = 0;
int argtaken = 0;
int inquo;
int sl;

while (*ch) {
......
}

if (!prgtaken) {
arg = (const char**)apr_array_push(args);
*arg = cgiprg;
}

if (!argtaken) {
const char *cgiarg = cgiargs;
for (;;) {
char *w = ap_getword_nulls(p, &cgiarg, '+');
if (!*w) {
break;
}
ap_unescape_url(w);
prep_string((const char**)&w, p);
arg = (const char**)apr_array_push(args);
*arg = ap_escape_shell_cmd(p, w);
}
}

arg = (const char**)apr_array_push(args);
*arg = NULL;

return args;
}

通过上面代码可以发现,split_argv函数将会通过+对GET参数进行分割,然后通过ap_unescape_url函数进行url解码,接着通过prep_string函数进行unicode编码转换。

以上就是apache cgi模块中设置cgi参数的全过程,接着我们对run_cgi_child函数进行分析,相关代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static apr_status_t run_cgi_child(apr_file_t **script_out,
apr_file_t **script_in,
apr_file_t **script_err,
const char *command,
const char * const argv[],
request_rec *r,
apr_pool_t *p,
cgi_exec_info_t *e_info)
{
......
rc = ap_os_create_privileged_process(r, procnew, command, argv, env,
procattr, p);
......
}

接着跟进到ap_os_create_privileged_process函数中,代码如下所示:

1
2
3
4
5
6
7
8
9
10
// os/win32/util_win32.c
AP_DECLARE(apr_status_t) ap_os_create_privileged_process(
const request_rec *r,
apr_proc_t *newproc, const char *progname,
const char * const *args,
const char * const *env,
apr_procattr_t *attr, apr_pool_t *p)
{
return apr_proc_create(newproc, progname, args, env, attr, p);
}

接着跟进到apr_proc_create函数中,该函数不是apache的代码,属于apr的代码,所以我们需要在apr代码中搜索该函数的定义,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// apr/threadproc/win32/proc.c
APR_DECLARE(apr_status_t) apr_proc_create(apr_proc_t *new,
const char *progname,
const char * const *args,
const char * const *env,
apr_procattr_t *attr,
apr_pool_t *pool)
{
......
/* Handle the args, seperate from argv0 */
cmdline = "";
for (i = 1; args && args[i]; ++i) {
if (has_space(args[i]) || !args[i][0]) {
cmdline = apr_pstrcat(pool, cmdline, " \"", args[i], "\"", NULL);
}
else {
cmdline = apr_pstrcat(pool, cmdline, " ", args[i], NULL);
}
}
......
/* A simple command we are directly invoking. Do not pass
* the first arg to CreateProc() for APR_PROGRAM_PATH
* invocation, since it would need to be a literal and
* complete file path. That is; "c:\bin\aprtest.exe"
* would succeed, but "c:\bin\aprtest" or "aprtest.exe"
* can fail.
*/
cmdline = apr_pstrcat(pool, argv0, cmdline, NULL);

if (attr->cmdtype == APR_PROGRAM_PATH) {
progname = NULL;
}
......
#if APR_HAS_UNICODE_FS
IF_WIN_OS_IS_UNICODE
{
STARTUPINFOW si;
DWORD stdin_reset = 0;
DWORD stdout_reset = 0;
DWORD stderr_reset = 0;
apr_wchar_t *wprg = NULL;
apr_wchar_t *wcmd = NULL;
apr_wchar_t *wcwd = NULL;

if (progname) {
apr_size_t nprg = strlen(progname) + 1;
apr_size_t nwprg = nprg + 6;
wprg = apr_palloc(pool, nwprg * sizeof(wprg[0]));
if ((rv = apr_conv_utf8_to_ucs2(progname, &nprg, wprg, &nwprg))
!= APR_SUCCESS) {
if (attr->errfn) {
attr->errfn(pool, rv,
apr_pstrcat(pool,
"utf8 to ucs2 conversion failed"
" on progname: ", progname, NULL));
}
return rv;
}
}

if (cmdline) {
apr_size_t ncmd = strlen(cmdline) + 1;
apr_size_t nwcmd = ncmd;
wcmd = apr_palloc(pool, nwcmd * sizeof(wcmd[0]));
if ((rv = apr_conv_utf8_to_ucs2(cmdline, &ncmd, wcmd, &nwcmd))
!= APR_SUCCESS) {
if (attr->errfn) {
attr->errfn(pool, rv,
apr_pstrcat(pool,
"utf8 to ucs2 conversion failed"
" on cmdline: ", cmdline, NULL));
}
return rv;
}
}
......
rv = CreateProcessW(wprg, wcmd, /* Executable & Command line */
NULL, NULL, /* Proc & thread security attributes */
TRUE, /* Inherit handles */
dwCreationFlags, /* Creation flags */
pEnvBlock, /* Environment block */
wcwd, /* Current directory name */
&si, &pi);

apr_proc_create函数需要仔细分析,因为该函数的代码比较多,并且该函数也是apache受宽字节影响的主要原因。

通过上面的代码可以看出,当windows是一个unicode文件系统时,将会把cmdline通过apr_conv_utf8_to_ucs2转换成宽字节的变量wcmd

cmdline是一个char *类型的变量,该类型的变量是不存在宽字节的问题。而wcmd是一个apr_wchar_t *类型变量,在windows下wchar就是一个宽字节变量,占2字节。

最终wcmd将会被传递给CreateProcessW函数用来执行系统命令,做过windows开发的同学都知道,CreateProcessW函数接受宽字节参数,而其执行的php-cgi.exe程序是一个不接受宽字节的程序,所以宽字节又会被转换成char类型值进入到php当中。

宽字节和char类型变量的转换关系可以查看unicode相关文档[2]

最后,代码就到了php-cgi程序中,该部分代码位于sapi/cgi/cgi_main.c文件中,相关代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int main(int argc, char *argv[])
{
......
if((query_string = getenv("QUERY_STRING")) != NULL && strchr(query_string, '=') == NULL) {
/* we've got query string that has no = - apache CGI will pass it to command line */
unsigned char *p;
decoded_query_string = strdup(query_string);
php_url_decode(decoded_query_string, strlen(decoded_query_string));
for (p = (unsigned char *)decoded_query_string; *p && *p <= ' '; p++) {
/* skip all leading spaces */
}
if(*p == '-') {
skip_getopt = 1;
}
free(decoded_query_string);
}

php_ini_builder_init(&ini_builder);

while (!skip_getopt && (c = php_getopt(argc, argv, OPTIONS, &php_optarg, &php_optind, 0, 2)) != -1) {
......

php代码原来的处理逻辑是,当QUERY_STRING中不存在等号时,对其进行url解码,接着去除掉开头的空格,当第一个字符是-时,跳过参数处理的逻辑。

总结一下漏洞路径,当GET参数为%ads时,r->args = %ads,经过split_argv函数处理后,argv[1]被设置为\xc2\xads

QUERY_STRING环境变量在ap_add_cgi_vars函数中被设置为%ads

最后进入到apr_proc_create函数执行CGI程序,首先经过拼接cmdline变量被设置为:php-cgi.exe \xc2\xads

接着进入apr_conv_utf8_to_ucs2函数中,把char类型转换成wchar类型,其中的\xc2\xads将会被转换成0x00ad 0x0073,最后通过CreateProcessW函数执行CGI程序,php-cgi.exe程序接受到的参数为-s,接受到的QUERY_STRING环境变量为%ads

php-cgi代码中的检查不通过skip_getopt被设置为0,因此进入处理-s参数流程,达到最后利用的目的。

补丁绕过分析

该漏洞的php补丁代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
	if((query_string = getenv("QUERY_STRING")) != NULL && strchr(query_string, '=') == NULL) {
unsigned char *p;
decoded_query_string = strdup(query_string);
php_url_decode(decoded_query_string, strlen(decoded_query_string));
for (p = (unsigned char *)decoded_query_string; *p && *p <= ' '; p++) {
/* skip all leading spaces */
}
if(*p == '-') {
skip_getopt = 1;
}

/* On Windows we have to take into account the "best fit" mapping behaviour. */
#ifdef PHP_WIN32
if (*p >= 0x80) {
wchar_t wide_buf[1];
wide_buf[0] = *p;
char char_buf[4];
size_t wide_buf_len = sizeof(wide_buf) / sizeof(wide_buf[0]);
size_t char_buf_len = sizeof(char_buf) / sizeof(char_buf[0]);
if (WideCharToMultiByte(CP_ACP, 0, wide_buf, wide_buf_len, char_buf, char_buf_len, NULL, NULL) == 0
|| char_buf[0] == '-') {
skip_getopt = 1;
}
}
#endif

free(decoded_query_string);
}

补丁代码逻辑很简单,在windows环境下,在之前处理的基础上,在进行宽字节转换成char类型,判断是否是-符号。

因此,在考虑绕过的可能时,会有以下几种思路。

1. 其他宽字节编码

验证该思路的逻辑很简单,自行编写一个调用WideCharToMultiByte函数的代码进行爆破,或者直接对apache进行fuzz。

不过尝试都失败,并且该思路也根本没有这么复杂,直接通过unicode网站进行查询就能知道,该思路的可能性比较小。

并且通过分析apache代码可以知道,在进入apr_proc_create函数之前,对参数的处理都是以char类型为单位,所以可能的字符只有0x00 - 0xFF这256个字符。

在处理参数的函数中,只有一个函数需要我们关注,那就是prep_string函数,该函数的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
static void prep_string(const char ** str, apr_pool_t *p)
{
const char *ch = *str;
char *ch2;
apr_size_t widen = 0;

if (!ch) {
return;
}
while (*ch) {
if (*(ch++) & 0x80) {
++widen;
}
}
if (!widen) {
return;
}
widen += (ch - *str) + 1;
ch = *str;
*str = ch2 = apr_palloc(p, widen);
while (*ch) {
if (*ch & 0x80) {
/* sign extension won't hurt us here */
*(ch2++) = 0xC0 | ((*ch >> 6) & 0x03);
*(ch2++) = 0x80 | (*(ch++) & 0x3f);
}
else {
*(ch2++) = *(ch++);
}
}
*(ch2++) = '\0';
}

对于大于等于0x80的字符,会进行标志扩展,就比如前面说的0xad会变成0xc2,0xad

最后在apr_conv_utf8_to_ucs2函数中,又会被转换回来,当遇到小于0x80的字符时,直接进行charwchar的转换,代码如下:

1
2
3
apr_wchar_t *out;
unsigned char ch;
*(out++) = ch;

当大于等于0x80时,要求字符必须大于等于0xc0,否则报错。接着进行一系列的反向处理把0xc2,0xad转换成0x00ad

经过研究后,该部分也未发现问题,验证方法:可以尝试把apr_conv_utf8_to_ucs2prep_string函数提取出来,然后进行fuzz。

另外的思考,如果仍然是宽字节编码的问题,那么应该是可以通过fuzz apache跑出来的。

仍然是php端代码的问题

因为php端的代码检测逻辑如下:

  1. 进行url解码。
  2. 去除开头的空格。
  3. 判断是否是-符号。
  4. 对第一个字符转换成宽字节,然后再通过WideCharToMultiByte函数转换成char变量,判断是否是-符号。

首先第一步,尝试对比php的php_url_decode函数和apacheap_unescape_url函数是否在处理逻辑上又差别。结果失败,两个函数几乎没区别。

接下来第二步和第三步是一起的,除了空格,判断是否有其他字节能达到空格类似的效果,-字符不是第一个是否能进行参数处理。这部分需要查看php的php_getopt函数代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
PHPAPI int php_getopt(int argc, char* const *argv, const opt_struct opts[], char **optarg, int *optind, int show_err, int arg_start) /* {{{ */
{
static int optchr = 0;
static int dash = 0; /* have already seen the - */
static char **prev_optarg = NULL;

php_optidx = -1;

if(prev_optarg && prev_optarg != optarg) {
/* reset the state */
optchr = 0;
dash = 0;
}
prev_optarg = optarg;

if (*optind >= argc) {
return(EOF);
}
if (!dash) {
if ((argv[*optind][0] != '-')) {
return(EOF);
} else {
if (!argv[*optind][1])
{
/*
* use to specify stdin. Need to let pgm process this and
* the following args
*/
return(EOF);
}
}
}
......

从上面的代码可以看出,如果参数的第一个字符不是-,就会报错退出。那么为什么需要过滤空格呢?因为在windows下apr_proc_create函数中,CreateProcessW函数的参数不像Linux下的execve函数,参数是一个数组。CreateProcessW函数的参数是宽字节字符串,比如:php-cgi.exe -s,在php-cgi.exe字符和-s字符中间不论存在多少空格都会被视作一个空格。

是否有其他字符能代替空格,经过fuzz后,没有任何发现。

最后就是php patch后的代码,仍然是宽字节的问题,同样可以尝试提取相关代码进行爆破,无果。

QUERY_STRING环境变量

经过分析apache代码,QUERY_STRING环境变量来源于r->args变量,而CGI执行的参数同样是来源于r->args变量。

QUERY_STRING环境变量到php-cgi之前只会经过apr_conv_utf8_to_ucs2函数转换为宽字节。

而参数首先需要经过url解码,preg_string函数转换,最后经过apr_conv_utf8_to_ucs2函数转换。

但是呢,在这两者之间只能找到让QUERY_STRING被识别为-s,而参数不是-s的方案。可是这样却本末倒置了,我们需要的方案是让QUERY_STRING的第一个值不为-,而参数的第一个值为-

并且还考虑过是否=符号能进行宽字节转换,但是通过fuzz和unicode网站查询,没有发现。

总结

该漏洞的通用方案目前发现的只影响到了xampp,可通过zoomeye获取到目标,成功率还比较高,并且已经有勒索软件进行批量攻击行为。

而非通用方案,却没有探测方法,除非有apache配置代码,否则等同于大海捞针。

参考链接

  1. https://devco.re/blog/2024/06/06/security-alert-cve-2024-4577-php-cgi-argument-injection-vulnerability-en/
  2. https://www.unicode.org/Public/MAPPINGS/VENDORS/MICSFT/WindowsBestFit/bestfit936.txt

CVE-2024-4577漏洞分析

https://nobb.site/2024/07/11/0x8C/

Author

Hcamael

Posted on

2024-07-11

Updated on

2024-07-11

Licensed under