0x00 前言
前些日子看见了关于用php在处理请求的查询参数特殊性来绕过相关IDS,IPS检测一文https://www.secjuice.com/abusing-php-query-string-parser-bypass-ids-ips-waf/。确实在我看来确实可以绕过很多特殊的规则。原文只是展示了结果,我想看看为什么php内部要这么处理,这么做的意义在哪?一开始我确实不明白,因为例如GET参数,最终会写入$_GET[]
这个全局的数组里面,数组在PHP就是HashTable,在我印象里面需要对里面的key特殊处理吗?当时十分不解,遂有了下文。
0x01 调用链
在探究这个问题之前,先简单的想一下,整个处理流程。先得拿到查询语句,在进行分割,有点同学说可以直接看parse_str
处理过程不就行了?这里我尽量模拟真实的请求过程,说到这个问题,让我想起了p牛曾经提到的一个问题,在php-cli
下怎么传$_GET
和$_POST
参数?因为时间问题,最后也没有深究,但是肯定是可以的,因为$_GlOBAL
里面是定义了这两个变量的。回归正题,处理请求之前,我们得先确定用什么sapi
吧,常见的cli,apache2Handler,cgi,fastcgi
等等。这里sapi
选择一下较为常见php_mod
的情况下apache2handler
。
php
被编译成了apache
扩展形式,当php_mod加载时,每一个请求的apache_hook_handler
就多了一个的php_handler
,如下 1
ap_hook_handler(php_handler, NULL, NULL, APR_HOOK_MIDDLE);
SG(request_info)
的初始化,不同的sapi
其中一个不同之处就在于这个地方,里面有随后我们的需要的SG(request_info).query_string
,这个过程发生在php_apache_request_ctor()
中,当初始化完成,apache
将控制权交给了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
29
30
31int php_request_startup(void)
{
int retval = SUCCESS;
zend_interned_strings_activate();
....
zend_try {
PG(in_error_log) = 0;
PG(during_request_startup) = 1;
php_output_activate();
/* initialize global variables */
PG(modules_activated) = 0;
PG(header_is_being_sent) = 0;
PG(connection_status) = PHP_CONNECTION_NORMAL;
PG(in_user_include) = 0;
zend_activate();
sapi_activate();
...
php_hash_environment();
zend_activate_modules();
PG(modules_activated)=1;
} zend_catch {
retval = FAILURE;
} zend_end_try();
SG(sapi_started) = 1;
return retval;
}php_hash_environment()
这个函数就是用来初始化每个请求的相关全局变量的函数,跟一下。 1
2
3
4
5
6
7PHPAPI int php_hash_environment(void)
{
memset(PG(http_globals), 0, sizeof(PG(http_globals)));
zend_activate_auto_globals();
...
return SUCCESS;
}zend_activate_auto_globals()
1
2
3
4
5
6
7
8
9
10
11
12
13
14ZEND_API void zend_activate_auto_globals(void) /* {{{ */
{
zend_auto_global *auto_global;
ZEND_HASH_FOREACH_PTR(CG(auto_globals), auto_global) {
if (auto_global->jit) {
auto_global->armed = 1;
} else if (auto_global->auto_global_callback) {
auto_global->armed = auto_global->auto_global_callback(auto_global->name);
} else {
auto_global->armed = 0;
}
} ZEND_HASH_FOREACH_END();
}1
2
3
4
5
6typedef struct _zend_auto_global {
zend_string *name; //超全局变量的变量名
zend_auto_global_callback auto_global_callback;//处理超全局变量的handler
zend_bool jit;
zend_bool armed;
} zend_auto_global;CG(auto_globals)
是一个存储zend_auto_global
的HashTable
,所以这里我们必须先得找到GET,POST等待超全局变量的注册地方。
在apache中注册php_handler
的地方还有一个ap_hook_post_config(php_apache_server_startup, NULL, NULL, APR_HOOK_MIDDLE);
,其中的调用链如下: 1
php_apache_server_startup() -> apache2_sapi_module.startup -> php_module_startup -> php_startup_auto_globals
php_startup_auto_globals
1
2
3
4
5
6
7
8
9
10void php_startup_auto_globals(void)
{
zend_register_auto_global(zend_string_init_interned("_GET", sizeof("_GET")-1, 1), 0, php_auto_globals_create_get);
zend_register_auto_global(zend_string_init_interned("_POST", sizeof("_POST")-1, 1), 0, php_auto_globals_create_post);
zend_register_auto_global(zend_string_init_interned("_COOKIE", sizeof("_COOKIE")-1, 1), 0, php_auto_globals_create_cookie);
zend_register_auto_global(zend_string_init_interned("_SERVER", sizeof("_SERVER")-1, 1), PG(auto_globals_jit), php_auto_globals_create_server);
zend_register_auto_global(zend_string_init_interned("_ENV", sizeof("_ENV")-1, 1), PG(auto_globals_jit), php_auto_globals_create_env);
zend_register_auto_global(zend_string_init_interned("_REQUEST", sizeof("_REQUEST")-1, 1), PG(auto_globals_jit), php_auto_globals_create_request);
zend_register_auto_global(zend_string_init_interned("_FILES", sizeof("_FILES")-1, 1), 0, php_auto_globals_create_files);
}1
auto_global->armed = auto_global->auto_global_callback(auto_global->name);
_GET
的handler
1 | static zend_bool php_auto_globals_create_get(zend_string *name) |
这里的variables_order
是一个ini配置项,指定是否解析相关变量,默认是解析所有超全局变量。这里我们重点看sapi_module.treat_data
,看看apache_sapi相对应的处理函数。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23static sapi_module_struct apache2_sapi_module = {
"apache2handler",
"Apache 2.0 Handler",
php_apache2_startup, /* startup */
php_module_shutdown_wrapper, /* shutdown */
NULL, /* activate */
NULL, /* deactivate */
php_apache_sapi_ub_write, /* unbuffered write */
php_apache_sapi_flush, /* flush */
php_apache_sapi_get_stat, /* get uid */
php_apache_sapi_getenv, /* getenv */
php_error, /* error handler */
php_apache_sapi_header_handler, /* header handler */
php_apache_sapi_send_headers, /* send headers handler */
NULL, /* send header handler */
php_apache_sapi_read_post, /* read POST data */
php_apache_sapi_read_cookies, /* read Cookies */
php_apache_sapi_register_variables,
php_apache_sapi_log_message, /* Log message */
php_apache_sapi_get_request_time, /* Request Time */
NULL, /* Child Terminate */
STANDARD_SAPI_MODULE_PROPERTIES
};treat_data
在STANDARD_SAPI_MODULE_PROPERTIES
里面,所以这里我们继续看STANDARD_SAPI_MODULE_PROPERTIES
的定义。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
NULL, /* php_ini_path_override */ \
NULL, /* default_post_reader */ \
NULL, /* treat_data */ \
NULL, /* executable_location */ \
0, /* php_ini_ignore */ \
0, /* php_ini_ignore_cwd */ \
NULL, /* get_fd */ \
NULL, /* force_http_10 */ \
NULL, /* get_target_uid */ \
NULL, /* get_target_gid */ \
NULL, /* input_filter */ \
NULL, /* ini_defaults */ \
0, /* phpinfo_as_text; */ \
NULL, /* ini_entries; */ \
NULL, /* additional_functions */ \
NULL /* input_filter_init */NULL
啊,这里需要回到php_module_startup
里面,在php_startup_auto_globals()
调用结束后面有一个php_startup_sapi_content_types()
的调用过程。这里会注册默认的treat_data
给当前的sapi_module
1
2
3
4
5
6
7int php_startup_sapi_content_types(void)
{
sapi_register_default_post_reader(php_default_post_reader);
sapi_register_treat_data(php_default_treat_data);
sapi_register_input_filter(php_default_input_filter, NULL);
return SUCCESS;
}
0x02 Query具体处理过程
这里看一下默认的treat_data
是如何处理的,这个处理函数有点长,我稍微处理压缩一下分支处理,因为这里只关注处理_GET
的过程。 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
62SAPI_API SAPI_TREAT_DATA_FUNC(php_default_treat_data)
{
char *res = NULL, *var, *val, *separator = NULL;
const char *c_var;
zval array;
int free_buffer = 0;
char *strtok_buf = NULL;
zend_long count = 0;
ZVAL_UNDEF(&array);
switch (arg) {
case PARSE_POST:
case PARSE_GET:
case PARSE_COOKIE:
array_init(&array);
switch (arg) {
...
case PARSE_GET:
zval_ptr_dtor_nogc(&PG(http_globals)[TRACK_VARS_GET]); //初始化array
ZVAL_COPY_VALUE(&PG(http_globals)[TRACK_VARS_GET], &array);
break;
...
}
break;
}
if (arg == PARSE_GET) { /* GET data */
c_var = SG(request_info).query_string; //取请求中的查询参数即?后的部分
if (c_var && *c_var) {
res = (char *) estrdup(c_var);
free_buffer = 1;
} else {
free_buffer = 0;
}
}
...
switch (arg) {
case PARSE_GET:
case PARSE_STRING:
separator = PG(arg_separator).input; // 参数对分割符,这里默认是“&”
break;
}
var = php_strtok_r(res, separator, &strtok_buf);//就是strtok分割函数,依次将&置\0,返回分割的token
while (var) {
val = strchr(var, '='); // 取=字符的位置
...
if (val) { /* have a value */
size_t val_len;
size_t new_val_len;
*val++ = '\0';
php_url_decode(var, strlen(var)); //key urldecode
val_len = php_url_decode(val, strlen(val));//value urldecode
val = estrndup(val, val_len);
if (sapi_module.input_filter(arg, var, &val, val_len, &new_val_len)) {
php_register_variable_safe(var, val, new_val_len, &array);//进入真正注册变量的位置
}
efree(val);
}
...
}
...
} php_register_variable_safe
过程,这里其实进入的是php_register_variable_ex
。 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
41PHPAPI void php_register_variable_ex(char *var_name, zval *val, zval *track_vars_array){
...
while (*var_name==' ') {
var_name++;
}/*去掉参数对中key前面的空格*/
var_len = strlen(var_name);
var = var_orig = do_alloca(var_len + 1, use_heap);
memcpy(var_orig, var_name, var_len + 1);
/* ensure that we don't have spaces or dots in the variable name (not binary safe) */
for (p = var; *p; p++) {
if (*p == ' ' || *p == '.') {
*p='_';
} else if (*p == '[') {
is_array = 1;
ip = p;
*p = 0;
break;
}
}/*将key中出现空格和点都替换成了下划线*/
...
if (is_array) {
...
while (1) {
...
ip++;
index_s = ip;
if (isspace(*ip)) {
ip++;
}
if (*ip==']') {
index_s = NULL;
} else {
ip = strchr(ip, ']');
if (!ip) {
*(index_s - 1) = '_';
....
} //这里是匹配参数key是数组的形式,如果[]不匹配,则会把先前的[的重新置为下划线。
....key=value
,当key前面有空格,先去掉空格,如果key中包含空格和点,不匹配的方括号,都会变为_,还有关于前面引文中出现的%00问题,在urldecode的时候就引入\0,后在计算index的时候就被截断了。 1
2
3if (index) {
index_len = strlen(index);
}1
/* ensure that we don't have spaces or dots in the variable name (not binary safe) */
register_globals
,保证变量名不会因为非法字符而导致注册失败。但是register_globals
在5.4.0就被遗弃了,为什么我这里PHP 7.4.0alpha3,依然存在呢?有点意思。
随后我想到了extract
它的功能和register_globals
差不多相似,都是用来注册变量。但是extract
里面对变量名有严格的检验。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21static zend_always_inline int php_valid_var_name(const char *var_name, size_t var_name_len) /* {{{ */
{
if (var_name[0] != '_' &&
(ch < 65 /* A */ || /* Z */ ch > 90) &&
(ch < 97 /* a */ || /* z */ ch > 122) &&
(ch < 127 /* 0x7f */ || /* 0xff */ ch > 255)
) {
return 0
} //开头字母 [a-zA-Z_\x7f-\xff]
...
do {
if (var_name[i] != '_' &&
(ch < 48 /* 0 */ || /* 9 */ ch > 57) &&
(ch < 65 /* A */ || /* Z */ ch > 90) &&
(ch < 97 /* a */ || /* z */ ch > 122) &&
(ch < 127 /* 0x7f */ || /* 0xff */ ch > 255)
) {
return 0;
}
} while (++i < var_name_len); // [a-zA-Z0-9_\x7f-\xff]extract($_GET)
这样的情况,但这里有一个比较有意思的情况。如果请求参数是下面这种情况 1
maple[ . [=amazing => maple_ .[=amazing
[
,就会直接退出当前循环,以导致后面的数据不会被处理,这里同样引进了空格和点,我不懂这里依然做替换的意义是什么?为什么其他特殊字符并没有替换?看起来确实比较混乱,才导致了bypass,这可能是php的一个历史遗留问题至今也没有解决。希望php官方能给出一个比较合理的解决方式。