标签归档:安全模式

PHP脚本运行超时管理机制

PHP脚本运行超时管理机制

在我们平常的开发中,也许曾经都遇到过PHP脚本运行超时的情况,此时PHP会显示错误说: “Fatal error: Maximum execution time of XXX seconds exceeded in XXX”,并终止脚本的运行。当遇到这种情况我们经常会通过使用 set_time_limit(非安全模式),或修改配置文件并重启服务器,或者修改程序减少程序的执行时间,使其在允许的范围之内,以解决此问题。但是,这些都是在应用层上我们可以看到的的表象,在PHP内核中有一套这样的机制支撑这样一个表象。

这是PHP为防止某些业务脚本长时间执行而阻塞其它脚本的处理或耗尽服务器资源,从而实现的脚本运行的超时管理机制。其本质上是PHP通过针对不同的平台实现定时器,依赖运行时的超时全局变量(EG(timeout_seconds))管理并控制定时器的运行。所有对脚本运行时长的管理,包括接口函数和配置文件对于最大运行时长的配置,最终都是通过管理超时全局变量并重启定时器来实现的。

初始化和超时配置项

在PHP内核的核心层文件/main/main.c文件中,定义了PHP的核心配置项以及每个配置项对应的on_modify方法。在模块初始化(php_module_startup)时,PHP内核会调用ini配置的注册函数,将定义的核心配置项添加到ini配置的指令集中,并且会调用每个配置项对应的on_modify方法。

用于定义脚本运行最长时间的max_execution_time配置项也是这些核心配置项的一员,它的默认值为30秒,对应的on_modify方法是OnUpdateTimeout。当注册这些核心配置项时,max_execution_time的on_modify方法将被调用,此时配置项的值将传递给超时全局变量:EG(timeout_seconds),并通过zend_set_timeout方法启动定时器。

针对WIN平台和类unix平台,PHP内核实现了不同的定时器。 Win32平台的定时器是在WM_TIME的基础上封装了一个计时器。通过创建一个独立线程控制计时器,并创建一个消息环,WaitForSingleObject用来阻塞zend_init_timeout_thread 返回。当接收到WM_REGISTER_ZEND_TIMEOUT时开始计时,实际上此时计时的任务是SetTimer(timeout_window, wParam, lParam*1000, NULL); 系统会在 seconds * 1000 后发个 WM_TIMER,这个时候就结束计时,中间可以被 WM_UNREGISTER_ZEND_TIMEOUT 打断。

类unix平台使用Linux的API函数setitimer,指定SIGPROF信号为超时处理信号,对应超时处理函数zend_timeout,当发生超时时,会发送此信号并触发函数zend_timeout显示错误信息并中止程序。

如果需要取消定时器,Win平台通过PostThreadMessage发送WM_UNREGISTER_ZEND_TIMEOUT给线程即可,类unix平台会重置定时器的时长为0。

超时管理

超时机制的管理非常灵活,有三种修改运行时长的方法。

1、 修改配置项。默认情况下PHP脚本的最长运行时长为30s。如果需要调整此项,可以通过修改php.ini文件中的max_execution_time项并重启动服务器达到修改最长运行时长的目的。此种方法适用于最开始的默认配置修改,或在其它方法无效的情况下使用。

2、 使用set_time_limit接口函数。此函数的作用是设置脚本最大执行时间。当此函数被调用时,set_time_limit()会从零开始重新启动超时计数器。比如,每一次设置是5秒,待脚本运行4秒后,脚本中又设置了5秒,那么,脚本在超时之前可运行总共时间为10秒。如下脚本示例:

    <?php
    set_time_limit(5);
    for ($i = 0; $i < 4; $i++) {
        sleep(1);
        echo $i, "<br />";
    }
 
    set_time_limit(5);
 
    for ($i = 0; $i < 4; $i++) {
        sleep(1);
        echo $i, "<br />";
    }

如上的代码,程序会执行完两个循环,都输出0,1,2,3。如果我们注释掉中间的set_time_limit(5),程序再运行一次,此时就会在第二个循环输出0后报错。

在安全模式下,无法通过set_time_limit和ini_set重新设置max_execution_time,只有关闭安全模式或改变php.ini中的时间限制才能达到修改此参数的目的。

3、 通过ini_set修改max_execution_time参数。

以上的三种方法,其实现过程基本类似,前一种是在初始化时调用on_modify指针函数。后两种在处理了参数后,调用zend_alter_ini_entry_ex函数,触发on_modify函数。于是,管理超时机制的所有操作最终都汇集到OnUpdateTimeout函数。在此函数中,通过zend_set_timeout重新设置脚本的超时时间。

