阴雨绵绵正好眠,奈何 KPI 催人奋起
介绍
哥斯拉 (Godzilla) 是一款国内流行且优秀的红队 webshell 权限管理工具,使用 java 开发的可视化客户端,shell 支持 java、php、asp 环境,通信流量使用 AES 算法加密,具有文件管理、数据库操作、命令执行、内存马、隧道反弹等后门功能。上传特征 WAF,IDS/IPS 都能做,这里不做分析,主要关注下通讯阶段的特征。
流量特征
User-Agent (弱特征)
哥斯拉客户端使用 JAVA 语言编写,在默认的情况下,如果不进行修改,可能是:User-Agent: Java/1.8.0_131 。但是哥斯拉支持自定义 HTTP 头部,这个默认特征是可以很容易去除的。
Accept(弱特征)
Accept 为 text/html, image/gif, image/jpeg, *; q=.2, /; q=.2
对这个默认特征应该很熟悉了,之前冰蝎也出现过同样的Accept。为什么会这么巧合出现两个工具都会出现这个特征呢,其实这个也是 JDK 引入的一个特征,并不是作者自定义的 Accept(参考:https://link.zhihu.com/?target=https%3A//bugs.openjdk.java.net/browse/JDK-8177439)同样的这个默认特征也可以通过自定义头部去除,只能作为默认情况下的辅助检测特征。
Cookie (强特征)
在请求包的 Cookie 中有一个非常致命的特征,最后的分号。标准的 HTTP 请求中最后一个 Cookie 的值是不应该出现 ; 的,这个可以作为现阶段的一个辅助识别特征。后面如果作者意识到这个问题的话应该会发布新版本修复这个问题。
请求体特征 (较强特征)
哥斯拉支持对加密的数据进行 base64 编码以及原始的加密 raw 两种形式的通讯数据,对于请求体的检测也要考虑两种情况。
Base64 编码后包含大写字母:A-Z、小写字母:a-z、数字:0-9、符号:+ 和 /、填充字符:= 。正则匹配
RAW 的话,排除掉文件上传,也就是 multipart/form-data 数据包,然后匹配到大量不可见字符。
响应体特征 (强特征)
和请求体一样,请求响应体也分两个格式,base64 编码的和原始加密 raw 数据。如果请求体采用 base64 编码,响应体返回的也是 base64 编码的数据。在使用 base64 编码时,响应体会出现一个很明显的固定特征。这个特征是客户端和服务端编写的时候引入的。
从代码可以看到会把一个 32 位的 md5 字符串按照一半拆分,分别放在 base64 编码的数据的前后两部分。整个响应包的结构体征为:md5 前十六位 + base64 + md5 后十六位。
从响应数据包可以明显看到这个特征,检测时匹配这个特征可以达到比较高的检出率,同时也只可以结合前面的一些弱特征进行检查,进一步提高检出率。因为 md5 的字符集范围在只落在 0123456789ABCDEF 范围内,因此很容易去匹配,正则匹配类似于 (?i:[0-9A-F]{16})[\w+/]{4,}=?=?(?i:[0-9A-F]{16})。需要注意的是 md5 需要同时匹配字母大小写两种情况,因为在 JAVA 版 webshell 响应中为大写字母,在 PHP 版中为小写字母。
哥斯拉静态特征
在默认脚本编码的情况下,jsp 会出现 xc、pass 字符和 Java 反射 (ClassLoader,getClass().getClassLoader()),base64 加解码等特征。
哥斯拉动态特征
User-Agent 字段(弱特征),如果采用默认的情况,会暴露使用的 jdk 信息。不过哥斯拉支持自定义 HTTP 头部,这个默认特征是可以很容易去除的。
Accept 字段(弱特征),默认是 Accept:text/html, image/gif, image/jpeg, *; q=.2, /; q=.2。同上,这个也可修改,只能作为辅助检测的特征。
Cookie 中有一个非常关键的特征,最后会有个分号。估计后续的版本会修复。
响应体的数据有一定特征,哥斯拉会把一个 32 位的 md5 字符串按照一半拆分,分别放在 base64 编码的数据的前后两部分。整个响应包的结构体征为:md5 前十六位 + base64 + md5 后十六位。
哥斯拉长流量特征
第三篇参考文章中写着哥斯拉使用了长 TCP 连接,中间的前三个 http 会话具备明显的统计特征。(这里的情况有待验证)
客户端首次连接 shell 会上传一段代码 payload,以备后续操作调用。查看其请求,发现内容长度居然超过 23000 字节。同时,http 响应内容为空。
第二个http的请求内容为:
s4kur4=VzFlBQUiW1ljVSNFaWJUU2dXaQM%2BICcLZ2lYDA%3D%3D
解密得到原始代码 methodName=dGVzdA==,即 methodName=test。跟踪执行过程,发现最终目的是测试 shell 的连通情况,并向客户端打印输出ok。这个过程是典型的固定特征,与第一个 http 请求一样,上传的原始代码是固定的。
第三个http的作用是获取目标的环境信息,请求内容为:
s4kur4=VzFlBQUiW1ljVSNFaWJUWXgKakIxMlN1UlUjaWdYFWxjHGVBPQsBC2dpWAw%3D
解密得到原始代码 methodName=Z2V0QmFzaWNzSW5mbw==,即 methodName=getBasicsInfo。此操作调用 payload 中的 getBasicsInfo 方法获取目标环境信息向客户端返回。显然,这个过程又是一个固定特征。
至此,成功挖掘到哥斯拉客户端与 shell 建连初期的三个固定行为特征,且顺序出现在同一个 TCP 连接中。
特征:发送一段固定代码(payload),http 响应为空
特征:发送一段固定代码(test),执行结果为固定内容
特征:发送一段固定代码(getBacisInfo)
由于对内容的加密,即使哥斯拉每次都发送一段固定代码,检测引擎也无法通过规则直接匹配。另外, webshell 的密码、密钥均不固定,代码加密后的密文也不同。
回看 webshell 代码,$P 和 $T 在生成时属于非固定值,但在 shell 连接的整个生命周期,却又是固定值。$T 是密钥的 md5 值前 16 位,属于唯一的加密因子,被用于与原始代码进行异或。哥斯拉进行异或加密时,循环使用加密因子 $T 的每一位与被加密字符串进行异或位运算。(具体生成算法参考文章 [3])这就引出了第一个真理:
长度为 l 的字符串与长度为 n 的加密因子循环按位异或,密文的长度为 l。
对于哥斯拉中频繁使用的 Base64 编码,又会引出真理二:
长度为l的字符串进行Base64编码后长度为定值。
检测思路(ZEEK)(估计在我公司的流量产品上实现应该比这个简单)
对规则的落地要依托流量层检测的基础设施,上面总结出的三条规则具有上下文关联性,传统的 IDS 无法直接实现。这里的难点在于,需要一次性对三个数据包做实时判断,并且需要对包内容做一些字符串的切割、解码操作。能想到的要么是大数据实时计算,要么是 Zeek 了。
想必熟悉 Zeek 的同学一定了解其统计框架 Summary Statistics,你可以对符合特定条件的数据进行统计、计算。例如统计同一个源 IP 发起的 SSH 登录行为并计算次数,在某个时间段内超过阈值 $threshold 就产生一条 SSH 暴力破解的告警。在哥斯拉的场景里,可以巧妙的用 Zeek 统计框架收集同一 TCP 连接中的 http 数据。Zeek 脚本语言也完全满足统计数据以后的匹配计算。
先创建一个统计实例,设置延时 $epoch 为 10 秒,统计阈值 $threshold 为 3,即统计 10 秒钟内产生的连续 3 个 http 包。当事件 http_message_done 发生时执行统计并收集数据:
event http_message_done(c: connection, is_orig: bool, stat: http_message_stat)
{
if ( c?$http && c$http?$status_code && c$http?$method )
{
if ( c$http$status_code == 200 && c$http$method == "POST" )
{
local key_str: string = c$http$uid + "$_$" + cat(c$id$orig_h) + "$_$" + cat(c$id$orig_p) + "$_$" + cat(c$http$status_code) + "$_$" + cat(c$id$resp_h)+ "$_$" + cat(c$id$resp_p) + "$_$" + c$http$uri;
local observe_str: string = cat(c$http$ts) + "$_$" + c$http$client_body + "$_$" + c$http$server_body;
SumStats::observe("godzilla_webshell_event", SumStats::Key($str=key_str), SumStats::Observation($str=observe_str));
}
}
}
其中,统计条件为同一 TCP 连接中 HTTP 响应为 200 的数据包,并且具备相同的 URI。收集的数据内容主要为包的捕获时间、http 请求内容、http 响应内容。收集到符合这些条件的数据后数据被带进 $threshold_crossed,此处开始对三个 http 包进行解析匹配:
if ( |result["godzilla_webshell_event"]$unique_vals| == 3 )
{
for ( value in result["godzilla_webshell_event"]$unique_vals )
{
local observe_str_vector: vector of string = split_string(value$str, /\$_\$/);
# 对请求内容进行URL解码
observe_str_vector[1] = unescape_URI(observe_str_vector[1]);
local request_body_only_value: string;
# 从请求中分离出加密代码部分
request_body_only_value = observe_str_vector[1][strstr(observe_str_vector[1], "=") : |observe_str_vector[1]|];
# 规则1:
# 发送的加密代码长度为23068 && HTTP响应内容为空
if ( |request_body_only_value| == 23068 && |observe_str_vector[2]| == 0 )
{
sig1 = T;
}
local response_body: string = observe_str_vector[2];
# 规则2:
# 加密代码长度为40 && HTTP响应内容长度为40 && 响应内容首尾各16位md5字符串
if ( |request_body_only_value| == 40 && |response_body| == 40 && response_body == find_last(response_body, /[a-z0-9]{16}.+[a-z0-9]{16}/) )
{
sig2 = T;
}
# 规则3:
# 发送的加密代码长度为60 && 响应内容首尾各16位md5字符串
if ( |request_body_only_value| == 60 && response_body == find_last(response_body, /[a-z0-9]{16}.+[a-z0-9]{16}/) )
{
sig3 = T;
}
}
# 三个规则同时符合,进行告警
if ( sig1 && sig2 && sig3 )
{
print fmt("[+] Godzilla traffic detected, %s:%s -> %s:%s, webshell URI: %s", key_str_vector[1], key_str_vector[2], key_str_vector[4], key_str_vector[5], key_str_vector[6]);
}
}
个人思考:这个方案对于针对版本哥斯拉甚至只针对某个默认的 webshell,如果稍作改变应该表现效果不佳,既然第一个 http 是上传,后面的两个 http 是调用,则通过长度范围
来匹配比较好。哥斯拉不支持自定义,全部提取一遍也可以,算是检测比较简单的一种远控了。