freeBuf
主站

分类

漏洞 工具 极客 Web安全 系统安全 网络安全 无线安全 设备/客户端安全 数据安全 安全管理 企业安全 工控安全

特色

头条 人物志 活动 视频 观点 招聘 报告 资讯 区块链安全 标准与合规 容器安全 公开课

官方公众号企业安全新浪微博

FreeBuf.COM网络安全行业门户,每日发布专业的安全资讯、技术剖析。

FreeBuf+小程序

FreeBuf+小程序

如何利用 Zeek 检测 Sliver HTTP C2
2023-11-12 22:26:47

Sliver是什么?我反手就是一个ChatGPT!

1699711274_654f892ae5a9dc5cd4819.png!small

image-20231103173433297


Sliver HTTP C2 流量检测思路

我们的检测目标是Sliver HTTP beacon traffic,通常被称为“心跳”包。分析和检测的思路如下:

一、密钥交换

当Client首次启动时,它会通过一个POST请求与Server进行通讯。这一步骤的主要目的是进行密钥(Session Key)交换。为了避免流量检测,Sliver的Client在每次连接到Server时都会产生随机的请求。Sliver HTTP C2允许高度定制化。用户可以简单地修改http-c2.json文件中的配置项,以实现URI部分的定制化。以下是三次执行Client端后的POST请求截图。

image-20231104203402427image-20231104203213374image-20231104203502838


正如之前所述,尝试从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的查询参数中。这种方法生成的参数看似随机,但实际上是有规律的,这对于我们的检测策略至关重要。


image-20231108103916316


// 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值反向推导出使用的编码器。

image-20231108104044549


// 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字节的加密数据。这些数据是怎么来的?为了更清楚地说明数据是如何生成的,我将详细描述其生成流程,并以图形方式展示:

image-20231108111232203

具体生成过程如下:

第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。image-20231107175632660

第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数据并不是其原始形态。这是因为所观察到的数据已经经过编码处理。

image-20231108112034359

结论:

我们可以看到,整个过程的核心目的是确保sKey的安全性,防止其被泄露。最终,我们可以得出一个简化的数据结构图,表明POST Body由前述的三个主要部分组成。

image-20231107211334773

这是我模拟加密过程的输出,其中每个阶段的字节数都已经打印在界面上。

image-20231108110145849


二、信标流量

在完成第一个可疑连接的捕获之后,我们的重点转移到如何检测HTTP中的“信标”流量上。这部分相对简单,尤其是与交换密钥特征的检测相比。如图所示,所有POST请求之后的GET请求都可视为“信标”通信流量。Sliver使用“抖动”技术来规避基于周期性的检测,包括URI的随机化。这种方法为减少流量模式的可预测性,从而使得基于模式的自动化检测变得更加困难。默认轮训时间3s,抖动时间4s。

image-20231101170122908


源码分析

在上述截图中,我们可以看到三个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. 检测逻辑

image-20231108163024578

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. 检测逻辑

image-20231103160641641

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")
                ]);
            }
        }
    ]);
}


写在最后

其实算不上什么高大上的内容,攻防本就是不断“博弈”的过程。虽然以后自己越来越少机会写这些了,但我还是想说:开源是“理念”,分享是“精神”。

# 网络安全 # 企业安全 # 网络安全技术 # NTA # Zeek
本文为 独立观点,未经允许不得转载,授权请联系FreeBuf客服小蜜蜂,微信:freebee2022
被以下专辑收录,发现更多精彩内容
+ 收入我的专辑
+ 加入我的收藏
相关推荐
  • 0 文章数
  • 0 关注者
文章目录