调试PHP tsrm_realpath_r
请在没有实际调试的情况下判断下面3个代码哪几个会报错,哪几个可以成功读取到文件?
echo file_get_contents("file:///etc/passwd/.");echo file_get_contents("file:///etc/aa/../passwd/.");,并且/etc/aa/目录不存在echo file_get_contents(file:///etc/bb/../passwd/.);,并且/etc/bb/目录存在
通过源代码调试,我知道了原因,本文是调试过程的详细记录。
异常现象
实际运行结果如下:
有且只有echo file_get_contents("file:///etc/aa/../passwd/.");成功读取了/etc/passwd文件。
这个结果很诡异,为什么路径中间带有不存在目录反而可以读到文件,而带有存在目录的路径反而读不到文件?😢
缘起
在复盘2023年年底CTF比赛没解出题目时,寻求了知识星球网友的帮助,123qwer师傅给出了解题技巧。成功解题后,我发现file_get_contents("file:///etc/passwd/.");无法读取文件,而file_get_contents("file:///etc/aa/../passwd/.");可以成功读取到文件。
|
题目有两个考察点:
Yangqidasai类里需求元素string1和string2值不同但是md5相同- 要读取的文件名会被
$filename.$char拼接一个字符后缀
如果你前面没有做过这个题目,可以自行尝试解答。
点我查看解题技巧
为了实现两个元素值不同但md5相同,有两种可能思路:
- 通过md5碰撞,实现指定前缀的文本其md5相同,例如使用fastcoll工具。
- 使用PHP内置类的
__toString函数,我简单测试发现报错、异常类的__toString大多都有前缀,此处不满足。这里没有详细测试,如果您知道可以告诉我。
为了绕过文件结尾字符后缀限制,经过123qwer师傅的指点,学习了wonderkun师傅的php_apache2_操作系统之间的一些黑魔法,知道可以利用tsrm_realpath_r的技巧,即file:///var/www/html/..../../../../../flag.txt/.读取文件。
调试环境搭建
调试过程参考了php_apache2_操作系统之间的一些黑魔法文章的最后一部分。
PHP源代码编译
编译过程中命令如下:
git clone https://github.com/php/php-src.git |
编译之后,运行./sapi/cli/php -v得到结果如下:
PHP 7.4.31-dev (cli) (built: Mar 30 2024 11:16:05) ( NTS ) |
调试工具
调试工具使用了gdb和pwndbg。
将要测试的文件写入php文件,使用gdb调试。
echo file_get_contents("file:///etc/passwd/."); |
在gdb的命令行窗口中,可以source mygdb文件批量对调试参数进行设置。
# 设置输出显示格式 |
开始调试的命令:
kali@kali ~/D/php-src (PHP-7.4.33)> gdb ./sapi/cli/php |
代码调试
file:///etc/passwd/.
调试断点:b /home/kali/Desktop/php-src/Zend/zend_virtual_cwd.c:1145,定位断点的过程可以参考wonderkun师傅文章,也可以自行调试得出。
经过527行的循环之后,i指向path最后的/的后一位:
随后在连续的if判断中,代码通过第一处if的校验,即i + 1 == len && path[i] == '.'),len指向最后一个/,而is_dir被设置为1。
随后执行到continue,重新进入while (1)的循环,再次经过527行的循环,i指向p:
在连续的if判断中,均不满足条件,在581行,path的最后一个/被设置为0,path被截断为/etc/passwd:
此时save值为1,接着单步往下运行,在947行时进行判断,is_dir为1而directory为0,返回(size_t)-1,读取文件失败。
总结,file:///etc/passwd/.在tsrm_realpath_r函数中结尾的/.被删除,is_dir为1(535行);而文件本身不是文件夹,即directory为0,(943行)。这两个变量的值相互矛盾,在950行导致tsrm_realpath_r函数返回(size_t)-1,结果是读取文件失败。
file:///etc/aa/../passwd/.
初始情况下,i指向最后的.:
len往前移,is_dir为1,重新进入while (1)的循环:
此时,i指向p,所有if条件都没通过:
在581行,path变成/etc/aa/../passwd:
此时php_sys_lstat(path, &st) < 0)满足,save从1变为0:
在958行,代码进入嵌套:
line 958 嵌套
嵌套里面经过循环之后,i指向.:
满足537行的条件判断,开始处理/..:
在546行,代码再次进入嵌套:
line 546 嵌套
嵌套里面经过循环之后,i指向a:
此时所有if条件都不满足,在581行,path变为/etc/aa:
代码满足899行的php_sys_lstat(path, &st) < 0条件,将save设置为1:
在958行,代码再次进入嵌套:
line 958 再次嵌套
嵌套里面经过循环之后,i指向e:
此时所有if条件都不满足,在581行,path变为/etc/:
代码不满足899行的php_sys_lstat(path, &st) < 0条件,save仍为1:
进入943行代码块:
首次进入955行代码:
进入999行代码:
随后代码退出嵌套。
line 959 嵌套之后
960行,重新将path第5个元素设置为斜线,path变为/etc/aa:
line 547 嵌套之后
对j的值进行调整:
line 959 嵌套之后
再一次将path第5个元素设置为斜线,path变为/etc/aa:
第992行,memcpy(path+j, tmp+i, len-i+1);将path变为/etc/passwd:
至此,tsrm_realpath_r完成了文件路径的处理,在main/fopen_wrappers.c:836得到了真实文件路径:
file:///etc/bb/../passwd/.
在943行,directory = S_ISDIR(st.st_mode);使directory的值变为0:
进而在949行,直接返回了(size_t)-1,导致后续报错:
aa 与 bb 的差异分析
zend_virtual_cwd.c与save相关代码如下:
save = (use_realpath != CWD_EXPAND); |
通过比较两者差异,可以发现path是/etc/aa/../passwd时,save的值是0,见下图右侧;path是/etc/bb/../passwd时,save的值是1:
关键是php_sys_lstat函数,在bash下使用stat查看两个文件的结果:
kali@kali ~/D/php-src (PHP-7.4.33) [1]> stat /etc/aa/../passwd |
path是/etc/aa/../passwd时,php_sys_lstat(path, &st)的结果是-1,满足899行if条件,save被设置为0,代码不经过后续的检验;
而path是/etc/bb/../passwd或者/etc/passwd时,php_sys_lstat(path, &st)的结果是0,不满足899行if条件,save的值是1,代码经过947行检验时报错。
总结
关于tsrm_realpath_r和php_sys_lstat的说明,网上已经有一些文章了,我写文章时才去搜索,有一种重复发现已知的感觉。
- wonderkun:php_apache2_操作系统之间的一些黑魔法
- littlefisher:php源码分析 require_once 绕过不能重复包含文件的限制
- sky:2018 0ctf-ezdoor
- zsx:0CTF2018之ezDoor的全盘非预期解法
另外,linux的stat和PHP的tsrm_realpath_r在处置模式上的差异,类似于多种网络安全设备协同工作、但是他们在某一个细节上处理方式有差异,结果检测被绕过、造成攻击的情况。

































