调试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
在处置模式上的差异,类似于多种网络安全设备协同工作、但是他们在某一个细节上处理方式有差异,结果检测被绕过、造成攻击的情况。