Add files via upload
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
### 恶意样本给出函数家族的 md5,如何进行分类
|
||||
|
||||
**1. 样本预处理**
|
||||
|
||||
首先,我们需要拿到恶意样本文件。为了确保分析的安全性,这些样本通常在沙箱环境或隔离的虚拟机中运行和处理
|
||||
|
||||
**2. 静态分析与函数提取**
|
||||
|
||||
这是最关键的一步。我们需要使用专业的反汇编或反编译工具(如 IDA Pro、Ghidra、Binary Ninja 等)对样本进行静态分析,提取其中的所有函数
|
||||
|
||||
在提取过程中,我们要确保做到以下几点:
|
||||
|
||||
- **识别所有函数:** 准确地识别出样本中所有的函数入口点和函数体
|
||||
- **清理和标准化:** 许多编译器会在函数中插入一些无用的代码(如栈帧设置、调试信息等)。为了确保哈希的一致性,我们需要**清理**这些与核心逻辑无关的代码。例如,可以使用工具去除 NOP(空操作)指令、对齐填充等
|
||||
- **标准化函数代码:** 即使是相同的逻辑,不同的编译器或编译选项也会产生略有差异的机器码。为了让哈希值保持一致,我们需要对函数进行**标准化**。这通常涉及将函数体转化为一种更抽象、更稳定的表示形式,比如:
|
||||
- **代码归一化(Code Normalization):** 替换寄存器名称、删除地址无关的指令,使得哈希值不受编译地址的影响
|
||||
- **指令序列哈希:** 只对核心的指令序列进行哈希,忽略一些可变的部分
|
||||
|
||||
**3. 计算函数哈希**
|
||||
|
||||
在函数代码被标准化和清理后,我们就可以计算它们的 MD5 哈希值了。这里通常采用两种策略:
|
||||
|
||||
- **MD5 哈希:** 直接对标准化后的函数二进制代码或其序列进行 MD5 计算。这是最简单也最直接的方法
|
||||
- **模糊哈希(Fuzzy Hashing):** 对于一些变种较大的函数,使用 MD5 可能会失效。这时,我们可以使用模糊哈希算法,如 **ssdeep** 或 **TLSH**。这些算法能够计算出相似度分数,而不是一个绝对的哈希值,从而可以匹配那些有细微改动的函数
|
||||
|
||||
**4. 构建哈希数据库**
|
||||
|
||||
在提取并计算出哈希值后,我们需要将这些信息存储到一个哈希数据库中。这个数据库通常包含以下信息:
|
||||
|
||||
- **函数 MD5 哈希值**
|
||||
- **该函数所属的样本文件名或哈希**
|
||||
- **该函数的家族分类信息(如果已知)**
|
||||
- **该函数的功能描述(如果分析过)**
|
||||
|
||||
通过不断地分析新的样本并填充这个数据库,我们就能建立一个庞大的恶意软件函数指纹库
|
||||
|
||||
**5. 家族分类**
|
||||
|
||||
现在我们有了函数哈希和数据库,就可以开始进行分类了
|
||||
|
||||
- **第一步:** 拿到一个新的未知样本
|
||||
- **第二步:** 按照上述步骤,提取该样本中的所有函数,并计算它们的 MD5 哈希值
|
||||
- **第三步:** 将这些新计算出来的函数哈希值与我们的哈希数据库进行比对
|
||||
- **第四步:** 如果一个或多个函数哈希在数据库中找到了匹配项,并且这些匹配项都指向同一个恶意软件家族(例如,都匹配到“Emotet”家族中的多个样本),那么我们就可以初步判断这个新样本也属于这个家族
|
||||
- **第五步:** 如果匹配到了多个不同的家族,我们需要进行进一步的分析,比如:
|
||||
- **函数数量匹配:** 看看哪个家族匹配到的函数数量最多
|
||||
- **核心功能函数匹配:** 某些函数(如加密、持久化)比其他函数(如日志记录)更能代表一个家族的特征。如果核心功能函数匹配上了,分类的准确度会更高
|
||||
@@ -0,0 +1,47 @@
|
||||
### 面对静态编译的大型木马如何通过 IDA 定位其网络传输部分的逻辑
|
||||
|
||||
**第一步:宏观审视与初步筛选**
|
||||
|
||||
在深入细节之前,先从高层次了解程序的整体结构
|
||||
|
||||
1. **字符串分析 (Strings)**:这是最有效的切入点。在 IDA Pro 中打开 `View -> Open subviews -> Strings` 窗口。大型木马通常会包含大量的硬编码字符串,这些字符串往往与网络通信直接相关。寻找以下关键字:
|
||||
|
||||
- **IP 地址或域名**:`"192.168.1.1"`, `"example.com"`, `"evil.org"`
|
||||
- **URL 路径**:`"/api/v1/data"`, `"download.php"`, `"update"`
|
||||
- **User-Agent**:`"Mozilla/5.0"`, `"User-Agent:"`
|
||||
- **协议头**:`"HTTP/1.1"`, `"GET"`, `"POST"`, `"FTP"`, `"socks"`
|
||||
- **端口号**:`"Port:"`, `"8080"`, `"443"`
|
||||
- **错误信息**:`"Connection failed"`, `"Socket error"`, `"Network busy"`
|
||||
|
||||
一旦找到可疑的字符串,右键点击它,选择 `Xrefs from` (交叉引用),就可以跳转到使用该字符串的代码位置。这通常是网络通信函数附近
|
||||
|
||||
2. **函数列表筛选 (Functions)**:在 `Functions` 窗口中,IDA 会列出所有识别出的函数。虽然数量可能非常庞大,但我们可以通过函数名进行筛选
|
||||
|
||||
- **自动生成的函数名**:如果 IDA Pro 识别了标准库(如 `libc` 或 `libcurl`)的函数,它会给它们一个有意义的名字。搜索与网络相关的函数名:`socket`, `connect`, `send`, `recv`, `bind`, `listen`, `inet_addr`, `gethostbyname`, `HttpSendRequest` 等。这些是网络编程的常用 API
|
||||
- **被调用的函数**:点击这些被识别的网络函数,查看它们的 `Xrefs to` (交叉引用),这会告诉你木马代码中哪些地方调用了这些网络 API。这通常就是网络通信逻辑的起点
|
||||
|
||||
**第二步:深入分析与代码追踪**
|
||||
|
||||
找到可疑的网络 API 调用后,接下来要做的就是分析其上下文
|
||||
|
||||
1. **参数分析**:检查网络 API 调用的参数
|
||||
- `send/recv`:观察它们的缓冲区参数,这可以帮助你判断数据是发送还是接收,并了解数据的大小和内容
|
||||
- `connect`:查看它的地址和端口参数,这会告诉你木马试图连接哪个远程服务器
|
||||
- `bind/listen`:如果木马是一个服务器,会使用这些函数。查看它们的端口参数,了解木马监听的端口号
|
||||
2. **向上追溯调用链**:从找到的网络 API 调用点开始,沿着**函数调用链**向上追溯
|
||||
- 使用 IDA Pro 的 `Graph View` (空格键),这会以图形化方式显示函数的控制流
|
||||
- 检查调用了网络 API 的函数。这个函数可能是一个高层封装,比如 `send_data_to_c2`
|
||||
- 进一步向上追溯,你可能会发现一个主循环或主逻辑函数,它负责决定何时进行网络通信
|
||||
3. **识别加密/编码逻辑**:许多木马在网络传输前会对数据进行加密或编码,以逃避检测
|
||||
- **特征**:在 `send` 或 `recv` 调用之前,寻找复杂的循环、数学运算或位操作。这很可能就是数据处理(加密/编码)的代码
|
||||
- **字符串线索**:查找 `xor`, `aes`, `rsa`, `base64` 等字符串,它们可能是加密或编码算法的实现
|
||||
|
||||
**第三步:高级分析与数据流追踪**
|
||||
|
||||
如果常规方法不起作用,可能需要更深入的分析
|
||||
|
||||
1. **数据流分析**:使用 IDA Pro 或其他工具(如 Binary Ninja)来追踪数据从源头到网络API调用的路径
|
||||
- **源头**:数据的来源可能是键盘记录、文件读取、屏幕截图等
|
||||
- **追踪**:从这些可能的源头变量开始,分析它们如何被处理、加密,最终作为 `send` 函数的参数
|
||||
- **使用插件**:一些 IDA 插件(如 **Lighthouse**)可以辅助进行数据流分析和图表可视化
|
||||
2. **交叉引用矩阵**:在 `Functions` 窗口中,你可以查看函数之间的交叉引用矩阵。通过分析哪些函数被频繁调用,哪些函数调用了其他网络相关的函数,可以构建一个更完整的网络通信图谱
|
||||
@@ -0,0 +1,48 @@
|
||||
### 如何动态地去找导入表
|
||||
|
||||
**为什么要动态地查找?**
|
||||
|
||||
静态地查找导入表非常简单,我们只需要解析 PE 文件头中的数据目录(Data Directory),找到导入表的结构体 `IMAGE_IMPORT_DESCRIPTOR`,然后就可以找到所有的导入函数。但这种方法有几个局限性:
|
||||
|
||||
1. **脱壳(Unpacking):** 许多恶意软件会使用加壳技术(packer),将原始的 PE 文件压缩或加密。这种情况下,原始的导入表会被隐藏或破坏,静态分析工具无法找到它。当程序运行时,加壳器会自行解压和修复导入表,因此只有在内存中才能找到真正的导入表
|
||||
2. **动态加载(Dynamic Loading):** 程序可能会使用 `LoadLibrary` 和 `GetProcAddress` 等函数在运行时动态加载 DLL 和获取函数地址。这种方式下,导入的函数根本不会出现在 PE 文件的静态导入表中
|
||||
3. **防止逆向工程:** 有些程序开发者故意混淆或破坏导入表,以增加逆向工程的难度
|
||||
|
||||
因此,动态地查找导入表是进行脱壳、恶意软件分析和深入逆向工程的必备技能
|
||||
|
||||
**方法一:利用 `EAT` (导出地址表)**
|
||||
|
||||
这是最直接、也是最不寻常的方法。如果一个程序(比如一个 DLL)将自己的导入表中的函数地址作为导出函数暴露出来,你就可以通过解析它的**导出表 (Export Address Table, EAT)** 来找到导入表。但这种情况非常少见,通常只在一些特殊的系统 DLL 或驱动程序中出现。这种方法不具有通用性
|
||||
|
||||
**方法二:利用函数调用指令**
|
||||
|
||||
这是最常见、最实用的方法。当程序调用一个导入函数时,通常会使用 `CALL` 指令,其目标地址就是导入表中的一个条目
|
||||
|
||||
**具体步骤:**
|
||||
|
||||
1. **调试器附加:** 使用调试器(如 OllyDbg、x64dbg、IDA Pro Debugger)附加到目标进程
|
||||
2. **设置断点:** 在程序执行的早期,比如 `main` 函数或 `WinMain` 函数的入口点设置断点
|
||||
3. **单步调试/跟踪:** 逐步执行(Step Over)程序,并密切关注 `CALL` 指令
|
||||
4. **识别导入调用:**
|
||||
- **相对 `CALL`:** 如果你看到 `CALL [地址]` 这样的指令,并且这个地址是一个外部函数的地址,那么这个 `[地址]` 就是一个导入表项。例如,`CALL DWORD PTR [EAX]`
|
||||
- **直接 `CALL`:** 如果你看到 `CALL MessageBoxA`,这通常是 IDA Pro 这样的反汇编器帮你标记的,它已经识别出这个调用指向了一个导入函数
|
||||
5. **内存转储和分析:** 当你找到一个导入表项的地址后,你可以从这个地址开始,向前和向后扫描内存,寻找连续的、看起来像函数指针的地址序列。这个序列很可能就是完整的导入表。然后你可以将这一块内存转储出来进行进一步分析
|
||||
|
||||
这种方法需要对汇编语言有深入理解,并且需要耐心和细致的调试
|
||||
|
||||
**方法三:利用内存断点和内存扫描**
|
||||
|
||||
当程序加载时,加载器会向导入表写入外部函数的真实地址。我们可以利用这个特性
|
||||
|
||||
**具体步骤:**
|
||||
|
||||
1. **调试器附加:** 附加到目标进程
|
||||
2. **查找 `IMAGE_IMPORT_DESCRIPTOR`:** 使用静态分析工具(如 PE Explorer、CFF Explorer)找到 PE 文件中导入表的 `RVA` (Relative Virtual Address)
|
||||
3. **计算内存地址:** 将 `RVA` 加上基址(ImageBase)得到导入表在内存中的实际地址
|
||||
4. **设置硬件断点:** 在导入表的第一个条目上设置一个硬件写入断点(Hardware Write Breakpoint)
|
||||
5. **运行程序:** 运行程序。当加载器填充导入表时,断点会被触发。这通常发生在 `LoadLibrary` 函数调用之后,但在 `main` 函数之前
|
||||
6. **分析内存:** 断点触发后,你就可以检查内存中的导入表,它的内容已经被加载器填充好了。如果程序进行了脱壳,此时的导入表才是真实的
|
||||
|
||||
**另一种高级变体是:**
|
||||
|
||||
- 在程序执行早期,在整个 `.text` 段(代码段)设置一个硬件写入断点。当断点触发时,检查写入的地址是否在代码段内部,并分析写入的指令。这可以用来检测自修改代码,是更高级的逆向技术
|
||||
@@ -0,0 +1,63 @@
|
||||
### 如何不在编码时直接导入相关 API 的前提下进行攻击
|
||||
|
||||
从攻击者的角度来看,**不在编码时直接导入相关 API** 是一个核心的规避手段。这种技术通常被称为**动态 API 调用**或**运行时 API 解析**,其主要目的是:
|
||||
|
||||
1. **绕过签名检测:** 传统的杀毒软件和安全工具会扫描可执行文件中的**导入表**。如果导入表里有 `CreateRemoteThread`、`WriteProcessMemory`、`LoadLibrary` 等高危函数,文件就会被标记为可疑。动态调用 API 可以让导入表看起来非常“干净”,从而躲过静态扫描
|
||||
2. **增加逆向分析难度:** 逆向工程师通常会从导入表入手,快速了解程序的功能。如果导入表是空的或只导入了少数几个基础函数,逆向分析师就必须花费大量时间去跟踪程序的运行时行为,才能发现其真正意图
|
||||
3. **支持多操作系统版本和架构:** 有些 API 的地址在不同版本的 Windows 上可能会有细微差异。动态获取 API 地址可以确保代码在不同系统上都能正确运行,提高攻击的通用性
|
||||
4. **按需加载:** 只有在需要执行特定恶意行为时才去获取和调用相应的 API,这可以减少不必要的代码和数据,使恶意程序更小、更精简
|
||||
|
||||
下面是几种具体的技术实现,从初级到高级:
|
||||
|
||||
**1. 使用 `LoadLibrary` 和 `GetProcAddress`**
|
||||
|
||||
这是最基础、最常见的方法。攻击者只需要在代码中静态导入 `LoadLibraryA/W` 和 `GetProcAddress` 这两个函数,然后用它们来动态获取所有其他需要的 API
|
||||
|
||||
**实现步骤:**
|
||||
|
||||
1. **加载 DLL:** 调用 `LoadLibraryA`,传入需要加载的 DLL 名称(例如 "kernel32.dll")。这个函数会返回该 DLL 在内存中的基址
|
||||
2. **获取函数地址:** 调用 `GetProcAddress`,传入 DLL 的基址和需要获取的函数名称(例如 "CreateRemoteThread")。这个函数会返回该 API 的内存地址
|
||||
3. **函数指针调用:** 将获取到的地址赋值给一个函数指针,然后通过这个指针像调用普通函数一样来调用它
|
||||
|
||||
```c
|
||||
// 伪代码
|
||||
#include <windows.h>
|
||||
#include <stdio.h>
|
||||
|
||||
int main() {
|
||||
HMODULE hKernel32 = LoadLibraryA("kernel32.dll");
|
||||
if (hKernel32 == NULL) {
|
||||
// 处理错误
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 定义一个函数指针类型
|
||||
typedef HANDLE (WINAPI* CreateRemoteThread_t)(HANDLE, LPSECURITY_ATTRIBUTES, SIZE_T, LPTHREAD_START_ROUTINE, LPVOID, DWORD, LPDWORD);
|
||||
|
||||
// 获取 CreateRemoteThread 的地址
|
||||
CreateRemoteThread_t pCreateRemoteThread = (CreateRemoteThread_t)GetProcAddress(hKernel32, "CreateRemoteThread");
|
||||
if (pCreateRemoteThread == NULL) {
|
||||
// 处理错误
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 现在可以使用 pCreateRemoteThread 来调用 CreateRemoteThread 函数了
|
||||
// pCreateRemoteThread(..., ..., ...);
|
||||
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
这种方法虽然简单,但 `LoadLibrary` 和 `GetProcAddress` 依然会出现在程序的导入表中,因此安全软件仍然可以进行识别
|
||||
|
||||
**2. 手动解析 PEB**
|
||||
|
||||
这是更高级、更隐蔽的方法。其核心是**完全不依赖任何静态导入**,通过**手动遍历内存中的数据结构**来找到所需的 API 地址
|
||||
|
||||
**实现步骤:**
|
||||
|
||||
1. **获取 PEB 地址:** 在 32 位系统上,PEB 的地址可以通过 `FS:[0x30]` 寄存器来获取;在 64 位系统上,可以通过 `GS:[0x60]` 来获取
|
||||
2. **遍历 PEB LDR 数据结构:** PEB 结构体中包含一个指向已加载模块列表的指针(`LDR_DATA`)。攻击者可以遍历这个列表,找到 `ntdll.dll`、`kernel32.dll` 等已加载的 DLL 模块
|
||||
3. **解析 DLL 的导出表(EAT):** 找到目标 DLL 的基址后,手动解析其**导出地址表 (EAT)**。EAT 是一个包含所有导出函数名称和地址的结构
|
||||
4. **哈希值匹配:** 为了避免在代码中硬编码函数名称字符串(字符串会暴露恶意意图),攻击者通常会为每个函数名称计算一个哈希值。遍历 EAT 中的函数名称,计算哈希值,然后与预设的目标哈希值进行匹配。如果匹配成功,就找到了所需 API 的地址
|
||||
5. **函数指针调用:** 获取到地址后,同样通过函数指针进行调用
|
||||
@@ -0,0 +1,39 @@
|
||||
### Windows 下有哪些常用的反调试技术
|
||||
|
||||
**1. 基于 Windows API 的反调试**
|
||||
|
||||
这是最常见、最容易实现的反调试技术,利用 Windows 系统提供的特定函数来查询进程状态
|
||||
|
||||
- **`IsDebuggerPresent`:** 这是最直接、最经典的 API。它位于 `kernel32.dll` 中,会检查 **进程环境块 (PEB)** 中的 `IsDebugged` 标志位。如果该位被设置为 `1`,函数就返回 `TRUE`。攻击者只需要简单地调用这个函数,然后根据返回值决定是否执行恶意代码
|
||||
- **`CheckRemoteDebuggerPresent`:** 这个 API 用来检查**另一个进程**是否正在被调试。它通常用于一个父进程检查其子进程是否被调试。攻击者可以启动一个子进程,然后父进程不断调用此 API 来监视子进程的状态
|
||||
- **`OutputDebugString`:** 这个函数原本用于向调试器输出调试信息。如果程序在没有调试器附加的情况下调用此函数,并随后调用 `GetLastError`,返回的错误码通常是 `ERROR_NOT_ENOUGH_MEMORY`(`0x08`)。如果返回其他值,则很可能存在调试器
|
||||
- **`NtQueryInformationProcess`:** 这是一个更底层的、功能强大的未公开(undocumented)API。通过查询 `ProcessDebugPort`、`ProcessDebugObject` 或 `ProcessDebugFlags` 等信息,可以精确地判断程序是否被调试。这是许多高级反调试技术的基石,因为不像 `IsDebuggerPresent`,它更难被简单地 Hook 或篡改
|
||||
|
||||
**2. 基于异常和 SEH(结构化异常处理) 的反调试**
|
||||
|
||||
调试器在处理异常时与正常程序有不同的行为,这为反调试提供了可乘之机
|
||||
|
||||
- **`INT 3` 断点:** 调试器通常通过 `INT 3` (opcode `0xCC`) 指令来设置软件断点。攻击者可以在代码中故意插入一个 `INT 3` 指令,并设置自己的异常处理器。如果程序在执行 `INT 3` 后进入了预设的异常处理器,说明没有调试器存在(因为调试器会捕获 `INT 3` 并暂停程序)。如果程序没有进入异常处理器,就说明有调试器存在
|
||||
- **`Trap Flag (TF)` 检测:** 当调试器设置了硬件断点或启用单步执行时,CPU 的 EFLAGS 寄存器中的 `Trap Flag` 会被设置。攻击者可以通过内联汇编代码来检查这个标志位,判断是否正在进行单步调试
|
||||
- **利用 `VEH`(向量化异常处理):** `VEH` 是比 `SEH` 更早被调用的异常处理机制。攻击者可以在 `VEH` 中设置反调试逻辑,因为它更难被调试器忽略或绕过
|
||||
|
||||
**3. 基于时间差和指令计数的反调试**
|
||||
|
||||
调试器在执行单步调试或设置断点时,会引入额外的延迟。正常程序可以利用这个时间差来检测调试器的存在
|
||||
|
||||
- **`RDTSC` (Read Time-Stamp Counter):** 这是一个 CPU 指令,用于读取 CPU 的时间戳计数器。攻击者可以在代码中的两个点调用 `RDTSC`,并计算两次调用之间的时间差。如果这个时间差异常地长,很可能是因为调试器在单步执行或处理断点
|
||||
- **`QueryPerformanceCounter`:** 这是一个高精度的 Windows API。与 `RDTSC` 类似,攻击者可以调用两次这个 API 并计算时间差。如果时间差超过一个阈值,则认为存在调试器
|
||||
- **线程休眠检测:** 当程序进入休眠状态时,调试器通常会唤醒它以便继续执行。攻击者可以调用 `Sleep()` 函数,然后检查实际休眠的时间是否与期望的时间相符。如果实际休眠时间比期望的短,说明调试器可能干预了线程的运行
|
||||
|
||||
**4. 基于进程和线程状态的反调试**
|
||||
|
||||
调试器通常会改变被调试进程或线程的某些状态
|
||||
|
||||
- **父进程检测:** 正常程序通常由 `explorer.exe` 或 `cmd.exe` 等合法进程启动。而调试器会成为被调试进程的父进程。攻击者可以调用 `NtQueryInformationProcess` 或 `CreateToolhelp32Snapshot` 等 API,检查父进程 ID (`PPID`) 是否是已知的调试器进程 ID,或者干脆检查 `PPID` 是否不等于 `explorer.exe` 的 `PPID`
|
||||
- **线程上下文检测:** 调试器会修改被调试线程的寄存器和线程上下文。攻击者可以检查 `TEB` (Thread Environment Block) 中与调试相关的字段,或者检查线程的上下文信息是否被篡改
|
||||
|
||||
**5. 其他高级反调试技术**
|
||||
|
||||
- **调试器检测点:** 在代码中故意创建多个 `INT 3` 断点或 `CALL` 调试 API 的分支,但让程序在没有调试器的情况下跳过这些分支。当调试器附加时,这些分支被执行,从而暴露调试器的存在
|
||||
- **自修改代码:** 在程序运行时修改自己的代码,例如用 `NOP` 指令覆盖反调试代码,或用 `jmp` 指令跳转到真正的逻辑代码。调试器很难追踪和分析这种行为,因为其静态分析视图与运行时视图不符
|
||||
- **反汇编器检测:** 有些技术不仅针对调试器,还针对 IDA Pro 等反汇编工具。例如,在代码中插入一些特殊指令序列,这些序列在 CPU 上执行正常,但在反汇编器中会被错误地解析,导致代码流被混淆
|
||||
@@ -0,0 +1,33 @@
|
||||
### 单步执行的原理是什么
|
||||
|
||||
**1. 陷阱标志**
|
||||
|
||||
在 x86 架构的 CPU 中,有一个特殊的寄存器叫做 **EFLAGS**(在 64 位系统中是 RFLAGS)。这个寄存器中的每一位都代表一个特定的状态或控制标志。其中的第 8 位就是**陷阱标志(TF)**
|
||||
|
||||
- **当 TF=0 时**:CPU 正常执行指令,不会触发单步中断
|
||||
- **当 TF=1 时**:这是单步执行的关键。CPU 在执行完一条指令后,会自动产生一个 **INT 1** 异常(也就是**单步中断**)
|
||||
|
||||
**2. 单步执行的原理流程**
|
||||
|
||||
当你在调试器中点击“单步”按钮时,幕后会发生以下几个步骤:
|
||||
|
||||
1. **调试器设置 TF 标志位:** 调试器通过系统调用或直接操作,将 **EFLAGS 寄存器中的 TF 位设置为 1**
|
||||
2. **CPU 执行下一条指令:** CPU 继续正常执行程序代码中的下一条指令
|
||||
3. **CPU 产生 INT 1 异常:** 在这条指令执行完毕后,CPU 检查到 TF 标志位为 1,于是自动停止正常的程序执行流程,并产生一个 **INT 1 异常**
|
||||
4. **操作系统捕获异常:** 操作系统有一个专门的**中断描述符表(IDT)**。当 INT 1 异常发生时,操作系统会根据 IDT 中预先设置好的入口点,将控制权交给处理 INT 1 异常的程序
|
||||
5. **调试器接管控制权:** 由于调试器是操作系统中管理被调试进程的组件,它会事先向操作系统注册一个异常处理函数。因此,当 INT 1 异常发生时,**控制权实际上被交给了调试器**
|
||||
6. **调试器暂停程序:** 调试器在接管控制权后,会暂停被调试程序的执行,并显示当前的程序状态(寄存器值、内存数据等)
|
||||
7. **等待用户操作:** 此时,程序在调试器中处于暂停状态,等待你进行下一步操作,比如再次单步、查看变量或继续运行
|
||||
|
||||
如果你再次点击“单步”,这个循环会重新开始:调试器再次设置 TF,程序执行一条指令,然后控制权再次回到调试器
|
||||
|
||||
**3. 特殊情况:`INT 3` 断点**
|
||||
|
||||
除了利用 TF 标志位,调试器还有一个常用的单步执行辅助手段,那就是**`INT 3` 指令**
|
||||
|
||||
- 当你在某行代码上设置了一个断点(Breakpoint)时,调试器会偷偷地将该行代码的第一个字节替换成 **`0xCC`**
|
||||
- `0xCC` 对应的汇编指令就是 **`INT 3`**
|
||||
- 当程序执行到这个位置时,CPU会像处理单步中断一样,产生一个 **INT 3 异常**
|
||||
- 操作系统将控制权交给调试器,程序暂停,从而实现了“断点”的功能
|
||||
|
||||
所以,当调试器在一个断点处暂停后,你点击单步,调试器会先**将 `0xCC` 恢复为原来的指令**,然后**设置 TF 标志位**,让程序执行那条被恢复的指令,最后在指令执行完毕后,再次设置 `0xCC`,等待下一次的断点触发
|
||||
@@ -0,0 +1,86 @@
|
||||
### 在内存中已 Load 的程序如何快速找到其具有执行权限的段
|
||||
|
||||
**方法一:利用操作系统 API**
|
||||
|
||||
这是最常用、最稳定的方法。Windows 提供了强大的内存查询 API,可以快速遍历和检查一个进程的内存空间
|
||||
|
||||
1. **`VirtualQueryEx` 函数:** 这是最核心的 API。通过循环调用 `VirtualQueryEx`,你可以遍历整个进程的虚拟地址空间。这个函数会填充一个 `MEMORY_BASIC_INFORMATION` 结构体,其中包含了每个内存页面的信息,例如:
|
||||
- `BaseAddress`:内存区域的起始地址
|
||||
- `RegionSize`:内存区域的大小
|
||||
- `State`:内存区域的状态(如已提交 `MEM_COMMIT`)
|
||||
- `Protect`:最重要的字段,描述内存区域的保护权限
|
||||
2. **检查 `Protect` 字段:** `Protect` 字段是一个位掩码,你需要检查它是否包含代表**可执行**的标志。常见的可执行权限标志有:
|
||||
- `PAGE_EXECUTE`
|
||||
- `PAGE_EXECUTE_READ`
|
||||
- `PAGE_EXECUTE_READWRITE`
|
||||
- `PAGE_EXECUTE_WRITECOPY`
|
||||
|
||||
**示例代码(伪C++):**
|
||||
|
||||
```c++
|
||||
#include <windows.h>
|
||||
#include <iostream>
|
||||
|
||||
void FindExecutableRegions(HANDLE hProcess) {
|
||||
MEMORY_BASIC_INFORMATION mbi;
|
||||
LPVOID pBaseAddress = nullptr;
|
||||
|
||||
while (VirtualQueryEx(hProcess, pBaseAddress, &mbi, sizeof(mbi)) == sizeof(mbi)) {
|
||||
// 检查内存状态,确保它已提交并有可执行权限
|
||||
if (mbi.State == MEM_COMMIT &&
|
||||
(mbi.Protect & (PAGE_EXECUTE | PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY))) {
|
||||
|
||||
// 找到了一个可执行的区域
|
||||
std::cout << "Executable region found at: " << std::hex << mbi.BaseAddress
|
||||
<< ", Size: " << mbi.RegionSize << " bytes" << std::endl;
|
||||
}
|
||||
|
||||
// 移动到下一个内存区域
|
||||
pBaseAddress = (LPVOID)((DWORD_PTR)mbi.BaseAddress + mbi.RegionSize);
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
// 假设你已经获取了目标进程的句柄
|
||||
// HANDLE hTargetProcess = OpenProcess(...);
|
||||
// FindExecutableRegions(hTargetProcess);
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
**方法二:利用 PEB(进程环境块)和 PE 结构**
|
||||
|
||||
对于已加载的 **PE 文件**,你可以直接解析其在内存中的结构来找到可执行段。这通常比遍历所有内存区域更快,但只适用于目标是主模块或已知的 DLL
|
||||
|
||||
1. **定位 PEB:**
|
||||
- 32 位:通过 `FS:[0x30]` 寄存器获取
|
||||
- 64 位:通过 `GS:[0x60]` 寄存器获取
|
||||
2. **获取 ImageBase:** 从 PEB 中找到 `ImageBase` 字段,它存储了主模块在内存中的基址
|
||||
3. **解析 PE 头部:**
|
||||
- 从 `ImageBase` 开始,找到 `e_lfanew` 字段,它指向 **NT 头部(NT Header)**
|
||||
- 在 NT 头部中,找到**可选头部(Optional Header)**
|
||||
- 在可选头部中,找到**节表(Section Table)**的偏移
|
||||
4. **遍历节表:**
|
||||
- 节表是一个 `IMAGE_SECTION_HEADER` 结构体数组
|
||||
- 遍历这个数组,检查每个节的**特征(Characteristics)**字段
|
||||
- 寻找 `IMAGE_SCN_MEM_EXECUTE` 标志。如果这个标志被设置,那么这个节就是可执行的
|
||||
5. **计算地址:** 找到可执行的节后,其在内存中的实际地址是 `ImageBase + VirtualAddress`
|
||||
|
||||
**方法三:利用调试器或反汇编器**
|
||||
|
||||
如果你在使用调试器(如 x64dbg 或 IDA Pro)或反汇编器(如 Ghidra),这个过程会变得非常直观
|
||||
|
||||
- **x64dbg:**
|
||||
- 打开内存视图(Memory Map),通常是快捷键 `Alt+M`
|
||||
- 在这里,你会看到所有已加载的模块和内存区域的列表
|
||||
- 列表会清楚地显示每个区域的权限(`R`、`W`、`X`),你可以直接找到所有带有 `X` 标志的区域
|
||||
- **IDA Pro / Ghidra:**
|
||||
- 这些工具会自动解析 PE 文件并显示其所有节
|
||||
- 你可以进入“段”(Segments)或“内存区域”(Memory Regions)视图
|
||||
- 视图中会明确标记每个段的权限,通常`.text` 段会显示为 `EXECUTE` 权限
|
||||
|
||||
| 方法 | 优点 | 缺点 | 适用场景 |
|
||||
| --------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | -------------------------------------- |
|
||||
| API调用 | 最通用、最稳定。能找到所有可执行内存区域,包括非PE结构(如JIT编译的代码) | 需要编写代码。性能相对较低,需要遍历整个虚拟内存空间 | 动态分析、恶意软件分析、注入器等 |
|
||||
| PE结构解析 | 速度快,精确。能快速定位.text段和其它可执行的PE节 | 只能用于已加载的PE文件。如果程序有自解压或自修改行为,可能会失效 | 静态分析,当你知道目标是合法的PE文件时 |
|
||||
| 调试器/反汇编器 | 最直观、最简单。提供可视化的界面,无需编写代码 | 依赖于外部工具。无法自动化(除非通过脚本) | 交互式调试、快速逆向工程 |
|
||||
@@ -0,0 +1,45 @@
|
||||
### 恶意软件有哪些方案检测自己处于沙箱中
|
||||
|
||||
**1. 基于环境特征的检测**
|
||||
|
||||
沙箱为了快速分析大量样本,通常会使用标准化的、不完整的系统配置。恶意软件可以利用这些不寻常的特征来判断自己是否被分析
|
||||
|
||||
- **硬件特征**:
|
||||
- **CPU 指令**:通过 `cpuid` 指令查询 CPU 供应商字符串。虚拟机通常会返回 `VMwareVMware`, `KVMKVMKVM` 或 `Microsoft Hv` 等特殊字符串,而真实的物理机则会返回 `GenuineIntel` 或 `AuthenticAMD`
|
||||
- **MAC 地址**:检查网卡(MAC)地址。某些虚拟化厂商的 MAC 地址范围是公开的,例如 VMware 的 MAC 地址通常以 `00-50-56` 开头
|
||||
- **内存大小**:沙箱为了节省资源,通常会分配较少的内存(例如 1GB 或 2GB)。恶意软件可以查询系统内存大小,如果远低于正常桌面系统的配置,就可能怀疑自己处于沙箱中
|
||||
- **文件系统和注册表**:
|
||||
- **特定文件**:检查是否存在某些虚拟化工具或分析工具的特定文件,例如 `C:\windows\System32\Drivers\VBoxGuest.sys` (VirtualBox) 或 `C:\Program Files\VMware\VMware Tools\vmtoolsd.exe` (VMware Tools)
|
||||
- **注册表项**:查询与虚拟化相关的注册表键值,例如 `HKEY_LOCAL_MACHINE\HARDWARE\DEVICEMAP\Scsi\Scsi Port 0\Scsi Bus 0\Target Id 0\Logical Unit Id 0\Identifier`,其值可能会包含 `VBOX` 或 `VMware` 等字符串
|
||||
|
||||
**2. 基于行为和用户交互的检测**
|
||||
|
||||
正常的桌面环境会有频繁的用户活动,而沙箱通常是自动化运行的,缺乏这些活动
|
||||
|
||||
- **用户活动检测**:
|
||||
- **鼠标移动**:检查鼠标自启动以来的移动距离。如果鼠标几乎没有移动,可能表明没有真实用户在操作
|
||||
- **窗口数量**:查询桌面上的窗口数量。沙箱通常只运行一个应用程序,所以窗口数量可能很少
|
||||
- **键盘输入**:监控键盘输入事件。如果长时间没有按键操作,也可能是沙箱
|
||||
- **屏幕分辨率**:沙箱环境通常使用一个固定的、非标准的分辨率
|
||||
- **执行时间检测**:
|
||||
- **延迟执行**:恶意软件会在运行初期休眠一段较长的时间(例如几分钟或几小时)。沙箱通常只运行几十秒到几分钟,无法捕捉到后续的恶意行为
|
||||
- **时间戳**:检查系统启动时间,或使用 `Sleep` 函数,然后比较前后两次获取系统时间的时间差。如果时间差比预期的短得多(因为沙箱可能通过加快时钟来加速分析),就表明处于虚拟化环境
|
||||
|
||||
**3. 基于进程和文件分析工具的检测**
|
||||
|
||||
沙箱环境通常会运行一些监控工具,这些工具的进程或文件名可能暴露它的身份
|
||||
|
||||
- **进程列表**:遍历运行中的进程列表,查找常见的分析工具进程名,例如:
|
||||
- `procmon.exe` (Process Monitor)
|
||||
- `wireshark.exe` (Wireshark)
|
||||
- `Fiddler.exe`
|
||||
- `idaq64.exe` (IDA Pro)
|
||||
- **文件名**:恶意软件可以检查自身的可执行文件名。一些自动化分析系统会把样本重命名为固定的、可预测的名字,例如 `sample.exe` 或 `malware.exe`
|
||||
|
||||
**4. 基于硬件和底层代码的检测**
|
||||
|
||||
这是更高级的反沙箱技术,直接利用虚拟机和物理机底层实现的差异
|
||||
|
||||
- **指令时序分析**:某些 CPU 指令在虚拟机中执行所需的时间与在物理机上不同。恶意软件可以执行这些特定指令,然后测量其执行时间,通过比较时间差来判断
|
||||
- **中断描述符表(IDT)检测**:虚拟机管理程序(Hypervisor)通常会修改 IDT 以拦截某些特权指令。恶意软件可以检查 IDT 的地址或内容,寻找被篡改的迹象
|
||||
- **内存布局**:检查内存布局,如某些特殊的地址或结构,它们在虚拟机中可能会有独特的模式
|
||||
@@ -0,0 +1,25 @@
|
||||
### 做一个反汇编器,指令集 opcode 的意义去哪查
|
||||
|
||||
**主流 CPU 架构的指令集手册**
|
||||
|
||||
根据你要反汇编的 CPU 架构,你需要查阅不同的手册:
|
||||
|
||||
**1. x86 / x64 架构 (Intel / AMD)**
|
||||
|
||||
这是最常见的桌面和服务器架构,也是大多数恶意软件和 Windows/Linux 程序所使用的架构
|
||||
|
||||
- **Intel 64 和 IA-32 架构软件开发人员手册**: 这是 x86 / x64 架构的黄金标准。它包含好几卷,其中:
|
||||
- **卷 2A、2B 和 2C**:详细描述了每条指令的 **操作码(Opcode)**、**汇编助记符(Mnemonic)**、**操作数(Operands)**、以及指令功能
|
||||
- **AMD 64 架构程序员参考手册**: 如果你需要支持 AMD 的处理器,这套手册是必不可少的。它与 Intel 的手册内容高度兼容,但也有一些 AMD 特有的指令
|
||||
|
||||
**2. ARM 架构**
|
||||
|
||||
ARM 架构主导着移动设备(手机、平板)和嵌入式系统,现在也越来越多地用于服务器和桌面
|
||||
|
||||
- **ARM 架构参考手册**: 这是 ARM 架构的官方文档。它详细介绍了 ARM 和 Thumb 指令集,以及各种架构特性,如 AArch64、AArch32 等
|
||||
|
||||
**3. MIPS 架构**
|
||||
|
||||
MIPS 在早期的嵌入式设备、路由器和游戏机(如 PS2)中非常流行
|
||||
|
||||
- **MIPS32/MIPS64 架构参考手册**:这是 MIPS 架构的官方手册,提供了所有指令的详细信息
|
||||
@@ -0,0 +1,60 @@
|
||||
### 怎么识别指令跳转条件和内存访问
|
||||
|
||||
**如何识别指令跳转条件**
|
||||
|
||||
在汇编语言中,跳转指令分为**无条件跳转**和**有条件跳转**
|
||||
|
||||
**1. 无条件跳转**
|
||||
|
||||
这是最简单的一种。它们总是会改变程序的执行流程
|
||||
|
||||
- **指令**:`jmp` (Jump)
|
||||
- **识别方法**:在反汇编代码中,`jmp` 指令后面通常跟着一个目标地址。它像一个程序里的 `goto` 语句,直接将控制权转移到另一个位置,没有其他条件
|
||||
|
||||
**2. 有条件跳转**
|
||||
|
||||
这些跳转指令依赖于 CPU 的**标志寄存器(Flags Register)**的状态。标志寄存器中的位(如零标志、符号标志、进位标志等)在执行算术或比较指令后会被设置
|
||||
|
||||
- **指令**:有条件跳转指令通常以字母 `j` 开头,后面跟着一个或两个字母来表示其条件
|
||||
- `je` (Jump if Equal):如果零标志(ZF)为1,则跳转。通常跟在 `cmp` 或 `test` 指令之后,用于判断两个值是否相等
|
||||
- `jne` (Jump if Not Equal):如果零标志(ZF)为0,则跳转
|
||||
- `jg` (Jump if Greater):如果大于则跳转(有符号)
|
||||
- `jl` (Jump if Less):如果小于则跳转(有符号)
|
||||
- `ja` (Jump if Above):如果大于则跳转(无符号)
|
||||
- `jb` (Jump if Below):如果小于则跳转(无符号)
|
||||
- **识别方法**:
|
||||
1. **寻找前置指令**:有条件跳转指令通常紧跟在**比较(`cmp`)**或**测试(`test`)**指令之后
|
||||
2. **分析标志位**:`cmp` 指令会执行一次减法操作,但不保存结果,只根据结果设置标志位。`test` 指令会执行一次逻辑与操作,也不保存结果,同样只设置标志位
|
||||
3. **理解逻辑**:当看到 `cmp eax, ebx` 后跟着 `je` 时,它的逻辑就等同于 C 语言的 `if (eax == ebx)`
|
||||
|
||||
**如何识别内存访问**
|
||||
|
||||
内存访问涉及程序从内存中读或写数据。在汇编代码中,这通常通过方括号 `[]` 来表示
|
||||
|
||||
**1. 直接内存访问**
|
||||
|
||||
这是最直接的方式,通常是访问全局变量或特定地址
|
||||
|
||||
- **格式**:`mov eax, [0x401000]`
|
||||
- **识别方法**:指令的操作数直接是一个十六进制地址,且用方括号包围。这表示从这个地址读取数据。例如,`mov eax, [0x401000]` 的意思是把内存地址 `0x401000` 处的值加载到 `eax` 寄存器
|
||||
|
||||
**2. 间接内存访问**
|
||||
|
||||
间接访问更为常见,它通过寄存器中存储的地址来访问内存
|
||||
|
||||
- **格式**:`mov eax, [ebx]`
|
||||
- **识别方法**:方括号中是一个寄存器。这表示程序从寄存器 `ebx` 中存储的地址读取数据。这在访问指针、数组元素或动态分配的内存时非常常见
|
||||
|
||||
**3. 相对内存访问**
|
||||
|
||||
这种方式结合了基址寄存器和偏移量
|
||||
|
||||
- **格式**:`mov eax, [ebx + 8]`
|
||||
- **识别方法**:方括号中包含一个基址寄存器(如 `ebx`)和一个数字偏移量。这通常用于访问结构体成员或栈上的局部变量。例如,`mov eax, [ebp-4]` 是一种非常常见的模式,它表示访问栈上**栈帧基址**(`ebp`)向下偏移 4 字节处的局部变量
|
||||
|
||||
**4. 复杂内存访问**
|
||||
|
||||
更复杂的访问模式包括索引寄存器和比例因子,通常用于访问数组
|
||||
|
||||
- **格式**:`mov eax, [ebx + esi * 4]`
|
||||
- **识别方法**:这表示一个数组访问,`ebx` 是数组的基地址,`esi` 是索引,`4` 是每个元素的大小(例如,一个 `int` 占 4 字节)。这等同于 C 语言的 `eax = array[esi]`
|
||||
@@ -0,0 +1,53 @@
|
||||
### 做一个沙箱,有什么需要重定向的
|
||||
|
||||
**1. 文件系统**
|
||||
|
||||
这是最基本的重定向。一个恶意程序通常会读写文件、创建新文件或删除现有文件。你必须让它以为自己在操作真实的文件系统,但实际上,所有这些操作都被隔离在一个虚拟的、临时的环境中
|
||||
|
||||
- **重定向读写操作**:
|
||||
- **拦截** `open`, `read`, `write`, `close` 等系统调用
|
||||
- 将所有对宿主机文件的访问重定向到沙箱内部的**虚拟文件系统**或一个特定的临时文件夹
|
||||
- 如果程序试图访问关键系统文件(如 `C:\Windows\System32`),应拒绝其请求或返回一个虚拟的、无害的版本
|
||||
|
||||
**2. 注册表**
|
||||
|
||||
在 Windows 环境下,注册表是恶意软件进行持久化和存储配置的关键
|
||||
|
||||
- **重定向注册表操作**:
|
||||
- **拦截** `RegCreateKey`, `RegSetValue`, `RegOpenKey` 等注册表 API 调用
|
||||
- 将所有写入操作重定向到沙箱内存中的一个**虚拟注册表**
|
||||
- 当程序读取注册表时,先从虚拟注册表中查找,如果不存在,再从宿主机注册表读取,但绝不允许它修改宿主机注册表
|
||||
|
||||
**3. 网络通信**
|
||||
|
||||
恶意软件的另一大特征就是与外部服务器进行通信,例如下载其他恶意模块、发送窃取的数据或接收指令
|
||||
|
||||
- **重定向网络连接**:
|
||||
- **拦截** `socket`, `connect`, `send`, `recv` 等网络系统调用
|
||||
- 你可以选择将所有网络连接**完全禁止**,或者将它们重定向到一个**本地代理**,由代理来记录和控制所有流量
|
||||
- 理想的沙箱会**伪造 DNS 解析**,将恶意域名指向一个本地 IP,然后让代理服务器来处理这些连接,从而捕获所有网络数据包,而不让它们真正离开宿主机
|
||||
|
||||
**4. 进程与线程**
|
||||
|
||||
许多恶意软件会创建新进程、注入代码或修改其他进程的内存以隐藏自身
|
||||
|
||||
- **重定向进程操作**:
|
||||
- **拦截** `CreateProcess`, `CreateThread`, `InjectProcess`, `WriteProcessMemory` 等 API 调用
|
||||
- 沙箱应监控所有新创建的进程,确保它们也运行在受控环境中。
|
||||
- 对于进程注入,你可以直接**拒绝**这类行为,或者将其重定向到一个虚拟的环境,以防止对宿主机上其他进程的感染
|
||||
|
||||
**5. 系统信息**
|
||||
|
||||
为了躲避沙箱,恶意软件会查询系统信息来判断自己是否被监控。你需要对这些查询进行欺骗
|
||||
|
||||
- **重定向系统信息查询**:
|
||||
- **拦截** `cpuid`, `GetSystemInfo` 等 API 调用
|
||||
- 当恶意程序查询 CPU 制造商、内存大小或系统时间等信息时,你需要返回**虚假的值**,让它以为自己在一个真实的物理机环境中。比如,将 `VMware` 制造商字符串替换为 `GenuineIntel`,或者让系统时间流逝得更慢
|
||||
|
||||
**6. 渲染与屏幕**
|
||||
|
||||
某些恶意软件可能会尝试截屏或与桌面环境交互
|
||||
|
||||
- **重定向屏幕操作**:
|
||||
- **拦截** `GetDC`, `BitBlt` 等与屏幕渲染相关的 API
|
||||
- 你可以将截屏数据重定向到沙箱的临时文件,而不是让它访问真实的桌面内容
|
||||
@@ -0,0 +1,39 @@
|
||||
### Linux 程序分为哪几个段
|
||||
|
||||
**1. 代码段**
|
||||
|
||||
代码段也叫文本段,**存储的是程序的可执行机器码**。它通常是只读的,这样可以防止程序意外地修改自身的指令,从而增强了程序的健壮性。由于代码段是只读的,当有多个进程执行同一个程序时(比如多个用户同时运行 `ls` 命令),它们可以共享同一个代码段,从而节省内存
|
||||
|
||||
**2. 数据段**
|
||||
|
||||
数据段主要用于**存储已初始化的全局变量和静态变量**。这个段在程序加载到内存时就被分配,并在程序运行期间一直存在。它的内容是可读写的,允许程序修改这些变量的值
|
||||
|
||||
例如,在 C 语言中:
|
||||
|
||||
```c
|
||||
int global_var = 10; // 存储在数据段
|
||||
static int static_var = 20; // 存储在数据段
|
||||
```
|
||||
|
||||
**3. 未初始化数据段**
|
||||
|
||||
BSS(Block Started by Symbol)段用于**存储未初始化的全局变量和静态变量**。与数据段不同,这个段在程序加载时并不会占用实际的磁盘空间。操作系统会在程序加载时为其分配内存,并自动将所有值初始化为零。这样做可以节省可执行文件的大小
|
||||
|
||||
例如,在 C 语言中:
|
||||
|
||||
```C
|
||||
int uninitialized_global; // 存储在 BSS 段
|
||||
static int uninitialized_static; // 存储在 BSS 段
|
||||
```
|
||||
|
||||
**4. 堆段**
|
||||
|
||||
堆段用于**动态内存分配**。当程序在运行时需要额外内存时(例如,使用 C 语言中的 `malloc()` 或 C++ 中的 `new`),这些内存就会在堆上分配。堆是自下而上增长的,通常由低地址向高地址扩展。程序的生命周期中,堆的大小是可变的
|
||||
|
||||
**5. 栈段**
|
||||
|
||||
栈段用于**存储局部变量、函数参数和返回地址**。它是一种后进先出(LIFO)的数据结构。每次调用函数时,新的栈帧(stack frame)就会被推入栈中,包含了该函数的局部变量和参数。函数调用结束后,该栈帧就会被弹出。栈段是自上而下增长的,通常由高地址向低地址扩展
|
||||
|
||||
**6. 命令行参数和环境变量段**
|
||||
|
||||
这个段通常位于栈段的上方,用于**存储传递给程序的命令行参数和环境变量**。例如,当你在终端运行 `ls -l` 时,`-l` 这个参数就会被存储在这个段中
|
||||
@@ -0,0 +1,14 @@
|
||||
### ESP 定律原理知道吗
|
||||
|
||||
**ESP 定律的原理**
|
||||
|
||||
ESP 定律的核心思想是:在正常的函数调用和返回过程中,**栈指针(ESP 在 32 位系统,RSP 在 64 位系统)在进入函数时和离开函数时是相等的**
|
||||
|
||||
或者更准确地说,`call` 指令在将返回地址压入栈后,会将 ESP 减小。当函数返回(通过 `ret` 指令)时,`ret` 指令会弹出返回地址,并自动调整 ESP,使其回到 `call` 指令执行之前的状态
|
||||
|
||||
**ESP 定律的两个核心结论:**
|
||||
|
||||
1. 在函数内部,只要没有发生新的函数调用或异常,`push` 和 `pop` 指令的操作是**对称的**。也就是说,每一个 `push` 都有一个对应的 `pop`,所以 ESP 的最终变化是零
|
||||
2. 在函数返回时,`ret` 指令会正确地将控制权返回给调用方,其前提是**函数执行完毕时 ESP 寄存器的值正好指向 `call` 指令压入的返回地址**
|
||||
|
||||
如果一个函数在返回时,ESP 的值不是返回地址,那么 `ret` 指令会从一个错误的位置取地址,导致程序崩溃或跳转到不可预知的地址
|
||||
@@ -0,0 +1,42 @@
|
||||
### C++ 程序怎么去逆向找虚表
|
||||
|
||||
**虚表(vtable)的编译后形态**
|
||||
|
||||
在 C++ 中,当一个类包含虚函数时,编译器会做两件事:
|
||||
|
||||
1. 为该类生成一个**虚表**。这个虚表本质上是一个**函数指针数组**。数组中的每个元素都指向该类中一个虚函数的实际地址
|
||||
2. 为该类的每个对象(实例)在内存布局的开头添加一个隐藏的**虚表指针(vptr)**。这个指针指向该类的虚表
|
||||
|
||||
因此,逆向寻找虚表的过程,就是找到这个隐藏的 `vptr`,并顺藤摸瓜找到它指向的虚表
|
||||
|
||||
**逆向寻找虚表的三种主要方法**
|
||||
|
||||
**方法一:寻找虚表指针(vptr)的初始化**
|
||||
|
||||
这是最直接也最常用的方法。虚表指针通常在对象的构造函数中被初始化
|
||||
|
||||
1. **定位构造函数**:在 C++ 程序中,当你使用 `new` 关键字创建一个对象时,编译器会调用该类的构造函数。在反汇编代码中,你会看到对 `new` 操作的封装,然后是对构造函数的调用
|
||||
2. **查找虚表指针的赋值**:在构造函数的开头,通常会有类似 `mov [ecx], offset class_vtable` 或 `mov [this], offset class_vtable` 的指令(取决于调用约定和寄存器)
|
||||
- `this` 指针(通常在 `ecx` 或 `rcx` 寄存器中)指向新创建的对象
|
||||
- `offset class_vtable` 是虚表的地址,这是一个常量,通常由链接器确定
|
||||
- 这条指令的含义是:将虚表的地址存入对象的第一个成员变量中,这个变量就是 `vptr`
|
||||
3. **识别虚表**:一旦你找到了虚表的地址,你就可以跳转到这个地址,IDA Pro 或 Ghidra 通常会将其识别为数据段中的一个指针数组
|
||||
|
||||
**方法二:从虚函数的调用处反推**
|
||||
|
||||
如果你无法直接找到构造函数,可以从虚函数的调用点入手。虚函数的调用通常是通过 `vptr` 进行的间接调用
|
||||
|
||||
1. **识别间接调用**:寻找类似 `call [eax+offset]` 或 `call [vptr]` 的指令
|
||||
- `eax` 通常包含 `this` 指针,指向对象实例
|
||||
- `offset` 是一个数字,通常是 `vptr` 在对象内存布局中的偏移量(在单继承情况下通常是 `0`)
|
||||
- 这条指令的含义是:从 `eax` 指向的内存位置(即 `vptr`)获取一个地址,然后再加上一个偏移量,最终跳转到那个地址执行代码
|
||||
2. **分析偏移量**:通过观察偏移量,你可以判断这是虚表中的第几个虚函数。例如,`call [eax+8]` 意味着调用虚表中的第二个函数(因为每个函数指针通常是 4 或 8 字节)
|
||||
3. **反推虚表**:找到 `vptr` 的地址,然后跳转到该地址。你可以向上或向下遍历这个指针数组,来识别其他的虚函数
|
||||
|
||||
**方法三:利用 IDA Pro 的自动化识别功能**
|
||||
|
||||
IDA Pro 和 Ghidra 这样的高级反汇编器拥有强大的自动化分析能力,可以极大地简化寻找虚表的过程
|
||||
|
||||
1. **启用 C++ RTTI 分析**:在 IDA 的 `Options -> General -> IDA` 窗口中,确保 C++ 的**RTTI (Run-Time Type Information)** 分析选项已启用。这能帮助 IDA 识别类和虚表结构
|
||||
2. **函数识别**:让 IDA 自动分析程序,它通常会尝试识别标准库中的虚表
|
||||
3. **数据段搜索**:在 IDA 的数据段(通常是 `.data` 或 `.rdata`)中搜索,寻找**指针数组**。如果一个数组中的元素都是函数地址,并且这些函数之间有逻辑关联,那它很可能就是一个虚表。IDA 通常会把这些识别出来的虚表标记为 `vftable` 或类似的名字
|
||||
@@ -0,0 +1,41 @@
|
||||
### 进程隐藏技术是什么,如何检测
|
||||
|
||||
**常见的进程隐藏技术**
|
||||
|
||||
这些技术通常分为两大类:用户态隐藏和内核态隐藏。
|
||||
|
||||
**1. 用户态进程隐藏**
|
||||
|
||||
这类技术在用户态运行,主要通过 Hook(钩取)或篡改 API 调用来欺骗进程列表工具
|
||||
|
||||
- **API Hooking**:恶意软件可以 Hook 掉用于枚举进程的 Windows API 函数,例如 `CreateToolhelp32Snapshot`、`Process32First` 和 `Process32Next`。当任务管理器或其他进程查看器调用这些函数时,Hook 函数会拦截调用,并过滤掉恶意进程的信息,只返回其余正常进程的列表
|
||||
- **直接修改内存**:一些恶意软件会直接在内存中找到任务管理器或进程列表工具的进程列表,然后将自身从这个列表中移除。这种方法更具侵略性,但成功率较低,因为不同版本的操作系统或工具,其内存结构可能不同
|
||||
- **进程名伪装**:这是最简单、最基础的伪装。恶意软件会将自己的进程名修改成系统关键进程的名字,例如 `svchost.exe` 或 `lsass.exe`。这虽然不能隐藏进程,但可以有效迷惑用户和安全人员。
|
||||
|
||||
**2. 内核态进程隐藏**
|
||||
|
||||
这类技术通常通过加载驱动程序来获得内核权限,从更底层的数据结构中隐藏自己,使其更难被检测
|
||||
|
||||
- **DKOM (Direct Kernel Object Manipulation)**:这是最强大的进程隐藏技术之一。在 Windows 内核中,所有进程的信息都存储在一个双向链表 `PsActiveProcessHead` 中。恶意驱动程序可以在内核态直接操作这个链表,将自身的进程对象从链表中移除。由于任务管理器、进程查看器等工具最终都依赖这个链表来获取进程信息,这种方法可以从根本上隐藏进程
|
||||
- **修改进程对象属性**:除了从链表中移除,恶意驱动还可以直接修改进程对象(`EPROCESS` 结构)中的某些标志,使其看起来像是已终止或不活跃的进程,从而欺骗依赖于这些标志的工具
|
||||
- **内核 API Hooking**:类似于用户态 Hooking,恶意驱动程序可以 Hook 掉内核中用于进程枚举的函数,例如 `PsLookupProcessByProcessId` 或 `ZwQuerySystemInformation`。这种 Hook 更加底层,也更难被发现
|
||||
|
||||
**如何检测进程隐藏技术**
|
||||
|
||||
检测进程隐藏是一个复杂的任务,需要使用多层次的方法和工具
|
||||
|
||||
**1. 跨进程检测**
|
||||
|
||||
- **进程列表比对**:从两个或更多不同的数据源获取进程列表,然后进行比对。例如,同时使用任务管理器和 Sysinternals 工具套件中的 `Process Explorer`。如果某个进程在一个列表中出现,而在另一个列表中没有,那么它很可能被隐藏了
|
||||
- **命令行工具**:使用命令行工具(如 `tasklist` 或 `wmic`)获取进程列表,并将其结果与图形化工具进行比对
|
||||
|
||||
**2. 底层数据结构检查**
|
||||
|
||||
- **利用 DKOM 的逆向检测**:反恶意软件工具可以在内核态直接遍历 `PsActiveProcessHead` 链表,并与通过 `ZwQuerySystemInformation` 等高层 API 获取的进程列表进行比对。如果链表中的某个进程没有出现在 API 返回的列表中,就说明存在 DKOM 隐藏
|
||||
- **检查 `EPROCESS` 结构**:反恶意软件工具可以检查 `EPROCESS` 结构中与隐藏相关的标志,例如进程状态和父进程 ID,来识别异常情况
|
||||
|
||||
**3. 行为分析**
|
||||
|
||||
- **网络连接监控**:即使进程被隐藏,它仍然需要进行网络通信。通过监控所有网络连接,并将其与已知的进程列表进行比对,可以发现那些没有对应进程的异常网络活动
|
||||
- **文件句柄和互斥量**:恶意进程可能会创建文件句柄或互斥量。通过枚举这些系统资源,可以发现那些属于隐藏进程的资源
|
||||
- **CPU 使用率和内存占用**:即使进程被隐藏,它仍然会占用 CPU 和内存。通过监控系统的整体资源使用情况,可以识别出那些没有对应进程的异常资源消耗
|
||||
@@ -0,0 +1,34 @@
|
||||
### 如果多进程下,A 进程的 Source 触发到了 B 进程的 sink 点,如何溯源
|
||||
|
||||
**1. 识别并关联进程间通信(IPC)**
|
||||
|
||||
首先,你需要将进程 A 的 `source` 和进程 B 的 `sink` 关联起来
|
||||
|
||||
- **监控 IPC 调用**:在两个进程中,同时监控所有 IPC 相关的系统调用。在 Linux 上,这可能包括 `pipe()`, `socket()`, `shmget()`, `msgget()` 等。在 Windows 上,这可能是命名管道、共享内存的 API 调用
|
||||
- **记录数据流**:不仅要记录 IPC 调用,还要记录通过 IPC 传递的**数据内容**。这是将 `source` 和 `sink` 联系起来的关键。例如,如果进程 A 通过管道写入了一个特定的恶意数据,你需要记录下这个数据,然后追踪它是否被进程 B 从管道中读出
|
||||
- **使用动态分析工具**:利用动态分析工具来自动化这个过程
|
||||
- **Frida/Ptrace**:你可以编写脚本,利用 Frida 或 Ptrace 这样的动态插桩框架,在两个进程中同时 Hook 所有 IPC 相关的函数
|
||||
- **eBPF**:在 Linux 上,eBPF 是一个强大的工具。你可以编写 eBPF 程序,在内核层面监控所有进程间的通信,并记录下通信的数据和进程 ID。这比用户态 Hooking 更稳定、更难以被绕过
|
||||
|
||||
**2. 构建跨进程的控制流图**
|
||||
|
||||
传统的控制流图只在单个进程内工作。要解决多进程溯源问题,你需要构建一个**跨进程的、依赖于 IPC 事件的控制流图**
|
||||
|
||||
- **进程内 CFG**:首先,分别构建进程 A 和进程 B 的独立控制流图
|
||||
- **跨进程边(Inter-Process Edges)**:在两个 CFG 之间,根据 IPC 事件添加“边”
|
||||
- 当进程 A 执行 `write()` 系统调用时,在 CFG 中添加一条从该 `write()` 指令到进程 B 的 `read()` 系统调用的边。这条边代表了数据的流动
|
||||
- 这条边需要包含**时间戳**和**数据内容**,以确保因果关系的正确性。
|
||||
|
||||
通过这种方式,你可以将 A 进程的 `source` 点和 B 进程的 `sink` 点在同一个图中连接起来,从而实现完整的溯源
|
||||
|
||||
**3. 自动化与工具支持**
|
||||
|
||||
手动进行上述分析几乎是不可能的。你需要依赖强大的自动化工具
|
||||
|
||||
- **进程监控工具**:
|
||||
- **Linux**:`strace` 可以追踪系统调用。`ltrace` 可以追踪库函数调用。但它们只对单个进程有效,你需要同时对两个进程使用
|
||||
- **Windows**:Sysinternals 的 `Procmon` 是一个强大的工具,可以记录所有进程的系统调用和文件/注册表操作
|
||||
- **动态污点分析**:
|
||||
- **Taint Analysis** 是一种强大的技术,它可以标记“不干净”(untainted)的数据,并追踪其在程序中的传播
|
||||
- 你可以将进程 A 的 `source` 数据标记为“污点”。然后,追踪这个污点数据在进程 A 内的传播。当它通过 IPC 传递给进程 B 时,这个污点也会被传递过去。最终,如果污点数据到达了进程 B 的 `sink` 点,你就可以得到完整的溯源路径
|
||||
- 这通常需要修改虚拟机监视器(VMM)或使用专门的动态分析框架来实现
|
||||
@@ -0,0 +1,35 @@
|
||||
### JNDI 如何做 Hook
|
||||
|
||||
**1. 使用 Java Agent 动态修改字节码**
|
||||
|
||||
这是最强大和最通用的 Hook 方法。Java Agent 可以在不修改源代码的情况下,在 JVM 运行时动态地修改类的字节码
|
||||
|
||||
- **原理**:创建一个 Java Agent,并在 JVM 启动时通过 `-javaagent` 参数加载它。在 Agent 的 `premain` 或 `agentmain` 方法中,你可以使用 **ASM**、**Javassist** 或 **Byte Buddy** 等字节码操作库,找到 `InitialContext.lookup(name)` 所在的类和方法
|
||||
- **Hook 实现**:找到目标方法后,可以修改它的字节码,在其原始逻辑执行前或执行后插入你自己的代码
|
||||
- **插入安全检查**:在 `lookup` 方法的开头,插入一段代码来检查传入的 URL。你可以判断 URL 是否符合预设的白名单,或者直接拒绝所有远程 JNDI 请求
|
||||
- **记录日志**:将 `lookup` 方法的参数和调用堆栈记录下来,以便进行审计
|
||||
- **修改返回对象**:如果 URL 被判定为恶意,你可以修改 `lookup` 方法的返回值为一个安全的对象,而不是让其继续进行远程查找
|
||||
- **优点**:非常灵活,可以 Hook 任何类的任何方法,无需访问源代码
|
||||
- **缺点**:需要深入理解 Java 字节码,且实现起来比较复杂。
|
||||
|
||||
**2. 使用动态代理**
|
||||
|
||||
动态代理是一种更高级的 Hook 方法,它通过 Java 的反射机制来创建接口的代理对象
|
||||
|
||||
- **原理**:如果你知道应用程序使用的是某个 JNDI 接口(例如 `Context`),你可以创建一个代理对象,这个代理对象会实现相同的接口,并在所有方法调用时,将调用转发给你自己的处理逻辑
|
||||
- **Hook 实现**:
|
||||
1. 找到应用程序创建 `InitialContext` 的地方
|
||||
2. 用你自己的代理类替换 `InitialContext` 的实例
|
||||
3. 在代理类中,拦截 `lookup(name)` 方法的调用
|
||||
4. 在 `lookup` 方法的实现中,你可以先执行安全检查,然后再决定是否调用原始的 `InitialContext.lookup(name)`
|
||||
- **优点**:不需要字节码操作,相对简单
|
||||
- **缺点**:只能 Hook 接口,对于没有实现接口的类不起作用。此外,需要修改应用程序的某些部分来插入代理,不像 Java Agent 那样完全透明
|
||||
|
||||
**3. 修改 JVM 参数**
|
||||
|
||||
这是最简单的 Hook 方法,但功能也最有限
|
||||
|
||||
- **原理**:一些 JVM 实现了特殊的参数来控制 JNDI 的行为。例如,在一些版本的 Java 中,可以通过设置 `com.sun.jndi.rmi.object.trustURLCodebase=false` 来阻止 RMI 客户端加载远程对象
|
||||
- **Hook 实现**:只需在 JVM 启动命令中添加这些参数即可
|
||||
- **优点**:非常简单,无需编写代码
|
||||
- **缺点**:依赖于特定的 JVM 版本和参数,无法进行细粒度的控制,也不能用于日志记录等目的
|
||||
@@ -0,0 +1,9 @@
|
||||
### .data 段存放哪些数据
|
||||
|
||||
`.data` 段,即数据段,主要存放**已经初始化的全局变量和静态变量**
|
||||
|
||||
当编译器编译程序时,如果发现一个全局变量或静态变量被赋予了一个非零的初始值(比如 `int global_var = 10;`),那么这个变量的值就会被存储在`.data`段中。这个段在可执行文件(比如`.exe` 或可执行的 ELF 文件)中是实际存在的,并占用文件空间。当程序加载到内存时,操作系统的加载器会把这部分数据原封不动地加载到内存中
|
||||
|
||||
- **例子**:
|
||||
- `int initialized_global = 100;`
|
||||
- `static char static_string[] = "Hello World";`
|
||||
@@ -0,0 +1,11 @@
|
||||
### .bss 段存放哪些数据
|
||||
|
||||
`.bss` 段,即未初始化数据段(**B**lock **S**tarted by **S**ymbol),主要存放**未初始化的全局变量和静态变量**
|
||||
|
||||
`.bss`段的特殊之处在于,它在可执行文件中**不占用任何实际的磁盘空间**。它只在可执行文件中有一个占位符,告诉操作系统在加载程序时需要为这块区域分配多大的内存。当程序被加载到内存后,操作系统会为 `.bss` 段分配一片连续的内存空间,并且会**自动将其所有字节初始化为零**
|
||||
|
||||
这种设计是为了节省可执行文件的大小。如果一个程序有很多未初始化的全局变量,将它们全部写进文件会非常浪费空间,因为它们的值都是已知的(默认是0)
|
||||
|
||||
- **例子**:
|
||||
- `int uninitialized_global;`
|
||||
- `static char static_array[1024];`
|
||||
@@ -0,0 +1,36 @@
|
||||
### 函数调用时的流程,参数如何传入以及寄存器、栈的变化
|
||||
|
||||
**函数调用前的准备 (Caller)**
|
||||
|
||||
在调用函数前,调用方(caller)会进行以下准备:
|
||||
|
||||
1. **参数传递**:
|
||||
- **寄存器优先**:对于前几个参数(通常是前4个,具体数量取决于调用约定),它们会被放入特定的通用寄存器中。在 x64 `fastcall` 约定下,参数会依次放入 **RCX, RDX, R8, R9** 寄存器。这种方式非常快,因为它避免了昂贵的内存操作
|
||||
- **栈传递**:如果参数数量超过了寄存器的限制,剩下的参数就会被从右到左(或从左到右,取决于具体约定)压入栈中
|
||||
2. **栈帧对齐**:为了保证性能,特别是对于一些高级指令集(如 SSE、AVX),栈帧需要对齐到特定的字节边界(通常是16字节)。调用方会确保在调用 `call` 指令前,栈指针 **RSP** 是对齐的
|
||||
3. **调用指令**:最后,调用方会执行 `call` 指令。`call` 指令有两个主要作用:
|
||||
- 将**下一条指令的地址**(即函数的返回地址)压入栈中
|
||||
- 跳转到被调用函数(callee)的入口地址
|
||||
|
||||
**函数执行过程中的变化 (Callee)**
|
||||
|
||||
一旦 `call` 指令将控制权转移给被调用函数(callee),它会做以下几件事:
|
||||
|
||||
1. **保存旧栈帧**:函数的第一条指令通常是 `push rbp`。这将调用方函数的基址寄存器 **RBP** 的值压入栈中,保存了调用方的栈帧信息
|
||||
2. **建立新栈帧**:接下来,函数会执行 `mov rbp, rsp`。这会将栈指针 **RSP** 的当前值复制到 **RBP**,从而建立起当前函数的栈帧。从现在开始,所有局部变量和参数都可以通过 **RBP** 加上或减去一个偏移量来访问
|
||||
3. **局部变量分配**:如果函数有局部变量,它会通过 `sub rsp, [size]` 指令在栈上分配空间。这个操作会使栈指针 **RSP** 向低地址方向移动,为局部变量腾出空间
|
||||
4. **保存非易失性寄存器**:
|
||||
- **易失性寄存器(Volatile / Caller-saved)**:`RAX`, `RCX`, `RDX`, `R8`-`R11`等。这些寄存器被认为是临时的,调用方**不指望**它们在函数返回后保持原值
|
||||
- **非易失性寄存器(Non-volatile / Callee-saved)**:`RBX`, `RBP`, `RDI`, `RSI`, `R12`-`R15`等。这些寄存器被认为需要保持其值不变。如果被调用函数需要使用它们,就必须在使用前将它们的值压入栈中,并在返回前恢复
|
||||
5. **执行函数主体**:现在,函数开始执行其核心逻辑。它可以使用传递进来的参数(通过寄存器或栈),也可以使用自己栈上的局部变量
|
||||
|
||||
**函数返回时的清理 (Callee & Caller)**
|
||||
|
||||
当函数执行完毕,准备返回时,会进行以下清理工作:
|
||||
|
||||
1. **恢复栈指针**:函数会执行 `mov rsp, rbp`。这个指令会将 **RSP** 的值恢复到进入函数时的状态,从而**释放所有局部变量**所占用的栈空间
|
||||
2. **恢复旧栈帧**:接着,函数会执行 `pop rbp`。这会将调用方保存的 **RBP** 值从栈中弹出并恢复到 **RBP** 寄存器中,从而恢复到调用方的栈帧
|
||||
3. **返回指令**:最后,函数执行 `ret` 指令。`ret` 指令的作用是:
|
||||
- 从栈中弹出返回地址
|
||||
- 将 **RIP**(指令指针寄存器)的值设置为弹出的返回地址,从而将程序的控制权交还给调用方的下一条指令
|
||||
4. **参数清理**:在某些调用约定(如 `cdecl`)中,调用方需要负责清理栈上用于参数传递的空间。然而在 `fastcall` 等现代约定中,由于参数主要通过寄存器传递,这个步骤变得简化。如果参数是通过栈传递的,`ret` 指令后面通常会带一个立即数,告诉 CPU 在返回前额外弹出多少字节的栈空间
|
||||
@@ -0,0 +1,46 @@
|
||||
### 解释程序的编译和链接,编译的过程中会有哪些操作
|
||||
|
||||
**编译:从源代码到目标代码**
|
||||
|
||||
**编译(Compilation)** 是一个多阶段的过程,它将我们用高级语言(如 C、C++)编写的源代码,转换成机器能理解的低级代码。这个过程通常由编译器(如 GCC, Clang)完成,可以细分为以下几个阶段:
|
||||
|
||||
**1. 预处理 (Preprocessing)**
|
||||
|
||||
**预处理器**是编译过程的第一个阶段。它的主要工作是处理源代码中的预处理指令,这些指令以 `#` 开头
|
||||
|
||||
- **头文件包含**:`#include <stdio.h>` 指令会将 `stdio.h` 文件的内容完整地复制到当前文件中
|
||||
- **宏展开**:`#define PI 3.14159` 会将所有出现的 `PI` 替换为 `3.14159`
|
||||
- **条件编译**:`#ifdef DEBUG` 和 `#endif` 这类指令会根据特定条件决定是否编译某段代码
|
||||
|
||||
预处理阶段完成后,会生成一个**`.i` 文件**,它是一个纯文本文件,包含了所有展开后的代码,没有任何 `#` 指令
|
||||
|
||||
**2. 编译 (Compiling)**
|
||||
|
||||
在这个阶段,**编译器**开始真正的工作。它会检查预处理后的 `.i` 文件,进行语法分析和语义分析,并将其转换成**汇编代码**
|
||||
|
||||
- **语法分析**:检查代码是否符合语言的语法规则,比如括号是否匹配,分号是否遗漏
|
||||
- **语义分析**:理解代码的含义,比如变量是否已声明,类型是否匹配
|
||||
- **中间代码生成**:生成一种与特定机器无关的中间代码
|
||||
- **代码优化**:对中间代码进行各种优化,例如删除不必要的代码、简化表达式等,以提高程序运行效率
|
||||
|
||||
这个阶段会生成一个**`.s` 文件**,里面全是人类可读的汇编语言指令
|
||||
|
||||
**3. 汇编 (Assembling)**
|
||||
|
||||
**汇编器**负责将汇编代码 `.s` 文件转换成机器码
|
||||
|
||||
- 它将每条汇编指令翻译成对应的二进制机器指令
|
||||
- 同时,它会处理程序中使用的各种符号(比如函数名和全局变量名),并在一个**符号表**中记录下它们的位置
|
||||
|
||||
汇编完成后,会生成一个**`.o` 文件**,也称为**目标文件**(Object File)。这个文件是二进制格式,但它还不是一个可执行文件,因为它可能依赖于其他文件中的函数或数据
|
||||
|
||||
**链接:将目标文件组装成可执行文件**
|
||||
|
||||
**链接(Linking)** 是编译过程的最后一个阶段。**链接器**(Linker)的工作是把一个或多个**目标文件**以及需要的**库文件**(Library Files)组合在一起,创建出一个完整的可执行文件
|
||||
|
||||
链接过程主要解决两个问题:
|
||||
|
||||
1. **符号解析(Symbol Resolution)**:当你在一个目标文件中调用另一个目标文件中的函数时,比如 `main.o` 调用了 `printf` 函数,编译器在 `main.o` 中只知道 `printf` 的名字,但不知道它在哪里。链接器会找到 `printf` 所在的库文件(比如 `libc.a`),并用 `printf` 函数的实际内存地址来替换 `main.o` 中对它的引用
|
||||
2. **地址重定位(Address Relocation)**:目标文件中的代码和数据地址都是相对于文件开头的。链接器会将它们重新分配到最终可执行文件的内存地址空间中,确保每个函数和变量都有一个唯一的、确定的地址
|
||||
|
||||
链接完成后,我们最终得到一个完整的、可以直接运行的**可执行文件**
|
||||
@@ -0,0 +1,53 @@
|
||||
### 说说 If/Else 语法树
|
||||
|
||||
**`If/Else` 语法树的结构**
|
||||
|
||||
一个典型的 `if/else` 语法树通常包含一个根节点和三个子节点,反映了 `if/else` 语句的三个核心部分:
|
||||
|
||||
1. **条件表达式(Condition Expression)**:这是 `if` 语句括号里的布尔表达式。它会被编译成判断条件是否为真的机器码。在语法树中,它通常是 `if/else` 根节点的一个子节点
|
||||
2. **真分支(True Branch)**:这是当条件表达式为真时执行的代码块。在语法树中,它是一个子树,根节点通常表示为 `Then` 或 `True`,其子节点代表了该代码块中的所有语句
|
||||
3. **假分支(False Branch)**:这是当条件表达式为假时执行的代码块(即 `else` 后面的部分)。它也是一个子树,根节点通常表示为 `Else` 或 `False`,其子节点代表了该代码块中的所有语句
|
||||
|
||||
如果是一个简单的 `if` 语句(没有 `else`),那么假分支节点可能为空或不存在
|
||||
|
||||
**举例说明**
|
||||
|
||||
让我们以一段简单的 C 语言代码为例,看看它的 `if/else` 语法树长什么样
|
||||
|
||||
**源代码:**
|
||||
|
||||
```c
|
||||
if (a > 5) {
|
||||
b = 10;
|
||||
} else {
|
||||
c = 20;
|
||||
}
|
||||
```
|
||||
|
||||
**对应的语法树结构:**
|
||||
|
||||
```
|
||||
If-Else
|
||||
/ | \
|
||||
/ | \
|
||||
条件 真分支 假分支
|
||||
/ | \
|
||||
> = =
|
||||
/ \ / \ / \
|
||||
a 5 b 10 c 20
|
||||
```
|
||||
|
||||
**节点解释:**
|
||||
|
||||
- **根节点**:`If-Else`,表示这是一个条件语句
|
||||
- **左子节点**:`>`,表示条件表达式是比较操作。它的子节点是 `a` 和 `5`,表示比较的是变量 `a` 和常量 `5`
|
||||
- **中间子节点**:`=`,表示真分支的代码是赋值操作。它的子节点是 `b` 和 `10`
|
||||
- **右子节点**:`=`,表示假分支的代码也是赋值操作。它的子节点是 `c` 和 `20`
|
||||
|
||||
**语法树的作用**
|
||||
|
||||
在编译过程中,生成语法树是一个非常关键的中间步骤。编译器利用这个树形结构来:
|
||||
|
||||
- **进行语法检查**:确保代码结构正确
|
||||
- **生成中间代码**:编译器可以遍历这棵树,将其转换成更低级别的代码表示,如三地址码
|
||||
- **进行代码优化**:例如,如果 `if` 语句的条件是一个常量,并且总是为真或假,编译器可以在编译时就删除掉永远不会执行的分支,从而优化代码
|
||||
@@ -0,0 +1,43 @@
|
||||
### 如何比较两个 C 函数的相似度
|
||||
|
||||
**1. 二进制层面比较**
|
||||
|
||||
这是最直接但最不健壮的方法,通常只作为初步筛选
|
||||
|
||||
- **字符串哈希 (MD5/SHA-256)**:这是最简单的方法。将函数编译后的机器码提取出来,然后计算其哈希值。
|
||||
- **优点**:速度快,可以快速识别完全相同的函数
|
||||
- **缺点**:非常脆弱。任何微小的改动,比如插入一条 `NOP` 指令、改变局部变量的顺序,都会导致哈希值完全不同。因此,它无法检测代码克隆或相似的代码
|
||||
- **模糊哈希 (Fuzzy Hashing)**:与传统的哈希算法不同,模糊哈希(如 **ssdeep** 或 **TLSH**)能够生成一个代表文件或代码块结构特征的哈希值。两个哈希值之间的距离可以用来衡量它们的相似度
|
||||
- **优点**:能够容忍代码中的小改动,可以发现有细微变化的函数
|
||||
- **缺点**:对于较大的代码重构(如改变控制流),效果不佳
|
||||
|
||||
**2. 结构层面比较**
|
||||
|
||||
这种方法比二进制比较更抽象,对编译器的影响和一些代码改动不敏感
|
||||
|
||||
- **控制流图 (Control Flow Graph, CFG) 比较**:
|
||||
- **方法**:将每个函数表示为一个控制流图,其中节点是基本块(一系列没有跳转的连续指令),边代表控制流。比较两个函数的相似度就变成了比较它们 CFG 的结构相似度。这通常通过图匹配算法来实现,比如子图同构或图编辑距离(graph edit distance)
|
||||
- **优点**:非常健壮。它对寄存器分配、指令顺序等改动不敏感。如果两个函数的逻辑结构相同,即使它们用不同的编译器编译,CFG 也会非常相似
|
||||
- **缺点**:算法复杂,计算量大,尤其是在面对大型函数时
|
||||
- **函数特征向量 (Function Feature Vectors)**:
|
||||
- **方法**:为每个函数提取一系列特征,并将其表示为一个向量。这些特征可以包括:
|
||||
- 基本块数量
|
||||
- 指令数量
|
||||
- 算术指令、逻辑指令、跳转指令的比例
|
||||
- 循环嵌套深度
|
||||
- 函数调用的数量和类型
|
||||
- **优点**:将函数简化为数值向量,可以使用简单的距离度量(如欧几里得距离或余弦相似度)进行快速比较,非常适合大规模数据集
|
||||
- **缺点**:丢失了结构信息。两个结构完全不同的函数可能会有相似的特征向量,反之亦然
|
||||
|
||||
**3. 语义层面比较**
|
||||
|
||||
这是最强大但也是最困难的方法,旨在比较函数的行为和功能,而不是其形式
|
||||
|
||||
- **抽象语法树 (Abstract Syntax Tree, AST) 比较**:
|
||||
- **方法**:将源代码或反编译代码解析成抽象语法树。比较两个函数的相似度就变成了比较它们 AST 的结构。这通常通过树编辑距离(tree edit distance)算法来实现
|
||||
- **优点**:高度抽象,对变量名、代码格式等变化完全免疫,能够准确反映代码的逻辑结构
|
||||
- **缺点**:依赖于高质量的反编译器,并且 AST 比较算法同样非常耗时
|
||||
- **污点分析 (Taint Analysis) 或数据流分析**:
|
||||
- **方法**:分析函数的输入如何影响其输出。如果两个函数在给定相同输入时产生相同的输出,并且内部数据流路径相似,它们就是相似的
|
||||
- **优点**:能够发现功能上完全相同的代码,即使它们的实现方式天差地别
|
||||
- **缺点**:非常复杂,很难自动化,且无法处理所有情况
|
||||
@@ -0,0 +1,33 @@
|
||||
### 什么情况下源代码与 IDA 反编译程序的代码差别很大
|
||||
|
||||
**1. 编译器优化级别很高**
|
||||
|
||||
现代编译器(如 GCC、Clang、MSVC)在优化程序性能时,会彻底改变代码的结构,使其变得对机器更友好,但对人来说却很难理解
|
||||
|
||||
- **循环展开 (Loop Unrolling)**:编译器会将一个简单的 `for` 循环展开成一长串重复的代码,以减少循环控制的开销。这会使得原本紧凑的循环逻辑在反编译代码中变得冗长且难以识别
|
||||
- **内联函数 (Function Inlining)**:为了消除函数调用的开销,编译器会将小型函数的代码直接插入到调用它的地方。这会使得原本独立的函数在反编译代码中“消失”,并融入到其他函数的逻辑里
|
||||
- **寄存器优化**:编译器会尽可能地将变量存储在 CPU 寄存器中,而不是内存。这会使得原本清晰的变量赋值和操作在反编译代码中变得像一系列杂乱的寄存器操作
|
||||
- **死代码消除和指令重排**:编译器会移除那些永远不会执行的代码,并重新排列指令以更好地利用 CPU 的流水线。这都会使反编译结果与源代码大相径庭
|
||||
|
||||
**2. 原始代码使用了复杂的语言特性**
|
||||
|
||||
一些高级语言的特性在编译后会产生非常独特的机器码,这给反编译带来了巨大挑战
|
||||
|
||||
- **多态和虚函数**:C++ 中的虚函数和继承机制通常依赖于虚函数表(vtable)。反编译器很难准确地重建类的层次结构和虚函数调用,你看到的可能只是一堆对地址和偏移量的复杂操作
|
||||
- **模板和泛型**:C++ 模板在编译时会实例化成多个独立的函数,每个函数对应一种数据类型。反编译器无法知道这些函数原本是模板,只会将它们视为独立的、名字可能被混淆的函数
|
||||
- **异常处理**:`try-catch` 块的实现非常复杂,通常涉及到隐藏的表格和栈展开机制。反编译工具很难将这些底层的跳转和数据表恢复成高级语言的 `try-catch` 结构
|
||||
|
||||
**3. 程序被混淆或加壳**
|
||||
|
||||
恶意软件或一些商业软件为了防止逆向分析,会使用各种代码混淆(obfuscation)技术或加壳(packing)
|
||||
|
||||
- **代码混淆**:
|
||||
- **控制流平坦化 (Control Flow Flattening)**:将函数原本的线性控制流打乱,通过一个大的 `switch` 语句或多个 `if/else` 块来控制程序的执行,使得反编译出来的代码变得像一个复杂的意大利面条式代码
|
||||
- **垃圾指令插入**:插入大量无用的指令,使得反编译工具和分析人员难以理解真正的代码逻辑
|
||||
- **间接跳转**:使用复杂的计算来确定跳转目标,而不是直接跳转
|
||||
- **加壳**:程序被压缩或加密,原始代码只有在运行时才会被解密和执行。IDA Pro 看到的只是一个加载器或解密器,而不是原始代码,除非你先脱壳
|
||||
|
||||
**4. 编译器不同或使用了特定编译器**
|
||||
|
||||
- 不同的编译器,甚至同一编译器的不同版本,都会产生不同的机器码
|
||||
- 某些编译器或工具链(如嵌入式系统编译器)可能会使用不寻常的调用约定或优化策略,这使得常见的反编译工具难以正确地识别函数参数和局部变量
|
||||
@@ -1 +1,20 @@
|
||||
# 网安面试题(涵盖护网、红队、逆向、二进制)
|
||||
|
||||
上万道安全面试题已经全部为您划分好,适用于网络安全所有岗位!!!
|
||||
|
||||
HR:请问…………
|
||||
|
||||
我:叽里咕噜说啥呢,看看八股文上写了没
|
||||
|
||||
(Summary.md 是目录噢!!)
|
||||
|
||||
**🙏 特别感谢名单**
|
||||
|
||||
在整理和完善本项目的过程中,以下朋友给予了宝贵的帮助与支持,在此表示诚挚的感谢!(排名不分先后)
|
||||
|
||||
- **@用户名1** —— 提供了大量安全面试题方向的补充
|
||||
- **@用户名2** —— 纠正了多个问题的答案与表述
|
||||
- **@用户名3** —— 贡献了真实面试题经验分享
|
||||
- **@用户名4** —— 对内容结构与目录提出改进建议
|
||||
|
||||
如果您也愿意参与本项目,欢迎通过 **微信: XR3327026244** 投稿面试题或反馈问题,我们会在后续版本中加入您的名字
|
||||
|
||||
Reference in New Issue
Block a user