请在没有实际调试的情况下判断下面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/.");可以成功读取到文件。

index.php
<?php
ini_set("display_errors", 0);
error_reporting(0);
class Yangqidasai
{
protected $string1;
protected $string2;
function __destruct()
{
if(($this->string1 != $this->string2) && (md5($this->string1) === md5($this->string2)))
{
$filename = "file:///var/www/html/".$this->string1;
$filename = substr($filename, 0, 47);
$char_arr = array_count_values(str_split($filename));
arsort($char_arr);
$char_arr = array_keys($char_arr);
do
{
$char = array_shift($char_arr);
}
while($char == "t");
$filename = $filename.$char;
echo file_get_contents($filename);
}
}
}
highlight_file(__FILE__);
unserialize($_REQUEST[0]);

?>

题目有两个考察点:

  1. Yangqidasai类里需求元素string1string2值不同但是md5相同
  2. 要读取的文件名会被$filename.$char拼接一个字符后缀

如果你前面没有做过这个题目,可以自行尝试解答。

点我查看解题技巧

为了实现两个元素值不同但md5相同,有两种可能思路:

  1. 通过md5碰撞,实现指定前缀的文本其md5相同,例如使用fastcoll工具。
  2. 使用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
cd php-src
git checkout PHP-7.4.33
sudo apt install autoconf bison re2c pkg-config
./buildconf
./configure

# 修改Makefile,将CFLAGS_CLEAN的值替换为 -g -O0 -fvisibility=hidden
make

编译之后,运行./sapi/cli/php -v得到结果如下:

PHP 7.4.31-dev (cli) (built: Mar 30 2024 11:16:05) ( NTS )
Copyright (c) The PHP Group
Zend Engine v3.4.0, Copyright (c) Zend Technologies

调试工具

调试工具使用了gdb和pwndbg

将要测试的文件写入php文件,使用gdb调试。

readFile.php
<?php echo file_get_contents("file:///etc/passwd/."); ?>

在gdb的命令行窗口中,可以source mygdb文件批量对调试参数进行设置。

mygdb
# 设置输出显示格式
set context-sections regs code ghidra stack backtrace expressions threads
set show-compact-regs on

# 添加对常用变量的监测
ctx-watch path
ctx-watch tmp
ctx-watch start
ctx-watch len
ctx-watch i
ctx-watch j

# 设置调试断点
b /home/kali/Desktop/php-src/Zend/zend_virtual_cwd.c:1145

# 运行文件
r readFile.php

# 跳过前三次断点命中,第四次开始才是要分析的读取文件过程
c
c
c

开始调试的命令:

kali@kali ~/D/php-src (PHP-7.4.33)> gdb ./sapi/cli/php
...
pwndbg> source mygdb

代码调试

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.csave相关代码如下:

zend_virtual_cwd.c
save = (use_realpath != CWD_EXPAND);

....
// 关键比较
if (save && php_sys_lstat(path, &st) < 0) {
if (use_realpath == CWD_REALPATH) {
/* file not found */
return (size_t)-1;
}
/* continue resolution anyway but don't save result in the cache */
save = 0;
}

....

if (save) {
directory = S_ISDIR(st.st_mode);
if (link_is_dir) {
*link_is_dir = directory;
}
if (is_dir && !directory) {
/* not a directory */
free_alloca(tmp, use_heap);
return (size_t)-1;
}
}

通过比较两者差异,可以发现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
stat: cannot statx '/etc/aa/../passwd': No such file or directory
kali@kali ~/D/php-src (PHP-7.4.33) [1]> stat /etc/bb/../passwd
File: /etc/bb/../passwd
Size: 3216 Blocks: 8 IO Block: 4096 regular file
Device: 8,1 Inode: 1574288 Links: 1
Access: (0644/-rw-r--r--) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2024-03-29 23:15:01.638940893 +0800
Modify: 2023-11-11 23:22:51.333183735 +0800
Change: 2023-11-11 23:22:51.333183735 +0800
Birth: 2023-11-11 23:22:51.333183735 +0800

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_rphp_sys_lstat的说明,网上已经有一些文章了,我写文章时才去搜索,有一种重复发现已知的感觉

另外,linux的stat和PHP的tsrm_realpath_r在处置模式上的差异,类似于多种网络安全设备协同工作、但是他们在某一个细节上处理方式有差异,结果检测被绕过、造成攻击的情况。