PHP 的 trim,form feed,以及一道 CTF 题解

缘起

trim 函数是编程语言中非常常见的一个函数,它可以消除字符串两端的空格。PHP 里,trim 是一个标准库的函数,其文档位于:https://www.php.net/manual/zh/function.trim.php

事情的起因是去年,我在看 PHP 8.5 内核的源代码时,看到了 ext/standard 里 trim 函数的实现: /ext/standard/string.c#L612

1
2
3
4
5
6
/* {{{ Strips whitespace from the beginning and end of a string */
PHP_FUNCTION(trim)
{
php_do_trim(INTERNAL_FUNCTION_PARAM_PASSTHRU, 3);
}
/* }}} */

跟进 php_do_trim: ext/standard/string.c#L625

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* {{{ php_do_trim
* Base for trim(), rtrim() and ltrim() functions.
*/
static zend_always_inline void php_do_trim(INTERNAL_FUNCTION_PARAMETERS, int mode)
{
zend_string *str;
zend_string *what = NULL;

ZEND_PARSE_PARAMETERS_START(1, 2)
Z_PARAM_STR(str)
Z_PARAM_OPTIONAL
Z_PARAM_STR(what)
ZEND_PARSE_PARAMETERS_END();

ZVAL_STR(return_value, php_trim_int(str, (what ? ZSTR_VAL(what) : NULL), (what ? ZSTR_LEN(what) : 0), mode));
}
/* }}} */

由上,可以知道 trim 函数里传递给 php_do_trim 的第一个参数是用户输入的需要 trim 的字符串。而第二个参数 mode 总是 3

插一嘴:在编程中利用魔术数字(magic number)是很不好的行为。我在未来大概率会重构这部分的代码。大家不要学习这样的写作方式 :)

其实,我们从其他两个函数 rtrimltrim 的源码里也能猜出来 mode 的含义

1
2
3
4
5
6
7
8
9
10
11
12
13
/* {{{ Removes trailing whitespace */
PHP_FUNCTION(rtrim)
{
php_do_trim(INTERNAL_FUNCTION_PARAM_PASSTHRU, 2);
}
/* }}} */

/* {{{ Strips whitespace from the beginning of a string */
PHP_FUNCTION(ltrim)
{
php_do_trim(INTERNAL_FUNCTION_PARAM_PASSTHRU, 1);
}
/* }}} */

好,我们接着跟进 php_do_trim。可以看到函数体主要是调用了 php_trim_int 函数。跟进:ext/standard/string.c#L518

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
84
85
86
87
88
89
90
91
/* {{{ php_trim_int()
* mode 1 : trim left
* mode 2 : trim right
* mode 3 : trim left and right
* what indicates which chars are to be trimmed. NULL->default (' \t\n\r\v\0')
*/
static zend_always_inline zend_string *php_trim_int(zend_string *str, const char *what, size_t what_len, int mode)
{
const char *start = ZSTR_VAL(str);
const char *end = start + ZSTR_LEN(str);
char mask[256];

if (what) {
if (what_len == 1) {
char p = *what;
if (mode & 1) {
while (start != end) {
if (*start == p) {
start++;
} else {
break;
}
}
}
if (mode & 2) {
while (start != end) {
if (*(end-1) == p) {
end--;
} else {
break;
}
}
}
} else {
php_charmask((const unsigned char *) what, what_len, mask);

if (mode & 1) {
while (start != end) {
if (mask[(unsigned char)*start]) {
start++;
} else {
break;
}
}
}
if (mode & 2) {
while (start != end) {
if (mask[(unsigned char)*(end-1)]) {
end--;
} else {
break;
}
}
}
}
} else {
if (mode & 1) {
while (start != end) {
unsigned char c = (unsigned char)*start;

if (c <= ' ' &&
(c == ' ' || c == '\n' || c == '\r' || c == '\t' || c == '\v' || c == '\0')) {
start++;
} else {
break;
}
}
}
if (mode & 2) {
while (start != end) {
unsigned char c = (unsigned char)*(end-1);

if (c <= ' ' &&
(c == ' ' || c == '\n' || c == '\r' || c == '\t' || c == '\v' || c == '\0')) {
end--;
} else {
break;
}
}
}
}

if (ZSTR_LEN(str) == end - start) {
return zend_string_copy(str);
} else if (end - start == 0) {
return ZSTR_EMPTY_ALLOC();
} else {
return zend_string_init(start, end - start, 0);
}
}
/* }}} */

逻辑非常清楚:如果是 mode 3 的话,则循环检测字符串最左边的字符是否为空格字符,若是,删除;若不是,退出循环。再将同样的逻辑适用于字符串的右边。

我们可以看到,这里被视为空格的字符串有:

  • “ “: ASCII SP 字符 0x20,一个普通的空格。
  • “\t”: ASCII HT 字符 0x09,一个制表符。
  • “\n”: ASCII LF 字符 0x0A,一个换行符。
  • “\r”: ASCII CR 字符 0x0D,一个回车符。
  • “\v”: ASCII VT 字符 0x0B,一个垂直制表符。
  • “\0”: ASCII NUL 字符 0x00,一个 NUL 字节。

不知道大家还记不记得 Cpp 里 std::isspace 函数会将哪些字符视作空格。如果你对这个领域不熟悉的话可以看他们的文档:https://cppreference.cn/w/cpp/string/byte/isspace

我们可以看到,这里被视为空格的字符串有:

  • “ “: ASCII SP 字符 0x20,一个普通的空格。
  • “\t”: ASCII HT 字符 0x09,一个制表符。
  • “\n”: ASCII LF 字符 0x0A,一个换行符。
  • “\r”: ASCII CR 字符 0x0D,一个回车符。
  • “\v”: ASCII VT 字符 0x0B,一个垂直制表符。
  • “\f”: ASCII FF 字符 0x0C,一个换页符。

有时候,真理往往隐藏在细节之中… 聪明的你,注意到区别了吗?

不统一性的诞生

站在网络安全的视角来看,白盒审计的经验告诉我们:漏洞往往是由不统一性(inconsistency)导致的。身为一个程序员的直觉告诉我们,\f(form feed,0x0c) 理应被视为空格,但是却不会在 php 的 trim 里被视为空格删除。那么,假设 PHP 里的其他函数将 form feed 视作空格的同时,我们又对 trim 函数不将 form feed 视为空格的条件加以利用,是不是可能导致潜在的逻辑漏洞?

我们来看 PHP 内核里的另外一个函数,is_numeric,文档:https://www.php.net/manual/zh/function.is-numeric.php

is_numeric 函数被广泛用于检测变量是否是数字或数字字符串。当字符串的开头或结尾出现空格时,它会自动删除这些空格。我们来看看这里,form feed 有没有被视作空格字符:

考虑 stub 文件:

1
ZEND_RAW_FENTRY("is_numeric", zif_is_numeric, arginfo_is_numeric, ZEND_ACC_COMPILE_TIME_EVAL, frameless_function_infos_is_numeric, NULL)

得到,is_numeric实际上就是暴露了内部的 _zend_is_numeric 函数接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static zend_always_inline void _zend_is_numeric(zval *return_value, zval *arg)
{
switch (Z_TYPE_P(arg)) {
case IS_LONG:
case IS_DOUBLE:
RETURN_TRUE;

case IS_STRING:
if (is_numeric_string(Z_STRVAL_P(arg), Z_STRLEN_P(arg), NULL, NULL, 0)) {
RETURN_TRUE;
} else {
RETURN_FALSE;
}
break;

default:
RETURN_FALSE;
}
}

那么,实际上就是 is_numeric_string 函数再发挥作用,跟进

1
2
3
static zend_always_inline uint8_t is_numeric_string(const char *str, size_t length, zend_long *lval, double *dval, bool allow_errors) {
return is_numeric_string_ex(str, length, lval, dval, allow_errors, NULL, NULL);
}

跟进:

1
2
3
4
5
6
7
8
static zend_always_inline uint8_t is_numeric_string_ex(const char *str, size_t length, zend_long *lval,
double *dval, bool allow_errors, int *oflow_info, bool *trailing_data)
{
if (*str > '9') {
return 0;
}
return _is_numeric_string_ex(str, length, lval, dval, allow_errors, oflow_info, trailing_data);
}

因此 is_numeric 函数内核里实际上封装了一个 _is_numeric_string_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
42
43
44
45
46
47
48
49
ZEND_API uint8_t ZEND_FASTCALL _is_numeric_string_ex(const char *str, size_t length, zend_long *lval,
double *dval, bool allow_errors, int *oflow_info, bool *trailing_data) /* {{{ */
{
const char *ptr;
int digits = 0, dp_or_e = 0;
double local_dval = 0.0;
uint8_t type;
zend_ulong tmp_lval = 0;
int neg = 0;

if (!length) {
return 0;
}

if (oflow_info != NULL) {
*oflow_info = 0;
}
if (trailing_data != NULL) {
*trailing_data = false;
}

/* Skip any whitespace
* This is much faster than the isspace() function */
while (*str == ' ' || *str == '\t' || *str == '\n' || *str == '\r' || *str == '\v' || *str == '\f') {
str++;
length--;
}
ptr = str;

/* 省略 */

if (ptr != str + length) {
const char *endptr = ptr;
while (*endptr == ' ' || *endptr == '\t' || *endptr == '\n' || *endptr == '\r' || *endptr == '\v' || *endptr == '\f') {
endptr++;
length--;
}
if (ptr != str + length) {
if (!allow_errors) {
return 0;
}
if (trailing_data != NULL) {
*trailing_data = true;
}
}
}

/* 省略 */
}

看到 \f 了么?这里,我们要找寻的 inconsistency,终于浮出水面了。

同样地,intval这个把字符串转为数字的函数也会自动去除字符串两端的空格,那么,不难想到这个函数也有这个问题。

利用

考虑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$rawRole = $_GET['role'] ?? '';
$roleText = trim($rawRole);

if ($roleText === '1') {
http_response_code(403);
exit('admin role is forbidden');
}

if (!is_numeric($roleText)) {
http_response_code(400);
exit('invalid role id');
}

$roleId = intval($rawRole);
grantRole($userId, $roleId);

考虑传入 ?role=1%0c

因为在这里,form feed(\f,%0c)不会被 trim 函数删除,所以得以保留;'1\f' === '1' 显然为假;is_numeric 时,由于 %0c 被正常视作空格,所以可以通过;intval 时,由于 %0c 被正常视作空格,所以被忽略,最后,用户就拿到了管理员权限。

解决

解决办法很简单:在 trimltrimrtrimchoprtrim 的别名)里把 form feed 加上就好了。

RFC:https://wiki.php.net/rfc/trim_form_feed

全票通过,补丁已经在 PHP 8.6 落地。