C/C++逆向开发
-
C/C++结构体大小如何计算?
结构体大小的计算涉及到内存对齐。编译器会根据成员变量的类型和默认的对齐字节数(通常是4字节或8字节,取决于平台和编译器设置)进行对齐。具体规则是:
- 结构体变量的起始地址能够被其最宽的基本类型成员的大小整除。
- 结构体每个成员相对于结构体首地址的偏移量都是该成员大小的整数倍。如果不是,会在前面填充字节。
- 结构体的总大小是其最宽的基本类型成员大小的整数倍。如果不是,会在结构体末尾填充字节。 可以使用
#pragma pack(n)
来设置结构体的对齐字节数。
-
C++的结构体和C的区别?
主要区别在于C++的
struct
具备了类的特性,而C的struct
仅仅是数据集合。- 成员函数: C++的
struct
可以包含成员函数,而C的struct
不能。 - 访问控制: C++的
struct
默认成员是public
的,而class
默认成员是private
的。但两者都可以使用public
、protected
、private
关键字进行访问控制。 - 继承: C++的
struct
可以继承,而C的struct
不能。 - 构造函数/析构函数: C++的
struct
可以有构造函数和析构函数,而C的struct
不能。 - 模板: C++的
struct
可以使用模板,而C的struct
不能。
- 成员函数: C++的
-
new和malloc的区别(delete和free的区别)
new/delete vs malloc/free
- 类型安全性:
new
和delete
是类型安全的运算符,它们返回指定类型的指针。malloc
和free
是库函数,返回void*
,需要进行显式类型转换。 - 内存分配过程:
new
不仅分配内存,还会调用对象的构造函数;delete
不仅释放内存,还会调用对象的析构函数。malloc
和free
仅仅进行内存的分配和释放,不涉及构造和析构。 - 返回失败:
new
在内存分配失败时会抛出std::bad_alloc
异常(可以通过nothrow
版本返回nullptr
),而malloc
在内存分配失败时返回nullptr
。 - 重载:
new
和delete
可以被重载,而malloc
和free
不能。 - 适用性:
new
/delete
适用于对象和基本类型,malloc
/free
主要用于基本类型或C风格的数据结构。
- 类型安全性:
-
如何找到main函数?(这里要继续细分,win32桌面程序,控制台程序,linux下的命令行程序)
程序的入口点通常由链接器决定,但最终会调用到用户定义的入口函数。
- Windows 控制台程序: 程序的入口函数通常是
main
。在内部,运行时库会有一个启动函数(如_tmainCRTStartup
)调用main
。 - Windows Win32桌面程序: 程序的入口函数通常是
WinMain
。运行时库的启动函数(如WinMainCRTStartup
)会调用WinMain
。 - Linux 命令行程序: 程序的入口函数是
main
。在内部,C运行时库(CRT)会有一个启动函数(如_start
)调用main
。在ELF文件中,通过readelf -h <executable_file>
可以查看入口点地址。
- Windows 控制台程序: 程序的入口函数通常是
-
构造函数与析构函数调用时机
构造函数调用时机:
- 对象创建时: 当一个对象被创建(通过
new
、栈上定义、作为另一个对象的成员等)时,会调用相应的构造函数。 - 函数返回对象时: 当函数返回一个局部对象时,可能会发生拷贝构造函数或移动构造函数的调用(取决于RVO/NRVO优化)。
- 类型转换: 当发生隐式类型转换时,如果需要,可能会调用拷贝构造函数或移动构造函数。
- 初始化列表: 当使用初始化列表初始化成员时,会调用成员的构造函数。
析构函数调用时机:
- 对象生命周期结束时:
- 栈对象: 当离开其作用域时,自动调用析构函数。
- 堆对象: 当使用
delete
运算符显式释放内存时,调用析构函数。 - 全局/静态对象: 程序结束时,调用析构函数。
- 容器销毁: 当包含对象的容器被销毁时,容器内所有对象的析构函数会被调用。
- 异常处理: 当发生异常时,栈展开(stack unwinding)过程中,局部对象的析构函数会被调用。
- 对象创建时: 当一个对象被创建(通过
-
C/C++编程有没有遇到的安全问题(我讲的一个浅构造导致的double free)
浅构造导致的double free: 这是一个常见的安全问题,发生在当类中包含指针成员,且没有正确实现拷贝构造函数和赋值运算符时。如果使用默认的(浅)拷贝构造函数,会导致多个对象共享同一块内存。当其中一个对象析构时,会释放这块内存,而其他对象再析构时,就会尝试释放已经被释放的内存,从而导致
double free
。这可能导致程序崩溃,甚至被攻击者利用进行任意代码执行。 其他常见的C/C++安全问题:- 缓冲区溢出: 读写超出缓冲区边界,覆盖了相邻内存区域。
- 整数溢出: 整数运算结果超出其类型表示范围,导致意外行为。
- 空指针解引用: 访问空指针指向的内存。
- 野指针: 指向无效或已释放内存的指针。
- 格式化字符串漏洞: 使用不可信的格式化字符串作为
printf
等函数的参数。 - 内存泄漏: 分配的内存没有被释放,导致内存耗尽。
- 竞争条件: 多个线程并发访问共享资源时,没有正确同步导致的数据不一致或错误行为。
-
重载如何实现(静态函数名重载,动态虚函数重写)
重载(Overload):
- 实现: 重载是指在同一个作用域内,函数名相同但参数列表(参数类型、参数个数或参数顺序)不同的多个函数。编译器会根据函数调用时提供的参数类型和数量来决定调用哪个重载函数。这是一种静态绑定(编译时决定)。
- 编译器如何区分: 编译器通过名字修饰(Name Mangling)或名字粉碎(Name Decoration)来实现重载。它会将函数名和参数类型编码到一个唯一的内部名称中。
重写/覆盖(Override):
- 实现: 重写是指派生类重新定义基类中已有的虚函数,要求函数名、参数列表和返回类型都与基类的虚函数相同(协变返回类型除外)。这是一种动态绑定(运行时决定)。
- 虚函数: 重写只能发生在继承关系中,并且只针对虚函数。
- 多态: 重写是实现多态的基础。通过基类指针或引用调用虚函数时,会根据实际指向的对象类型调用相应的派生类版本。
-
虚函数如何实现?(重点,几乎必问,虚表指针位置)
虚函数的实现主要依赖于虚函数表(VTable)\和**虚函数指针(VPTR)**。
- 虚函数表(VTable): 每个包含虚函数的类(或其基类包含虚函数)都会有一个虚函数表。这是一个静态的、只读的数组,其中存储着该类的所有虚函数的地址。
- 虚函数指针(VPTR): 每个含有虚函数的类的对象都会有一个虚函数指针(VPTR)。这个VPTR通常是对象内存布局的第一个成员(具体位置可能因编译器而异,但通常在对象开头)。VPTR指向该对象所属类的虚函数表。
- 调用过程: 当通过基类指针或引用调用一个虚函数时,编译器会生成代码,通过对象的VPTR找到对应的虚函数表,然后根据虚函数在虚函数表中的偏移量(编译时确定)找到对应的虚函数地址,最后调用该函数。这实现了运行时的多态性。
- 虚表指针位置: 在大多数编译器(如GCC和MSVC)中,虚表指针
_vptr
是对象内存布局的第一个成员,位于对象实例的起始地址。
-
虚继承/多重继承的内存结构(VC和G++中虚继承中虚表结构不太一样,这里我研究过,扯了一大堆)
多重继承: 在多重继承中,一个派生类可以继承多个基类。内存布局通常是将所有基类的子对象依次排列,然后是派生类自己的成员。如果基类中有同名函数,可能会导致命名冲突,需要使用作用域解析运算符明确指出。
虚继承: 虚继承是为了解决多重继承中的“菱形继承”问题(即一个类通过两条或多条路径继承同一个基类,导致基类成员在派生类中有多份拷贝)。
- 实现原理: 虚继承通过在派生类中引入虚基类表指针(VBTR)或虚基类表(VBT)来实现。每个虚继承的基类在派生类对象中只有一份拷贝。
- VC++中的虚继承: VC++通常采用虚基类表(VBT),它是一个指向虚基类子对象偏移量的数组。虚基类表指针(VBTR)位于派生类对象中,指向这个VBT。通过VBT,可以找到虚基类子对象的实际位置。
- G++中的虚继承: G++通常通过虚函数表(VTable)来处理虚继承。虚函数表中除了虚函数的地址外,还会包含到虚基类子对象的偏移量。这意味着虚函数表同时承担了虚函数查找和虚基类定位的功能。 特点: 无论VC还是G++,虚继承都会引入额外的指针或表,增加了对象的大小和访问基类成员的开销。但它有效地解决了菱形继承的二义性和重复成员问题。
-
switch的实现与优化(难点)
switch的实现:
switch
语句的实现通常有两种主要方式:- 跳转表(Jump Table): 当
case
标签是连续的或者分布比较密集时,编译器会生成一个跳转表。跳转表是一个数组,数组的每个元素是对应case
标签的代码块的地址。switch
语句会计算表达式的值,将其作为索引直接访问跳转表,然后跳转到对应的代码块。这种方式效率很高,因为是O(1)的查找时间。 - if-else if链: 当
case
标签不连续或者数量较少时,编译器可能会将其转换为一系列的if-else if
语句。这种方式的查找时间是O(N),效率相对较低。
switch的优化:
- 判断是否能使用跳转表: 编译器会分析
case
标签的分布,如果满足条件(如连续性、密度),则优先使用跳转表。 - 范围优化: 如果
case
标签在很小的范围内,编译器可能会对表达式进行优化,使其落在跳转表的有效索引范围内。 - 合并相同的case: 如果多个
case
标签执行相同的代码块,编译器可能会将其合并。 - default分支优化:
default
分支通常是跳转表之外的跳转目标。
- 跳转表(Jump Table): 当
-
try-catch的实现与优化(难点,会顺着问到windows异常处理机制)
try-catch的实现:
try-catch
块的实现通常依赖于操作系统的异常处理机制。主要有两种模型:- 栈展开(Stack Unwinding): 这是C++标准异常处理的实现方式。当异常被抛出时,运行时系统会沿着调用栈向上查找匹配的
catch
块。在这个过程中,所有在被抛出异常和catch
块之间的栈帧中的局部对象的析构函数都会被调用,以确保资源正确释放。 - 零开销异常(Zero-Cost Exception): 现代编译器和操作系统(如Windows)通常采用这种模型。在没有异常发生时,
try-catch
块的开销非常小,几乎为零。当异常发生时,会有一个异常调度机制来处理。它通过查找异常处理表(存储在可执行文件的元数据中)来确定哪些函数有catch
块,以及如何进行栈展开。
Windows异常处理机制(Structured Exception Handling - SEH): Windows提供了自己的结构化异常处理(SEH)机制,C++的
try-catch
在Windows上通常是基于SEH实现的。- 异常帧链: 在每个函数进入
try
块时,会在栈上创建一个异常帧,并将其添加到线程的异常帧链中。 - 异常分发: 当发生异常时,操作系统会遍历线程的异常帧链,查找能够处理该异常的异常处理程序。
- 异常处理: 如果找到匹配的异常处理程序,就会执行相应的
__except
块(SEH的关键字)或C++的catch
块。 - 资源清理: SEH也支持
__finally
块,确保无论是否发生异常,其中的代码都会被执行,用于资源清理。 优化: 编译器会尽量减少try-catch
块在正常执行路径上的开销。例如,通过预先生成异常处理表,避免在运行时进行复杂的计算。
- 栈展开(Stack Unwinding): 这是C++标准异常处理的实现方式。当异常被抛出时,运行时系统会沿着调用栈向上查找匹配的
-
三种循环哪种效率最高?
通常情况下,
for
循环、while
循环和do-while
循环在现代编译器下,经过优化后,效率差异微乎其微。 编译器通常能够将它们优化成相同的机器码,因为它们的核心逻辑都是条件判断和跳转。 然而,在某些非常特定的场景下,可能会有细微差别:do-while
循环: 如果循环体至少执行一次,do-while
循环可能比while
循环或for
循环稍快,因为它没有初始条件判断。- 优化能力: 现代编译器(如GCC、Clang、MSVC)非常强大,它们会进行循环展开、指令重排、缓存优化等多种优化,使得不同形式的循环在性能上趋于一致。 结论: 在实际编程中,选择哪种循环结构应主要考虑代码的可读性、逻辑清晰度和维护性,而不是微乎其微的性能差异。除非在极端性能敏感的场景下,否则无需过分关注循环类型带来的性能影响。
-
32位下调用约定有哪些?(stdcall c标准调用 fastcall thiscall)
调用约定(Calling Convention)规定了函数调用时参数传递、返回值处理以及栈清理的方式。在32位系统下,常见的调用约定有:
cdecl
(C Declaration):- 参数传递: 从右向左压栈。
- 栈清理: 调用者负责清理栈。
- 命名修饰: C函数通常不修饰(如
_func
),C++函数有复杂的修饰。 - 特点: 允许可变参数函数,是C/C++默认的调用约定(除非显式指定)。
stdcall
(Standard Call):- 参数传递: 从右向左压栈。
- 栈清理: 被调用者负责清理栈。
- 命名修饰: 函数名以
@
符号和参数字节数结尾(如_func@12
)。 - 特点: 广泛用于Win32 API函数,不能用于可变参数函数。
fastcall
:- 参数传递: 通常使用寄存器传递前几个参数(例如ECX和EDX),其余参数从右向左压栈。
- 栈清理: 被调用者负责清理栈。
- 命名修饰: 通常以
@
符号和参数字节数结尾。 - 特点: 比
stdcall
和cdecl
更快,因为减少了内存访问。具体使用哪些寄存器由编译器决定。
thiscall
:- 参数传递: 专门用于C++类的非静态成员函数。
this
指针通常通过ECX寄存器传递(或堆栈)。其他参数从右向左压栈。 - 栈清理: 被调用者负责清理栈。
- 特点: 编译器自动生成,用户无需显式指定。
- 参数传递: 专门用于C++类的非静态成员函数。
-
64位下调用约定?(VC:rdx rcx r8 r9,GCC: 多rdi rsi)
64位系统的调用约定与32位系统有显著不同,主要体现在更多的参数通过寄存器传递,以提高效率。
- Windows x64 Calling Convention (Microsoft Visual C++):
- 参数传递: 前四个整型或指针参数依次通过RCX, RDX, R8, R9寄存器传递。浮点参数通过XMM0-XMM3寄存器传递。
- 栈传递: 超过四个的参数通过栈从右向左压栈。
- 栈清理: 调用者负责清理栈。
- 返回: 整型或指针返回值通过RAX寄存器,浮点返回值通过XMM0寄存器。
- “Shadow Space”: 在函数序言中,会为前四个寄存器参数分配32字节的“影空间”在栈上,即使这些寄存器参数没有溢出到栈。这方便了调试和与可变参数函数的兼容性。
- System V AMD64 ABI (GCC, Clang, Linux等):
- 参数传递: 前六个整型或指针参数依次通过RDI, RSI, RDX, RCX, R8, R9寄存器传递。浮点参数通过XMM0-XMM7寄存器传递。
- 栈传递: 超过六个的参数通过栈从右向左压栈。
- 栈清理: 调用者负责清理栈。
- 返回: 整型或指针返回值通过RAX寄存器,浮点返回值通过XMM0寄存器。
- 特点: 没有“影空间”,栈帧结构相对更紧凑。
总结: 64位系统下,主要的区别在于用于参数传递的寄存器数量和具体种类。Windows x64使用RCX, RDX, R8, R9作为前四个参数,而System V AMD64 ABI使用RDI, RSI, RDX, RCX, R8, R9作为前六个参数。两者都倾向于使用寄存器传递参数以提高效率。
- Windows x64 Calling Convention (Microsoft Visual C++):
二进制逆向(反调试/脱壳/免杀/挂钩/注入)
这部分为安全岗面试重点
-
寄存器
通用寄存器:
EAX/RAX
:累加器,常用于函数返回值。EBX/RBX
:基址寄存器,保存指针数据。ECX/RCX
:计数器,用于循环指令(如LOOP
)。EDX/RDX
:数据寄存器,辅助运算(如除法时存余数)。ESI/RSI
、EDI/RDI
:源/目标索引(用于字符串/内存操作,如REP MOVSB
)。ESP/RSP
:栈指针,指向当前栈顶。EBP/RBP
:基址指针,标记栈帧起始位置。
标志寄存器:
EFLAGS/RFLAGS
:状态标志(如ZF
零标志、CF
进位标志)。
指令指针:
EIP/RIP
:指向下一条待执行指令。
-
32位程序如何在64位机器上运行?
系统先检查PE文件的头部信息,确定是一个32位程序后,并不会直接加载它,而是先加载一个名为
ntdll.dll
的64位版本,然后由ntdll.dll
加载WoW64子系统,这是一个专门用于在64位系统上运行32位程序的兼容层。64位操作系统使用的是64位指令集(x86-64),而32位程序使用的是x86指令。直接运行会出现指令不兼容或地址空间错误的问题,所以WoW64会拦截并转化32位系统调用位64位内科可以处理的格式,维护32位和64位环境的隔离,提供32位的系统DLL。WoW64的核心机制:
文件和注册表重定向,即将32位程序要访问的
C:\Windows\System32
重定向到C:\Windows\SysWOW64
,因为System32
里存的是64位 DLL,将要访问的注册表也重定向到32位的位置。让32位进程仍最多使用4GB虚拟内存,防止32位程序访问64位指针或结构,并负责将CPU从64位模式切换到32位模式
-
物理地址与虚拟地址
物理地址是内存硬件中实际存在的唯一的地址,虚拟地址是进程视角下看到的逻辑上的地址。
例如在32位机上,每个进程都认为自己独占4GB的完整内存空间,不同进程的虚拟地址是完全独立的,互不干扰
虚拟地址和物理地址的关系由内存管理单元(硬件)进行动态转换
-
PE格式(重点,几乎必问)
微软Windows操作系统下用于可执行文件、目标文件和动态链接库(DLL)的主要文件格式。它的作用是告诉操作系统如何处理这个文件,它分为以下几个部分:
- DOS头 (DOS Header):主要是为了兼容早期的MS-DOS系统。如果程序在不支持PE格式的DOS系统上运行,系统会识别这个头部并执行一小段预设的程序,通常是显示一段无法运行的提示信息
- PE头 (PE Header):它是PE格式的核心,它又包含了以下几个部分
- PE标识 (Signature): 一个4字节的标识,值为
PE\0\0
("PE"后面跟着两个空字符),用来表明这是一个有效的PE文件。 - 文件头 (File Header / COFF Header): 包含了文件的基本信息,比如文件是为哪种CPU架构(如x86、x64)编译的、文件中节(Section)的数量、文件创建的时间戳等。
- 可选头 (Optional Header): 这是PE头中最大、最重要的部分。它包含了程序加载到内存时所需的关键信息,如程序的入口点(代码开始执行的地址),镜像基址(程序建议的被加载地址),子系统(程序是GUI还是CUI),数据目录(指向其他重要数据结构(如dll)的指针和大小)
- PE标识 (Signature): 一个4字节的标识,值为
- 节表:PE文件将不同类型的数据存放在不同的“节”(Section)中。节表就像是这些节的目录,描述了每个节的名称、大小、在文件中的位置以及加载到内存后的属性
- 节:这是PE文件中存放实际内容的地方。每个节实际上是一个容器,可以包含 代码、数据 等等,每个节可以有独立的内存权限,比如代码节默认有读/执行权限。
PE格式的主要作用是为操作系统提供一个标准化的方式来处理可执行代码;总而言之,PE格式是连接程序文件和运行中进程的桥梁,是Windows平台软件开发和运行的基础。
节名称 常见用途 .text
存储可执行代码(主程序逻辑、函数等) .data
存储已初始化的全局变量和静态变量(可读写) .rdata
存储只读数据(如字符串常量、常量数组、导入表/IAT、导出表等) .idata
存储导入表(DLL函数声明信息),但现代编译器常合并到 .rdata
.edata
存储导出表(供其他模块调用的函数/符号列表),较少显式出现 -
PE装载进内存执行的过程(重点,内存对齐,IAT表建立,重定位)
流程:
创建进程内核对象:当用户运行一个
.exe
文件时,操作系统首先创建一个进程内核对象。创建虚拟地址空间:为新进程创建一个私有的、隔离的虚拟地址空间。
解析和加载依赖项(内存对齐):解析PE文件头,找出所有依赖的DLL,并将它们也映射到进程的地址空间。
执行必要的修正:进行重定位、构建导入地址表(IAT)等操作。
创建初始线程并启动:创建主线程,将CPU的指令指针(EIP/RIP)指向程序的入口点,然后开始执行。
为什么要对齐:现代处理器通常以特定的字节数(如4字节或8字节)为单位进行内存访问。如果数据没有按照对齐规则存放,处理器可能需要进行多次内存访问,从而降低性能。简单来说,内存对齐就是将文件中的节区“拉伸”开来,以适应内存分页管理的要求。
文件对齐:让文件中的每个节的大小对齐
内存对齐:为了提高内存的访问效率,不同的数据类型往往有不同的对齐边界,n字节的变量应该存放在n的倍数地址上
IAT表建立:一般程序都需要调用系统或其他DLL提供的函数。PE文件并不直接存储这些外部函数的地址,而是通过一个称为导入表(Import Table)的机制在加载时动态解析。加载器通过PE可选头的数据目录找到导入表的位置,其中包含被依赖的DLL,然后遍历这个导入表,读取指向DLL的指针,然后调用这些DLL加载到进程空间中。
重定位:PE文件默认加载到镜像基址,但如果该地址被占用,则必须重新加载到其他地址,此时代码中硬编码的地址(RVA + ImageBase)会出错,需要“修复”。加载器读取PE头的重定位表,该表中记录了代码和数据节中所有需要修正的硬编码地址的位置,然后对表中每一个位置的地址加上实际偏移量。
-
知道哪些反调试手段?(SEH,反断点,查调试环境)
SEH:调试器通常会接管异常处理流程,程序通过故意制造异常,再看是否能“正常”处理,借此判断是否有调试器存在。
技术 描述 除0异常检测 故意执行 div eax, 0
,未调试时由SEH处理,调试时可能被调试器中断访问非法地址 访问 0x0
、0xFFFFFFFF
,检查是否能捕获异常RaiseException 检测 主动抛出异常,分析异常链 反断点:调试器常通过在指令中插入断点(如
INT 3
=0xCC
)来中断程序,反调试程序可主动检查是否有被插入断点。技术 描述 检查函数首字节 检查 MessageBoxA
等API首字节是否为0xCC
使用 checksum / CRC 计算自身代码段校验和,检测是否被修改 INT3 单步陷阱检测 插入 0xCC
自己捕获后查看是否被调试器截获调试环境检测:通过系统API、PEB、CPU指令等检测是否处于调试环境中。
方法 描述 IsDebuggerPresent() 调用 Windows API,返回布尔值 CheckRemoteDebuggerPresent() 检查指定进程是否被调试 PEB 检测 BeingDebugged 从 fs:[30h]
(PEB)结构中读取标志NtQueryInformationProcess 查询 ProcessDebugPort
、ProcessDebugFlags
等CPUID + Debug寄存器检测 检查是否使用调试寄存器 DR0–DR7 Timing 检测(RDTSC) 判断指令是否被异常拖慢 -
调试器原理(三大断点实现)
断点类型 简介 本质 优点 缺点 软件断点 最常用的断点方式 修改目标代码,插入 INT 3
指令简单,通用 会修改程序代码 硬件断点 借助CPU调试寄存器(DR0–DR3) 设置CPU调试寄存器 不修改代码,支持读写断点 数量有限(最多4个) 内存断点(页断点) 依赖操作系统分页机制 修改页属性,引发页异常(Page Fault) 可设置大量读写断点 实现复杂,性能影响大 软件断点:将目标地址的第一条指令字节替换为
0xCC
(INT 3
指令),执行时CPU触发中断(INT 3
→ 中断号3
),调试器捕获异常硬件断点:CPU提供了 4个调试寄存器(DR0~DR3) 指定断点地址
内存断点:修改某段内存页的 页属性,比如去掉读写权限
-
什么是壳
壳:壳程序将原始代码加密或混淆,使得反编译或反汇编后的代码难以理解,壳程序可以对可执行文件进行压缩,减小文件体积,从而节省存储空间,并加快网络传输速度。
加壳程序的执行过程:
加壳后的文件,其真正的入口点(Original Entry Point, OEP)已经被修改。程序运行后,首先执行的是壳程序自身的代码,而不是原始程序的代码。
壳程序在内存中会负责对被加密或压缩的原始程序数据进行解密和解压缩,这个过程通常是在程序运行时动态完成的。
解密和解压缩后的原始程序代码和数据会被还原并加载到内存中的适当位置,这个过程通常会创建一个新的内存区域来存放解密后的原始程序。
因为在加壳过程中,原始程序的导入表和重定位表可能会被修改或破坏,壳程序在解密后,需要负责修复这些表,确保原始程序能够正确地调用系统API和访问数据。
当原始程序被完全解密、解压缩并还原到内存后,壳程序会找到原始程序的真正入口点(OEP),并将CPU的控制权转移给原始程序的入口点。
-
如何判断是否加壳
工具检测(如PEiD、Exeinfo PE)。
节区名称异常:
- 加壳程序的节区名可能被修改(如"UPX0"、"ASPack")。
- 正常程序的节区名通常是
.text
、.data
、.rdata
等。
观察入口点异常(如非标准编译器入口)。
- 标准入口:
- 编译器生成的入口通常是
main
或WinMain
,调用前会初始化运行时库(如_start
或__libc_start_main
)。 - 入口点代码清晰,包含明显的初始化逻辑(如栈分配、全局变量初始化)。
- 非标准入口:
- 加壳程序的入口点是壳代码,通常直接跳转到解密代码(如
JMP
到混乱地址)。 PUSHAD
后直接JMP
到未知地址,是壳的典型特征。PUSHAD
用于保存所有通用寄存器状态(压栈),确保在后续操作中不会丢失它们的原始值,正常情况下PUSHAD
后应跟随其他指令(如函数逻辑),最后用POPAD
恢复寄存器并RET
返回。
动态调试时代码段在运行时解压(内存转储)。例如,磁盘文件中代码节(如
.text
)大小很小(如UPX压缩),但内存中该节区大小显著增加(解密后)。 -
如何脱壳(压缩壳/加密壳/虚拟化壳)
脱壳的目标:将被加壳(加密、压缩、混淆等)的程序还原到其原始未加壳状态的过程。
压缩壳:主要是为了减小程序体积
- 动态解压到内存: 压缩壳会在运行时将原始程序解压到内存中,并跳转到原始程序的入口点 (OEP)。
- 寻找 OEP: 这是核心。主要方法有单步跟踪,逐行执行代码,直到发现明显的跳转到新的代码段;查找关键API,壳程序在解压并转移控制权之前,通常会调用
VirtualAlloc
、VirtualProtect
等内存操作函数。在这些函数上设置断点,然后观察它们的返回值和参数,通常能找到解压后的区域。 - OEP 处 Dump: 一旦找到 OEP,使用调试器自带的 Dump 功能将当前内存中的解密后的程序 Dump 到文件。
- 修复导入表:使用工具修复导入表
加密壳:不仅对程序进行了压缩,还进行了加密,反调试,阻止逆向分析
- 绕过反调试/反虚拟机: 修改 PE 头中的调试标志,或者 Hook 某些 API 函数。
-
为什么脱完壳要修复导入表?
加壳程序为了保护原始程序,通常会对导入表进行以下几种操作,导致其在脱壳后需要修复:
隐藏或清空导入表: 许多壳程序在加壳时会直接清空或加密原始程序的导入表。这样做是为了让逆向工程师难以通过分析导入表来快速了解程序的功能和行为。
重定向导入表: 壳程序可能会将原始程序的导入表重定向到自身内部的一个隐藏区域。当程序运行时,壳程序会负责将真实的 API 地址解析出来并提供给原始程序。
动态加载 API: 更复杂的壳程序(特别是加密壳和虚拟化壳)不会直接依赖传统的导入表。它们可能会在运行时通过
LoadLibrary
和GetProcAddress
等函数动态地获取所需的 API 地址,而不是在程序启动时由操作系统加载器一次性完成。这样可以进一步增加逆向分析的难度。混淆或虚拟化导入表: 对于高级的加密壳和虚拟化壳,导入表本身可能也会被加密、混淆甚至虚拟化,使得其内容难以直接识别和重建。
-
如果一个程序没有字符串/字符串被混淆了如何找核心代码
动态执行追踪:
- 在输入验证或关键操作处下断点(如
ReadFile
、recv
),回溯调用栈。 - 示例:用
x64dbg
的条件断点监控特定内存访问。
常量与魔数搜索:
- 搜索代码中的独特数值(如错误码
0xDEADBEEF
、加密算法的常量0x9E3779B9
)。
行为监控:
- 使用
Process Monitor
观察文件/注册表操作,定位相关代码区域。
交叉引用系统API:
- 逆向调用系统API(如
CreateFile
、MessageBox
分别用于 文件操作 和 用户交互)附近的代码。
代码模式识别:
- 识别加密函数特征(如循环异或、
AES
的S-Box
访问)。
- 在输入验证或关键操作处下断点(如
-
沙箱有接触过吗?
沙箱是一种隔离的执行环境,用于安全运行不受信任的程序或代码,防止其对真实系统造成破坏。
涉及的技术包括:
- 最小权限原则:程序仅被授予必要的权限(如禁止写入系统目录)。
- Hook 系统调用:监控程序对文件、网络、进程等敏感操作的请求,动态允许或拒绝。
- 虚拟化系统资源:伪造文件系统或注册表视图(如 Docker 的联合文件系统)。
- 文件系统隔离:程序只能看到沙箱内的虚拟文件系统(如
chroot
)。 - 轻量级虚拟化:利用容器技术(如 Docker)隔离进程,共享宿主内核。
- API 监控:记录程序调用的所有 API(如
MessageBox
、CreateProcess
)。 - 地址空间随机化(ASLR):防止内存攻击。
浏览器安全:
- Chrome 每个标签页运行在独立沙箱中,防止恶意网页攻击系统。
移动应用(Android/iOS):
- 应用默认在沙箱内运行,无法直接访问其他应用的数据。
恶意软件分析:
- 在沙箱中动态执行可疑文件,记录其行为(如 Cuckoo Sandbox)。
与虚拟机区别:隔离级别位进程/应用级别,相较于完整的系统级隔离较弱,但是开销更低,启动更快
-
有没有用过虚拟机?(QEMU, VMware Bochs)虚拟化有哪几种方式实现?虚拟机查杀有什么思路过吗?
技术 虚拟化类型 虚拟化方式 底层依赖 VMware 硬件虚拟化(Full Virtualization) 基于 Hypervisor(VMM) CPU 虚拟化(Intel VT-x、AMD-V) Docker 操作系统级虚拟化(轻量级容器) 基于 Linux 内核的 namespace + cgroups 共享主机内核 WSL2 WSL2:轻量级虚拟机 基于 Hyper-V 使用 Hyper-V(轻量 VM) WSL2的隔离能力低于完整虚拟机,因为设计原因,WSL2与宿主Windows系统可以实现文件互相访问,且二者都可以通过localhost访问对方的服务,即网络层面不是完全隔离的
-
什么是Hook
钩子(Hook),是Windows消息处理机制的一个平台,应用程序可以在上面设置子程序以监视指定窗口的某种消息,而且所监视的窗口可以是其他进程所创建的。当消息到达后,在目标窗口处理函数之前处理它。钩子机制允许应用程序截获处理window消息或特定事件。
钩子实际上是一个处理消息的程序段,通过系统调用,把它挂入系统。每当特定的消息发出,在没有到达目的窗口前,钩子程序就先捕获该消息,亦即钩子函数先得到控制权。这时钩子函数即可以加工处理(改变)该消息,也可以不作处理而继续传递该消息,还可以强制结束消息的传递。每一个Hook都有一个与之相关联的指针列表,称之为钩子链表,由系统来维护。这个列表的指针指向指定的,应用程序定义的,被Hook子程调用的回调函数,也就是该钩子的各个处理子程序。当与指定的Hook类型关联的消息发生时,系统就把这个消息传递到Hook子程。一些Hook子程可以只监视消息,或者修改消息,或者停止消息的前进,避免这些消息传递到下一个Hook子程或者目的窗口。最近安装的钩子放在链的开始,而最早安装的钩子放在最后,也就是后加入的先获得控制权。
-
Hook有哪些方法?(几乎必问,inline hook,函数表hook)
Hook(钩子)是一种通过拦截并修改程序执行流程的技术,用于监视、修改或扩展目标程序的行为,通常通过重定向函数调用、消息传递或系统事件来实现。
Inline Hook:修改目标函数头部的指令为
JMP
跳转到自定义代码。IAT Hook:替换导入地址表(IAT)中的函数指针。
API Hook:拦截系统API调用(如用
Detours
库)。VMT Hook:修改C++虚函数表指针(适用于面向对象程序)。
-
flags寄存器有哪些位,有什么作用(OF, ZF, TF, 虚拟位)
进位标志CF(Carry Flag):如果运算结果的最高位产生了一个进位或借位,那么,其值为1,否则其值为0。
奇偶标志PF(Parity Flag):奇偶标志PF用于反映运算结果中“1”的个数的奇偶性。如果“1”的个数为偶数,则PF的值为1,否则其值为0。
辅助进位标志AF(Auxiliary Carry Flag):
-
在发生下列情况时,辅助进位标志AF的值被置为1,否则其值为0:
在字(word)操作时,发生低字节向高字节进位或借位时;
在字节(byte)操作时,发生低4位向高4位进位或借位时。
零标志ZF(Zero Flag):零标志ZF用来反映运算结果是否为0。
符号标志SF(Sign Flag):符号标志SF用来反映运算结果的符号位,它与运算结果的最高位相同。
溢出标志OF(Overflow Flag):溢出标志OF用于反映有符号数加减运算所得结果是否溢出。
-