跳转至

基础知识

逆向技术

分析恶意软件时,你可以采用两种基本技术。第一是静态分析,第二是动态分析。

静态分析使用软件工具检查可执行文件,而无需在汇编中运行实际反编译的指令。

动态分析使用反汇编器和调试器在实际运行恶意软件二进制文件时进行分析。目前市场上最受欢迎的工具叫做 IDA,它是一款多平台、多处理器的反汇编器和调试工具。目前市场上还有其他反汇编/调试工具,如 Hopper 反汇编器、OllyDbg 等。

反汇编器会将用汇编、C、C++等编写的可执行二进制文件转换成你可以调试和作的汇编语言指令。

逆向工程远不止于恶意软件分析。

恶意软件类型

恶意软件分为几类,下面我会简要介绍。

后门是一种恶意代码,嵌入计算机中,使远程攻击者能够在几乎没有或有时没有权限的情况下访问,从而在任何本地计算机上执行各种命令。

僵尸网络允许攻击者访问系统,但接收的指令不是来自一个远程攻击者,而是来自一个能够同时控制无限数量计算机的命令与控制服务器。

下载器不过是只有一个目的的恶意代码,那就是安装其他恶意软件。下载器通常在黑客最初入侵系统时安装。下载者随后安装额外软件来控制系统。

我们发现信息访问恶意软件会从计算机收集信息,直接发送给主机,比如键盘记录器或密码抓取器,通常用来获取各种非常敏感的在线账户访问权限。

有些恶意程序会启动其他恶意程序,使用非标准选项以增加访问权限或更强的隐蔽/隐藏技术,在渗透系统时使用。

最危险的恶意软件之一是 rootkit,它隐藏了自身和其他恶意软件的存在,使用户极难定位。rootkit 可以控诸如在 IP 扫描中隐藏 IP 等过程,使用户永远不会知道自己拥有直接连接到僵尸网络或其他远程计算机的套接字。

恐吓软件被用来诱骗用户购买额外软件,以虚假保护用户,而实际上根本没有任何真正的威胁。一旦用户付费将被欺骗的软件从计算机中移除,它就可以继续驻留,之后以改变的形式出现。

还有各种恶意软件会从目标机器发送垃圾邮件,攻击者通过向其他用户销售各种服务来赚取利润。

最后一种恶意软件形式是传统的蠕虫或病毒,它会自我复制并攻击其他计算机。

字节、字、双字等

内存是以字节为单位。一个字节是8位。两个字节称为字,两个字称为双字,即四字节(32位),四字节为八字节(64位)。

一个字节是8位,是2^8的幂次,即256。8位大小的二进制数是从0开始到255的256个值之一。

计算机中的每个内存字节都有其独特的地址。让我们回顾一下 Linux 中一个简单 Hello World 应用的反汇编说明,方法是在主函数上设置断点。我们将使用 GDB 调试器:

需要理解的是,0xffffd040 是 4 字节,是一个双字。正如我们在第六部分:十六进制数字系统中学到的,每个十六进制数字长度为 4 位,也称为半字节。0xffffd040,我们来看 0 的右边最右边的数字。在这个例子中,0(十六进制)是 4 位。如果我们看 40(十六进制),它长度为一个字节,即 8 位。如果我们看 d040,长度是两个字节或一个字。最后,ffffd040 是一个双字长,即 4 字节,长度为 32 位。地址开头的 0x 只是表示它是十六进制值。

计算机程序不过是存储在内存中的机器指令。32 位 CPU 从内存地址获取一个双字。双字是从内存读取并加载到 CPU 的 4 字节。执行完成后,CPU 会从指令指针获取内存中的下一条机器指令。

函数的栈

当程序执行一个函数时,会初始化一段栈空间使用,里面会存放函数参数、局部变量、返回地址等重要信息,其汇编代码大致结构如下:

GAS
section .text
global _start

_start:
    ; 函数调用的示例
    call example_function

example_function:
    push ebp          ; 保存调用者的rbp值
    mov ebp, esp      ; 设置当前函数的栈帧基址

    ; 函数体可以在这里执行任何操作
    ; 示例中没有实际操作

    pop ebp           ; 恢复调用者的rbp值
    ret               ; 返回到调用者

下面我们用一个程序来分析一下栈的变化

C
#include <stdio.h>

