对PHP中的mkdir()函数的研究

0x01 缘起

在前阵子分析WORDPRESS IMAGE 远程代码执行漏洞的过程中,在文末提到一点关于php中的mkdir()函数,在触发漏洞时这个地方存在一点疑惑,即当mkdir()第三个参数分别为falsetrue时,分别是能成功创建文件夹和创建失败,后来有同学发现和他的测试结果有偏差,两种情况都无法创建,在互相确认了php版本后,对mkdir()函数进行了深入的研究,发现里面大有文章。

当时的测试结果是这样的,环境是Windows+php-7.0.12-nts,在recursive=false时成功穿越目录并创建了文件夹

本文重新编译了php方便调试,版本是php-7.2.16-tsphp-7.2.16-nts,测试结果如下

可以看到只有在非线程安全下并且recursive=false时才成功创建,总结如下表所示

Windowsthread-safenon-thread safe
recursive=falsefail (No error)success
recursive=truefail (Invalid path)fail (Invalid path)

接下来从源码角度看看php如何实现mkdir()函数,探究一下为何会出现差异

0x02 调试

Visual Studio 2017打开项目,定位到php-7.2.16-src/main/streams/plain_wrapper.cline 1234,方法php_plain_files_mkdir()mkdir()的实现,在此处下个断点,然后运行脚本,接着选择调试-附加到进程,选择编译好的php.exe进程,成功命中断点。

0x03 源码分析

1. recursive=true

thread-safe

首先分析在recursive=true的情况,跟随断点来看一下php_plain_files_mkdir()这个方法

看到对recursive进行了判断,进了不同的分支,分别执行php_mkdir()expand_filepath_with_mode()recursive=true时进入expand_filepath_with_mode()

这个expand_filepath_with_mode()方法会判断当前路径是相对路径还是绝对路径,然后把路径传入virtual_file_ex(),如果是相对路径的话会在该方法中拼接成完整的路径,随后进行一个重要的判断

如果是Windows系统且路径中包含了*?,则直接返回错误,这也就是为什么在复现wordpress漏洞时构造的PoC中含有?无法创建目录的原因(wordpress指定了recursive=true),当时使用#绕过了这个限制

回到上面,virtual_file_ex()没有通过验证,最终抛出的异常是"Invalid path"

if (!expand_filepath_with_mode(dir, buf, NULL, 0, CWD_EXPAND )) {
    php_error_docref(NULL, E_WARNING, "Invalid path");
    return 0;
}
non-thread safe

在非线程安全模式下,流程是完全一样的,最终也会因为无法通过*?的检查,抛出"Invalid path"

2. recursive=false

接下来看一下recursive=false的情况,在这个情况下,线程安全与非线程安全产生了不一样的结果。

recursive=false时进入php_mkdir()方法,随后进入php_mkdir_ex()

在进行basedir检查后进入VCWD_MKDIR,这是一个宏命令,在源码中有三处定义,在php-7.2.16-src/Zend/zend_virtual_cwd.h中,分别是

  • mkdir(pathname, mode)
  • php_win32_ioutil_mkdir(pathname, mode)
  • virtual_mkdir(pathname, mode)

注意这三个定义是根据不同的条件执行的,看一下逻辑

#ifdef VIRTUAL_DIR
#define VCWD_MKDIR(pathname, mode) virtual_mkdir(pathname, mode)
#endif

#if defined(ZEND_WIN32)
#define VCWD_MKDIR(pathname, mode) php_win32_ioutil_mkdir(pathname, mode)
#else
#define VCWD_MKDIR(pathname, mode) mkdir(pathname, mode)

也就是说,如果定义了VIRTUAL_DIR,那么执行的是virtual_mkdir(),否则如果是Windows系统,就执行php_win32_ioutil_mkdir()创建目录,linux下则是mkdir命令

