从CTF题[鹤城杯 2021]EasyP剖析PHP安全:$_SERVER变量、正则绕过与basename的攻防实战
1. 从一道CTF题看PHP安全攻防
第一次看到这道题的时候,我也被它绕晕了。表面上就是一段简单的PHP代码,但仔细一看全是坑。这道题来自2021年鹤城杯CTF比赛,名字叫EasyP,但实际上一点都不"easy"。它巧妙利用了PHP中几个容易被忽视的安全特性,包括$_SERVER变量的差异、basename()函数的特性,以及正则表达式的绕过技巧。
这道题的核心在于如何通过精心构造的URL,绕过代码中的安全防护,最终读取到utils.php文件中的flag。整个过程涉及到三个关键技术点:$_SERVER['PHP_SELF']和$_SERVER['REQUEST_URI']的区别、basename()函数在处理路径时的特性,以及如何利用特殊字符绕过正则表达式检查。下面我们就来逐一拆解这些技术点,看看这道题到底是怎么被攻破的。
2. 理解$_SERVER变量的关键差异
2.1 $_SERVER['PHP_SELF']的玄机
$_SERVER['PHP_SELF']这个变量看起来简单,但实际上藏着不少坑。它返回的是当前执行脚本相对于网站根目录的路径。听起来很直接对吧?但关键在于它如何处理URL中的额外路径信息。
举个例子,如果你的网站结构是这样的:
/var/www/html/index.php当访问http://example.com/index.php时,$_SERVER['PHP_SELF']的值就是/index.php。但如果访问http://example.com/index.php/extra/path,它的值就会变成/index.php/extra/path。这个特性在本题中被巧妙地利用了。
我在实际测试中发现,即使extra/path这部分根本不存在,PHP也会原封不动地把它包含在PHP_SELF中。这就为路径操纵提供了可能。比如我们可以构造一个URL,让PHP_SELF看起来像是访问了utils.php,但实际上执行的是index.php。
2.2 $_SERVER['REQUEST_URI']的不同之处
相比之下,$_SERVER['REQUEST_URI']的行为就有所不同了。它返回的是请求的完整URI,包括查询字符串。比如访问http://example.com/index.php/test?param=value时,REQUEST_URI的值就是/index.php/test?param=value。
这道题中,代码用REQUEST_URI来检查是否包含show_source字符串。这里有个关键点:REQUEST_URI不会像PHP_SELF那样被URL解码。也就是说,如果你用URL编码的特殊字符,它们在REQUEST_URI中会保持原样,而在PHP_SELF中可能会被解码。
我做过一个测试:访问http://example.com/index.php/%61%62%63时:
PHP_SELF会显示/index.php/abc(解码后)REQUEST_URI则保持/index.php/%61%62%63
这个差异在后面绕过正则检查时非常重要。
3. basename()函数的安全隐患
3.1 basename的基本行为
basename()函数本意是用来获取路径中的文件名部分。比如:
echo basename('/var/www/html/index.php'); // 输出index.php看起来很简单对吧?但它的实际行为在某些情况下会很危险。特别是当路径中包含特殊字符时,basename()的输出可能出乎意料。
我在本地测试时发现,如果你传入/var/www/html/utils.php/../index.php,basename()会返回index.php,因为它只关心最后一个斜杠后的内容。但更关键的是它对非ASCII字符的处理方式。
3.2 利用非ASCII字符绕过检查
这道题的关键突破点就在于basename()对非ASCII字符的处理。当路径中包含某些特殊字符(特别是非ASCII字符)时,basename()会把这些字符原样保留,而正则表达式可能无法正确匹配这些字符。
举个例子:
$path = '/index.php/utils.php/我'; echo basename($path); // 输出"我"更妙的是,即使这些特殊字符是URL编码形式的,basename()也能正确处理。这就为我们绕过正则检查提供了可能。因为我们可以构造一个URL,使得:
- 正则检查时,路径看起来不像
utils.php - 但经过
basename()处理后,又变成了utils.php
3.3 实际利用方式
在本题中,我们可以构造这样的URL:
/index.php/utils.php/%88?show[source=1这里%88是一个关键。当这个URL被处理时:
$_SERVER['PHP_SELF']会变成/index.php/utils.php/加上解码后的%88(一个非ASCII字符)- 正则表达式
/utils\.php\/*$/i在匹配时,因为末尾有非ASCII字符,匹配失败 - 但
basename()处理时,会忽略index.php/部分,返回utils.php
这样就完美绕过了所有检查,成功让highlight_file()读取到utils.php。
4. 正则表达式的绕过技巧
4.1 分析题目中的正则防护
这道题设置了两道正则防线:
if (preg_match('/utils\.php\/*$/i', $_SERVER['PHP_SELF'])) { exit("hacker :)"); } if (preg_match('/show_source/', $_SERVER['REQUEST_URI'])){ exit("hacker :)"); }第一个正则检查PHP_SELF是否以utils.php结尾,第二个检查REQUEST_URI是否包含show_source字符串。我们的目标是要同时绕过这两个检查。
4.2 非ASCII字符绕过第一个正则
对于第一个正则/utils\.php\/*$/i,关键点在于$这个元字符。它表示匹配字符串的结尾。但是当字符串末尾有非ASCII字符时,这个匹配可能会失败。
我测试发现,PHP的正规表达式引擎在处理包含非ASCII字符的字符串时,$的行为有时会不一致。特别是当这些字符是URL编码形式时。因此,在utils.php后面加上一个非ASCII字符(如%88),就能让正则匹配失败。
4.3 特殊字符绕过第二个正则
第二个正则/show_source/看起来简单,但也有绕过的方法。在正则表达式中,点号(.)默认匹配任何字符(除了换行符)。所以我们可以用show.source或者show[source来绕过。
更妙的是,由于这个检查是针对REQUEST_URI的,而REQUEST_URI不会自动URL解码,所以我们可以直接在URL中使用这些特殊字符,不需要编码。这就是为什么我们的payload中使用show[source能成功绕过检查。
5. 完整攻击链的构建
5.1 攻击步骤拆解
现在我们把所有知识点串起来,看看完整的攻击是如何进行的:
- 构造特殊URL:
/index.php/utils.php/%88?show[source=1 - 服务器接收到请求后:
$_SERVER['PHP_SELF']值为/index.php/utils.php/加解码后的%88$_SERVER['REQUEST_URI']值为/index.php/utils.php/%88?show[source=1
- 正则检查:
- 第一个正则检查
PHP_SELF,因为末尾有非ASCII字符,匹配失败 - 第二个正则检查
REQUEST_URI,因为使用的是show[source而不是show_source,匹配失败
- 第一个正则检查
- 由于GET参数中有
show_source,执行highlight_file(basename($_SERVER['PHP_SELF']))basename()处理/index.php/utils.php/加特殊字符,返回utils.php
- 最终
highlight_file('utils.php')被执行,读取到flag
5.2 防御措施建议
从这道题中,我们可以学到几个重要的PHP安全实践:
- 不要依赖
basename()来做安全校验,因为它容易被特殊字符绕过 - 使用正则表达式检查路径时,要考虑非ASCII字符的影响
$_SERVER的不同变量有不同特性,安全检查时要选择正确的变量- 对于关键安全检查,最好使用多重验证机制
我在实际开发中就遇到过类似问题。有一次我们的系统检查文件上传路径时只用了basename(),结果被攻击者用包含特殊字符的路径绕过了检查。从那以后,我们都会对用户输入进行多重验证和过滤。
6. 深入理解PHP特性与安全
6.1 PHP_SELF与REQUEST_URI的深入比较
为了更清楚地理解这两个变量的区别,我做了一系列测试:
| URL | PHP_SELF | REQUEST_URI |
|---|---|---|
| /index.php | /index.php | /index.php |
| /index.php/test | /index.php/test | /index.php/test |
| /index.php/test?param=1 | /index.php/test | /index.php/test?param=1 |
| /index.php/%74%65%73%74 | /index.php/test | /index.php/%74%65%73%74 |
| /index.php/我 | /index.php/我 | /index.php/%E6%88%91 |
从表中可以看出,PHP_SELF会自动URL解码,而REQUEST_URI保持原样。此外,PHP_SELF不包含查询字符串,而REQUEST_URI包含。
6.2 basename()的更多陷阱
basename()还有一些其他需要注意的行为:
- 它会去掉末尾的斜杠:
echo basename('/path/to/file/'); // 输出file - 它对非ASCII字符的处理可能因系统locale设置而异
- 它不能识别真实的文件系统路径,只是简单的字符串处理
我曾经遇到过一个案例:攻击者上传了一个名为evil.php\x00.jpg的文件,然后利用basename()的特性,最终执行了PHP代码。这说明在安全敏感的上下文中,不能单纯依赖basename()。
6.3 正则表达式的强化写法
为了防止类似的绕过,我们可以强化正则表达式的写法:
- 使用
\A和\z代替^和$,它们是真正的字符串开头和结尾锚点 - 明确指定字符集:
if (preg_match('/utils\.php[\/]*\z/i', $_SERVER['PHP_SELF'])) { // 更严格的匹配 } - 对输入先进行规范化处理(如URL解码、去除非法字符等)
在实际项目中,我通常会先对输入进行标准化处理,然后再进行安全检查,这样可以避免很多因解析差异导致的安全问题。
7. 从CTF到真实世界的安全思考
这道CTF题目虽然设计精巧,但它反映出的安全问题在真实Web应用中也很常见。很多PHP应用都会用basename()来处理上传文件名,用$_SERVER变量来做路由或安全检查,但却没有充分考虑到这些函数的边缘情况。
我在审计真实项目时,就发现过几个类似的漏洞:
- 一个CMS系统用
basename()来检查上传文件的后缀名,结果被特殊字符绕过 - 一个框架用
PHP_SELF来做路由分发,导致可以注入任意路径 - 一个API系统用简单的正则检查访问权限,被精心构造的URL绕过
这些案例都说明,理解PHP的这些底层特性对于编写安全的代码至关重要。作为开发者,我们不仅要让代码能工作,还要考虑各种可能的边界情况和恶意输入。