// 函数声明
int add(int a, int b);
int multiply(int x, int y);
void print_result(int value);

int main() {
    int num1;
    int num2;
    printf("Enter first number: ");
    scanf("%d", &num1);
    printf("Enter second number: ");
    scanf("%d", &num2);
    int result;

    printf("=== Starting main ===\n");
    result = add(num1, num2);        // 调用 add
    print_result(result);            // 调用 print_result
    printf("=== End of main ===\n");

    return 0;
}

int add(int a, int b) {
    int sum;
    printf("In add: a=%d, b=%d\n", a, b);
    sum = a + b;
    return multiply(sum, 2);         // 调用 multiply
}

int multiply(int x, int y) {
    int product;
    printf("In multiply: x=%d, y=%d\n", x, y);
    product = x * y;
    return product;
}

void print_result(int value) {
    char msg[32] = "Result is: ";
    printf("%s%d\n", msg, value);
}

VC++控制台程序入口点

在我们写C/C++程序时,默认都使用main函数作为主程序,但这是否就代表着程序入口点就是main呢?程序是否是从main函数开始执行的呢?下面我们来调试看一下

image-20260106173152748

查看调用堆栈,勾选显示外部代码,先入栈先出栈,明显在main函数前还调用了很多其他函数,我们来看一下

image-20260106173852838

从图中我们可以发现,真正的程序入口是mainCRTStartup(void * __formal),其调用了__scrt_common_main()

接下来我们看看__scrt_common_main函数的源代码

C
static __forceinline int __cdecl __scrt_common_main()
{
    __security_init_cookie();

    return __scrt_common_main_seh();
}

__cdecl关键字指定了函数的调用约定。__security_init_cookie函数用于初始化缓冲区溢出全局变量,用于在函数中检查缓冲区是否溢出。随后__scrt_common_main函数调用了__scrt_common_main_seh函数。

接下来看看__scrt_common_main_seh这个函数的代码。由于该函数代码较多们这里选取关键部分。

image-20260106174046498

_initterm_e用于C语言语法的全局数据和浮点寄存器的初始化,参数为函数数组

_initterm用于初始化C++全局对象和IO流等,调用对象的构造函数,这就是我们说的全局对象在main函数之前就初始化的原因。

随后__scrt_common_main_seh中调用了invoke_main函数

在invoke_main函数实现如下,它获取main函数所需的3个参数信息之后,当调用main函数时,便可以将_argc、_argv、env这3个全局变量作为参数,传递到main函数中。

C
static int __cdecl invoke_main()
{
    return main(__argc, __argv, _get_initial_narrow_environment());
}

Note

所以程序真正的入口为mainCRTStartup函数,其描述如下:

mainCRTStartup 是一个程序启动函数,用于在程序执行前进行一系列初始化操作。它是由编译器在生成可执行文件时自动添加的,主要用于设置运行时环境、初始化全局变量和对象,并最终调用程序的主函数(如 main、wmain、WinMain 或 wWinMain)。

主要功能

  1. 安全初始化:首先调用 _security_init_cookie 函数进行安全初始化,防止缓冲区溢出攻击。
  2. 运行时环境初始化:调用 _scrt_common_main_seh 函数,完成 C/C++ 运行时环境的初始化,包括全局变量和静态对象的构造函数调用。
  3. 调用主函数:在完成初始化后,调用程序的主函数(如 main 或 wmain),开始执行程序的主要逻辑。
  4. 异常处理:设置结构化异常处理(SEH)机制,用于捕获和处理可能发生的异常。

main函数的参数

在我们开发中,通常main函数都是没有写参数的,但我们在调用堆栈中发现main函数其实是存在三个参数的,即使我们开发没有写,编译器也会自动加上,下面我们来分析一下这些函数的含义

C
int main( int argc, char *argv[ ], char *envp[ ] );

自变量定义如下所示:

argc 包含 argv 后面的参数计数的整数。 argc 参数始终大于或等于 1。

argv 表示由杂注用户输入的命令行自变量的以 null 结尾的字符串的数组。 按照约定,argv[0] 是用于调用程序的命令。 argv[1] 是第一个命令行参数。 命令行的最后一个参数是 argv[argc - 1],并且 argv[argc] 始终为 NULL。