那么,既然在recursive=false的情况下,线程安全与非线程安全出现了不一样的结果,肯定是此处走的分支不一样,一个使用了virtual_mkdir(),另一个使用了php_win32_ioutil_mkdir(),分别进入两个方法

virtual_mkdir()中,同上面的情况一样,进行了virtual_file_ex()判断,因此也会走到对*?的判断,同样因为通不过检查而抛出"Invalid path",而在php_win32_ioutil_mkdir()中则是调用了CreateDirectoryW创建目录

CreateDirectoryWWindows下创建目录的API,走到这个分支并不检查*?,因此能够成功创建目录。

有关CreateDirectoryW参考Microsoft Doc

CreateDirectoryW对应的还有CreateDirectoryA,两个函数功能一样,只是第一个参数的类型不同,一个是LPCWSTR 另一个是LPCSTR ,这两者是CHAR 和WCHAR的区别 ,详细可以参考StackOverflow

现在剩下最关键的一个问题,什么情况下会走virtual_mkdir()的流程,也就是说VIRTUAL_DIR是在何处定义的?

php-7.2.16-src/Zend?zend_virtual_cwd.hline 41定义了这个变量,前置条件是ZTS,也就是线程安全的标识,只有在线程安全模式下,才使用virtual_mkdir()创建目录,调用的系统函数同样是CreateDirectoryW,但是在此之前得先通过virtual_file_ex()校验,含有*?则无法创建成功。

0x04 流程图

0x05 深入

现在清楚了phpmkdir()的实现,之所以结果不一样是因为ZTSNTS下的两种不同的处理流程,那么为什么在ZTS模式下,在调用WindowsAPI创建目录之前,需要设置一个“虚拟目录”呢?

这里涉及到php内核中的TSRM机制,也就是线程安全资源管理器(Thread Safe Resource Manager) ,这个机制的引入是为了解决线程并发的问题,我们知道,如果线程访问的内存地址空间相同,当一个线程修改资源时会影响其它线程,所以为了确保不会出现资源竞争,php将多个资源复制为多份,每个线程需要的资源在当前进程空间中各有一份,各取所取,这样就不会出现竞争问题。

那么不同线程怎么获取自身所需要的资源呢?php中通过ts_allocate_id()函数实现, 这个函数的作用就是遍历所有线程,为每一个分配一个线程安全资源id,每一次调用ts_allocate_id()函数时,都会执行这个操作,而为了避免重复分配,这个过程是在调用模块初始化的时候就完成了

TSRMG的定义如下,其中tsrm_get_ls_cache()有多个定义,但功能是一样的,就是根据资源id的tls_key取出相应value的过程:

#define TSRMG(id, type, element)    (TSRMG_BULK(id, type)->element)
#define TSRMG_BULK(id, type)    ((type) (*((void ***) tsrm_get_ls_cache()))[TSRM_UNSHUFFLE_RSRC_ID(id)])

# define tsrm_tls_get()         pthread_getspecific(tls_key)

在启动cli或者cgi时,都会通过SAPI调用tsrm_startup()启动TSRM ,随后进行模块初始化,在这个过程中分配资源id,初始化时的调用栈如下图所示

当非ZTS模式时,线程直接调用全局变量的属性, 而ZTS模式设置“虚拟目录”的概念其实就是“根据资源id查找所需的全局变量”的过程,本质上是为了避免线程间资源读取出现竞争,保证了线程安全。

0x06 总结

本文通过phpmkdir()函数在不同环境下表现结果不一致的现象,分析了php内核对mkdir()函数的实现,引申出php中线程安全与非线程安全两个重要的机制,抛砖引玉,如有表述不妥或者错误之处欢迎指正,最后感谢@maple提出最初的问题以及探讨过程中给予的莫大的帮助。

参考:

http://php.net/manual/en/function.mkdir.php

http://blog.codinglabs.org/articles/zend-thread-safety.html

https://segmentfault.com/a/1190000010004035

发表评论

电子邮件地址不会被公开。 必填项已用*标注