Sliver是什么?我反手就是一个ChatGPT!
Sliver HTTP C2 流量检测思路
我们的检测目标是Sliver HTTP beacon traffic,通常被称为“心跳”包。分析和检测的思路如下:
一、密钥交换
当Client首次启动时,它会通过一个POST请求与Server进行通讯。这一步骤的主要目的是进行密钥(Session Key)交换。为了避免流量检测,Sliver的Client在每次连接到Server时都会产生随机的请求。Sliver HTTP C2允许高度定制化。用户可以简单地修改http-c2.json文件中的配置项,以实现URI部分的定制化。以下是三次执行Client端后的POST请求截图。
正如之前所述,尝试从URI(指session_paths/session_files.session_file_ext)的角度检测Sliver HTTP beacon traffic基本上是不可行的,因为绕过这种检测的成本极低。因此,我们需要寻找新的检测方法。通过分析Sliver的源代码,我们决定将检测重点放在那些看似“随机”生成的请求参数上。以图1的POST请求为例,我们发现了一些关键特征:
参数
ib=6578885j6
实际上是基于时间戳生成的一次性密钥。参数
i=8148k7556
是经过混淆处理的EncoderID。请求的Body长度也是一个潜在的特征。然而,由于Zeek在某些编码上的解码限制,我们暂时未能将其作为特征考虑进来。
这种方法使我们能够更有效地识别和区分Sliver生成的流量,即使其URI部分高度随机化和定制化。
源码分析
1. 参数ib=6578885j6的生成过程是通过Sliver HTTP Client的OTPQueryArgument方法实现的。这个方法基于时间戳来生成一个查询参数。OTPQueryArgument函数接受一个URL和一个字符串值。它使用nonceQueryArgs数组中的随机字符生成Key。然后,它在输入值中的随机位置插入0到2个随机字符。最后,这个键值对被添加到URL的查询参数中。这种方法生成的参数看似随机,但实际上是有规律的,这对于我们的检测策略至关重要。
// OTPQueryArgument - Adds an OTP query argument to the URL func (s *SliverHTTPClient) OTPQueryArgument(uri *url.URL, value string) *url.URL { values := uri.Query() key1 := nonceQueryArgs[insecureRand.Intn(len(nonceQueryArgs))] key2 := nonceQueryArgs[insecureRand.Intn(len(nonceQueryArgs))] for i := 0; i < insecureRand.Intn(3); i++ { index := insecureRand.Intn(len(value)) char := string(nonceQueryArgs[insecureRand.Intn(len(nonceQueryArgs))]) value = value[:index] + char + value[index:] } values.Add(string([]byte{key1, key2}), value) uri.RawQuery = values.Encode() return uri }
2. 参数i=8148k7556
的生成涉及到Sliver HTTP Client中的NonceQueryArgument
方法和RandomEncoder
方法。NonceQueryArgument函数接受一个URL和一个uint64值。它使用nonceQueryArgs数组中的随机字符生成Key,并在Value中插入随机字符。RandomEncoder函数生成一个随机的encoderID和对应的nonce值。编码器映射表(Encoder Map)列出了不同ID对应的编码方式。decode_nonce函数可以根据nonce值反向推导出使用的编码器。
// NonceQueryArgument - Adds a nonce query argument to the URL func (s *SliverHTTPClient) NonceQueryArgument(uri *url.URL, value uint64) *url.URL { values := uri.Query() key := nonceQueryArgs[insecureRand.Intn(len(nonceQueryArgs))] argValue := fmt.Sprintf("%d", value) for i := 0; i < insecureRand.Intn(3); i++ { index := insecureRand.Intn(len(argValue)) char := string(nonceQueryArgs[insecureRand.Intn(len(nonceQueryArgs))]) argValue = argValue[:index] + char + argValue[index:] } values.Add(string(key), argValue) uri.RawQuery = values.Encode() return uri } // RandomEncoder - Get a random nonce identifier and a matching encoder func RandomEncoder() (int, Encoder) { keys := make([]int, 0, len(EncoderMap)) for k := range EncoderMap { keys = append(keys, k) } encoderID := keys[insecureRand.Intn(len(keys))] nonce := (insecureRand.Intn(maxN) * EncoderModulus) + encoderID return nonce, EncoderMap[encoderID] }
例如,当我们调用decode_nonce("8148k7556")
时,输出是'gzip'
,表明这个nonce值对应的编码器是gzip。
import re encoders = { 13: "b64", 31: "words", 22: "png", 43: "b我吧", # 竟然是敏感词。。。freebuf 不让发 45: "gzip-words", 49: "gzip", 64: "gzip-b64", 65: "b32", 92: "hex" } def decode_nonce(nonce_value): """Takes a nonce value from a HTTP Request and returns the encoder that was used""" nonce_value = int(re.sub('[^0-9]','', nonce_value)) encoder_id = nonce_value % 101 return encoders[encoder_id] In [1]: decode_nonce("8148k7556") Out[1]: 'gzip'
3. 请求内容里面有什么?POST请求的Body包含了一段长度为266字节的加密数据。这些数据是怎么来的?为了更清楚地说明数据是如何生成的,我将详细描述其生成流程,并以图形方式展示:
具体生成过程如下:
第1步:生成密钥
Client使用cryptography.RandomKey()方法随机生成一个32字节的密钥(称为sKey)。这个sKey在随后的命令执行中非常重要,因为所有命令的加密和解密都会使用到它。此步骤解释了为何首次POST请求被视为密钥交换过程:Server需要这个sKey来解密后续请求的内容。
sKey := cryptography.RandomKey() // RandomKey - Generate random ID of randomIDSize bytes func RandomKey() [chacha20poly1305.KeySize]byte { randBuf := make([]byte, 64) rand.Read(randBuf) return deriveKeyFrom(randBuf) }
第2步:序列化sKey
使用proto.Marshal()方法对第一步中生成的sKey进行序列化。序列化后,sKey的长度从32字节变为34字节。
s.SessionCtx = cryptography.NewCipherContext(sKey)
httpSessionInit := &pb.HTTPSessionInit{Key: sKey[:]}
data, _ := proto.Marshal(httpSessionInit)
关于为什么会多出2个字节0a20?我反手又是一个GPT。
第3步:确保sKey的完整性和真实性
这一步骤使用HMAC(Hash-based Message Authentication Code,基于哈希的消息认证码)来验证sKey的完整性和真实性。首先,创建一个新的HMAC实例。然后,使用implant的私钥的哈希值作为HMAC的密钥。最后,对原始明文(plaintext)进行处理,计算出相应的HMAC值。
peerKeyPair := GetPeerAgeKeyPair() // First HMAC the plaintext with the hash of the implant's private key // this ensures that the server is talking to a valid implant privateDigest := sha256.New() privateDigest.Write([]byte(peerKeyPair.Private)) mac := hmac.New(sha256.New, privateDigest.Sum(nil)) mac.Write(plaintext)
第4步:加密处理
使用AgeEncrypt()方法,借助Server的公钥对明文(plaintext)进行加密。在这个过程中,会将HMAC值(长度为32字节)附加到明文消息的前端。这一步骤是为了确保消息在传输过程中的完整性和安全性。Server端通过计算并比对HMAC值来验证消息是否在传输过程中被篡改。它会计算收到的消息的HMAC值,并将其与消息前32字节中的HMAC值进行对比。如果两者一致,说明消息未被篡改。
ciphertext, err := AgeEncrypt(recipientPublicKey, append(mac.Sum(nil), plaintext...)) // AgeEncrypt - Encrypt using Nacl Box func AgeEncrypt(recipientPublicKey string, plaintext []byte) ([]byte, error) { if !strings.HasPrefix(recipientPublicKey, agePublicKeyPrefix) { recipientPublicKey = agePublicKeyPrefix + recipientPublicKey } recipient, err := age.ParseX25519Recipient(recipientPublicKey) if err != nil { return nil, err } buf := bytes.NewBuffer([]byte{}) stream, err := age.Encrypt(buf, recipient) if err != nil { return nil, err } if _, err := stream.Write(plaintext); err != nil { return nil, err } if err := stream.Close(); err != nil { return nil, err } return bytes.TrimPrefix(buf.Bytes(), ageMsgPrefix), nil }
第5步:构建msg
这一步中,代码构建了一个名为msg的消息,这个消息实际上就是POST请求Body的原始形态,在进行编码处理之前的状态。消息msg的内容包括implant的公钥的HASH值和加密后的数据。整体消息的总长度为266字节。C2 Server通过比对消息前32个字节中的HASH值来验证implant的公钥的真实性。一旦验证通过,Server将使用其私钥来解密消息中的其余部分。
// Sender includes hash of it's implant specific peer public key publicDigest := sha256.Sum256([]byte(peerKeyPair.Public)) msg := make([]byte, 32+len(ciphertext)) copy(msg, publicDigest[:]) copy(msg[32:], ciphertext)
最后阶段:数据编码
在数据生成流程的最终阶段,Client使用encoder.Encode()方法对266字节的加密数据进行编码。因此,我们通过网络流量捕获得到的PCAP数据并不是其原始形态。这是因为所观察到的数据已经经过编码处理。
结论:
我们可以看到,整个过程的核心目的是确保sKey的安全性,防止其被泄露。最终,我们可以得出一个简化的数据结构图,表明POST Body由前述的三个主要部分组成。
这是我模拟加密过程的输出,其中每个阶段的字节数都已经打印在界面上。
二、信标流量
在完成第一个可疑连接的捕获之后,我们的重点转移到如何检测HTTP中的“信标”流量上。这部分相对简单,尤其是与交换密钥特征的检测相比。如图所示,所有POST请求之后的GET请求都可视为“信标”通信流量。Sliver使用“抖动”技术来规避基于周期性的检测,包括URI的随机化。这种方法为减少流量模式的可预测性,从而使得基于模式的自动化检测变得更加困难。默认轮训时间3s,抖动时间4s。
源码分析
在上述截图中,我们可以看到三个GET请求,其中每个请求的URI都是随机生成的,甚至它们的编码方式也各不相同。幸运的是,对于“信标”特征的检测,我们可以借鉴之前用于参数检测的方法。因此,GET请求中的参数实际上是通过NonceQueryArgument
方法生成的。这种方法的使用意味着,尽管URI和编码方式可能每次都在变化,但参数生成的一致性为我们提供了一个可靠的检测手段。
// NonceQueryArgument - Adds a nonce query argument to the URL
func (s *SliverHTTPClient) NonceQueryArgument(uri *url.URL, value int) *url.URL {
values := uri.Query()
key := nonceQueryArgs[insecureRand.Intn(len(nonceQueryArgs))]
argValue := fmt.Sprintf("%d", value)
for i := 0; i < insecureRand.Intn(3); i++ {
index := insecureRand.Intn(len(argValue))
char := string(nonceQueryArgs[insecureRand.Intn(len(nonceQueryArgs))])
argValue = argValue[:index] + char + argValue[index:]
}
values.Add(string(key), argValue)
uri.RawQuery = values.Encode()
return uri
}
提取特征
一、密钥交换
1. 检测逻辑
2. 检测代码
event http_message_done(c: connection, is_orig: bool, stat: http_message_stat) { if (c$http$method == "POST" && c$http$status_code == 200 && c$http?$set_cookie && is_suspicious_cookie(c$http$set_cookie) && is_suspicious_query(c$http$method, c$http$uri)) { # record suspicious connections http_connection_state[c$http$uid] = split_string(c$http$set_cookie, /;/)[0]; } }
二、信标流量
1. 检测逻辑
2. 检测代码
# Event handler to process and inspect completed HTTP messages event http_message_done(c: connection, is_orig: bool, stat: http_message_stat) { if (c$http$method == "GET" && c$http$status_code == 204 && c$http?$cookie && (c$http$uid in http_connection_state) && (c$http$cookie == http_connection_state[c$http$uid])) { local key_str = fmt("%s#####%s#####%s#####%s#####%s", c$http$uid, c$id$orig_h, c$http$host, c$id$resp_p, c$http$cookie); local observe_str = c$http$uri; } }
实现中的一些难点
1. 如何在同一个TCP连接中,关联多个HTTP请求的上下文,实现场景的判断?
答:这里可以尝试通过维护一个HTTP状态表来实现同一个uid下的多个HTTP请求关联。例如,将符合阶段-1的处理结果存储到http_connection_state
中,后续只需通过检查HTTP状态表中的状态来执行之后的逻辑。
## Global table to store cookie state. global http_connection_state: table[string] of string;
2. 如何避免http_connection_state set无限扩大,降低”无效”uid的填充set?
答:对于这个问题,可以使用Zeek的create_expire属性,用它来管理整个http_connection_state set的生命周期。按照我们之前的分析,Sliver的HTTP C2流量在密钥交换之后,会进行C2数据包轮训,“信标”时间会有1~4秒抖动。所以,我们可以给这个可疑uid设置一个create_expire
属性。如:create_expire=300sec
,那么300秒之后我们会自动删除http_connection_state 中的 http_uid,通常这个时间内已经足够我们判断该HTTP请求流中是否为Sliver HTTP beacon traffic了。
## Global table to store cookie state. global http_connection_state: table[string] of string &create_expire=300sec;
3. 如何实现对规则的快速启停以及调整,避免规则更新重启整个Zeek集群?
答:这里建议使用Zeek的Configuration Framework,只需将规则中的配置写入到文件中,后续只需要更改配置文件就可以实现”热“加载,从而不需要对Zeek进行deploy
。
## Define module-level configuration options. export { ## Option to turn on/off detection. option enable: bool = T; ## Path to additional configuration for detection. redef Config::config_files += { "/usr/local/zeek/share/zeek/site/rules/Sliver/config.dat" }; }
4. 如何让规则只对指定的Zeek Worker生效?例如,我的环境中有30台Zeek,但是实际接入内对外流量Zeek只有2台,剩下都是外对内的流量。
答:可以在代码中增加针对Zeek机器IP的判断,只针对指定的Zeek Worker IP进行规则的生效。这样一来,该规则也只需要在内对外的2台Zeek上生效,避免在负载很高的外对内的Zeek Worker上进行计算。
event http_message_done(c: connection, is_orig: bool, stat: http_message_stat) { ## Return early if detection is disabled or sensor IP is not allowed. if (c$http$sensor_ip ! in allow_sensor) return; }
5. 如何解码请求参数中的EncoderID?
答:废话少说,放“码”过来
## Decode nonce value to identify the encoder used in traffic encoding. function decode_nonce(nonce: string): int { local nonce_value = to_int(gsub(nonce, /[^[:digit:]]/, "")); return nonce_value % 101; }
Zeek 代码
## Module to detect Sliver HTTP traffic based on specific criteria. module SliverHttpTraffic; # Define a new notice type for Sliver HTTP beacon traffic. redef enum Notice::Type += { Sliver_HTTP_Beacon_Traffic }; # Global configuration and settings global http_connection_state: table[string] of string &create_expire=3600sec; global encoder_ids: set[int] = [13, 22, 31, 43, 45, 49, 64, 65, 92]; global cookie_len: int = 32; # Extending the HTTP::Info record to capture cookie-related details redef record HTTP::Info += { cookie: string &log &optional; # Value of the Cookie header set_cookie: string &log &optional; # Value of the Set-Cookie header }; # Extend the default notice record to capture specific details related to Sliver traffic. redef record Notice::Info += { host: string &log &optional; # Hostname involved in the suspicious activity uris: set[string] &log &optional; # Set of suspicious URIs accessed cookie: string &log &optional; # Suspicious cookie associated with the request }; # Event handler to capture HTTP header information event http_header(c: connection, is_orig: bool, name: string, value: string) { if (is_orig && name == "COOKIE") { c$http$cookie = value; } else if (!is_orig && name == "SET-COOKIE") { c$http$set_cookie = value; } } # Utility function to decode nonce values function decode_nonce(nonce: string): int { local nonce_value = to_int(gsub(nonce, /[^[:digit:]]/, "")); return nonce_value % 101; } # Function to check if an HTTP query is suspicious function is_suspicious_query(method: string, uri: string): bool { local url = decompose_uri(uri); local encoder_id: int; if (!url?$params) return F; if (method == "POST" && |url$params| == 2) { local key_length: table[count] of string; for (k, v in url$params) { if (|k| > 2) return F; key_length[|k|] = v; } if ((2 ! in key_length) || (1 ! in key_length)) return F; encoder_id = decode_nonce(key_length[1]); return encoder_id in encoder_ids; } if (method == "GET" && |url$params| == 1) { for (k, v in url$params) { if (|k| > 1) return F; encoder_id = decode_nonce(v); return encoder_id in encoder_ids; } } return F; } # Check if a cookie's structure is suspicious function is_suspicious_cookie(cookie: string): bool { local cookies = split_string(split_string(cookie, /;/)[0], /=/); return (|cookies| == 2 && |cookies[1]| == cookie_len); } # Event handler to process and inspect completed HTTP messages event http_message_done(c: connection, is_orig: bool, stat: http_message_stat) { if (is_orig || !c?$http || !c$http?$status_code || !c$http?$method || !c$http?$uri) return; if (c$http$method == "POST" && c$http$status_code == 200 && c$http?$set_cookie && is_suspicious_cookie(c$http$set_cookie) && is_suspicious_query(c$http$method, c$http$uri)) { http_connection_state[c$http$uid] = split_string(c$http$set_cookie, /;/)[0]; } if (c$http$method == "GET" && c$http$status_code == 204 && c$http?$cookie && (c$http$uid in http_connection_state) && (c$http$cookie == http_connection_state[c$http$uid])) { local key_str = fmt("%s#####%s#####%s#####%s#####%s", c$http$uid, c$id$orig_h, c$http$host, c$id$resp_p, c$http$cookie); local observe_str = c$http$uri; # Create an observation for statistical analysis SumStats::observe("sliver_http_beacon_traffic_event", [$str=key_str], [$str=observe_str]); } } # Event to initialize and configure statistical mechanisms event zeek_init() { local r1 = SumStats::Reducer($stream="sliver_http_beacon_traffic_event", $apply=set(SumStats::UNIQUE)); # Set up the statistical analysis parameters SumStats::create([ $name="sliver_http_beacon_traffic_event.unique", $epoch=300sec, $reducers=set(r1), $threshold=3.0, $threshold_val(key: SumStats::Key, result: SumStats::Result) = { return result["sliver_http_beacon_traffic_event"]$num + 0.0; }, $threshold_crossed(key: SumStats::Key, result: SumStats::Result) = { if (result["sliver_http_beacon_traffic_event"]$unique == 3) { local key_str_vector: vector of string = split_string(key$str, /#####/); local suspicious_uri: set[string]; for (value in result["sliver_http_beacon_traffic_event"]$unique_vals) { if (! is_suspicious_query("GET", value$str)) return; add suspicious_uri[value$str]; } # Issue a notice if suspicious behavior is observed NOTICE([ $note=Sliver_HTTP_Beacon_Traffic, $uid=key_str_vector[0], $src=to_addr(key_str_vector[1]), $host=key_str_vector[2], $p=to_port(key_str_vector[3]), $cookie=key_str_vector[4], $uris=suspicious_uri, $msg=fmt("[+] Sliver HTTP beacon traffic detected, %s -> %s:%s", key_str_vector[1], key_str_vector[2], key_str_vector[3]), $sub=cat("Sliver HTTP beacon traffic") ]); } } ]); }
写在最后
其实算不上什么高大上的内容,攻防本就是不断“博弈”的过程。虽然以后自己越来越少机会写这些了,但我还是想说:开源是“理念”,分享是“精神”。