自从绒绒与大家分享病毒分析报告以来,评论区的相关讨论不仅让绒绒成就感满满,也下定决心(暗暗攥拳)要和大家分享更多有意义的文章。总览评论后,绒绒发现大家不仅对病毒本身充满探究精神,也对工程师是通过什么方式对病毒进行分析以及在分析过程中使用什么工具感到好奇。那么今天就不讲病毒!让我们讲一讲如何“研究”病毒——特别是追踪技术在病毒分析中的应用。
随着网络环境的不断变化,恶意软件与病毒变种开始层出不穷,给安全研究和病毒分析工作带来了不小的挑战。比如在病毒逆向分析过程中,有高效代码保护能力的强壳加密技术,这种技术通常通过加密、压缩和虚拟化等手段隐藏或混淆程序代码,导致分析人员难以获得有效的解密方法,进而使得对病毒样本的调试、分析和修改工作变得复杂。常见的强壳如Themida、VMProtect,以及越来越广泛的自定义混淆壳(代码虚拟化),通常采用多种复杂的保护策略,包括但不限于代码加密、API钩子、反调试、反虚拟机技术等,这些都一定程度上增加了病毒逆向分析的难度。
病毒在升级,但我们的技术手段也在不断升级。比如在病毒逆向分析的过程中,分析者可通过追踪解析被保护程序或加壳程序。通过追踪程序的执行过程,分析者可以观察到程序运行时的行为,逐步揭示其加壳或加密的方式。追踪可通过多种方法进行,例如调试器、动态分析工具、代码注入等,这些方法有助于分析者跟踪程序的调用栈、数据流以及了解加壳后的解密过程,进而破除壳保护,恢复程序的原始功能和代码。
以下分享几种我们常用的代码追踪工具。
Pin
Pin 是 Intel 推出的一款适用于 IA-32 和 x86-64 架构的动态二进制插桩框架,支持包括指令级、基本块级、镜像级和函数级插桩在内的多种插桩方式。同时 Pin 拥有丰富的 API,这使得 Pin 能够抽象底层指令集的特性,并允许将上下文信息(如寄存器内容)作为参数传递给注入的代码。Pin 会自动保存和恢复被注入代码覆盖的寄存器内容,从而确保应用程序能够继续正常运行。此外,Pin 还可提供有限的符号及具有调试信息访问功能。作为一种前端工具,Pin 能够有效支持代码逆向分析,尤其在数据提取方面表现出色。
同时,Pin 也可视为一种即时翻译器(JIT),与其他翻译器不同的是,其输入的内容并非是字节码,而是常规的可执行文件,当文件在执行第一条指令时,Pin 会进行拦截控制,并为该指令及其后续代码序列生成新的“翻译”代码。之后,控制权被转交到新生成的中间代码序列,该序列与原始代码几乎一致。每当程序执行到分支退出时,Pin 会重新获得控制权,并为分支目标生成新的代码,继续执行后续操作。通过将所有生成的代码保留在内存中,Pin 提高了执行效率,使得代码可以被重复使用,并允许程序从一个序列直接跳转到另一个序列。
在 JIT 模式下,实际运行的是新生成的中间代码。原始代码仅作为参考,在生成代码时,Pin 给用户提供了注入自己代码(插桩)的机会。
Pin原理
回调函数
Pin 提供了一系列回调函数,用于实现程序的动态插桩。通过这些回调函数,可以对指令级、基本块级和函数级的程序进行插桩分析,并允许通过编写自定义的 Pintool 对程序进行不同程度的干预。这些功能使得 Pin 能够执行多种分析任务,例如代码性能分析、内存访问分析、代码覆盖率评估,以及检测潜在的漏洞或恶意代码。
-
INS_AddInstrumentFunction (INSCALLBACK fun, VOID *val) 注册以指令粒度插桩的函数
-
TRACE_AddInstrumentFunction (TRACECALLBACK fun, VOID *val) 注册以 trace 粒度插桩的函数 (基本块插桩)
-
RTN_AddInstrumentFunction (RTNCALLBACK fun, VOID *val) 注册以 routine 粒度插桩的函数,函数级别的插桩需要符号信息
-
IMG_AddInstrumentFunction (IMGCALLBACK fun, VOID *val) 注册以 image 粒度插桩的函数
-
PIN_AddFiniFunction (FINICALLBACK fun, VOID *val) 注册在应用程序退出前执行的回调函数
-
PIN_AddDetachFunction (DETACHCALLBACK fun, VOID *val) 注册在 Pin 通过PIN_Detach()函数放弃对应用程序的控制权限之前执行的函数,一个进程只调用一次,可以被任何线程调用
BBL(基本块)
BBL(Basic Block)即基本块,是程序执行中最小的执行单元之一。它通常由一系列连续的指令组成,这些指令之间不存在跳转,因此在基本块内程序控制流程是顺序执行的。在动态分析中,基本块被视为程序的一个“原子”执行单元。
Pin_BBL
Pin 保证每个 trace (追踪)只有一个顶部入口点,但可以有多个出口点。如果一个分支指令指向 trace 的中间位置,Pin 会生成一个新的 trace,并以该分支为起点。Pin 将 trace 切分成基本块,每个基本块称为“BBL”,每个 BBL 是一个具有单一入口和单一出口的指令序列。如果有分支指向 BBL 的中间位置,则会定义一个新的 BBL。通常,分析调用会以 BBL 为单位插入,这样可以减少分析调用对性能的影响。
Trace Instrumentation 通过 TRACE_AddInstrumentFunction API 进行注册 Trace 回调。
BBL_InsertCall
-
函数:VOID BBL_InsertCall(BBL bbl, IPOINT ipoint, AFUNPTR fun, ...)
-
功能:在基本块插入回调函数
-
说明:允许在指定的基本块中插入回调函数,并在程序执行时触发该回调函数。IOPOINT 可指定回调函数插入位置的枚举类型,可以选择如 IOPOINT_BEFORE 、 IOPOINT_AFTER、IPointAny 等位置进行插桩
-
IPointBefore:在基本块开始执行之前调用回调函数
-
IPointAfter:在基本块执行结束之后调用回调函数
-
IPointAny:在基本块的任何位置都可以调用回调函数
Pintools
基于 Pin 开发的 Pintools 是一种动态二进制插桩工具,也相当于动态程序分析工具,可用于对Linux、Windows 上的用户空间应用程序进行程序分析。因其能够实现无需重新编译源代码,即可在程序运行时进行插桩,所以 Pintools 也支持对动态生成代码进行插桩。Pintools 包括Intel®VTune™Amplifier、Intel® Inspector、Intel® Advisor以及Intel®软件开发模拟器(Intel® SDE)。
通过编写简单的示例代码,可以对代码覆盖率进行简单分析,但这种方法在处理大量执行流的明文字符串时,会涉及到频繁的 IO 操作,特别是在分析那些被 VMProtect、Themida 等强壳保护的程序时,如果虚拟化的指令数量达到千万条时,追踪效率会大幅降低。为了优化这一过程,我们可以利用 ProtoBuf 库对数据进行序列化,并通过对 BBL 进行白名单标记,取消对重复执行代码块的插桩,从而提高追踪效率。
static void OnTrace(TRACE trace, void* v) { auto& context = *reinterpret_cast<ToolContext*>(v); if (!context.m_tracing_enabled || !context.m_images->isInterestingAddress(TRACE_Address(trace))) { return; } // 获取线程ID和线程本地数据 auto tid = PIN_ThreadId(); ThreadData* data = context.GetThreadLocalData(tid); // 处理第一个基本块 auto bbl = TRACE_BblHead(trace); auto firstBlockAddr = BBL_Address(bbl); data->m_blocks[firstBlockAddr] = static_cast<uint16_t>(BBL_Size(bbl)); // 遍历trace中的后续基本块 for (bbl = BBL_Next(bbl); BBL_Valid(bbl); bbl = BBL_Next(bbl)) { ADDRINT blockAddr = BBL_Address(bbl); if (data->m_blocks.find(blockAddr) != data->m_blocks.end()) { continue; } BBL_InsertCall( bbl, IPOINT_ANYWHERE, reinterpret_cast<AFUNPTR>(OnBasicBlockHit), IARG_FAST_ANALYSIS_CALL, IARG_THREAD_ID, IARG_ADDRINT, blockAddr, IARG_UINT32, BBL_Size(bbl), IARG_PTR, v, IARG_END ); } }
以样本 c997772c5f498acdc2bc3e94dccc4b76f1bb6c2f 为例,下面是对其进行插桩分析、生成追踪日志以及序列化后的数据情况。
追踪数据
序列化数据
利用 IDA 可以进一步增强代码覆盖率分析的准确性。通过统计每条指令的执行次数,可以计算出每个函数的执行覆盖率。即使在缺乏符号信息的情况下,借助 IDA 的函数识别功能,也能协助逆向分析人员精确分析出每个函数的代码覆盖率。注:绿色表示代码块已被执行。
代码覆盖图
通过代码覆盖率分析,可以看到病毒样本在添加启动项时的程序执行流程。结合 IDA 反编译器生成的伪代码,能够清晰标记出伪代码的覆盖率情况。通过分析伪代码的覆盖率,逆向分析人员可以轻松追踪病毒在执行过程中对注册表的操作过程。
指令代码覆盖率
代码覆盖率
代码覆盖率通过 Pin 对指令进行插桩,从而实现对执行地址的追踪。通过获取 RIP 寄存器的值,Pin 可以记录指令的执行情况,从而分析出哪些指令被执行过,哪些指令没有被执行过。此外,Pin 可通过对事件的监控来检测映像和模块的加载情况,并对相关模块进行追踪。通过对指令的插桩,Pin 可实时 Dump内存,查看内存状态,并对内存修改进行断点分析。同时,它也能对指令执行过程进行详细分析。
代码覆盖率分析通过追踪已执行的指令来判断哪些指令被执行过,哪些未被执行,并将这些信息记录到日志中。这些日志对于逆向分析人员来说非常有用,尤其是在判断 JCC 指令执行情况时,可以更准确的进行静态分析。
回溯分析
通过使用 IDA 插件分析日志中的 RIP 值,可以回溯 JCC 指令的相关执行流程,并实现内存断点执行的效果。
以下以指令粒度插桩进行打印 RIP 的例子:
#include <stdio.h> #include "pin.H" FILE* trace; // This function is called before every instruction is executed // and prints the IP VOID printip(VOID* ip) { fprintf(trace, "%p\n", ip); } // Pin calls this function every time a new instruction is encountered VOID Instruction(INS ins, VOID* v) { // Insert a call to printip before every instruction, and pass it the IP INS_InsertCall(ins, IPOINT_BEFORE, (AFUNPTR)printip, IARG_INST_PTR, IARG_END); } // This function is called when the application exits VOID Fini(INT32 code, VOID* v) { fprintf(trace, "#eof\n"); fclose(trace); } /* ===================================================================== */ /* Print Help Message */ /* ===================================================================== */ INT32 Usage() { PIN_ERROR("This Pintool prints the IPs of every instruction executed\n" + KNOB_BASE::StringKnobSummary() + "\n"); return -1; } /* ===================================================================== */ /* Main */ /* ===================================================================== */ int main(int argc, char* argv[]) { trace = fopen("itrace.out", "w"); // Initialize pin if (PIN_Init(argc, argv)) return Usage(); // Register Instruction to be called to instrument instructions INS_AddInstrumentFunction(Instruction, 0); // Register Fini to be called when the application exits PIN_AddFiniFunction(Fini, 0); // Start the program, never returns PIN_StartProgram(); return 0; }
首先,通过 INS_AddInstrumentFunction(Instruction, 0) 注册一个以指令粒度为单位的回调函数。接着,通过 INS_InsertCall 插入一个 pre 回调,用于在指令执行前记录并打印 RIP 寄存器的值。
采用上述方法,可以进行简单的代码覆盖率分析:通过记录 RIP 寄存器的值,可以追踪程序的指令流。RIP 寄存器的值有助于逆向分析人员分析哪些代码被执行过,以及识别程序中的“死代码”。此外,还可以统计执行的指令总数,为他们提供有价值的参考,帮助他们更高效地进行代码分析。
#include "pin.H" #include <iostream> #include <fstream> #include <unordered_set> std::unordered_set<ADDRINT> covered_instructions; UINT64 covered_count = 0; std::ofstream output_file; // 指令覆盖回调函数 VOID OnInstructionExecuted(THREADID tid, ADDRINT rip) { // 记录覆盖的指令 RIP 地址 if (covered_instructions.find(rip) == covered_instructions.end()) { covered_instructions.insert(rip); covered_count++; } } // 在基本块上插桩,插入指令回调 VOID InstructionCallback(INS ins, VOID *v) { INS_InsertCall(ins, IPOINT_BEFORE, (AFUNPTR)OnInstructionExecuted, IARG_THREAD_ID, IARG_INST_PTR, IARG_END); } // 程序结束时输出覆盖的指令信息 VOID FiniCallback(INT32 code, VOID *v) { output_file << "Total covered instructions: " << covered_count << std::endl; output_file << "Covered instruction addresses:" << std::endl; for (auto &rip : covered_instructions) { output_file << "0x" << std::hex << rip << std::dec << std::endl; } output_file.close(); } int main(int argc, char *argv[]) { output_file.open("coverage_output.txt"); // 初始化 Pin if (PIN_Init(argc, argv)) { std::cerr << "PIN initialization failed!" << std::endl; return -1; } // 注册指令插桩回调函数 INS_AddInstrumentFunction(InstructionCallback, nullptr); // 注册程序结束时的回调函数 PIN_AddFiniFunction(FiniCallback, nullptr); // 启动程序执行 PIN_StartProgram(); return 0; }
结合 IDA,可以通过颜色标识代码的执行状态直观展示代码覆盖率。不同的颜色代表指令是否被执行过,使得逆向分析人员能够快速识别区分哪些代码已经被执行,哪些代码未被执行。这种方法可以更高效地分析程序的控制流和潜在的死代码区域。
import idaapi import idautils import idc # 用于存储Pin报告中的RIP地址(即已执行的指令) covered_rips = set() # 读取Pin生成的文件 def read_pin_output(file_path): try: with open(file_path, 'r') as f: for line in f: rip = line.strip() if rip.startswith('0x'): covered_rips.add(int(rip, 16)) # 转换为整数并添加到集合中 print(f"Loaded {len(covered_rips)} covered RIP addresses from {file_path}") except Exception as e: print(f"Error reading file {file_path}: {e}") # 统计指令总数 def count_instructions(): instruction_count = 0 for ea in idautils.Heads(idc.get_segm_by_name('.text').startEA, idc.get_segm_by_name('.text').endEA): if idc.isCode(idc.get_flags(ea)): instruction_count += 1 return instruction_count # 上色已覆盖的指令 def color_covered_instructions(): for rip in covered_rips: if idc.isCode(idc.get_flags(rip)): idc.set_color(rip, idc.CIC_ITEM, 0x00FF00) # 绿色 # 分析并输出指令统计信息 def run(): read_pin_output("pin_output.txt") total_instructions = count_instructions() print(f"Total instructions in the program: {total_instructions}") color_covered_instructions() if __name__ == '__main__': run()
污点分析
污点分析是一种动态的信息流分析技术,它通过跟踪程序中不可信数据的流动来发现潜在的风险行为。在软件安全领域,有一个普遍认可的原则:“所有用户输入都是不可信的”。在动态污点分析中,用户输入的所有数据被视为“污点”,并对其在程序中的传播过程进行追踪。污点分析在逆向工程中被广泛应用于漏洞挖掘、软件破解等领域。通过动态污点分析,结合程序的执行轨迹和运行时的信息,能够追踪污点数据的传播路径,从而识别程序中的漏洞以及在软件破解中起到关键作用的 JCC (条件跳转指令)。污点分析的输入来源主要包括以下几个方面:
1.本地文件
2.网络报本
3.环境变量
4.程序消息事件响应
5....
污点分析一般原则
1.如果一条指令中至少有一个读操作涉及污染数据,则该指令的所有写操作都视为污染。
2.如果一条指令中的所有读操作均不涉及污染数据,则该指令的所有写操作都视为去污染。
add eax,ecx r:eax,ecx w:eax,efl mov eax,[esp+4] r:esp,[esp+4] w:eax
如对程序进行指令级动态插桩,可以在每条指令执行之前分析出污点是否需要传播,以及涉及到的隐式读写过程中的污点是否需要传播。
-
确定污点传播记录数据结构每个线程的污点信息使用 thread_info 结构进行管理,每个寄存器的污点信息使用一个集合。
#include <iostream> #include <set> #include <unordered_map> using namespace std; typedef unsigned int THREADID; typedef unsigned int REG32_INDEX; // 用于存储每个寄存器的污点数据 struct ThreadInfo { unordered_map<REG32_INDEX, set<int>> reg_taint; }; unordered_map<THREADID, ThreadInfo> thread_info; // 存储每个线程的污点信息
-
污点传播回调函数。
// 检查指令是否有读操作数,且其中至少一个读操作数是污染的 bool has_read_taint(const INS& ins, THREADID tid) { for (int i = 0; i < INS_OperandCount(ins); ++i) { if (INS_OperandIsMemory(ins, i) || INS_OperandIsReg(ins, i)) { REG32_INDEX reg = INS_OperandReg(ins, i); if (!thread_info[tid].reg_taint[reg].empty()) { return true; } } } return false; } // 污染写操作数 void contaminate_writes(const INS& ins, THREADID tid) { for (int i = 0; i < INS_OperandCount(ins); ++i) { if (INS_OperandIsMemory(ins, i) || INS_OperandIsReg(ins, i)) { REG32_INDEX reg = INS_OperandReg(ins, i); if (INS_OperandIsWrite(ins, i)) { thread_info[tid].reg_taint[reg].insert(tid); // 使用tid作为污点源,代表当前线程的污点 } } } } // 清除写操作数的污点 void clear_writes(const INS& ins, THREADID tid) { for (int i = 0; i < INS_OperandCount(ins); ++i) { if (INS_OperandIsMemory(ins, i) || INS_OperandIsReg(ins, i)) { REG32_INDEX reg = INS_OperandReg(ins, i); if (INS_OperandIsWrite(ins, i)) { thread_info[tid].reg_taint[reg].clear(); } } } }
-
执行回调函数分析污点传播。
void insert_taint_callbacks(INS ins, THREADID tid) { bool read_is_tainted = has_read_taint(ins, tid); if (read_is_tainted) { // 如果有污染的读操作数,污染所有的写操作数 contaminate_writes(ins, tid); } else { // 如果所有的读操作数都没有污染,去污染所有的写操作数 clear_writes(ins, tid); } REG32_INDEX reg_dst, reg_src; if (INS_MemoryOperandCount(ins) == 0) { // 只处理寄存器操作 reg_dst = INS_OperandReg(ins, OP_0); reg_src = INS_OperandReg(ins, OP_1); if (REG_is_gr32(reg_dst)) { switch (INS_Opcode(ins)) { case XED_ICLASS_XOR: case XED_ICLASS_SUB: case XED_ICLASS_SBB: if (reg_dst == reg_src) { INS_InsertCall(ins, IPOINT_BEFORE, (AFUNPTR)r_clrl, IARG_FAST_ANALYSIS_CALL, IARG_UINT32, reg_dst, IARG_THREAD_ID, IARG_END); } break; default: INS_InsertCall(ins, IPOINT_BEFORE, (AFUNPTR)r2r_binary_opl, IARG_FAST_ANALYSIS_CALL, IARG_UINT32, reg_dst, IARG_UINT32, reg_src, IARG_THREAD_ID, IARG_END); break; } } } }
通过简单的污点传播,可以找到污点传播过程中涉及到的关键跳转,以及那些被执行过且容易触发缓冲区的漏洞危险函数,这为进一步的 fuzz 执行提供了基础,有助于触发代码逻辑中的缺陷,污点分析详细过程可见下文Unicorn污点分析。
Unicorn
Unicorn 是一种基于动态二进制翻译的开源模拟器,作为 QEMU 的轻量级子集,支持模拟多种架构的指令集,如 x86、ARM、MIPS、SPARC 等。它能够在不同平台上模拟程序执行,是二进制分析、逆向工程、漏洞研究、恶意软件分析等领域的强大工具。
Unicorn 提供了对 CPU 仿真进行细粒度控制的能力,适用于开发自定义的二进制分析工具。借助 x64dbg 的 SDK,用户可以实现调试和模拟执行插件,详细记录指令执行过程中的寄存器状态和内存情况。这一功能为分析控制流、执行流及 JCC 相关代码提供了有力支持。
在强壳虚拟化的条件下,一条指令可能被混淆并扩展为数千万条指令,相比之下,常规调试器,如 x64dbg ,其指令追踪速度通常被限制在每秒 3k 条指令,远远无法满足逆向分析的需求。然而,通过 Unicorn 模拟指令执行,模拟速度能够达到每秒 5w 条指令,并完整记录指令执行过程中的寄存器和内存状态,可为逆向工程提供更高效的支持。
追踪效率
记录完整追踪过程
通过 Unicorn 模拟执行过程时,可以将涉及到的地址、指令、寄存器数据以及状态信息写入日志,进行完整的追踪和执行流记录。为了提高效率,这些日志数据会通过 Protobuf 进行序列化,以减少 I/O 操作,从而优化日志记录和存储过程。
通过SDK填充Map内存
通过 x64dbg SDK 中的 DbgGetRegDumpEx 和 VirtualQueryEx 等内存相关函数,可以将内存中的节区映射到 Unicorn 的内存中,并对堆栈内存进行映射,从而实现对程序的快速跟踪。
REGDUMP lpRegDump; if (DbgGetRegDumpEx(&lpRegDump, sizeof(lpRegDump)) == false) { MessageBoxW(hwndDlg, L"获取寄存器环境失败.", PLUGIN_NAME_utf16, MB_OK | MB_ICONERROR); return; } bool Track::mem_map_range(uint64_t address) { bool bSuccess = false; HANDLE hProcess = m_process_info.Process; if (hProcess != NULL) { size_t max_mem_size = 0; MEMORY_BASIC_INFORMATION mem_info; MEMORY_BASIC_INFORMATION mem_info_1; if (VirtualQueryEx(hProcess, (LPCVOID)address, &mem_info_1, sizeof(mem_info_1)) != NULL) { if (VirtualQueryEx(hProcess, (LPCVOID)mem_info_1.AllocationBase, &mem_info_1, sizeof(mem_info_1)) != NULL) { mem_info = mem_info_1; do { max_mem_size += mem_info.RegionSize; void* mem_buff = malloc(mem_info.RegionSize); SIZE_T read_size = 0; if (ReadProcessMemory(hProcess, (LPCVOID)mem_info.BaseAddress, mem_buff, mem_info.RegionSize, &read_size)) { if (m_VME.sim_uc_mem_map((uint64_t)(uintptr_t)mem_info.BaseAddress, mem_info.RegionSize, mem_win_protect_to_uc_protect(mem_info.Protect)) == UC_ERR_OK) { if (m_VME.sim_uc_mem_write((uint64_t)(uintptr_t)mem_info.BaseAddress, mem_buff, mem_info.RegionSize) == UC_ERR_OK) { if ((uint64_t)(uintptr_t)mem_info.BaseAddress + mem_info.RegionSize > address) { bSuccess = true; } } } } free(mem_buff); mem_info.BaseAddress = (PVOID)((uint64_t)mem_info.BaseAddress + mem_info.RegionSize); if (VirtualQueryEx(hProcess, (LPCVOID)mem_info.BaseAddress, &mem_info, sizeof(mem_info)) == NULL) break; } while (mem_info_1.AllocationBase == mem_info.AllocationBase); } } } return bSuccess; }
在将各个内存区段映射到 Unicorn 内存后,可利用 x64dbg 获取的寄存器状态信息对 Unicorn 进行设置,完成对程序的完整追踪。同时,记录指令执行情况,并将 API 输出结果打印到日志控制台。
API完整跟踪演示
追踪模拟至用户断点
对于用户设置的断点,经过强壳混淆的病毒样本通常会使关键跳转和 API 调用的返回地址不保存在栈中(即便保存了返回地址,也可能无法准确找到调用地址)。这种情况下,回溯关键位置变得十分困难。然而,通过 Unicorn 模拟执行,将相关信息打印到 x64dbg 控制台,以及追踪过程保存到日志文件,可以方便地查看调用地址前三条指令的跳转地址,从而帮助逆向分析。
bool is_dbg_bp = is_hit_user_bp(exit_msg); // 命中用户断点 if (is_dbg_bp) { _plugin_logprintf(u8"[%s] uc_err:[%d] exit_code:[%d] 结束地址:%llx 回溯地址:%llx -> %llx 本次跟踪数量:%d 总跟踪数量:%d \n", PLUGIN_NAME, ucerr, exit_msg.exit_code, exit_msg.exit_base, exit_msg.third_base, exit_msg.prev_base, exit_msg.track_num, m_Track.m_db_insn_size); if (!bMapAllContextToProcess) { DbgCmdExecV(u8"go"); } }
用户断点演示
追踪模拟至API调用
在没有符号信息的情况下,当模拟执行至 API 调用时,如果程序执行跳转到用户区段之外的地址,模拟会停止。具体来说,一旦检测到程序跳转至不属于用户区段的地址,模拟就会停止,以防止继续执行无效或无法追踪的代码。
uc_err Track::start_track(_track_exit_msg* exit_msg) { uc_err err = UC_ERR_OK; bool is_continue = false; do { #ifdef _WIN64 uint64_t lpCip = 0; m_VME.sim_uc_reg_read(UC_X86_REG_RIP, &lpCip); #else uint32_t lpCip = 0; m_VME.sim_uc_reg_read(UC_X86_REG_EIP, &lpCip); #endif m_track_write_mem_range.clear(); m_exit_msg.exit_code = track_exit_code::uc_exit; m_exit_msg.user_exit_err = UC_ERR_OK; m_last_insn.clear(); m_VME.sim_uc_ctl(UC_CTL_WRITE(UC_CTL_TB_FLUSH, 0)); size_t track_num = m_db_insn_size; err = m_VME.sim_uc_emu_start(lpCip, -1, NULL, 0); *exit_msg = m_exit_msg; } while (is_continue); return err; }
对于一个经过 VMP 虚拟化、变异以及导入表保护的 EXE 文件,使用 Unicorn 模拟可以快速定位 API 调用的位置,并有效回溯到 API 调用的源头。这种方法可以绕过强壳混淆,精确追踪程序的执行流,从而帮助分析人员完成逆向分析。
模拟到API调用演示
追踪模拟到跨节区执行
根据获取到的区段大小信息,我们可以定位当前所在节区并获取相关信息。通过调用 x64dbg 的 API ,以及读取 PE 内存中的 PE 节表信息,可获取所在节区的具体数据,并在模拟执行过程中,当程序跳转到下一个节区时停止执行。
// 获取地址节区内存范围 bool get_addr_section_range(HANDLE hProcess, uint64_t address, _track_mem_range* mem_rage) { bool bSuccess = false; if (hProcess != nullptr) { HMODULE lpBase = GetMainModuleBase(hProcess); IMAGE_DOS_HEADER dos_header; if (!ReadProcessMemory(hProcess, (LPCVOID)lpBase, &dos_header, sizeof(dos_header), NULL)) { DWORD ERror = GetLastError(); return bSuccess; } // 读取NT头 IMAGE_NT_HEADERS32 nt_headers; if (!ReadProcessMemory(hProcess, (LPCVOID)((DWORD)lpBase + dos_header.e_lfanew), &nt_headers, sizeof(nt_headers), NULL)) return bSuccess; // 获取节表 DWORD section_count = nt_headers.FileHeader.NumberOfSections; IMAGE_SECTION_HEADER* sections = new IMAGE_SECTION_HEADER[section_count]; if (!ReadProcessMemory( hProcess, (LPCVOID)((DWORD)lpBase + dos_header.e_lfanew + sizeof(IMAGE_NT_HEADERS32)), sections, section_count * sizeof(IMAGE_SECTION_HEADER), NULL )) { delete[] sections; return bSuccess; } // 遍历节区,找到包含目标地址的节 for (DWORD i = 0; i < section_count; ++i) { uint64_t section_start = (uint64_t)lpBase + sections[i].VirtualAddress; uint64_t section_end = section_start + sections[i].Misc.VirtualSize; if (address >= section_start && address < section_end) { mem_rage->base = section_start; mem_rage->end = section_end; bSuccess = true; delete[] sections; CloseHandle(hProcess); return bSuccess; } } delete[] sections; } }
根据 Unicorn 模拟执行到节区地址范围,我们可以更精准地确认脱壳过程中的 OEP,dump 出来更好的进行静态分析。
UPX壳跨节区演示
VMP壳虚拟+变异+导入表保护演示
Unicorn 污点分析
Unicorn 污点分析与 Pin 污染分析的原理相同。Unicorn 可以通过 Capstone 反汇编引擎分析指令中的读写操作以及隐式的读写操作。如果指令中的读地址被标记为污染,那么相应的写操作地址也会被标记为污染;相反,如果读地址标记为非污染,写操作地址则也被标记为非污染。污染的地址会被记录并存储到动态数组中。除了这些基本规则,如有特殊指令涉及的污染地址则需要手动分析,具体包括:
-
jmp reg,jmp [mem],call reg,call [mem],ret,Jcc
-
栈指令(push,pop,pushad,popad,pushfd,popfd)
-
lea
-
xchg,xadd
-
使用被污染的寄存器寻找找内存
-
串操作指令(stos *,scas*,...)
-
去污染(xor eax,eax)
-
.......
以push为例:
push eax将会读 eax,esp,写 esp,[esp-4]以一般原则中,如果 eax 是被污染的,则将 esp,[esp-4] 都给污染,人为分析可以发现 esp 是不用被污染的,只需要污染[esp - 4]。
if (!strcmp(insn->mnemonic, "push")) {//push cs_x86_op op = x86.operands[0]; do_taint_sp_push(op); return g_taint_handled; } inline static void do_taint_sp_push(cs_x86_op &op) { DWORD esp_after = regs.u[reg_transfer_table[X86_REG_ESP]] - 4; switch (op.type) { case X86_OP_MEM: { DWORD addr = get_mem_addr(op.mem); if (is_addr_tainted(addr) || is_addr_tainted(addr+1) || is_addr_tainted(addr+2) || is_addr_tainted(addr+3)){ for (int i = 0; i<4; i++) taint_addr(esp_after + i); } else { for (int i = 0; i<4; i++) untaint_addr(esp_after + i); } } break; case X86_OP_REG: { x86_reg reg = op.reg; if (is_reg_tainted(reg)){ for (int i = 0; i<4; i++) taint_addr(esp_after + i); } else { for (int i = 0; i<4; i++) untaint_addr(esp_after + i); } } break; case X86_OP_IMM: for (int i = 0; i<4; i++) untaint_addr(esp_after + i); break; default: __asm int 3 } }inline static void do_taint_sp_push(cs_x86_op &op) { DWORD esp_after = regs.u[reg_transfer_table[X86_REG_ESP]] - 4; switch (op.type) { case X86_OP_MEM: { DWORD addr = get_mem_addr(op.mem); if (is_addr_tainted(addr) || is_addr_tainted(addr+1) || is_addr_tainted(addr+2) || is_addr_tainted(addr+3)){ for (int i = 0; i<4; i++) taint_addr(esp_after + i); } else { for (int i = 0; i<4; i++) untaint_addr(esp_after + i); } } break; case X86_OP_REG: { x86_reg reg = op.reg; if (is_reg_tainted(reg)){ for (int i = 0; i<4; i++) taint_addr(esp_after + i); } else { for (int i = 0; i<4; i++) untaint_addr(esp_after + i); } } break; case X86_OP_IMM: for (int i = 0; i<4; i++) untaint_addr(esp_after + i); break; default: __asm int 3 } }
以用户的输入为例,进行污点分析:
#include <stdio.h> #include <stdlib.h> #include <Windows.h> char buf[1204]; int advanced_strcmp(const char *str1, const char *str2) { if (str1 == NULL && str2 == NULL) { return 0; } if (str1 == NULL) { return -1; } if (str2 == NULL) { return 1; } while (*str1 && *str2) { if (*str1 != *str2) { return *(unsigned char *)str1 - *(unsigned char *)str2; } str1++; str2++; } if (*str1) { return 1; } if (*str2) { return -1; } return 0; } void main() { while (1) { scanf("%s", buf); if (advanced_strcmp(buf, (char *)"123")) printf("ok\n"); else printf("fail\n"); } system("pause"); }
对用户输入 ESP 寄存器的值进行污点分析,通过污染传播可以清晰的发现用户输入的值经过了哪些处理。同时,通过追踪 EFLAGS ,在输入错误跳转到关键判断点时进行爆破。例如,je 0x4011a6 就是成功与否的关键判断点。
污点分析过程图
污点分析
通过上述污点分析,我们可以发现输入变量经过了哪些修改,以及这些修改是如何影响控制流执行的。
mov ebp,esp ;esp流向了ebp mov eax, dword ptr [ebp+8] ;参数一 mov edx, dword ptr [ebp+0xC] ;参数二 and ecx, eax ;判断1 je 0x40113b ;跳转1 cmp edx, ecx;判断2 je 0x401127 ;跳转2 test ecx,ecx;判断3 je 0x4011a6 ;关键跳转
通过追踪输入变量在程序中的传播路径,我们可以观察到指令的变更,并进一步探究这些变更是如何对控制流产生影响的。
1.识别输入点
-
通常是程序接收到外部输入的地方,例如函数参数、文件读取、网络数据包或用户输入。
-
[EBP + 8] 和 [EBP + 0xC] 可以视为两个输入点。
2.追踪变量的传播
-
分析输入数据在寄存器、内存和栈之间的流动,记录其影响的范围。
-
mov eax, dword ptr [ebp + 8]:输入被加载到 EAX。
-
mov ecx, byte ptr [eax]:EAX 的内容被用作指针,提取字节加载到 ECX。
-
and ecx, eax:输入数据间接修改了 ECX 的值。
-
每一步都标记污点,并评估可能出现的影响。
3.识别控制流相关操作
-
条件跳转(如 je、jne)等指令是控制流的核心。
-
and ecx, eax 的结果可能影响后续的跳转指令,从而影响决定程序的执行路径。
4.记录修改和分支点
-
通过寄存器和内存值的变化,构建数据依赖关系图。
-
确定哪些修改会影响到关键路径,评估是否可能被恶意输入利用。
Intel PT 硬件追踪
英特尔处理器跟踪(Intel PT)是一种高性能的调试和性能分析工具,具备最新的英特尔 CPU 硬件辅助跟踪技术,旨在提供对程序执行行为的详细跟踪信息,广泛应用于调试、性能分析、安全研究等领域。目前,Intel Skylake 及更高版本的 CPU 均已配置此工具。Intel PT 能够在指令级别上触发和过滤代码执行的跟踪,通过仅存储以后重构程序控制流所必需的数据,用户可以根据需求选择需要跟踪的内容,例如特定线程、地址范围等,从而减少不必要的数据记录。对于病毒分析,通常使用 Filtering by CR3对指定进程进行追踪,使用 Intel PT 进行追踪的效率以及内存的消耗可成指数级的加快。
Intel PT(Processor Trace)通过生成多种数据包记录控制流信息,这些数据包与程序的二进制代码结合使用,可以通过后处理工具生成精确的执行跟踪。数据包中包含的信息包括指令指针(IP)、间接分支的目标地址,以及基本块中条件分支的执行方向等。
Intel PT 的工作原理在于记录处理器执行过程中发生的分支指令。当 CPU 遇到分支指令(如je、call、ret等)时,会记录相应的执行情况:
-
条件跳转:用一位表示分支是否被执行("Taken" 或 "Not Taken")
-
间接调用和跳转:记录目标地址
-
无条件跳转:不会记录变化,因为目标地址可以从指令本身推断出
Intel PT 记录的指令指针(IP)会与之前的记录进行比较,并生成相关的数据包(如FUP、TIP、TIP.PGE或TIP.PGD)。如果地址的高位字节重叠,匹配的部分将被压缩存储。此外,对于"近返回"指令,如果返回目标正好是调用指令的下一条指令,则不会记录这一跳转,因为它可以根据控制流推断出来。
这种高效的记录机制使得 Intel PT 能够在消耗较少资源的情况下记录复杂的执行流程,同时为调试和性能分析提供了强有力的支持。
Intel Processor Trace Components
Trace Check
-
检测是否支持 Intel Processor Trace (PT)。 通过执行 CPUID 指令来检查 Intel 处理器是否支持 Intel Processor Trace (PT)。将 EAX 寄存器设置为 07H,将 ECX 寄存器设置为 0H,然后检查返回结果中的 EBX 寄存器的第25位(从0开始计数)。如果该位的值为1,表示处理器支持 Intel 处理器跟踪。
bool isIntelPTSupported() { unsigned int check; __asm { mov eax, 0x7 cpuid mov check,ebx } bool isValue = check & 0x1000000; return isValue; }
-
检测是否支持 Filtering by CR3。
bool isFiltering_by_CR3() { unsigned int check; __asm { mov eax, 0x14 cpuid mov check,ebx } bool isValue = check & 0x1; return isValue; }
check Filtering by CR3
Filtering by CR3
Intel Processor Trace (Intel PT) 提供了一种基于 CR3 寄存器值的精确跟踪过滤机制,允许开发者通过选择性地启用或禁用数据包生成,实现对特定内存上下文的细粒度性能分析和代码执行追踪。
其核心过滤原理如下:
-
实时监控 CR3 寄存器的值
-
仅在 CR3 值与预设条件匹配时生成体系结构状态数据包
-
显著减少跟踪数据量,专注于特定进程或内存上下文
实现机制包括:
-
将目标 CR3 值写入 IA32_RTIT_CR3_MATCH MSR
-
设置 IA32_RTIT_CTL.CR3Filter 控制位
-
当 CR3 值不匹配时,强制将 ContextEn 设置为 0,以阻止状态数据包的生成
关键特性:
-
精确过滤单一进程的执行轨迹
-
最小化性能开销
-
灵活的内存上下文追踪
通过精确配置 MSR 寄存器标志,Intel PT 实现了对单个进程执行可控且高效的追踪。
void ConfigureProcessorTraceSettings(ProcessTraceConfig* processTraceConfig, ProcessTraceCaps* processorTraceCaps) { // 定义跟踪控制描述符 ProcessorTraceControlDescriptor traceControlDescriptor = {0}; ProcessorTraceStatusDescriptor traceStatusDescriptor = {0}; // 获取跟踪选项 TraceOptions& traceOptions = processTraceConfig->traceOptions; // 配置基本跟踪设置 traceControlDescriptor.Fields.FabricEn = 0; traceControlDescriptor.Fields.Os = (processTraceConfig->shouldTraceKernelSpace ? 1 : 0); traceControlDescriptor.Fields.User = (processTraceConfig->shouldTraceUserSpace ? 1 : 0); traceControlDescriptor.Fields.BranchEn = traceOptions.Fields.shouldTraceBranchPackets; // 配置页表过滤 if (processTraceConfig->targetProcessPageTable) { __writemsr(MSR_IA32_RTIT_CR3_MATCH, reinterpret_cast<ULONGLONG>(processTraceConfig->targetProcessPageTable)); assert(__readmsr(MSR_IA32_RTIT_CR3_MATCH) == reinterpret_cast<ULONGLONG>(processTraceConfig->targetProcessPageTable)); traceControlDescriptor.Fields.CR3Filter = 1; } else { __writemsr(MSR_IA32_RTIT_CR3_MATCH, 0); assert(__readmsr(MSR_IA32_RTIT_CR3_MATCH) == 0); traceControlDescriptor.Fields.CR3Filter = 0; } // 初始化地址范围配置 traceControlDescriptor.Fields.Addr0Cfg = 0; traceControlDescriptor.Fields.Addr1Cfg = 0; traceControlDescriptor.Fields.Addr2Cfg = 0; traceControlDescriptor.Fields.Addr3Cfg = 0; // 配置指令指针地址范围 auto configureAddressRange = [&](size_t rangeIndex) { if (rangeIndex >= processTraceConfig->activeRangesCount) return; auto& addressRange = processTraceConfig->instructionRanges[rangeIndex]; uint8_t* addressConfigField = nullptr; DWORD msrStartAddr = 0, msrEndAddr = 0; switch (rangeIndex) { case 0: addressConfigField = &traceControlDescriptor.Fields.Addr0Cfg; msrStartAddr = MSR_IA32_RTIT_ADDR0_START; msrEndAddr = MSR_IA32_RTIT_ADDR0_END; break; case 1: addressConfigField = &traceControlDescriptor.Fields.Addr1Cfg; msrStartAddr = MSR_IA32_RTIT_ADDR1_START; msrEndAddr = MSR_IA32_RTIT_ADDR1_END; break; case 2: addressConfigField = &traceControlDescriptor.Fields.Addr2Cfg; msrStartAddr = MSR_IA32_RTIT_ADDR2_START; msrEndAddr = MSR_IA32_RTIT_ADDR2_END; break; case 3: addressConfigField = &traceControlDescriptor.Fields.Addr3Cfg; msrStartAddr = MSR_IA32_RTIT_ADDR3_START; msrEndAddr = MSR_IA32_RTIT_ADDR3_END; break; } *addressConfigField = addressRange.shouldStopTrace ? 2 : 1; __writemsr(msrStartAddr, reinterpret_cast<QWORD>(addressRange.startAddress)); __writemsr(msrEndAddr, reinterpret_cast<QWORD>(addressRange.endAddress)); }; for (size_t i = 0; i < 4; ++i) { configureAddressRange(i); } // 配置MTC(Memory Trace Clock) if (processorTraceCaps->isMtcSupported) { traceControlDescriptor.Fields.MTCEn = traceOptions.Fields.shouldTraceMtcPackets; if ((1 << traceOptions.Fields.mtcFrequency) & processorTraceCaps->mtcPeriodBitmap) { traceControlDescriptor.Fields.MTCFreq = traceOptions.Fields.mtcFrequency; } } // 配置周期性采样和循环计数 if (processorTraceCaps->isPsbAndCycSupported) { traceControlDescriptor.Fields.CycEn = traceOptions.Fields.shouldTraceCyclePackets; if ((1 << traceOptions.Fields.cycleThreshold) & processorTraceCaps->cycleThresholdBitmap) { traceControlDescriptor.Fields.CycThresh = traceOptions.Fields.cycleThreshold; } if ((1 << traceOptions.Fields.psbFrequency) & processorTraceCaps->psbFrequencyBitmap) { traceControlDescriptor.Fields.PSBFreq = traceOptions.Fields.psbFrequency; } } // 额外配置 traceControlDescriptor.Fields.DisRETC = !traceOptions.Fields.isReturnCompressionEnabled; traceControlDescriptor.Fields.TSCEn = traceOptions.Fields.shouldTraceTscPackets; // 启用跟踪 traceControlDescriptor.Fields.TraceEn = 1; __writemsr(MSR_IA32_RTIT_CTL, traceControlDescriptor.All); assert(__readmsr(MSR_IA32_RTIT_CTL) == traceControlDescriptor.All); // 读取状态寄存器 traceStatusDescriptor.All = __readmsr(MSR_IA32_RTIT_STATUS); }
数据包解包
Intel PT 在追踪过程中生成的记录数据包需要使用官方的 Intel libipt 库来进行解包和分析。libipt 是解码 Intel PT 数据包的标准库,提供了如 ptdump 和 ptexd 等基本工具。通过对生成的 PSB 数据包进行解码,开发者可以分析关键跳转指令及其相关数据,从而获得更深入的执行过程理解和性能分析。
以下是使用Libipt库 API 实现对 PSB 进行解包的操作:
static int dump_packets(struct pt_packet_decoder *decoder, struct ptdump_tracking *tracking, const struct ptdump_options *options, const struct pt_config *config) { uint64_t offset; int errcode; offset = 0ull; for (;;) { struct pt_packet packet; //获取当前解码器位置作为偏移量进入 Intel PT 缓冲区 errcode = pt_pkt_get_offset(decoder, &offset); if (errcode < 0) return diag("error getting offset", offset, errcode); errcode = pt_pkt_next(decoder, &packet, sizeof(packet)); if (errcode < 0) { if (errcode == -pte_eos) return 0; if (pt_errcode(errcode) != pte_bad_packet) return diag("error decoding packet", offset, errcode); else return errcode; } errcode = dump_one_packet(offset, &packet, tracking, options, config); if (errcode < 0) return diag("error printing the packet", offset, errcode); } return 0; }
以下是《Intel®64 and IA-32 Architectures Software Developer Manuals》中的一些常用数据字段解析:
PSB(数据包流边界)
PSB 数据包作为追踪包解码的同步标志,定义了追踪日志中的一个边界。在此处,解压缩过程可以独立进行,不会产生其他影响。在 libipt 库中,这个偏移量被称为“同步偏移量”(sync offset),因为它标志着追踪文件中的一个位置,从这个位置开始可以安全地解码后续的数据包。
TIP(目标 IP)
TIP 数据包表示目标 IP 地址。????????压缩省略地址高四字节。
TNT(条件分支执行状态)
TNT 数据包用于指示某个条件分支是否被执行。无条件跳转的分支不会被记录,因为这些流程控制可以从程序的状态中推导出来。
解包数据如下:
解包数据
通过解包数据,可以发现程序在执行过程中的 TIP 跳转到 7FFFFFFFF0a26c90 以及 0x405e0 偏移处存在的 TNT 跳转,这些信息有助于我们进一步借助代码实现更深入的分析。
数据包分析
解包后的数据包通过 IDA 进行分析,可使我们更方便地分析代码的执行流程及计算代码覆盖率。
class PtAnalyzer: #解析转储文件中的基址 def parse_base_address(self, hDump): try: base_addr_line = hDump.readline().strip() if not base_addr_line.lower().startswith("base address:"): return False # 提取基址字符串 base_addr_str = base_addr_line[14:].strip().split('-')[0].strip() self.BaseAddr = int(base_addr_str, 16) if base_addr_str.startswith("0x") else int(base_addr_str, 10) return True except Exception as e: print(f"基址解析错误: {e}") return False finally: hDump.close() # 使用IDA进行代码执行流程分析 def start_pt_analysis(self, start_ea): current_ea = start_ea line_number = 0 try: while True: # 读取下一个数据包 line = self.hTraceFile.readline() if not line or line.strip() == 'END': break line = line.strip() if not line: continue next_packet = self.get_packet(line) line_number += 1 next_ea = self.analyse_next_chunk(current_ea, next_packet) if next_ea == 0: if next_packet[1][:3] not in ["fup", "tip"]: print(f"内部错误。数据包ID: {hex(next_packet[0])}, 类型: {next_packet[1]}, 当前IP: {hex(current_ea)[:-1]}") break complete_pt_addr = self.get_pt_pck_full_ip(next_packet) next_ea = self.get_ida_address(complete_pt_addr) print(f"发现不相关的代码块。起始地址: {hex(next_ea)[:-1]},转储文件行号: {line_number}") elif next_ea == -1: break self.last_pt_pck = next_packet current_ea = next_ea # 处理最后一个数据包 self._finalize_analysis(current_ea, next_packet) return True except Exception as e: print(f"PT分析过程中发生错误: {e}") return False def _finalize_analysis(self, current_ea, last_packet): last_ea = ida_funcs.get_fchunk(current_ea).endEA if last_packet[1][-3:] == "pgd" and current_ea != last_ea: while current_ea < last_ea: mnem = idc.GetMnem(current_ea) self.color_instruction(current_ea) # 遇到特定指令则停止 if mnem[0] == "j" or mnem[:4] == "loop" or mnem in ["call", "ret", "retn"]: break current_ea = idc.NextHead(current_ea)
火绒内部工具
虚拟沙盒
火绒虚拟沙盒实现了对超过数万个 API 的模拟,覆盖了绝大多数操作系统的核心机制,并支持多个操作系统平台:
-
Windows x86/x64
-
Linux x86/x64
-
MacOS x86/x64
虚拟沙盒支持病毒查杀并对病毒进行追踪。通过虚拟化执行引擎,能够为目标代码划分独立的地址空间,并通过接管中断和异常为目标代码分配私有时间片,从而使目标代码得以受控制,执行效率几乎可以达到与真实机相当,且通过模拟目标代码的执行,可以快速进行通用脱壳和dump区段,免去了对强壳外壳的复杂分析工作。
火绒查杀引擎与调试器集合实现了对病毒的追踪,脱壳并自动修复 IAT,自动运行到调用 API,单步步过显示调用的 API。
模拟输出调用API演示
引擎追踪至跨节区演示
Dump 脱壳内存映像自动修复IAT演示
今天的技术分享到这里就结束啦,希望本篇文章,可以对大家探索病毒分析领域起到一定帮助。绒绒相信,这些工具和技术不仅仅是我们对抗恶意软件的利器,更是我们解码溯源、守卫网络安全的法宝。
在这个信息安全越来越被重视的时代,每一次技术的进步和应用都显得尤为重要,网络安全也不仅仅是专业人士的责任,更是与我们每个人的生活都息息相关。让我们一起筑起防护之盾,为整个网络空间的和谐与安全贡献力量。
最后,绒绒也要感谢大家的陪伴和支持,正是因为有了你们的好奇心和探索欲,我们的分享才更有意义。未来,绒绒会继续为大家挖掘更多网络安全的“宝藏”,分享更多有价值的技术知识,敬请期待吧~
4A评测 - 免责申明
本站提供的一切软件、教程和内容信息仅限用于学习和研究目的。
不得将上述内容用于商业或者非法用途,否则一切后果请用户自负。
本站信息来自网络,版权争议与本站无关。您必须在下载后的24个小时之内,从您的电脑或手机中彻底删除上述内容。
如果您喜欢该程序,请支持正版,购买注册,得到更好的正版服务。如有侵权请邮件与我们联系处理。敬请谅解!
程序来源网络,不确保不包含木马病毒等危险内容,请在确保安全的情况下或使用虚拟机使用。
侵权违规投诉邮箱:4ablog168#gmail.com(#换成@)