从PHP源码与扩展开发谈PHP任意代码执行与防御
PHP的灵活性极强,其可以通过各种意想不到的办法来动态执行代码。正因如此,PHP界的“一句话木马”(“后门”,backdoor),写法极其神奇,充满了脑洞,大部分变种完全无法通过静态扫描查到(当然如果用沙盒执行+启发式拦截的方式大概可以,这就变成传统杀毒软件了)。因此,我们不如从这些一句话木马,看看PHP是如何执行动态代码的吧。
原文地址:https://blog.zsxsoft.com/post/30
先说明,如果只是要在自己服务器上防御的话,可以只看下面几行后关闭这篇文章:
-
升级到PHP 7.1,该版本对大部分常见的执行动态代码的方法进行了封堵。
-
php.ini中,关闭“
allow_url_fopen
”。在打开它的情况下,可以通过phar://
等协议丢给include
,让其执行动态代码。 -
php.ini中,通过
disable_functions
关闭exec,passthru,shell_exec,system
等函数,禁止PHP调用外部程序。 -
永远不要在代码中使用eval。
-
设置好上传文件夹的权限,禁止从该文件夹执行代码。
-
include 文件的时候,注意文件的来源;需要动态include时做好参数过滤。
当然,本文未经特别标注,全部以PHP 7.1为基础。
先从最简单的一句话木马开始吧:
这种代码因为用了eval
,所以是最好封堵或查杀的。eval
不可通过disable_functions
关闭,也不可以通过字符串来调用。它是一个语言特性,而不是一个函数。用关键字扫描即可解决。
PHP不是解释执行的(即读一行执行一行)。在代码执行前,先要由Zend引擎将其编译为一种中间语言,称之为“OPCode”。我们通过vld
扩展(https://github.com/derickr/vld),可以在代码执行前看到这一段PHP到底被解析成了什么。
可以看到的是,这个opcode是“INCLUDE_OR_EVAL
”。看看什么东西会被组合成这个opcode:https://github.com/php-src/php/blob/0eb3c377d49a331282b943dba165b4b9df56fad2/Zend/zend_ast.c#L1256
于是,自然而然地得知了,include
/ require
和eval
的效果都一样。于是引申出了以下做法:
显而易见,这种就显得极难查杀了;如果我们监控了文本文件写,那我们还有许多方式来绕过检测。比如说通过SQLite:
这里的防御就显得极为复杂了,因为考虑到现在世界上绝大多数的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的代码:
不过问题来了——
每个PHP_FUNCTION
返回的zval
都是全新生成的,新的zval
是不继承之前的flag的。这就代表我们必须重写所有的函数……所以无法通过检测用户输入来下手……
那,这里最好的方案也就只有白名单了。这个也不太适合通过外部ptrace
等监控PHP的fopen
系统调用来实现,还是需要通过扩展。不过目前没有扩展能实现这个白名单机制,我之后会在我的扩展内实现。
但如果关闭了PHP的文件读写,还可以继续执行吗?我们可以追一下代码,很容易就追到了php_resolve_path
:https://github.com/php-src/php/blob/0eb3c377d49a331282b943dba165b4b9df56fad2/main/fopen_wrappers.c#L475。于是我们发现我们可以include
各种协议,比如说:
甚至,如果打开phar
扩展的话,因为zend_resolve_path
函数指针被指去了phar_resolve_path
,我们还可以通过构造一个phar
来动态执行代码。
这就显得很尴尬了,应该怎么防御呢?在php.ini中,关闭“allow_url_fopen
”即可解决。
另外,还有一个通过MySQL来写文件,然后由PHP来include的神奇方案。仅作记录。
使用
这个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
。
切到Opcode里:
嗯,解决方式是干掉create_function
这个函数。这个函数因为特征比较明显(谁没事会从字符串创建函数?)了,所以用的人少一些;assert
这一些函数隐蔽的多(断言很常见)。
如:
会生成
它的opcode调用是INIT_FCALL
,说明这是一个函数。这也就是说,我们可以通过各种方式对其进行隐藏:
转到Opcode,就变成
还有以下各种变种:
甚至还可以:
这种方式应该怎么防御呢?就有好几种办法了。
办法一,升级到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_FCALL
ZEND_DO_ICALL
ZEND_DO_UCALL
ZEND_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 7。
办法二,Hook住preg_replace
、preg_filter
函数。只有这两个函数调用了preg_replace_impl
,才会用到PREG_REPLACE_EVAL
。当然,对于老版本PHP,还需要Hook住ereg
_、mb_preg
系列函数。
文末最后说一下,因为某些需求(不会部署在自己的服务器上),我需要一套一句话木马检测方案,所以写出了本文以及半成品扩展:https://github.com/zsxsoft/fval 。
参考:
-
深入理解PHP原理之Opcodes http://www.laruence.com/2008/06/18/221.html
-
深入理解PHP7之zval https://github.com/laruence/php7-internal/blob/master/zval.md
-
浅谈变形PHP WEBSHELL检测 https://security.tencent.com/index.php/blog/msg/19
-
MySQL Native Driver http://php.net/manual/en/intro.mysqlnd.php
发表评论
木有头像就木JJ啦!还木有头像吗?点这里申请属于你的个性Gravatar头像吧!