wordpress升级到3.3引起的血案

某位哥哥因为看着wordpress3.3的升级提示激动了,一不小心就将它升级了,升级后一切正常,只是之前可用的附件上传功能现在不能用了,显示报错如下:

Warning: touch() [function.touch]: SAFE MODE Restriction in effect.
 The script whose uid is 10041 is not allowed to access /tmp owned by uid 0 in ......

各种纠结……

依据提示我们知道是由于PHP开启了安全模式的原因,最简单的办法:修改php.ini文件,将safe_mode设置为off,将安全模式关闭。

只是这是生产环境,关闭安全模式不太靠谱。只能换方案。
依据刚才的错误提示,我们知道是安全模式在检查执行程序的文件的UID与需要写入的文件夹的UID不一样,这是因为在PHP的安全模式中,对于部分函数是需要检查被操作的文件或目录是否与被执行的脚本有相同的 UID(所有者),很不幸,touch就是这样的函数之一。当然,有这个规则就应该有跳过这个规则的办法,在php.ini文件中,我们可以设置safe_mode_include_dir参数,多个路径以冒号隔开(Windows是分号),在此参数内设置的目录及其子目录都将会越过 UID/GID 检查。

除此之外,安全模式相关的还有一个open_basedir设置项,PHP 所能打开的文件限制在open_basedir指定的目录树,包括文件本身。本指令不受安全模式打开或者关闭的影响。

safe_mode_include_dir和open_basedir指定的限制实际上是一个前缀,而非一个目录名。这也就是说“safe_mode_include_dir = /tmp”将允许访问“/tmp和“/template”(如果它们存在的话),。如果希望将访问控制在一个指定的目录,那么请在结尾加上一个斜线,例如:“safe_mode_include_dir = /tmp”。

这两个与本次安全问题相关的内容都添加了/tmp和当前操作的目录,发现依旧报错。

追根溯源,从源码中开始吧,如果对于PHP源码不太熟悉,那么我们可以从本次事件的关键点safe_mode_include_dir开始。

在PHP源码中搜索safe_mode_include_dir,除了与此配置项相关的初始化操作,我们可以找到验证此项的函数php_check_safe_mode_include_dir。现在,我们可以用GDB debug的方法查看,给php_check_safe_mode_include_dir函数增加断点,运行一个你认为一定会经过此参数检查的PHP函数调用,如fopen等。运行,发现会在断点处停下,如果我们运行一个只包含了touch函数的代码,会发现程序会执行完,即touch函数根本就没有调用php_check_safe_mode_include_dir函数,即在安全模式下,根本无法跳过UID的检测。

或者我们直接查看touch函数的PHP源码实现,其代码在/ext/standard/filestat.c文件649行。在其代码中我们可以清楚的看到其实现过程在处理了相关参数后,就直接判断完全模式和检测UID了,如下代码:

PHP_FUNCTION(touch)
{
 
...
 
/* Safe-mode */
if (PG(safe_mode) && (!php_checkuid(filename, NULL, CHECKUID_CHECK_FILE_AND_DIR))) {
RETURN_FALSE;
}
 
/* Check the basedir */
if (php_check_open_basedir(filename TSRMLS_CC)) {
RETURN_FALSE;
}
 
...
 
}

找到了问题的根源,也许是PHP的BUG,也许是其它考量,待确认。

那么这个问题怎么解决呢?

比较wordpress3.3和wordpress3.2的代码,我们发现wordpress在处理附件上传时最终都会调用wp_handle_upload函数(/wp-admin/include/file.c)。在此函数中wordpress3.3版本多了一句:

$tmp_file = wp_tempnam($filename);

在wp_tempnam函数中,最终会通过touch生成临时文件,从前面我们可以知道我们暂时无法解决在此环境下的touch函数的问题,那么我们只能适应这个函数,也就是说我们需要将新创建的临时文件的目录UID和执行的PHP文件的UID设置为一样,那么这个临时文件的目录是否可以改变?我们查看wp_tempnam函数,发现其最先取的临时目录的地址为常量WP_TEMP_DIR的值,此值默认是没有设置的,如果这个值没有才会取其它值。此时,解决方案就浮出水面了:在wp-config.php文件中设置常量WP_TEMP_DIR的值为网站目录下某个UID和PHP脚本一样UID的目录即可。

附gdb bt显示的内容

