文章总结: 本文披露越南APT32海莲花组织针对国内常用应用投放的恶意样本,采用白加黑手法盗用合法签名,通过五层私有加密机制实现载荷释放与远控通信。样本具备高度反调试、反沙箱能力,完整逆向解密流程涉及AES-256-CBC及自定义混淆算法,展示了高级持续性威胁的复杂攻击链。
综合评分: 88
文章分类: 恶意软件,漏洞分析,红队,威胁情报,逆向分析
【首发披露】越南APT32-海莲花盯向国内常用应用,投放样本多层私有加密,完整逆向解密流程全拆解…
原创
问鼎安全
问鼎安全
问鼎安全应急响应中心
2026年9月16日 14:00
湖北
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
——免责声明——
本文内容仅供学习交流与借鉴,旨在提升安全意识、加强安全防护。请勿用于非法用途,擅自操作后果自负。如有内容存在争议或侵权请联系我们删除。感谢您的理解与支持,让我们共同维护网络安全环境。
本文涉及样本与功能均为实际真实样本,非杜撰情节
前言
问鼎安全应急响应中心在长期威胁狩猎与一线应急响应工作中,持续捕获并处置多起APT高级持续性入侵事件。本次将公开一例当前存活、复现攻击链路的APT真实样本。该样本采用五层自定义私有加密机制,整体解密链路复杂、对抗强度极高。
下期预告:【应急案例】国产统信服务器遭APT入侵的排查全流程与样本详解…
行业内多数公开分析文章仅对境外APT组织做概念性介绍,多数安全从业者日常工作中难以接触真实存活APT载荷。本次披露的样本利用合法软件签名软件(白加黑)投放链路,打破大众对APT攻击的固有认知。区别于传统黑灰产木马易检测、易逆向、特征公开的特点,该APT样本在检测规避、逆向对抗、通信协议层面均采用高度私有化定制逻辑,动态密钥轮转、多层嵌套加密、多维度反调试沙箱虚拟机检测,整体拆解难度极大。
本文(我司于五月捕获,本文为重做版本,其中该批已被作用投放含搜狗输入法,蓝信等多个日常应用程序投放)将针对五月底标记捕获的海莲花APT最新存活样本,完成全链路逆向拆解。攻击者通过盗用合法搜狗输入法数字签名程序落地,加载私有加密恶意DLL,通过四轮嵌套加解密完成载荷释放,依托TLS加密隧道、隐蔽POST请求实现远控通信与持久化潜伏。全文完整覆盖样本溯源、组织画像、行为分析、反对抗机制、多层载荷加解密全流程,内容篇幅较长,敬请耐心阅读。
一、APT组织画像
本次捕获样本关联越南海莲花(OceanLotus)高级持续性威胁组织,该组织为东南亚活跃度最高、针对我国政企目标最频繁的APT组织之一,详细属性如下:
| 属性 | 详细信息 |
| — | — |
| 组织名称 | 海莲花(OceanLotus) |
| 组织别名 | APT32、SeaLotus、Canvas Assassin、APT-C-00、BISMUTH |
| 归属国家 | 越南 |
| 活跃时间 | 2012年至今,持续活跃超14年 |
| 主要攻击目标 | 中国政府部门、国防军工、外交机构、海事科研企业、越南本土重点企业 |
| 攻击动机 | 长期潜伏驻留、窃取政治、军事、经济核心情报 |
二、初始加载器分析
本次攻击初始加载器为盗用合法搜狗输入法数字签名的程序,使用白加黑手法,文件体积仅230KB,基于C++开发,白程序内置公开PDB调试路径。
白程序内置PDB路径:
D:\data\landun\workspace\p-3d7f88f5f52b43179dd219a5c6dd1b
5c\src\bin\Release_Win32\biz_render.pdb
路径中包含完整MD5特征值:3d7f88f5f52b43179dd219a5c6dd1b5c,可作为该批次样本白程序同源溯源特征。
加载器核心行为为恶意DLL侧加载,全程设计多重容错、规避机制,保证攻击链路稳定落地:
通过双重调用 SHGetFolderPath API 获取系统 %LOCALAPPDATA% 用户目录,规避不同Windows版本、域漫游用户目录读取异常问题,防止加载链路断裂;
组合调用 SetDllDirectoryW + LOAD_WITH_ALTERED_SEARCH_PATH 双重加载策略,精准加载本地恶意DLL文件;
全程加入Try异常捕获、重复容错逻辑,保证加载行为稳定执行;
依托合法厂商签名规避静态查杀,加载过程仅执行极简恶意逻辑,无高危行为特征,规避动态行为检测。
样本加载器加载DLL,如下:
三、远控核心组件行为分析
恶意DLL为本次攻击核心远控组件,仅暴露唯一导出函数,由上层加载器调用执行核心恶意逻辑。
3.1 注册表隐蔽配置操作
组件运行后会读写注册表路径: HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced
该注册表项为当前用户文件资源管理器高级配置项,控制文件夹视图、文件隐藏、扩展名显示等核心配置。攻击者通过修改该项实现恶意文件隐藏、行为隐匿,仅对当前登录用户生效,无全局篡改痕迹,隐蔽性极强。
3.2 无显性网络API导入(动态加密通信)
逆向分析发现,该核心组件未导入任何常规网络通信模块:
无 ws2_32.dll、wininet.dll、WinHttp.dll、UrlMon.dll 等网络通信库导入;
无 ADVAPI32.dll 注册表、权限相关显性导入。
同时程序内置完整XML解析、字符串处理、BOM编码检测、UTF-16代理对处理、复杂数据初始化逻辑,且全程无硬编码C2地址。
以上特征充分证明:样本通信逻辑为动态加载+多层加密解析,C2地址、通信规则均运行时解密获取,静态分析无法直接提取。
3.3 全维度反调试、反沙箱对抗机制
该样本内置多重私有对抗机制,专门针对逆向分析、调试、沙箱虚拟机环境检测,本文大致列举几个:
异常钩子反调试:通过 SetUnhandledExceptionFilter、Unhandled
ExceptionFilter 挂钩异常回调,检测调试器状态,命中调试环境则置零特征标志位,终止恶意逻辑;
系统环境校验:检测屏幕分辨率、系统硬件特征,过滤虚拟机、沙箱、动态分析容器;
原生API调试探测:哈希动态解析 ntdll 核心未导出API,调用 NtQueryInformationProcess、NtOpenProcess、NtClose 检测进程调试状态;
硬件断点检测:初始化 CONTEXT_DEBUG_REGISTERS 调试上下文,通过 GetThreadContext 读取 DR0-DR3 硬件断点寄存器,检测到任意非零断点值直接调用 ExitProcess 退出进程,阻断逆向调试。
四、第一阶段:AES-256-CBC 整体载荷解密
核心组件内置超大加密资源Blob,采用私有AES-256-CBC加密机制,全程动态密钥枚举,无硬编码密钥,解密难度极高。
4.1 第一阶段解密完整流程
- 程序入口 0x1007F6C0 接收加载器调用,启动解密调度逻辑;
- 执行 sub_1004A930 完成环境、调试、沙箱合法性校验,过滤分析环境;
- 环境校验通过后,枚举 0~100 数字字符串作为候选解密密钥;
- 爆破得到正确种子密钥:字符串 “10”;
- 对种子密钥进行 SHA256 哈希,生成 32字节 AES-256 密钥;
- 读取DLL rdata段固定偏移 0x100A778C、大小 0x3036F0 的加密Blob数据;
- 采用全零16字节IV、AES-256-CBC模式解密;
- 手动去除PKCS#7填充,得到第二阶段完整但仍然加密Shellcode载荷。
4.2 配套底层加密支撑逻辑
样本内置完整加密工具链,全程模块化封装:
1.第一阶段加密数据被放置在加载器加载的核心组件.rdate结构中;
2.初始进行主解密调度,环境检查后进入 key 枚举和内置数据解密;
3.进行枚举候选KEY,”0″到”100″候选;
4.然后进行大块面积内存拷贝用以解密;
5.进行SHA256 封装 wrapper;
6.进行AES的上下文准备工作;
7.AES16字节块处理wrapper。
五、样本私有混淆算法体系
为对抗静态分析与特征提取,样本内置两套自研混淆算法,分别用于字符串混淆、API名称哈希隐藏,彻底隐藏恶意行为特征。
5.1 短字符串逐字节混淆算法
算法公式:
buf[i] ^= (i % 30) + 1
通过位置偏移动态异或加密所有关键字符串,无固定密钥,每个位置加密值均不相同。
解密样例:
密文:TQFV64)LEF
明文:USER32.DLL
密文:BPZTQ55&MFG
明文:CRYPT32.DLL
5.2 自定义API哈希混淆算法
样本不存储原始API名称,通过自研哈希算法将所有导入API、模块名转为纯数字哈希值,运行时动态解析,规避特征扫描。
哈希算法逻辑:
h = 11 for each byte c in export_name: h = (2 * (h ^ (510 * c))) mod 2^32
哈希映射样例:
52494320→USER32.DLL
-838327828→GetSystemMetrics
242433520→KERNEL32.DLL
473686296→CreateToolhelp32Snapshot
1693843220→Process32FirstW
846802340→Process32NextW
-853432512→GetThreadContext
1687266160→CheckRemoteDebuggerPresent
119492592→CRYPT32.DLL
249272828→VirtualAlloc
1027729008→CertEnumSystemStoreLocation
我们已知了加密的算法就可以进行解密算法的设计和使用,解密算法如下:
import hashlibfrom cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modesfrom cryptography.hazmat.backends import default_backendDLL_PATH = "******"OUT_SHELLCODE = "browser_host_stage2_shellcode.plain.bin"BLOB_VA = 0x100a778cBLOB_SIZE = 0x3036f0EXPECTED_PAYLOAD_SHA256 = "*******"ZERO_IV = b"\x00" * 16backend = default_backend()def read_u16_le(buf, offset): return int.from_bytes(buf[offset:offset+2], byteorder="little")def read_u32_le(buf, offset): return int.from_bytes(buf[offset:offset+4], byteorder="little")def va_to_offset(file_data, va): pe_header_off = read_u32_le(file_data, 0x3c) section_count = read_u16_le(file_data, pe_header_off + 6) opt_hdr_size = read_u16_le(file_data, pe_header_off + 20) opt_hdr_start = pe_header_off + 24 image_base = read_u32_le(file_data, opt_hdr_start + 28) section_table_start = opt_hdr_start + opt_hdr_size for i in range(section_count): sec_off = section_table_start + i * 40 virt_size = read_u32_le(file_data, sec_off + 8) virt_addr = image_base + read_u32_le(file_data, sec_off + 12) raw_size = read_u32_le(file_data, sec_off + 16) raw_ptr = read_u32_le(file_data, sec_off + 20) span = max(virt_size, raw_size) if virt_addr <= va < virt_addr + span: return raw_ptr + (va - virt_addr) raise Exception(f"VA not mapped: 0x{va:x}")def sha256_buf(buf: bytes) -> str: return hashlib.sha256(buf).hexdigest()def decrypt_candidate(file_data: bytes, offset: int, blob_len: int, candidate: str) -> bytes: key = hashlib.sha256(candidate.encode("ascii")).digest() blob = file_data[offset : offset + blob_len] cipher = Cipher(algorithms.AES(key), modes.CBC(ZERO_IV), backend=backend) dec = cipher.decryptor() decrypted = dec.update(blob) + dec.finalize() pad_len = decrypted[-1] return decrypted[:-pad_len]def main(): with open(DLL_PATH, "rb") as f: dll_data = f.read() blob_file_off = va_to_offset(dll_data, BLOB_VA) encrypted_blob = dll_data[blob_file_off : blob_file_off + BLOB_SIZE] found = False for seed in range(0, 101): candidate_str = str(seed) plain = decrypt_candidate(dll_data, blob_file_off, BLOB_SIZE, candidate_str) payload_hash = sha256_buf(plain) if payload_hash == EXPECTED_PAYLOAD_SHA256: with open(OUT_SHELLCODE, "wb") as f: f.write(plain) key_bin = hashlib.sha256(candidate_str.encode("ascii")).digest() print(f"Found seed: {candidate_str}") print(f"AES Key(SHA256): {key_bin.hex()}") print(f"Decrypted payload saved to: {OUT_SHELLCODE}") print(f"Payload SHA256: {payload_hash}") found = True break if not found: raise Exception("No candidate matched embedded payload hash")if __name__ == "__main__": main()
六、第二阶段Shellcode载荷分析
第一阶段解密得到的二进制文件,为自带自定义Loader的二次加密载荷,不依赖系统PE加载器,具备完整自解析能力。
自带自定义的loader如下:
二阶段会在开始校验自身,不符合则丢弃。
校验成功后才能执行加密内容。
6.1 关键偏移修正规则
该阶段Loader运行时会主动修正基地址:
运行基址 = 原始Bin基址 + 0x20
对应逆向偏移规则:IDA中RVA地址 X,对应原始文件偏移为 X + 0x20,即所有字符串、函数、资源偏移均需手动修正。
6.2 二阶段Loader核心能力
1.自主完成326个系统API动态导入、重定位修复;
2.内置 Lua 5.4.6 完整运行时环境;
3.内置 libcurl 8.3.0-DEV 网络通信库,支撑自定义HTTP隧道通信;
4.内置多层资源解码、解压、配置解析逻辑。
6.3 核心依赖系统模块
KERNEL32.dll:内存管理、线程调度、文件操作、环境变量读取、动态库加载、反调试校验;
API ADVAPI32.dll:权限令牌操作、系统注册表读取、Crypto加密校验;
API WS2_32.dll:底层Socket通信、连接、收发、域名解析、端口调度;
WTSAPI32.dll:用户令牌查询与窃取 IPHLPAPI.DLL:本机网卡、网络环境信息采集;
bcrypt.dll:系统随机密钥生成,用于动态加密轮转。
七、AES+CRC64+LZMA 多层资源解密
二阶段载荷内置私有动态配置资源包,采用密钥变换+CRC64校验+AES-256-CBC加密+LZMA-alone压缩四层嵌套处理逻辑,是存储C2通信规则、Lua核心配置的核心载体。 该模块无固定明文配置,所有参数均通过运行时动态解密、解压、校验后加载,对抗强度极高。
7.1 动态配置加解密完整流程
样本处理动态配置资源拥有固定执行链路,每一步均有严格校验逻辑,防止篡改与静态分析:
动态生成、变换资源密钥,完成密钥初始化调度;
读取内存Buffer数据,强制校验数据长度,仅处理长度大于0x28的合法数据包;
执行自定义CRC64循环校验,采用固定初始值校验数据完整性;
初始化AES-256密钥调度表,配置固定IV向量,完成加密上下文构建;
调用内置S-BOX置换表完成加密置换运算;
解密密文主体后,解析标准LZMA-alone压缩头,解压得到原始配置数据与Lua字节码。
7.2 动态配置包私有结构
样本自定义专属数据包格式,三段式拼接结构,所有通信配置均以此格式加密存储:
[ AES-256-CBC 密文主体 ][ 32字节 AES密钥 ][ 8字节 CRC64校验值 ]
| 字段 | 大小 | 说明 |
| — | — | — |
| AES 密文主体 | N(16字节对齐) | 解密后为LZMA-alone压缩数据、Lua字节码、通信配置明文 |
| AES key | 32字节 | 位于CRC64校验位前0x20字节,用于解密前段密文主体 |
| CRC64 | 8字节 | 小端序存储,对「密文主体+AES密钥」整体完整性校验 |
7.3 核心加密算法固定特征
该层加密所有参数均为私有化固定配置,无随机变量,可精准逆向还原:
1.CRC64算法:多项式 0x42F0E1EBA9EA3693、初始值 0xF0F77F2614251579;
2.AES加密标准:AES-256-CBC 分组模式;
3.初始化向量IV:16字节全零固定向量;
4.填充规则:标准 PKCS#7 填充;
5.压缩格式:LZMA-alone独立压缩,头部结构固定为「1字节属性位+4字节字典大小+8字节解压尺寸」。
基于以上完整算法特征,可精准编写反向解密脚本,逐层完成校验、解密、解压,还原出原始Lua字节码文件。
反向解密脚本如下:
from __future__ import annotationsimport argparseimport hashlibimport structfrom dataclasses import dataclass, fieldfrom pathlib import Pathfrom typing import AnyRESOURCE_RVA = 0x29920RESOURCE_SIZE = 0x31BCRC4_KEY_RVA = 0x2E010RC4_KEY_SIZE = 0x100RVA_TO_FILE_DELTA = 0x20class ParseError(Exception): passdef read_rva(data: bytes, rva: int, size: int) -> bytes: off = rva + RVA_TO_FILE_DELTA return data[off : off + size]def rc4_ksa(key: bytes) -> list[int]: s = list(range(256)) j = 0 for i in range(256): j = (j + s[i] + key[i % len(key)]) & 0xFF s[i], s[j] = s[j], s[i] return sdef decode_lua_resource(data: bytes) -> bytes: enc = read_rva(data, RESOURCE_RVA, RESOURCE_SIZE) key = read_rva(data, RC4_KEY_RVA, RC4_KEY_SIZE) s = rc4_ksa(key) i = 1 j = 0 out = bytearray() for b in enc: si = s[i & 0xFF] j = (j + si) & 0xFF s[i & 0xFF], s[j] = s[j], s[i & 0xFF] k = s[(s[i & 0xFF] + si) & 0xFF] out.append(b ^ k) i = (i + 1) & 0xFFFFFFFF return bytes(out)@dataclassclass LuaFunction: path: str source: str | None = None line_defined: int = 0 last_line_defined: int = 0 num_params: int = 0 is_vararg: int = 0 max_stack_size: int = 0 code_count: int = 0 constants: list[Any] = field(default_factory=list) upvalue_count: int = 0 nested: list["LuaFunction"] = field(default_factory=list) locvars: list[dict[str, Any]] = field(default_factory=list) upvalue_names: list[str | None] = field(default_factory=list)class Lua54Reader: def __init__(self, data: bytes): self.data = data self.pos = 0 self.int_size = 8 self.num_size = 8 self.strings: list[dict[str, Any]] = [] def need(self, size: int) -> None: if self.pos + size > len(self.data): raise ParseError(f"unexpected EOF at 0x{self.pos:x}, need {size}") def byte(self) -> int: self.need(1) b = self.data[self.pos] self.pos += 1 return b def raw(self, size: int) -> bytes: self.need(size) b = self.data[self.pos : self.pos + size] self.pos += size return b def unsigned(self) -> int: x = 0 while True: b = self.byte() x = (x << 7) | (b & 0x7F) if b & 0x80: return x def string(self) -> str | None: size = self.unsigned() if size == 0: return None raw = self.raw(size - 1) try: return raw.decode("utf-8") except UnicodeDecodeError: return raw.decode("latin1", errors="replace") def header(self) -> dict[str, Any]: sig = self.raw(4) if sig != b"\x1bLua": raise ParseError(f"not a Lua chunk, signature={sig!r}") version = self.byte() fmt = self.byte() data = self.raw(6) instr_size = self.byte() self.int_size = self.byte() self.num_size = self.byte() luac_int = int.from_bytes(self.raw(self.int_size), "little", signed=True) luac_num = struct.unpack("<d", self.raw(self.num_size))[0] return { "version": version, "format": fmt, "luac_data": data.hex(), "instruction_size": instr_size, "integer_size": self.int_size, "number_size": self.num_size, "luac_int": luac_int, "luac_num": luac_num, } def constant(self, fn_path: str, index: int) -> Any: tag = self.byte() if tag == 0: return None if tag == 1: return False if tag == 17: return True if tag == 3: return int.from_bytes(self.raw(self.int_size), "little", signed=True) if tag == 19: return struct.unpack("<d", self.raw(self.num_size))[0] if tag in (4, 20): s = self.string() self.strings.append( { "function": fn_path, "constant_index": index, "tag": tag, "value": s, } ) return s raise ParseError(f"unknown constant tag 0x{tag:x} at 0x{self.pos - 1:x}") def function(self, parent_source: str | None, path: str) -> LuaFunction: source = self.string() if source is None: source = parent_source fn = LuaFunction(path=path, source=source) fn.line_defined = self.unsigned() fn.last_line_defined = self.unsigned() fn.num_params = self.byte() fn.is_vararg = self.byte() fn.max_stack_size = self.byte() fn.code_count = self.unsigned() self.raw(fn.code_count * 4) const_count = self.unsigned() for i in range(const_count): fn.constants.append(self.constant(path, i)) fn.upvalue_count = self.unsigned() self.raw(fn.upvalue_count * 3) proto_count = self.unsigned() for i in range(proto_count): fn.nested.append(self.function(source, f"{path}.{i}")) lineinfo_count = self.unsigned() self.raw(lineinfo_count) abslineinfo_count = self.unsigned() for _ in range(abslineinfo_count): self.unsigned() self.unsigned() locvar_count = self.unsigned() for _ in range(locvar_count): fn.locvars.append( { "name": self.string(), "startpc": self.unsigned(), "endpc": self.unsigned(), } ) upvalue_name_count = self.unsigned() for _ in range(upvalue_name_count): fn.upvalue_names.append(self.string()) return fn def parse(self) -> tuple[dict[str, Any], LuaFunction]: header = self.header() header["main_upvalue_count"] = self.byte() root = self.function(None, "main") return header, rootdef fn_to_dict(fn: LuaFunction) -> dict[str, Any]: return { "path": fn.path, "source": fn.source, "line_defined": fn.line_defined, "last_line_defined": fn.last_line_defined, "num_params": fn.num_params, "is_vararg": fn.is_vararg, "max_stack_size": fn.max_stack_size, "code_count": fn.code_count, "constants": fn.constants, "upvalue_count": fn.upvalue_count, "locvars": fn.locvars, "upvalue_names": fn.upvalue_names, "nested": [fn_to_dict(n) for n in fn.nested], }def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("input", nargs="?", default="browser_host_stage2_shellcode.plain.bin") ap.add_argument("--out-prefix", default="shellcode_stage_resource_lua54") args = ap.parse_args() data = Path(args.input).read_bytes() chunk = decode_lua_resource(data) out_prefix = Path(args.out_prefix) luac_path = out_prefix.with_suffix(".luac") luac_path.write_bytes(chunk) reader = Lua54Reader(chunk) reader.parse() sha = hashlib.sha256(chunk).hexdigest() print(f"Decrypted Lua bytecode saved: {luac_path}") print(f"LUAC SHA256: {sha}") print(f"Extracted total string constants count: {len(reader.strings)}") return 0if __name__ == "__main__": raise SystemExit(main())
通过解密脚本处理后,最终输出标准Lua字节码文件。该文件无法直接读取明文,需借助Lua反编译工具或在线反编译平台,还原可读的Lua源码,我这里用的网站,因为适配的比较多。
反编译后的Lua脚本为样本核心C2/HTTP通信解释器,全权管控远控通信全流程,内置丰富的流量伪装与动态适配能力:支持环境变量替换、GUID随机化、Base64编解码、流量掩码混淆、请求字段增删改、自定义请求头/请求参数/分块数据处理、持久化存储配置,同时兼容GET/POST双请求模式、网络代理挂载、自定义UA伪装、SSL通信自定义配置等高级对抗功能。
7.4 标准远控请求体结构
Lua脚本定义了标准化TLS POST通信模板,所有远控指令、数据上报均遵循该结构传输:
POST
Host:
User-Agent:
Content-Length:\r\n\r\n
八、第四阶段:Lua RC4通信配置解密
通过上述流程解密得到的Lua通信脚本,依然无法直接读取C2地址与通信路径。
该组织进一步将所有核心通信配置采用自定义RC4算法加密,拆分多段独立密文存储,仅在程序运行时动态解密加载,进一步规避静态特征分析。
8.1 Lua加密配置功能分区映射
样本将C2通信规则、请求模板、响应解析逻辑拆分多段RC4加密数据块,每段对应独立通信功能,分区对应关系如下:
| 功能用途 | 加密配置段 | 备用配置段 |
| — | — | — |
| C2通信地址/基础模板 | L0_2 | L32_2 |
| GET请求规则模板 | L1_2 | L35_2 |
| GET响应解析规则 | L2_2 | L36_2 |
| POST请求封装规则 | L3_2 | L34_2 |
| POST响应解析规则 | L4_2 | L36_2 |
8.2 原生调用核心执行链路
二阶段Loader启动后,通过固定汇编调用链路,完成RC4密钥初始化、Lua密文解密、参数压入、脚本执行全流程,完整调用链如下:
sub_1A8D90
0x1A8DD3 -> 调用核心解密函数 sub_20ED30
sub_20ED30
0x20ED80 -> 读取 0x29920 偏移、0x31BC 大小的内置Lua字节码密文
0x20EDB0~0x20EEB4 -> 复制 0x2E010 偏移、0x100 字节全局RC4密钥表
0x20F8AF -> 调用 sub_181190 初始化自定义RC4状态机
0x20F90C / 0x20FB15 -> 调用 sub_14B650 / sub_FB820 执行PRGA流解密,还原完整Lua chunk
0x20F3C5 -> 初始化原生Lua虚拟机环境
0x20F426 -> 调用 sub_2A75C0 加载解密后的Lua字节码
0x20F44C -> 压入第一个参数 L0_2(核心C2通信配置)
0x20F46A -> 压入 L1_2(GET请求规则)
0x20F488 -> 压入 L2_2(GET响应解析规则)
0x20F4A6 -> 压入 L3_2(POST请求封装规则)
0x20F4C4 -> 压入 L4_2(POST响应解析规则)
0x20F4E2 -> 压入 L5_2(辅助通信配置)
0x20F500 -> 压入 L6_2(代理环境变量模板 $HTTP_PROXY)
0x20F51E -> 压入 L7_2(Profile专属RC4子密钥)
0x20F539 -> 压入 L8_2(数据上报、载荷输入参数)
0x20F553 -> 压入 L9_2(运行时字符串、会话指纹材料)
0x20F583 -> 调用 sub_1F16B0,传入10组核心参数执行Lua脚本,启动远控通信
8.3 C2配置最终还原说明
样本所有Lua配置参数均由Native层传递RC4密钥解密,且所有内存偏移、RVA地址均存在0x20偏移修正,逆向分析时需手动校准地址方可精准读取密钥与密文数据。
针对该层RC4自定义加密机制,我们基于原生算法逻辑、密钥特征、偏移规则,完成专属解密脚本的编写与落地使用,可批量解密所有加密配置段,最终完整还原明文C2地址、全套HTTP通信规则、伪装参数、代理配置、流量加密策略,实现该APT样本攻击链路的完整溯源拆解。
解密得到的Lua脚本为样本核心通信引擎,支持动态HTTP/POST隧道通信、请求伪装、环境变量替换、随机化GUID、Base64编码、流量混淆等全套对抗能力。 但原生Lua脚本中无明文C2地址,所有通信域名、请求规则均由RC4加密存储。
8.4 Lua通信配置加密分区
样本将所有C2通信规则拆分多段RC4密文存储,对应功能分区如下:
C2 URL/基础通信模板:L0_2 /L32_2
GET请求规则模板:L1_2 / L35_2
GET响应解析规则:L2_2 / L36_2
POST请求封装规则:L3_2 / L34_2
POST响应解析规则:L4_2 / L36_2
8.5 核心执行调用链路
二阶段Loader启动后,按固定链路解密加载Lua通信配置:
1.sub_1A8D90 入口调度,调用核心解密函数 sub_20ED30
2.读取 0x29920 偏移、0x31BC 大小的Lua字节码密文
3.读取 0x2E010 偏移、0x100 字节全局RC4密钥表
4.初始化RC4状态机,完成Lua字节码整体解密
5.创建Lua虚拟机环境,加载解密后的字节码
6.依次传入10类加密配置参数(C2模板、请求规则、UA、代理、RC4子密钥等)
7.执行Lua脚本,启动动态远控通信链路
8.6 最终C2还原结果
我们也发现了Lua profile 参数表,通过KEY去进行RC4解密,native传入,然后进行参数的上报等等,同样这里的地址也被偏移,需要手动调整。
针对于此项加解密机制,我们进行脚本设计以及还原,代码如下:
import sysimport osimport hashlibfrom pathlib import Pathinput_path = Path(sys.argv[2]) if len(sys.argv) >= 3 else Path("browser_host_stage2_shellcode.plain.bin")out_dir = Path(sys.argv[3]) if len(sys.argv) >= 4 else Path("browser_host_lua_config_decoded")image = input_path.read_bytes()def raw_off(ida_ea: int) -> int: return ida_ea + 0x20def sha256_buf(buf: bytes) -> str: return hashlib.sha256(buf).hexdigest()def rc4LuaProfile(key: bytes, data: bytes) -> bytes: s = list(range(256)) j = 0 # KSA for i in range(256): j = (j + s[i] + key[i % len(key)]) & 0xFF s[i], s[j] = s[j], s[i] out = bytearray() i = 0 j = 0 for b in data: i = (i + 1) & 0xFF j = (j + s[i]) & 0xFF s[i], s[j] = s[j], s[i] k = s[(s[i] + s[j]) & 0xFF] out.append(b ^ k) return bytes(out)def escaped(buf: bytes) -> str: parts = [] for c in buf: if c == 0x0D: parts.append(r"\r") elif c == 0x0A: parts.append(r"\n\n") elif c == 0x09: parts.append(r"\t") elif 0x20 <= c <= 0x7E: parts.append(chr(c)) else: parts.append(f"\\x{c:02x}") return "".join(parts)key_start = raw_off(0x2e010)key = image[key_start : key_start + 0x100]segments = [ {"name": "L0_C2_template", "idaEa": 0x2e110, "size": 0x9d, "encrypted": True}, {"name": "L1_GET_rules", "idaEa": 0x2caf8, "size": 0x1da, "encrypted": True}, {"name": "L2_POST_rules", "idaEa": 0x2ccd4, "size": 0xe1c, "encrypted": True}, {"name": "L3_GET_data_rules", "idaEa": 0x2daf4, "size": 0x1c9, "encrypted": True}, {"name": "L4_response_rules", "idaEa": 0x2dcc0, "size": 0x30d, "encrypted": True}, {"name": "L5_user_agent", "idaEa": 0x2e1b0, "size": 0x6b, "encrypted": False},]out_dir.mkdir(exist_ok=True, parents=True)summary = { "input": str(input_path), "ida_to_file_offset_delta": "0x20", "profile_rc4_key": { "ida_ea": "0x2E010", "file_offset": f"0x{raw_off(0x2e010):x}", "size": "0x100", "sha256": sha256_buf(key), }, "segments": []}import reurl_re = re.compile(r"^https?://", re.IGNORECASE)for seg in segments: off = raw_off(seg["idaEa"]) raw_data = image[off : off + seg["size"]] if seg["encrypted"]: plain_data = rc4LuaProfile(key, raw_data) else: plain_data = raw_data stem = f"{seg['name']}.plain" bin_out = out_dir / f"{stem}.bin" txt_out = out_dir / f"{stem}.txt" bin_out.write_bytes(plain_data) txt_out.write_text(escaped(plain_data), encoding="utf-8") record = { "name": seg["name"], "ida_ea": f"0x{seg['idaEa']:x}", "file_offset": f"0x{off:x}", "size": f"0x{seg['size']:x}", "encrypted_with_L20_2": seg["encrypted"], "encrypted_sha256": sha256_buf(raw_data), "plain_sha256": sha256_buf(plain_data), "output_bin": str(bin_out), "output_txt": str(txt_out), } if seg["name"] == "L0_C2_template": text = plain_data.decode("latin1") urls = [] for line in text.splitlines(): clean = re.sub(r"[^\x20-\x7e].*$", "", line).strip() if url_re.match(clean): urls.append(clean) record["urls"] = urls summary["segments"].append(record)json_path = out_dir / "summary.json"import jsonjson_path.write_text(json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8")print(json.dumps(summary, indent=2, ensure_ascii=False))
脚本运行结果如下:
当然还有很多,这些都是随机的请求字段配置,这里就不再过多展示。
最后
本次拆解的海莲花APT高对抗存活样本,与日常处置中仅普通载荷构成的常规木马存在代际技术差距。
整套攻击链路依托多层私有加密、动态密钥轮转、Lua动态通信引擎、全维度反调试沙箱对抗体系构建完整隐蔽入侵链路,无硬编码C2、无显性网络API、多层载荷嵌套解密,极大提升了安全设备检测与人工逆向分析难度,充分体现了高级APT组织成熟、专业化的武器迭代能力。
与此同时,问鼎安全周边外套、攻防纪念勋章抽奖福利活动即将再次开启,欢迎各位安全从业者持续关注、交流攻防技术,携手共筑企业网络安全防线。
若企业遭遇未知恶意程序入侵、远控木马、内核级驻留类安全事件包含攻防对抗,APT威胁等场景,均可随时联系我司应急响应团队,7×24 小时提供专业处置支持。
——THE END——
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:问鼎安全应急响应中心 问鼎安全
问鼎安全《【首发披露】越南APT32-海莲花盯向国内常用应用,投放样本多层私有加密,完整逆向解密流程全拆解…》