PHP的trim,formfeed,以及一道CTF题解"

admin 2026-10-17 08:45:37 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

cover_image

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

原创

LamentXU
LamentXU

黄豆安全实验室

2026年8月18日 10:37
天津

在小说阅读器读本章

去阅读

缘起

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

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

/* {{{ 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[2]

/* {{{ php_do_trim*Basefor 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)是很不好的行为。我在未来大概率会重构这部分的代码。大家不要学习这样的写作方式 :)

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

/* {{{ 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[3]

/* {{{ 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\
\\r\\v\\0')*/static zend_always_inline zend_string *php_trim_int(zend_string *str,constchar*what,size_t what_len,int mode){constchar*start = ZSTR_VAL(str);constchar*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((constunsignedchar*) what, what_len, mask);
if(mode &1){while(start !=end){if(mask[(unsignedchar)*start]){                        start++;}else{break;}}}if(mode &2){while(start !=end){if(mask[(unsignedchar)*(end-1)]){end--;}else{break;}}}}}else{if(mode &1){while(start !=end){unsignedchar c =(unsignedchar)*start;
if(c <=' '&&(c ==' '|| c =='\
'|| c =='\\r'|| c =='\\t'|| c =='\\v'|| c =='\\0')){                    start++;}else{break;}}}if(mode &2){while(start !=end){unsignedchar c =(unsignedchar)*(end-1);
if(c <=' '&&(c ==' '|| c =='\
'|| c =='\\r'|| c =='\\t'|| c =='\\v'|| c =='\\0')){end--;}else{break;}}}}
if(ZSTR_LEN(str)==end- start){return zend_string_copy(str);}elseif(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,一个制表符。•\"\
\": 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,一个制表符。•\"\
\": 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 文件:

    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 函数接口。

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 函数再发挥作用,跟进

static zend_always_inline uint8_t is_numeric_string(constchar*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);}

跟进:

static zend_always_inline uint8_t is_numeric_string_ex(constchar*str,size_t length, zend_long *lval,double*dval,bool allow_errors,int*oflow_info,bool*trailing_data){if(*str >'9'){return0;}return _is_numeric_string_ex(str, length, lval, dval, allow_errors, oflow_info, trailing_data);}

因此 is_numeric 函数内核里实际上封装了一个 _is_numeric_string_ex,跟进:

ZEND_API uint8_t ZEND_FASTCALL _is_numeric_string_ex(constchar*str,size_t length, zend_long *lval,double*dval,bool allow_errors,int*oflow_info,bool*trailing_data)/* {{{ */{constchar*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){return0;}
if(oflow_info != NULL){*oflow_info =0;}if(trailing_data != NULL){*trailing_data =false;}
/* Skip any whitespace*Thisis much faster than the isspace()function*/while(*str ==' '||*str =='\\t'||*str =='\
'||*str =='\\r'||*str =='\\v'||*str =='\\f'){        str++;        length--;}    ptr = str;
/* 省略 */
if(ptr != str + length){constchar*endptr = ptr;while(*endptr ==' '||*endptr =='\\t'||*endptr =='\
'||*endptr =='\\r'||*endptr =='\\v'||*endptr =='\\f'){            endptr++;            length--;}if(ptr != str + length){if(!allow_errors){return0;}if(trailing_data != NULL){*trailing_data =true;}}}
/* 省略 */}

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

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

利用

考虑:

$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 被正常视作空格,所以被忽略,最后,用户就拿到了管理员权限。

解决

解决办法很简单:在 trim,ltrim,rtrim,chop(rtrim 的别名)里把 form feed 加上就好了。

RFC:https://wiki.php.net/rfc/trim\_form\_feed

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

本文内链接

[1] /ext/standard/string.c#L612: https://github.com/php/php-src/blob/PHP-8.5/ext/standard/string.c#L612
[2] ext/standard/string.c#L625: https://github.com/php/php-src/blob/PHP-8.5/ext/standard/string.c#L625
[3] ext/standard/string.c#L518: https://github.com/php/php-src/blob/PHP-8.5/ext/standard/string.c#L518


PHP的trim,formfeed,以及一道CTF题解" 网络安全文章

PHP的trim,formfeed,以及一道CTF题解"

本文分析了PHP中trim函数与is_numeric/intval函数在处理formfeed字符(\\\\f,0x0C)时的行为不一致性。trim不将formfeed视为空白字符,而is_numeric和intval则将其视为空白并自动去除。这种不一致性可被利用绕过安全检查,例如在角色验证场景中通过传入包含formfeed的字符串来绕过trim的过滤,同时通过is\\_numeric和intval的检查,最终授予管理员权限。文章提供了具体的代码示例和利用方法,强调了安全审计中需关注此类细节差异
【0DAY漏洞】 全球5G手机芯片厂商之一‌的紫光展锐SOC存在RCE漏洞,影响数十家手机厂商,还未发布补丁" 网络安全文章

【0DAY漏洞】 全球5G手机芯片厂商之一‌的紫光展锐SOC存在RCE漏洞,影响数十家手机厂商,还未发布补丁"

紫光展锐多个5GSoC平台(T612/T616/T606/T7250)的modem固件存在0day远程代码执行漏洞(CWE-674不受控递归)。攻击者通过蜂窝网络发送特制SIPINVITE消息,利用SDP中acap属性解码函数无递归深度限制触发栈溢出,可在受害modem上执行任意代码。目前官方未发布补丁,建议用户保持固件最新并避免接入不可信网络。
两年磨一剑!密探2.0从底层到交互全面进化,究竟强在哪?" 网络安全文章

两年磨一剑!密探2.0从底层到交互全面进化,究竟强在哪?"

密探2.0是一款基于Rust+Tauri重构的渗透测试工具,集成资产测绘、漏洞检测、云安全等50余项能力,支持断点续扫、MCPServer及全平台运行。文章强调其项目级数据管理与聚合测绘功能,并提供VIP订阅模式,用户可通过贡献或推广免费获取。工具仅供授权测试使用,开发者明确要求用户遵守法律法规。
评论:0   参与:  0