#0 php_check_safe_mode_include_dir (path=0x86f4fcc "/tmp/aaa43243.tmp")
at /home/martin/project/c/phpsrc/main/fopen_wrappers.c:319
#1 0x0829d97b in php_plain_files_stream_opener (wrapper=0x85eb624, 
path=0x86f4fcc "/tmp/aaa43243.tmp", mode=0x86f6b38 "a", options=4, 
opened_path=0x0, context=0x86f5560)
at /home/martin/project/c/phpsrc/main/streams/plain_wrapper.c:991
#2 0x0829842d in _php_stream_open_wrapper_ex (
path=0x86f4fcc "/tmp/aaa43243.tmp", mode=0x86f6b38 "a", options=12, 
opened_path=0x0, context=0x86f5560)
at /home/martin/project/c/phpsrc/main/streams/streams.c:1867
#3 0x08229b73 in php_if_fopen (ht=2, return_value=0x86f5544, 
return_value_ptr=0x0, this_ptr=0x0, return_value_used=1)
at /home/martin/project/c/phpsrc/ext/standard/file.c:909
#4 0x0817b088 in phar_fopen (ht=2, return_value=0x86f5544, 
return_value_ptr=0x0, this_ptr=0x0, return_value_used=1)
at /home/martin/project/c/phpsrc/ext/phar/func_interceptors.c:418
#5 0x0831b9a1 in zend_do_fcall_common_helper_SPEC (execute_data=0x8727eb8)
at /home/martin/project/c/phpsrc/Zend/zend_vm_execute.h:313
#6 0x082f4bc6 in execute (op_array=0x86f545c)
at /home/martin/project/c/phpsrc/Zend/zend_vm_execute.h:104
#7 0x082d2256 in zend_execute_scripts (type=8, retval=0x0, file_count=3)
---Type <return> to continue, or q <return> to quit---
at /home/martin/project/c/phpsrc/Zend/zend.c:1194
#8 0x08281b70 in php_execute_script (primary_file=0xbffff2a0)
at /home/martin/project/c/phpsrc/main/main.c:2225
#9 0x08351b97 in main (argc=4, argv=0xbffff414)
at /home/martin/project/c/phpsrc/sapi/cli/php_cli.c:1190

PHP安全模式下的exec执行问题

PHP安全模式下的exec执行问题

今天同事遇到一个问题,在执行某个程序的时,将执行的命令写错了,发现程序依然可以执行,可是当把程序在终端运行时又显示此文件不存在。 于是最近一直沉迷于源码的我追踪了整个执行过程,得到如下答案。

以上的问题出现在安全模式开启的情况下。

按照PHP的源码结构,exec函数的实现应该在/ext/standard目录下,在此目录找到exec.c文件,exec函数的实现就在这里。如果不熟悉源码结构,可以考虑使用editplus全局搜索PHP_FUNCTION(exec),当然,你也可以使用其它的工具。这只是一个查找的方式。重点是接下来我们要看的代码。

整个调用顺序如下:

[PHP_FUNCTION(exec) -> php_exec_ex -> php_exec]

/* {{{ php_exec
 * If type==0, only last line of output is returned (exec)
 * If type==1, all lines will be printed and last lined returned (system)
 * If type==2, all lines will be saved to given array (exec with &$array)
 * If type==3, output will be printed binary, no lines will be saved or returned (passthru)
 *
 */
PHPAPI int php_exec(int type, char *cmd, zval *array, zval *return_value TSRMLS_DC)
{
    ... //  省略
    if (PG(safe_mode)) {    //  安全模式
        if ((c = strchr(cmd, ' '))) {
            *c = '\0';
            c++;
        }
        if (strstr(cmd, "..")) {    //  不能在指向程序的路径中包含 .. 成分
            php_error_docref(NULL TSRMLS_CC, E_WARNING, "No '..' components allowed in path");
            goto err;
        }

        b = strrchr(cmd, PHP_DIR_SEPARATOR);    //  #define PHP_DIR_SEPARATOR '/'

         ... //  省略

        spprintf(&d, 0, "%s%s%s%s%s", PG(safe_mode_exec_dir), (b ? "" : "/"), (b ? b : cmd), (c ? " " : ""), (c ? c : ""));
        if (c) {
            *(c - 1) = ' ';
        }
        cmd_p = php_escape_shell_cmd(d);
        efree(d);
        d = cmd_p;
    } else {
        cmd_p = cmd;
    }
    ...//省略
}
/* }}} */

从上面的代码可以看出,PHP在实现exec函数时,安全模式下,会去掉命令中包含的所有路径,只留下需要执行的命令及其参数。 最后将这个命令与PG(safe_mode_exec_dir)连接起来作为需要执行的命令返回 。这也就是我们在安全模式下,对于需要执行的命令,即使路径是错的也可以正常执行的原因。