本篇文章将对CVE-2024-4577漏洞进行详细分析。
CVE-2024-4577漏洞叫做PHP CGI参数注入漏洞[1] ,该漏洞的利用条件为:
Windows环境,并且系统默认语言为以下之一: 855: 西里尔字母 862: 希伯来语 866: 俄语(西里尔字母) 932: 日语(Shift-JIS) 936: 简体中文(GBK) 950: 繁体中文(Big5)
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, NULL , create_cgi_config, merge_cgi_config, cgi_cmds, 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); ...... 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; } 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_vars
,cgi_build_command
和run_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) { ...... 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) { *cmd = r->filename; if (r->args && r->args[0 ] && !ap_strchr_c(r->args, '=' )) { args = r->args; } } ...... 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 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_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) { ...... 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 ); } } ...... 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, NULL , NULL , TRUE, dwCreationFlags, pEnvBlock, wcwd, &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 ) { 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++) { } 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++) { } if (*p == '-' ) { skip_getopt = 1 ; } #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 ) { *(ch2++) = 0xC0 | ((*ch >> 6 ) & 0x03 ); *(ch2++) = 0x80 | (*(ch++) & 0x3f ); } else { *(ch2++) = *(ch++); } } *(ch2++) = '\0' ; }
对于大于等于0x80
的字符,会进行标志扩展,就比如前面说的0xad
会变成0xc2,0xad
。
最后在apr_conv_utf8_to_ucs2
函数中,又会被转换回来,当遇到小于0x80
的字符时,直接进行char
到wchar
的转换,代码如下:
1 2 3 apr_wchar_t *out;unsigned char ch;*(out++) = ch;
当大于等于0x80
时,要求字符必须大于等于0xc0
,否则报错。接着进行一系列的反向处理把0xc2,0xad
转换成0x00ad
。
经过研究后,该部分也未发现问题,验证方法:可以尝试把apr_conv_utf8_to_ucs2
和prep_string
函数提取出来,然后进行fuzz。
另外的思考,如果仍然是宽字节编码的问题,那么应该是可以通过fuzz apache跑出来的。
仍然是php端代码的问题 因为php端的代码检测逻辑如下:
进行url解码。
去除开头的空格。
判断是否是-
符号。
对第一个字符转换成宽字节,然后再通过WideCharToMultiByte
函数转换成char
变量,判断是否是-
符号。
首先第一步,尝试对比php的php_url_decode
函数和apache
的ap_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 ; static char **prev_optarg = NULL ; php_optidx = -1 ; if (prev_optarg && prev_optarg != optarg) { 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 ]) { 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配置代码,否则等同于大海捞针。
参考链接
https://devco.re/blog/2024/06/06/security-alert-cve-2024-4577-php-cgi-argument-injection-vulnerability-en/
https://www.unicode.org/Public/MAPPINGS/VENDORS/MICSFT/WindowsBestFit/bestfit936.txt