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官方能给出一个比较合理的解决方式。