PHP内核之请求参数解析

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
31
int 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
7
PHPAPI 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
14
ZEND_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
6
typedef 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_globalHashTable,所以这里我们必须先得找到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的生命周期。很容易就找到php_startup_auto_globals
1
2
3
4
5
6
7
8
9
10
void 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);
}
这里注册了所有超全局变量的处理的handler,回到上一步,
1
auto_global->armed = auto_global->auto_global_callback(auto_global->name);
通过遍历CG(auto_global)这个HashTable,然后分别调用相关的handler依次处理,这里我们看处理_GEThandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static zend_bool php_auto_globals_create_get(zend_string *name)
{
if (PG(variables_order) && (strchr(PG(variables_order),'G') || strchr(PG(variables_order),'g'))) {
sapi_module.treat_data(PARSE_GET, NULL, NULL);
} else {
zval_ptr_dtor_nogc(&PG(http_globals)[TRACK_VARS_GET]);
array_init(&PG(http_globals)[TRACK_VARS_GET]);
}

zend_hash_update(&EG(symbol_table), name, &PG(http_globals)[TRACK_VARS_GET]);
Z_ADDREF(PG(http_globals)[TRACK_VARS_GET]);

return 0; /* don't rearm */
}

这里的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
23
static 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_dataSTANDARD_SAPI_MODULE_PROPERTIES里面,所以这里我们继续看STANDARD_SAPI_MODULE_PROPERTIES的定义。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#define STANDARD_SAPI_MODULE_PROPERTIES \
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
7
int 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
62
SAPI_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
41
PHPAPI 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
3
if (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
21
static 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官方能给出一个比较合理的解决方式。