envp 可选 envp 参数是表示用户环境中设置的变量的字符串数组。 该数组由 NULL 项终止。 它可以声明为指向 char (char *envp[]) 的指针数组,也可以声明为一个指针来指向多个指向 char (char **envp) 的指针。 如果程序使用 wmain 而不是 main,请使用 wchar_t 数据类型而不是 char。

示例程序:

C++
// argument_definitions.cpp
// compile with: /EHsc
#include <iostream>
#include <string.h>

using namespace std;
int main( int argc, char *argv[], char *envp[] )
{
    bool numberLines = false;    // Default is no line numbers.

    // If /n is passed to the .exe, display numbered listing
    // of environment variables.
    if ( (argc == 2) && _stricmp( argv[1], "/n" ) == 0 )
         numberLines = true;

    // Walk through list of strings until a NULL is encountered.
    for ( int i = 0; envp[i] != NULL; ++i )
    {
        if ( numberLines )
            cout << i << ": "; // Prefix with numbers if /n specified
        cout << envp[i] << "\n";
    }
}

壳与脱壳

壳的类型

简单来说,程序壳是一种软件保护技术,通常用于对程序进行压缩、加密或反调试处理,以防止逆向工程和破解。

image-20260119215256614

IDA打开带有UPX壳的程序

程序壳可以由原理分几大类,比较常见的是以下的几种:

维度 压缩壳 加密壳 VM 壳
核心目的 减小体积 防静态分析 防动态 / 静态分析
逆向难度 低(自动工具可解) 中(需绕过反调试) 极高(需逆向 VM 指令集)
典型操作 压缩→解压 加密→解密→反调试 指令转换→VM 解释执行
脱壳关键 定位 OEP 后直接 dump 绕过反调试 + 修复 IAT 逆向 VM 指令集 + 代码重建

image-20260119215400575

压缩壳

  • 核心功能:压缩程序代码 / 数据以减小体积,运行时自动解压。
  • 反逆向原理:提前压缩代码,运行时解压,因为IDA静态分析不执行程序,因此无法看到原本程序。
压缩壳 核心特点 脱壳难度
UPX 逆向题目最常见的程序壳,简单题多数可以用程序自带脱壳命令(upx -d 程序地址)自动脱壳,部分魔改需要手动脱壳。 ★☆☆☆☆
ASPack 压缩率略高于 UPX,加入简单 CRC 校验防止篡改。用Aspackstripper自动脱壳。 不常见
PECompact 支持 “固实压缩”(将所有区段合并压缩),压缩后体积更小,但解压速度较慢。 不常见

下以64位程序的UPX壳为例

显示明显的红色字体:UPX(3.96)

img

首先可以尝试自动脱壳

Text Only
upx -d <file_name>

如果自动脱壳不成功,那么就需要手动脱壳,或者修复后再自动脱壳。不同位数的脱壳不一样。

手动脱壳参考:Windows逆向之脱壳-定位OEP-先知社区

加密壳

  • 核心功能:加密程序内容,需解密后才能运行,防静态分析。
  • 代表: Themida/WinLicense(强加密 + 反调试)、Armadillo、VMProtect(虚拟机加密)。
加密壳 核心特点 脱壳难度
Themida 集成 AES 加密 + 多层反调试(检测虚拟机、内存断点)+ 代码虚拟化(部分功能),商业级防护。 ★★★★★
Armadillo 支持 “硬件绑定”(用 CPU / 主板 ID 生成密钥),加密后脱离原始机器无法运行,适合软件授权。 ★★★★☆
Obsidium 轻量级加密 + 花指令混淆,反调试较弱但体积小,适合共享软件。 ★★★☆☆

VM壳

  • 核心功能:将程序指令转换为自定义虚拟机指令,逆向难度极高。
  • 代表:VMProtect、SVKP、TitanEngine。
VM 壳 核心特点 逆向难度
VMProtect 支持 x86/x64/ARM 架构,VM 指令集动态生成(每次加壳不同),集成反调试 + 反 dump。 ★★★★★
SVKP 轻量级 VM 壳,指令集简单(约 50 条),但混淆极强(大量无效指令)。 ★★★★☆
TitanEngine 企业级 VM 壳,支持 “VM 嵌套”(VM 引擎本身被另一层 VM 保护),几乎无法手动逆向。 ★★★★★