软件安全期末复习
第一章:软件安全基础
软件安全相关概念
- 软件定义:一系列按照特定顺序组织的计算机数据和指令的集合,分为系统软件、应用软件和支撑软件
- 软件安全定义:采用工程的方法使得软件在敌对攻击的情况下仍能够继续正常工作
- 威胁来源:软件自身(漏洞与缺陷)和外界(黑客恶意代码)
内在的缺陷暴露在外在威胁下的状态即为风险
内在的缺陷遭遇外在威胁则形成安全事件
网络空间安全属性 (CIA+)
软件安全关注程序及其信息的以下特性:
- 保密性 (Confidentiality):信息不泄漏给非授权用户
- 完整性 (Integrity):数据未经授权不能改变
- 可用性 (Availability):授权实体可按需访问
- 其他:真实性、可核查性(访问控制)、可靠性
软件安全研究范围
- 软件生命周期(设计、编码、运维等阶段)
- 软件环境要素(运行环境、开发环境、开发者、软件自身)
- 软件安全功能(防反汇编、防调试、防篡改…)
软件安全知识体系
- 软件漏洞:原理、挖掘、利用、检测与防范(安全编码)
- 恶意代码:机理(病毒、木马、蠕虫)与检测防御
- 软件保护:软件分析(破解)与反破解技术(混淆、水印、脱壳等)
PE 文件
PE 是 Win32 平台可执行文件的标准格式(如 .exe, .dll)
- 常见数据节:
.text:机器代码.data:已初始化数据.idata:导入函数信息.rsrc:资源(图标、菜单)
- 对齐差异:PE 文件在磁盘上按
0x200字节对齐,装入内存后按0x1000字节对齐
地址映射关系
- 文件偏移地址:指令或数据在磁盘文件中的位置
- 虚拟内存地址 (VA):当程序运行时,同一个指令或数据在内存中的位置
- 装载基址 (Image Base):程序被加载到内存的起始位置
- EXE文件的基地址是0x00400000
- DLL文件是0x10000000
- 相对虚拟地址 (RVA):内存地址相对于映射基址的偏移量
PE 与 ELF 的主要差异
| 特性 | PE (Portable Executable) | ELF (Executable and Linkable Format) |
|---|---|---|
| 操作系统 | Windows | Linux, BSD, Unix-like |
| 文件后缀 | .exe, .dll, .sys | .out, .so, .o, bin |
| 核心表 | 导入表 (IDATA)、导出表 | 符号表、重定位表 |
| 地址映射 | 依赖 RVA 和 Image Base | 基于段(Segment)加载 |
| 动态链接 | 使用 DLL 库 | 使用共享对象 (Shared Objects, .so) |
Win32 内存与寄存器
- 存储体系:寄存器 > 高速缓存(Cache) > 内存 > 外存
- 核心寄存器:
- EAX:通常存储函数返回值
- EIP:指令寄存器,指向下一条执行指令,控制它即控制进程
- ESP:栈指针寄存器,指向当前栈顶
- EBP:基址指针寄存器,指向当前栈帧底部,用于维护堆栈平衡
- 虚拟内存:Win32让每个进程拥有独立的 4GB 虚拟地址空间,通过内存管理器映射到物理内存
进程内存分类
| 名称 | 用途 |
|---|---|
| 代码区 (.text) | 存储二进制机器代码,处理器在此取指执行(只读) |
| 数据区 (.data) | 存储静态/全局变量 |
| 堆区 (Heap) | 动态申请和归还内存,增长方向向上 |
| 栈区 (Stack) | 存储函数调用关系及局部变量,向低地址方向增长 |
栈运行机制与函数调用
栈的基本性质
- 机制:一种先进后出 (LIFO) 的数据结构
- 操作:压栈 (PUSH) 和弹栈 (POP)
- 标识:栈顶 (TOP) 和栈底 (BASE)
函数调用时栈的变化机制
栈存储:局部变量、栈帧状态值(前EBP)、函数返回地址
栈需要被修改
- 在函数调用期间
- 函数初始化期间
- 从子例程序返回
函数调用步骤
- 调用前:
- 参数按从右向左 顺序压入栈
- 将返回地址(下一条指令地址)压入栈
- 处理器跳转至被调函数的入口
- 调用时:
- 保存当前栈帧底部 (PUSH EBP)
- 设置新栈帧底部 (MOV EBP, ESP)
- 为局部变量分配空间 (SUB ESP, XX)
- 返回时:
- 将返回值存入 EAX 寄存器
- 释放空间:
add ESP, N(回收局部变量空间) - 恢复旧帧:
pop EBP(将栈中保存的旧值弹回 EBP,回到上一个栈帧) - 执行返回: 弹出返回地址至 EIP,跳回原代码处继续执行
这是函数内部稳定执行时的状态(即完成了 PUSH EBP 和 MOV EBP, ESP 之后):
初始化后的栈布局如下(内存地址从高到低):
| 内存地址 | 内容 | 说明 |
|---|---|---|
| 低地址 | … | |
| ESP 指针 → | 当前栈顶 | 始终指向栈的最上方(低地址) |
| 局部变量 2 | 位于 EBP - 8 | |
| 局部变量 1 | 位于 EBP - 4 | |
| EBP 指针 → (当前栈帧) |
前栈帧 EBP (Saved EBP) | 保护调用者的基址指针 |
| 返回地址 | call 指令自动压入,指向下一条指令 |
|
| 参数 a (1) | 第一个参数最后入栈 | |
| 参数 b (2) | ||
| 高地址 | 参数 c (3) | 最后的参数最先入栈 |
缓冲区溢出原理
- 核心定义:当向缓冲区填充的数据超过其本身容量时,溢出的数据会覆盖在合法数据(如返回地址、EBP等)上
- 后果:由于相邻内存地址被覆盖,会导致程序崩溃或执行攻击者精心设计的恶意代码
缓冲区溢出的预防
- 静态保护:不执行代码,通过编译时限制、返回地址保护、源码审计等手段发现漏洞
- 动态保护:在程序运行时分析特性,测试漏洞或通过主机保护防止攻击
第二章:字符串安全
字符编码与字符串实现
- Unicode (统一码): 它是为世界上所有字符定义的唯一二进制代码(码点),通常写作
U+XXXX - Unicode 是字符的集合(码点),UTF-8 是其传输和存储的实现
字符编码:UTF-8
UTF-8是一种变长字节编码方式,其特点如下:
- 编码规则:字节数由第一个字节的高位二进制位决定
- 单字节:最高位为0(格式为
0xxxxxxx) - 多字节:第一个字节前N位为1,其后一位为0,表示该字符占用N个字节;其余字节均以
10开头
- 单字节:最高位为0(格式为
- 长度限制:UTF-8最多可使用6个字节
字符串的实现 (C/C++)
- C风格字符串:由连续字符序列组成,并以一个空字符 (
\0) 结尾- 指针:指向字符串起始字符的指针
- 字符串长度 指
\0之前的字符数;存储所需字节数 为 长度 + 1
- C++ 字符串:主要使用类模板
std::string- 更不容易出现安全漏洞
常见的字符串操作错误
- 无界字符串复制:从无界数据源复制到定长数组
- 空结尾错误:字符串末尾缺少正确的
\0终止符 - 截断:目标数组空间不足导致数据丢失,有时会引发漏洞
- 差一错误 (Off-by-one error):如循环次数或边界判断错误
- 数组写入越界:不调用函数直接进行不安全的字符操作
- 不恰当的数据处理:如将用户输入直接传递给
system()调用
不安全字符串 API
gets():无法限制输入长度,极易导致缓冲区溢出strcpy()/strcat():执行无界复制操作strncpy():若源字符串前 个字符无\0,结果字符串将不会以空字符结尾sprintf():在处理格式化字符串(如email地址)时可能被利用cin >> buf:在C++中,若未设置域宽且输入过长,也会导致越界写
字符串漏洞
常见漏洞种类
- 缓冲区溢出/栈溢出:数据写入超出分配内存的边界
- 栈粉碎:溢出覆写了执行栈中的返回地址,导致执行任意代码
- 代码注入:攻击者提供包含恶意代码(shellcode)的参数,并将控制权转移至该代码
- Shellcode:目的是在受害机器上开启远程 shell
- 限制条件:恶意参数不能包含空字符(直至最后一个字节),因为空字符会终止字符串复制
- 弧注入:不注入代码,而是通过覆写返回地址来调用内存中已有的库函数(如
system())
缓冲区溢出
- 通过修改变量、数据指针、函数指针 或栈返回地址 来利用漏洞

缓解与防范措施
静态防范方法
- 输入验证:确保输入大小不超过目标缓冲区(如使用
sizeof()检查) - 使用相对安全的API:
strlcpy()/strlcat():确保目标字符串总是以\0结尾,并返回希望创建的字符串长度以供检查截断- ISO/IEC TR 24731 (Security C):定义了
strcpy_s()、strcat_s()等函数。这些函数在源字符串无法完全复制时会失败并报错
动态防范方法
- 动态内存分配:根据输入长度使用
malloc()分配空间 - 使用不透明数据类型/管理库:
- SafeStr:基于
safestr_t类型,自动调整大小,防止溢出 - XXL库:为C/C++提供异常管理,处理内存溢出等错误
- SafeStr:基于
数据处理策略
- 黑名单:识别并替换危险字符(如下划线替代),但容易被编码绕过
- 白名单:定义可接受的安全字符列表,删除其余字符,安全性更高
第三章:指针安全
指针安全核心定义与原理
指针安全:通过修改指针值来利用程序漏洞的方法统称
- 函数指针:覆盖后转移控制权至 shellcode
- 对象指针:修改后可执行任意代码或导致任意内存写
- 指令指针
内存段基础知识
- Data段:存放已初始化的全局变量和常数
- BSS段:存放未初始化的全局变量
- Stack(栈):存放局部变量
- Heap(堆):存放动态分配的内存
控制流劫持
定义:通过修改指针值来利用程序漏洞,从而夺取程序控制权的方法统称
| 维度 | 考点内容 |
|---|---|
| 劫持目标 | 最终目标是控制 EIP / PC(指令指针) |
| 劫持手段 | 利用缓冲区溢出或任意内存写 |
| 覆盖内容 | 覆盖函数指针、返回地址、虚表指针或 GOT 表项 |
| 执行结果 | 程序控制权转移到 Shellcode 或攻击者指定的代码段 |
缓冲区溢出覆写指针的三个必要条件
- 同段性:缓冲区与目标指针必须在同一个内存段内
- 低地址性:缓冲区地址必须低于目标指针地址
- 界限不充分:缓冲区存在溢出漏洞
修改指令指针(EIP/PC)的 6 个目标
指令计数器 (IC/EIP) 存储下一条指令地址,不能直接访问,但可通过以下目标间接控制程序流:
| 目标 | 触发时机 | 覆盖点 |
|---|---|---|
| GOT | 调库函数时 | GOT 表项地址 |
| .dtors | 程序退出后 | 析构指针/尾标签 |
| 虚函数 | 调用虚方法时 | VPTR 指针或 VTBL 项 |
| atexit | 程序正常退出 | __exit_funcs 数组地址 |
| longjmp | 执行跳转指令 | jmp_buf 里的 JB_PC |
| 异常处理 | 程序崩溃报错 | 栈上的 Handler 地址 |
全局偏移表 (GOT)
- 定义:动态链接库(Linux ELF)用于解析函数地址的表
- 原理:程序调用动态库函数时,通过 GOT 表查找实际地址,GOT 表地址固定
- 攻击点:利用“任意内存写”,把表中某个函数的地址,改成 shellcode 地址
.dtors (析构函数区)
- 原理:GCC 特有,存放
main()结束后调用的函数指针 - 攻击点:该区默认可写 。直接覆写其中的指针或尾标签
0x00000000为攻击代码地址
虚函数 (C++ Virtual Function)
- 机制核心:
- VPTR (虚指针):位于对象内存空间头部
- VTBL (虚表):存放虚函数入口地址的指针数组
- 攻击点:
- 覆写 VTBL 指针:改写对象头部的 VPTR,使其指向伪造的虚表
- 覆写项:直接覆写虚表中的函数地址
- 局限性:由于 VPTR 通常在成员变量(缓冲区)之前,栈溢出时难以直接向后覆盖,除非能溢出到邻接对象的空间
atexit() 和 on_exit()
- 定义:用于注册在程序正常终止时被调用的函数
- 机制:注册的函数指针保存在全局数组中(Linux 下为
__exit_funcs),以 LIFO(后进先出)顺序调用 - 攻击点:覆写
__exit_funcs结构中保存的函数实际地址(通常是结构的最后一个双字)
longjmp()
- 定义:C 语言的非局部跳转,利用
jmp_buf恢复之前保存的寄存器状态 - 机制:跳转环境保存在
jmp_buf结构体中 - 攻击点:覆盖
jmp_buf中的 JB_PC 域(下标 5),它存的就是跳转后的下一条指令地址
异常处理 (SEH/VEH)
- 原理:Windows 的 SEH 结构分配在栈上,异常处理程序地址紧跟在局部变量之后
- 攻击点:
- 通过栈缓冲区溢出覆写栈上的 处理程序地址
- 修改线程环境块 (TEB) 中指向异常处理列表的首个指针
内存任意写技术
- 产生原因:攻击者在溢出缓冲区后,覆写了对象指针(ptr)和要写入的值(val)
- 后果:执行
*ptr = val指令时,会将任意值写入任意内存地址 - 意义:通过“任意内存写”可以改写关键数据结构或函数指针,从而改变控制流
strcpy 风险与安全替代
- 风险:
strcpy不检查缓冲区边界,导致缓冲区溢出 - 安全替代:使用有边界限制的函数,如
strncpy
Windows 内存安全机制(缓解措施)
| 机制名称 | 核心原理 | 保护目标与局限性 |
|---|---|---|
| GS 编译 (栈探测仪) | 在局部变量和保护区域(如返回地址)之间插入 Canary (随机数) | 保护栈返回地址 局限性:不能保护非栈段溢出,无法防止变量或指针被修改 |
| DEP (数据执行保护) | 即 W^X (Write xor Execute),内存区域要么可写,要么可执行 | 防止直接执行 shellcode 局限性:无法防止 atexit() 等目标的覆写攻击 |
| ASLR (地址空间随机化) | 随机化模块加载地址、堆、栈地址 | 增加攻击者定位 GOT、VTBL 等目标地址的难度 |
| Heap Cookie / SafeUnlink | 在堆管理结构中加入校验随机数或双向链表完整性检查 | 防止堆溢出导致的任意地址写攻击 |
第四章:动态内存管理
动态内存管理函数
| 操作 | C 语言函数 | C++ 运算符 |
|---|---|---|
| 分配内存 | malloc(size) / calloc(n, size) |
new / new[] |
| 释放内存 | free(p) |
delete / delete[] |
| 调整大小 | realloc(p, size) |
N/A |
new 与 malloc 的本质区别
- 属性:
new是 C++ 关键字;malloc是 C 标准库函数 - 单位:
new以类型为单位;malloc以字节为单位 - 初始化:
new可在申请时初始化;malloc不具备初始化特性
特殊函数:alloca()
- 功能:在调用者栈中分配内存,函数返回时自动释放
- 风险:不返回错误值,易导致溢出栈边界。如果搭配了
free是灾难性的(分配栈内存却释放堆内存)
内存分配算法
- 连续匹配:查第一个空闲区
- 最先匹配:从起点开始找第一个
- 最佳匹配:找满足条件的最小空闲块
- 最优匹配:通过取样选择最优策略
- 最差匹配:挑最大的空闲块
- 伙伴系统:只分配 大小的块,回收时合并相邻“伙伴”
- 隔离:维护大小一致的块的单独列表
伙伴系统
- 分裂:若申请大小为 ,则寻找大于等于 的最小 块。若没有,则将更大的块(如 )对半分裂,其中一半分配,另一半作为“伙伴”进入空闲链表
- 合并:当一个块被释放时,系统检查其“伙伴”块是否也空闲。若是,则合并为更大的 块
- 伙伴地址计算:
- 优缺点:
- 优点:合并与分裂速度极快;有效避免外部碎片
- 缺点:存在严重的内部碎片
常见内存错误
- 初始化错误:误认
malloc会清零(实际需用memset或calloc) - 未检查返回值:内存耗尽时
malloc返回NULL,直接引用会导致崩溃(new直接抛出异常,不能检查返回值是否为NULL) - 引用已释放内存 (UAF):内存被回收后再次读写,会导致数据损坏或漏洞
- 双重释放 (Double Free):对同一块内存释放两次,破坏内存管理器元数据
- 函数配对不当:如
malloc用delete释放,导致不可移植性 - 标量与数组不分:
new[]必须对应delete[] - 分配函数使用不当:如
malloc(0)可能返回伪地址
堆漏洞利用技术
篡改堆管理结构
- 元数据劫持:攻击者利用溢出覆盖
Size或Flags位- 篡改 Size:使内存管理器认为块比实际大或小,导致重叠分配
- 篡改 PREV_INUSE (P) 位:欺骗系统认为前一个块是空闲的,从而在
free时强制触发unlink合并
- 指针劫持
- dlmalloc:篡改
fd和bk指针 - RTL Heap:篡改
Flink和Blink指针 - 核心目标:利用“解链”或“入链”操作中的写入指令实现任意地址写入
- dlmalloc:篡改
| 操作系统 | 技术名称 | 核心宏 / 关键操作 | 偏移 (N) | 攻击策略 (构造方式) |
|---|---|---|---|---|
| Linux (dlmalloc) | Unlink 攻击 | FD->bk = BK |
12 | FD = 目标 - 12, BK = Shellcode地址 |
| Frontlink 攻击 | FD->bk = P |
12 | FD = 目标 - 12, (将当前块 P 的地址写入目标) |
|
| Double Free | FD->bk = BK |
12 | FD = 目标 - 12, BK = Shellcode地址 |
|
| Windows (RTL Heap) | 简单堆溢出 | Flink->Blink = Blink |
4 | Flink = 目标 - 4, Blink = Shellcode地址 |
| SEH 攻击 | Blink->Flink = Flink |
0 | Blink = SEH地址, Flink = Shellcode地址 |
|
| 写入已释放内存 | Flink->Blink = Blink |
4 | Flink = 目标 - 4, Blink = Shellcode地址 |
|
| Double Free | Flink->Blink = Blink |
4 | Flink = 目标 - 4, Blink = Shellcode地址 |
UAF (Use-After-Free) 漏洞检测与防御
释放后的内存被内存管理器回收,但程序仍通过旧指针进行访问
悬挂指针是 UAF 的诱因
后果:
- 读操作:可能读取到不正确的值(如果该内存已被重新分配)
- 写操作:可能损坏其他变量或损坏内存管理器的元数据(如
fd/bk指针) - 安全风险:它是许多漏洞利用的基础,且运行时错误极难诊断
缓解与工具:
- 编码规范:释放后立即将指针置为
NULL - 运行时工具:
- Purify/Valgrind:检测对已释放内存的读写
- Electric Fence:通过保护页技术,使对已释放内存的访问引发段故障
- Insure++:检查悬空指针 的使用
dlmalloc 内存块结构(Linux / Unix)
- 双重状态:内存块分为已分配 (Allocated) 和 空闲 (Free) 两种状态
- 边界标志:
- Size 字段:包含当前块的大小,且因为块大小总是偶数,其最低位被借用为 PREV_INUSE (P) 位
结构布局
0-4字节:前一个块的大小4-8字节:当前块的大小8-12字节:前向指针fd12-16字节:后向指针bk
P 位 (前一块的状态) |
“开始4字节” 的含义 | 目的 |
|---|---|---|
P = 1 (已分配) |
前一块的最后4字节数据 | 节约空间 |
P = 0 (空闲) |
前一块的大小 (Size) | 方便合并空闲块 |
- 空闲块组织:
- 空闲块通过双向链表组织
- 空闲块包含 fd (前向指针) 指向后一块,和 bk (后向指针) 指向前一块
- 指针存储在空闲块的用户数据区内
- 分筐管理 (Bins):空闲块按大小存放在不同的“筐”中,由头索引维护
- 筐中的块大约同样大小
- 缓存筐:包含最近释放块的筐
核心操作:Unlink 宏
1 |
|
P的下一个节点,存入FDP的上一个节点,存入BKP的下一个节点的 “back” 指针指向P的上一个节点 (BK)P的上一个节点的 “forward” 指针指向P的下一个节点 (FD)
触发时机
- free 时:如果被释放块的相邻块是空闲的,需要将其从链表中“脱链” (Unlink) 以便合并
- malloc 时:从空闲链表中取下一个块进行分配
漏洞利用技术
Unlink 攻击(任意地址写入)
- 利用
strcpy溢出 P 块,覆盖相邻块的元数据,FD为 目标地址-12,BK为 shellcode地址 - 强行将 FakeChunk 之后块的
PREV_INUSE置 0 - 执行
free(P),程序因标志位误判 FakeChunk 为空闲,启动合并 - 任意写入:合并调用
unlink(FakeChunk),执行FD->bk = BK- 结果:将 BK (Shellcode 地址) 写入 FD+12 (目标地址)
Frontlink 攻击
second块写好shellcode- 释放
fifth块,使其进入空闲链表(Bin) - 利用相邻
fourth溢出,改写已空闲fifth的fd指向 目标地址 - 12 - 释放
second块(与fifth大小相近),触发frontlink排序搜索,执行与fifth块合并 frontlink顺着被改写的fifth->fd找到了“假地址” 。执行入链指令FD->bk = P时,由于FD是假地址,程序误将second的地址 写入了 目标函数指针 的位置- 劫持执行:程序调用目标指针(如析构函数)时跳转至 Shellcode
入链代码
1 | P->bk = BK; // 将新块 P 的后向指针指向 BK |
双重释放
free(first)将块放入缓存筐- 释放
third块,将first移入普通筐并防止合并 - 再次
free(first),使同一块在链表中出现两次 sixth = malloc()返回first地址,但first仍留在空闲链表中- 通过
sixth指针修改空闲块 (first) 指针:fd = GOT-12,bk = Shellcode地址 - 解链操作:
seventh = malloc()再次请求同大小块,触发unlink(first) - 任意写实现:
unlink执行FD->bk = BK,将 Shellcode 地址写入 GOT 表中目标函数处
RTL Heap(Win32)
核心数据结构
PEB位于
PEB给出堆数据结构信息
- 堆的最大数量
- 堆的实际数量
- 默认堆的位置
- 一个指向包含所有堆位置的数组的指针
FreeList (空闲链表):
- 位于堆起始偏移 处
- 包含 128 个双向链表 的数组
Freelist[0]存放大于 1024 字节的块,按大小升序排列- 小块空闲链表:索引 (Index) = 块大小 / 8
- 例如,16字节的放在
Freelist[2],48字节的放在Freelist[6]
- 例如,16字节的放在

Look-aside List (后备缓存):
- 单向链表,用于加速小块内存(<1016 字节)分配
- 优先级最高:分配时先查 Look-aside,再查 FreeList
- 不合并:释放的块放入此处时不进行相邻合并,速度极快
- 自动增加频繁请求的大小的内存块,减少不再请求的
边界标志
位置:位于 HeapAlloc() 或 malloc() 返回地址的前 8 字节处
已分配块
- 自身大小 (Size):当前块的总长度
- 前一块大小 (Previous Size):物理相邻的前一个块的大小
- Busy 标志位:标记当前块正在使用,不可分配
空闲块
- 标志依然存在:释放后,Size 和 Previous Size 信息仍保留在边界标志中
- Busy 标志清空:Busy 位被设为 0,表示可被再次分配
- 新增指针:空闲块的用户数据区(原存放数据的地方)会被填入 flink (前向指针) 和 blink (后向指针),用于链入
Freelist
结构布局
0-4字节:Flink(前向指针)4-8字节:Blink(后向指针)
漏洞利用技术
简单堆溢出(覆盖返回地址)
- 释放留空:释放内存块
h2,使其进入双向空闲链表 - 溢出伪造:通过溢出相邻块
h1,覆盖h2的元数据,伪造其flink(目标地址 - 4) 和blink(shellcode地址) 指针 - 解链触发:再次申请内存触发“解链”操作,利用伪造指针执行任意地址写入,将返回地址覆盖为 Shellcode 地址
- 跳转执行:函数返回时劫持程序控制流,跳转至 Shellcode 执行
覆盖异常处理器(SEH 攻击)
- 通过
strcpy溢出,覆盖相邻空闲块,将flink覆盖为 Shellcode 地址,将blink覆盖为 异常处理函数(SEH)的存储地址 - 调用
HeapAlloc尝试分配该空闲块,触发 Unlink ,原本存储 异常处理函数指针 的内存被覆盖成 Shellcode 的地址 - 由于堆的元数据已被溢出破坏,引发 访问违例异常,执行攻击者的 Shellcode
若不知道 shellcode 的绝对地址,攻击者常利用“跳板”技术进行定位
写入已释放内存(UAF, Use-After-Free)
- 分配内存块 h1 后将其释放
- 程序错误地继续向已释放的 h1 写入数据,
flink改为 (待改写的目标地址 - 4),将blink替换为 shellcode 地址 - 下次
HeapAlloc分配内存时,重用这块内存,触发解链,将HeapFree的函数地址覆盖为 shellcode 地址,下次调用释放函数时触发攻击
双重释放
- 首次释放,内存块被放入空闲链表(Bin)中
- 再次释放同一块内存,导致链表逻辑错乱,产生一块“虚假空闲”区域
- 系统认为这块区域是“空闲”的,但它实际上正覆盖着程序正在使用的已分配内存
- 攻击者通过向重叠的已分配内存写入数据,伪造其中的
Flink(目标地址 - 4) 和Blink指针 (Shellcode 的起始地址) - 当程序下次申请内存执行“解链”操作时,利用伪造指针将 Shellcode 地址写入目标位置(如返回地址)
缓解策略
编程规范与约定
- 空指针策略:在完成
free()调用后,将指针置为NULL,以阻止写已释放内存 和双重释放漏洞 - 一致性管理:在同一模块和抽象层次中成对进行内存分配与释放,避免因分配与释放模式不匹配导致漏洞
内存管理器与系统增强
- 堆完整性检测:向堆结构添加随机 Canary 值,在内存块归还时比对校验和以检测堆修改
- 随机化技术:对
malloc()返回的地址进行随机化,使堆漏洞利用更加困难 - 哨位页:在已分配内存之间放置未映射的内存页,攻击者溢出触及时会引发段故障
- 空闲链表校验:如 Windows XP SP2 在从链表移除块之前,先验证前向指针(flink)和后向指针(blink)的有效性
- 安全分配器(PhkMalloc):能够检测所有双重释放错误,并提供 Junk 填充(写入垃圾数据)等检测功能
运行时分析工具
- 存取与破坏检测:
- Purify:检测内存破坏、泄漏及已释放内存的读写
- Valgrind:用于评测和调试 Linux 上的内存错误
- GNU checker:在程序读取未初始化变量或非法存取时发出警告
- 缓冲区与堆溢出检测:
- Electric Fence:通过在内存前后增加不可访问页来捕捉溢出
- Application Verifier:利用 Page Heap 工具检测堆破坏、泄漏及差一错误
- 静态与运行时结合:
- Insure++:通过在代码中插入测试函数,检查悬空指针、多次释放及非法指针参数
第五章:整数安全
整数表示与类型
三种表示法
- 原码:最高位为符号位(0正1负),其余为绝对值
- 反码:正数同原码;负数在原码基础上,除符号位外按位取反
- 补码:正数同原码;负数在反码基础上末位加1
- 优点:0只有一种表示(+0),最高位兼任符号位
取值范围(位补码)
- 带符号整数: 到
- 无符号整数: 到
整数取值范围最大值和最小值取决于
- 该类型的表示法
- 是否带符号
- 分配的内存位数大小
整数转换规则
整型提升
- 规则:比
int小的类型(char、short)操作前先转为int - 例外:如果
int无法容纳原类型所有值,则转为unsigned int - 适用场景:一元操作符、移位操作、算术运算
转换级别
- 级别排序:
Long long>Long>Int>Short>Signed char - 安全性总结:
- 从小到大:安全(零扩展或符号扩展)
- 从大到小:不安全(截断导致数据丢失)
- 符号互转:位模式保留,但最高位解释改变(数据曲解)
普通算术转换
- 同符号取大,异符号看下表
| 情形 | 判定条件 | 最终转换目标 | 安全性 |
|---|---|---|---|
| 无符号强 | 无符号级别 带符号级别 | 无符号类型 | 高风险(负数变正数) |
| 带符号极强 | 带符号级别高 且 范围全覆盖 | 带符号类型 | 安全 |
| 带符号略强 | 带符号级别高 但 无法全覆盖 | 带符号对应的无符号版 | 高风险 |
运算异常检测
标志位(IA-32)
- 溢出标志 (OF):指示带符号算术溢出
- 进位标志 (CF):指示无符号算术溢出(回绕)
数据扩展差异
movsx:如果原数最高位是 1,扩展的高位全补 1;若是 0 则补 0movzx:无论原数是什么,高位一律补 0
异常情况
- 有等号必有截断;除与余不谈回绕; 加减乘左移溢出 与回绕
后验检测法的局限性
- 对于除法:不设置溢出标志位,必须依赖硬件触发的中断向量 0、Linux 的
SIGFPE信号 或 Windows 的结构化异常处理(SEH)来捕获 - 对于乘法:乘法的后验检测需要双倍宽度的存储空间来评估结果积,比加法更复杂
- 编译器优化:可能把后验代码自动删除
strlen 返回值类型与截断风险
strlen()返回的是size_t类型 (unsigned long int),若将其赋值给较小的类型,截断导致malloc分配的内存远小于实际需求,但后续strcpy等函数仍按原长度拷贝,从而引发堆缓冲区溢出
整数加法 (+)
| 数据类型 | 扩展指令 | 加法指令 | 溢出标志位 | 跳转指令(溢出/无溢出) |
|---|---|---|---|---|
| unsigned char | movzx (零扩展) |
add |
CF (进位) | jc / jnc |
| signed char | movsx (符号扩展) |
add |
OF (溢出) | jo / jno |
| 32位 int | 直接 mov |
add |
有符号看OF,无符号看CF | 同上 |
| 64位 long long | 无(分两步) | add + adc(带进位) |
有符号看OF,无符号看CF | 同上 |
先验条件(防止溢出发生)
在执行 之前进行判定:
- 无符号加法:检查是否满足 。如果满足,则相加会溢出
- 带符号加法:
- 正数 + 正数:若 ,则发生溢出
- 负数 + 负数:若 ,则发生溢出
- 正数 + 负数:不可能溢出
后验条件(评估计算结果)
在执行加法得到结果 sum 后进行判定:
- 无符号整数:如果
sum比任意一个操作数都小,则表明发生了溢出 - 带符号整数:
- 如果 且 ,则溢出
- 如果 且 ,则溢出
整数减法 (-)
64 位(Long Long)减法
- 低 32 位运算:使用
sub指令进行减法。如果低位不够减,会将 进位标志(CF) 置 1,表示产生借位 - 高 32 位运算:使用
sbb指令。它会计算:高位A - (高位B + CF)
先验条件
| 数据类型 | 操作数情况 | 判定条件(满足则溢出/回绕) |
|---|---|---|
| 无符号 (Unsigned) | 任何情况 | (发生下溢/回绕) |
| 带符号 (Signed) | 同号 | 不可能溢出 |
| 带符号 (Signed) | 异号 (LHS 负, RHS 正) | |
| 带符号 (Signed) | 异号 (LHS 正, RHS 负) |
会直接导致溢出
后验条件
- 无符号整数:如果 ,则表明发生了溢出(回绕)
- 带符号整数:
- 若 且 ,则发生溢出
- 若 且 ,则发生溢出
整数乘法 (*)
| 操作数宽度 | 汇编指令 (无符号/有符号) | 结果存储寄存器 | 说明 |
|---|---|---|---|
| 8 位 | mul / imul |
AX (16位) | AL 源操作数 |
| 16 位 | mul / imul |
DX:AX (32位) | DX 存高位,AX 存低位 |
| 32 位 | mul / imul |
EDX:EAX (64位) | EDX 存高位,EAX 存低位 |
| 无符号8位 | mulb |
AX (16位) | AL 源操作数 |
| 溢出是指超过原来的操作数宽度,而不是超过扩展的操作数宽度 |
溢出标志位判定
- 置位 (CF=1, OF=1):如果积的高位部分(超出目标类型宽度的部分)包含有效数据
- 清除 (CF=0, OF=0):如果不需要高位来表示积(即高位全为 0),则标志位被清除
编译器实现的差异 (Visual C++ vs. g++)
| 维度 | Visual C++ | g++ |
|---|---|---|
| 字符乘法 (8-bit) | 先扩展 (movsx/movzx) 再统一用 imul 运算 |
直接使用字节指令 mulb (依赖补码) |
| 整数乘法 (32-bit) | 统一使用 imul |
统一使用 imul |
先验条件
在执行 之前,检查是否满足:
缺点:虽然安全,但除法运算的开销比乘法更大
后验条件
- 16位带符号技巧(充要条件): 为结果,若,则说明溢出
| 判定对象 | 溢出判定标准 (满足则为溢出) | 原理说明 |
|---|---|---|
| 无符号整数 | 高半部分 | 如果高位有任何一个 1,说明低位装不下了 |
| 带符号整数 | 高半部分不全是低半部分符号位的拷贝 | 没溢出时,高位应仅用于“符号扩展”(全 0 或全 1) |
整数除法 (/)
- 无符号 (
div);有符号 (idiv) - 不设置溢出或进位标志位
- 性能极慢
- 若除数为常量,常优化为乘法+右移
被除数的位数必须是除数的两倍(下面都是以32位除数为例)
| 角色 | 寄存器 / 要求 |
|---|---|
| 被除数 | edx:eax (64位) |
| 除数 | 32位,寄存器、内存或立即数 |
| 商 | eax |
| 余数 | edx |
高位扩展
- 程序员手动填充
edx寄存器以匹配 64 位的被除数要求 - 无符号除法 (
div):直接将edx清零(xor edx, edx),因为高位没有符号 - 有符号除法 (
idiv):必须进行符号扩展。使用cdq指令,将eax的最高位(符号位)拷贝到edx的所有位中
先验条件
- 防止溢出:检查分子是否为整型的最小值且分母是否为
-1 - 防止除零:确保除数不为
0
硬件层面的错误检测
- 由于除法不设置标志位,硬件通过触发中断来响应错误
- 错误有以下两种
- 除数为 0
- 商溢出:结果超出了目标寄存器(存商的)能表示的范围
平台处理机制对比
| 层面 | 响应方式 | 说明 |
|---|---|---|
| 硬件层 (Intel CPU) | 中断向量 0 | 处理器恢复到错误指令之前的状态 |
| Linux 环境 | SIGFPE 信号 | 将硬件异常转换为信号机制 |
| Windows 环境 | SEH (结构化异常处理) | 使用 __try / __except 捕获硬件异常 |
整数漏洞
- 基本定义:当程序对一个整数求出了一个非期望中的值时,软件漏洞就可能出现。漏洞是由数据丢失或错误的表示产生的
- 安全后果:当整数用于内存分配(
malloc参数)、数组索引、循环计数器或长度指示时,常诱发缓冲区溢出 - 产生原因:数据丢失或错误的表示
三大核心漏洞类型
| 漏洞类型 | 触发机制 | 核心原理与后果 |
|---|---|---|
| 整数溢出 | 操作结果超出该类型的表示范围 | 无符号数:发生“回绕” 带符号数:进位改变符号位 |
| 截断错误 | 将较大整型赋给较小整型 | 原值的高位被丢弃,仅保留低位 |
| 符号错误 | 带符号与无符号类型相互转换 | 位模式保留,但最高位的解释改变 1. 无转带:最高位置位则变负数 2. 带转无:负数变成极大的正数 |
典型逻辑漏洞举例
算术溢出类
- JPEG 注释域漏洞:读取长度域
len后执行size = len - 2。若len = 1,减法下溢产生极大正数,导致malloc分配异常或memcpy溢出 - 内存分配 (calloc/new):在计算
元素大小 * 元素个数时发生溢出,导致实际分配空间极小,后续写操作引发堆溢出
符号与逻辑绕过类
- NetBSD 范围检查绕过:代码为
if (off > len - sizeof(type))。因sizeof返回无符号size_t,导致整个减法按无符号计算,若len小于sizeof则产生极大正数绕过检查(if 内语句不被执行) - 负数检查绕过:长度变量声明为带符号
int,仅检查上限if (len < MAX)。攻击者输入负数绕过检查,在memcpy中被隐式转为巨大无符号数导致溢出 - Bash 变量声明错误:变量错误声明为
char *,在赋值给int时发生符号扩展 。特定字符(如 255)变为 -1,被解析器误认为命令结束标志
类型转换类
- 数据截断漏洞:将
long类型的cbBuf赋给unsigned short的bufSize。若原值过大发生截断,导致用小缓冲区接收大原始数据
缓解策略
显式查边界,ADT 封装严; SafeInt 算加减,编译器选陷阱; 审计看转换,测试攻极限
范围检查 (第一道防线)
- 输入验证:显式的上下界检查
- 双重检查:隐式检查(如声明为无符号整型)和显式检查(如验证数组边界)
强类型与抽象数据类型
- 强类型:利用编译器识别范围问题,如使用
unsigned保证非负 - 抽象数据类型 (ADT):将变量封装为私有成员,仅通过安全的方法访问以保证数值始终有效
编译器选项
- Visual C++:开启级别 4 警告检测数据丢失;使用
/RTCc报告截断错误 - GCC:使用
-ftrapv为带符号整数的溢出产生运行陷阱
安全整数操作库
对于受外部不确定来源影响的整数操作(如计算结构大小、分配元素个数),建议使用专门的安全库 :
- Howard 库 ©:利用 IA-32 汇编指令(如进位标志
jc)实时检测溢出 - SafeInt (C++):模板类,在运算前测试操作数,通过异常处理错误,移植性好
- GMP 库:用于需要任意精度算术运算的特殊场景
测试(动态)
- 测试所有整型变量的最大值与最小值
- 根据源码逻辑(白盒)或数据类型范围(黑盒)测试边界条件
- 提升代码安全性信心
审计(静态)
- 验范围:是否对所有整数进行了彻底的范围与用途约束检查
- 控非负:索引、大小、计数器是否声明为 unsigned 且有上下界检查
- 用安全库:所有来自不确定来源的操作是否使用了安全整数库
第六章:格式化输出
变参函数
- 定义:指参数数目可变的函数,通常由一个格式字符串 和可变数目 的参数构成
- 核心特征:
- 在省略号之前通常需要一个或多个固定参数(定参)
- 省略号必须出现在参数列表的最后
- 编译器对变参列表中的参数不会执行任何类型检查
- 安全风险:由于C语言中变参函数实现的局限性,如果用户能控制格式字符串的内容,即可控制输出函数的执行,从而产生格式化字符串漏洞
实现机制(ANSI C 标准)
通过 stdarg.h 头文件中的宏来处理可变参数
| 宏名称 | 功能描述 |
|---|---|
va_list |
定义一个指向参数列表的字符指针 |
va_start(ap, v) |
初始化参数指针 ap,使其指向最后一个定参 v 之后的第一个变参(最后一个定参地址 + 类型大小) |
va_arg(ap, t) |
根据指定的类型 t 获取当前参数的值,并将指针 ap 递增到下一个参数 |
va_end(ap) |
完成清理工作,在函数返回前将参数指针置为无效(0) |
参数终止契约
开发者必须与调用者达成契约:
- 指定值终止:例如在
average()函数中,约定以 作为参数列表的结束标志 - 潜在风险:忘记传入终止符将持续读取栈内存,直到遇到 或发生异常
变参函数的正常运作依赖于特定的函数调用约定和参数在栈中的连续压入
调用约定对比
| 特征 | _cdecl | _stdcall | _fastcall |
|---|---|---|---|
| 参数压栈方向 | 右 -> 左 | 右 -> 左 | 左边前两个 字节参数入寄存器(ECX, EDX),其余右 -> 左 |
| 清理栈方式 | 调用者清理 | 被调用函数清理 | 被调用函数清理 |
| 变参支持 | 支持(必备条件) | 不支持 | 不支持(变参需压栈) |
安全的变参函数实现
防止格式化输出漏洞导致的参数越界访问:
- 限制参数个数
- 修改宏定义示例:
- 在
va_start中初始化一个计数器va_count - 在
va_arg中每次调用时使va_count-- - 若
va_count == 0时仍请求参数,则强制终止程序(abort())
- 在
- 编译器支持:通过汇编指令在调用前将参数个数压入栈中,供变参函数内部校验使用
格式化输出函数
格式化输出函数根据格式字符串提供的指令,解释并执行相应的参数转换与输出
常用函数及其特性
| 函数分类 | 函数名称 | 输出目标与特性 |
|---|---|---|
| 标准输出 | printf() |
等同于 fprintf(),但输出流固定为 stdout |
| 流输出 | fprintf() |
按照格式字符串的内容将输出写入指定的流中 |
| 数组输出 | sprintf() |
将结果写入字符数组(字符串缓冲区)中 |
| 安全数组输出 | snprintf() |
指定写入字符最大值 ,防止缓冲区溢出,并在末尾添加空字符 |
| 系统日志 | syslog() |
SUSv2标准函数,接受优先级参数并在系统日志中生成消息 |
当参数列表在运行时决定时,以下带 v 前缀的函数接受 va_list 类型的参数:
vfprintf()、vprintf()、vsprintf()、vsnprintf()
格式字符串的语法结构
% [flags] [width] [.precision] [length-modifier] conversion-specifier
- 必需域:转换指示符,必须出现在所有可选域之后
- 可选域:
- 标志 (flags):调整符号、空白、八进制/十六进制前缀等
- 宽度 (width):指定输出的最小字符数;若用星号
*,由参数列表提供 - 精度 (.precision):指示打印位数、小数位或字符串截断长度
- 长度修饰符:指明参数的大小(如
ld表示 long int)
参数匹配要求与风险
- 参数过多:多余参数被忽略
- 参数不足:执行结果是未定义的,这是格式化字符串漏洞产生的根源
- 特殊指示符
%n:不输出字符,而是将到目前为止已成功输出的字符总数写入对应的整数指针参数所指的地址中
返回值与溢出
- 返回值:格式化输出函数以
int形式返回输出的总字符数 - 溢出风险:若总数超过
INT_MAX,会导致带符号整数溢出,返回负值
漏洞利用方式
| 指示符 | 利用功能 | 原理描述 |
|---|---|---|
%x / %p |
泄露栈内容 | 将栈上内容以十六进制/指针格式输出 |
%s |
读取任意内存 | 将对应参数视为地址,输出该地址指向的字符串 |
%n |
覆写内存 | 不输出内容,而是将已输出字符总数写入对应地址 |
使程序崩溃
- 操作:连续使用多个
%s - 原理:由于没给参数,函数会把栈上的随机数据当成地址去读。读到无效指针或未映射地址时,系统触发段错误(SIGSEGV)导致崩溃
内存内容泄露
- 查看栈内容:使用
%08x或%p指示符,通过内部参数指针的连续递增(多个%08x),按序显示栈上的变量、返回地址及参数 - 查看任意地址内存:构造格式字符串
[目标地址] + [%x步进] + [%s]- 利用
%x将指针移动到特定地址,再用%s将其内容作为 ASCII 字符串输出直至遇到空字节
- 利用
缓冲区溢出与“可伸展”缓冲区
- 普通溢出:
sprintf()等函数假定缓冲区无限长。若%s替换的用户输入超过缓冲区长度,会导致溢出 - 缓冲区伸展:通过宽度修饰符(如
%497d)强制输出大量填充字符,原本较短的内容被“伸展”到超过目标缓冲区的容量,从而覆写返回地址
覆写内存
%n:被用于改动程序流程(如覆写返回地址或 GOT 表)并执行恶意代码- 控制写入数值:通过操纵格式字符串中的宽度来控制写入的具体数值
- 例如:
printf("%100u%n", 1, &i);会使变量i变为 100
- 例如:
任意地址写入技术:四步写入法
为了写入一个 32 位的完整地址,通常分 4 次调用、每次递增地址并写入 1 个字节
| 步骤 | 操作 | 影响 |
|---|---|---|
| 1 | 写入第 1 个字节 | 覆写目标内存起始位置 |
| 2 | 递增地址并写入第 2 个字节 | 保留低/高位内存的字节尾值 |
| 3 | 写入第 3 个字节 | 逐步构建完整地址 |
| 4 | 写入第 4 个字节 | 完成 32 位值的构建 |
直接参数存取 (DPA)
- 语法:使用
%n\$(n 为十进制整数),允许直接指定参数列表中的第 n 个参数 - 优势:无需使用大量的步进指示符(如
%x)来移动指针,且支持同一个参数被多次引用
1 | int i, j, k = 0; |
| 指令 | 核心逻辑 (操作对象与方式) | 结果 / 变量赋值 |
|---|---|---|
%4$5u |
取第4个参数 (值5),以宽度5打印 | 打印 5,总字数 = 5 |
%3$n |
将总字数写入第3个参数 (&i) |
i = 5 |
%5$5u |
取第5个参数 (值6),以宽度5打印 | 打印 6,总字数 = 10 |
%2$n |
将总字数写入第2个参数 (&j) |
j = 10 |
%6$5u |
取第6个参数 (值7),以宽度5打印 | 打印 7,总字数 = 15 |
%1$n |
将总字数写入第1个参数 (&k) |
k = 15 |
国际化漏洞
攻击者可通过修改程序在运行时打开的外部目录或消息文本文件,从而控制格式字符串的值
栈随机化 ASLR
- 栈布局(Linux):在 Linux 系统中,栈通常从高内存地址(如
0xC0000000)开始,并向低内存方向增长 - 实现机制:通过在程序启动时向栈中插入随机的间隙来实现随机化
- 保护目标:使攻击者难预测栈上关键信息的位置,包括返回地址、自动变量和插入的 Shellcode 地址
局限性与绕过方法
转移 Shellcode 的存放位置
- 利用非栈区域
- 将 Shellcode 插入到数据段或堆上的变量中,更容易被定位
利用相对距离的稳定性
- 参数指针与格式字符串起始位置之间的相对距离(偏移量) 通常保持不变
- 只需计算两者之间的距离,并插入相应数量的
%x格式转换规范即可
寻找非随机化的攻击目标
- 覆写全局偏移表(GOT)入口
- GOT 入口独立于栈和堆等系统变量,其位置相对固定,不受栈随机化的影响
Linux 覆写 GOT 入口的步骤
- 植入 Shellcode:在程序中定义静态变量,被存储在进程的数据段
- 确定攻击目标:
- 覆写
exit()或其他在程序结束前必调用的库函数 GOT 条目 - 获取目标函数在 GOT 中的固定地址(如
0x08049bb4)
- 覆写
- 构造格式化字符串(分段写入)
- Linux 常用“双字(Word/2字节)”格式写入
- 计算宽度:
(目标Word值 - 当前已输出总数) % 0x10000 - 通过操纵
%u的宽度字段,使%n写入的值正好等于 Shellcode 的起始地址
- 触发漏洞:执行
printf(format_str)后,目标函数的 GOT 地址被篡改为 Shellcode 地址,调用时执行 Shellcode
Windows vs Linux 差异
| 特征 | Windows 常用方式 | Linux 变体方式 |
|---|---|---|
| 写入单位 | 每次 1 字节(分 4 次写入) | 每次 2 字节(分 2 次写入) |
| 目标地址 | 常用栈上的返回地址 | 常用 GOT 入口地址 |
| 主要障碍 | 栈地址随机化程度高 | 格式字符串中包含空字节 |
缓解与防御策略
- 根本原则:不允许使用动态格式字符串
- 最关键准则:不要将来自非信任源的输入直接合并到格式字符串中
编码层面的预防
限制写入量
- 精度限制:在
%s中指定精度(如%.495s)防止缓冲区溢出
使用安全替代函数
- 安全库函数:用
snprintf()、vsnprintf()替代sprintf() - GNU 扩展:
asprintf()和vasprintf()会自动为输出分配足够空间 - 增强安全 (
_s):使用printf_s()等函数,其特点是不支持%n且检查空指针
采用 C++ iostream 机制
- 流机制不使用格式字符串,从根源消除风险
测试与编译器选项 (GCC)
-Wformat-nonliteral (最严) 包含 -Wformat-security (针对风险) 包含 -Wformat (基础)
-Wformat-nonliteral(管变量,只要不是写死的字符串就报)-Wformat-security(非字面量且无参数)-Wformat(类型对不对)
自动化漏洞分析技术
- 词法分析 (
pscan):扫描非静态格式字符串,速度快但易误报 - 静态污点分析 (
cqual):追踪非信任源数据流向,若流向格式串则警告 - 静态二进制分析:检查二进制映像中的栈修正值,判断参数个数是否异常
系统与库级保护方案
- Exec Shield (Linux):一种针对 IA-32 的安全特征,实现栈、堆和共享库位置的随机化 (ASLR)
- FormatGuard:动态检测参数个数是否匹配
- Libsafe:通过共享库拦截有漏洞的函数调用,检查参数地址是否超出当前栈帧







