常见漏洞类型
本 Workshop 会介绍软件中最常见的漏洞类型。理解这些漏洞类别有助于具体检测的实现。
1. 栈缓冲区溢出
描述
栈区(stack)是程序运行时用于存储局部变量和函数调用信息的内存区域
栈缓冲区溢出是在程序向栈上分配的缓冲区写入超过其容量的数据时,会覆盖栈上相邻的内存。这可能导致局部变量、保存的帧指针和返回地址破坏。
常见原因
- 使用不带边界检查的不安全字符串/内存赋值/初始化函数
示例
void vulnerable_function(char *user_input) {
char buffer[64];
strcpy(buffer, user_input); // 没有边界检查!
}
int main() {
char malicious[256];
memset(malicious, 'A', 255);
malicious[255] = '\0';
vulnerable_function(malicious); // 溢出!
return 0;
}
安全影响
不劫持控制流的情况:
-
局部变量被破坏可能改变程序逻辑,从而为后续利用提供空间
-
破坏栈帧
-
通过崩溃导致拒绝服务
-
如果敏感数据被覆盖/泄露,可能导致信息泄露
劫持控制流的情况:
-
覆盖返回地址可以重定向程序执行(控制流劫持)
-
攻击者可以执行任意代码(ROP--return oriented programming)
-
如果程序以高权限运行,可能导致系统完全沦陷
2. 释放后使用(UAF)
描述
Use after free (UAF) 漏洞发生在程序的堆区。堆是用于动态内存分配的内存区域。
漏洞发生在程序在内存被释放后继续使用指向该内存的指针,大家初学 C 语言的时候可能就听说过 dangling pointer 的说法,那就是了。被释放的内存可能被重新分配用于其他目的,导致数据损坏或代码执行。即使被释放的内存没被用作其他目的,也有可能包含敏感信息,如堆地址,语言库地址等。
常见原因
- 释放后未将悬空指针设为 NULL
- 大型代码库中复杂的对象生命周期
- 引用计数错误
- 异步回调访问已释放的对象
- 错误处理路径释放了内存但未更新所有引用
示例
// TODO 给出一个 UAF 未被占用,但是含有堆上敏感信息(如堆/libc 地址)的例子
struct User {
char name[32];
int privilege_level;
};
void vulnerable() {
struct User *user = malloc(sizeof(struct User));
strcpy(user->name, "guest");
user->privilege_level = 0;
free(user); // 内存已释放
// ... 稍后在代码中,可能在错误处理路径中 ...
// 攻击者触发相同大小的分配
char *attacker_data = malloc(sizeof(struct User));
memset(attacker_data, 0x41, sizeof(struct User));
// UAF:user 指针仍然引用已释放(现在被重用)的内存
if (user->privilege_level != 0) {
grant_admin_access(); // 被利用!
}
}
安全影响
- 通过控制已释放内存的内容实现任意代码执行
- 信息泄露
- 浏览器和内核中最常被利用的漏洞类型之一
3. 堆缓冲区溢出/下溢
描述
堆溢出发生在写入堆分配缓冲区的数据超出其边界时,会破坏相邻的堆元数据或其他堆对象。堆下溢则是在分配缓冲区起始位置之前(低地址处)写入数据。
常见原因
- 动态分配的大小计算错误
- 数组索引缺少边界检查
- 整数溢出导致分配过小
示例
// 堆溢出
void heap_overflow_example(int user_size) {
char *buffer = malloc(100);
char *sensitive = malloc(100);
strcpy(sensitive, "SECRET_API_KEY");
// 用户控制写入的数据量
read(fd, buffer, user_size); // 如果 user_size > 100,溢出!
}
// 堆下溢
void heap_underflow_example(int index) {
int *array = malloc(10 * sizeof(int));
// 负索引在缓冲区起始位置之前写入
if (index < 10) { // 缺少 index >= 0 的检查
array[index] = attacker_value; // 如果 index < 0,下溢
}
}
安全影响
- 破坏堆元数据可实现任意写入原语
- 覆盖相邻对象的函数指针
- 任意代码执行
- 从相邻缓冲区泄露信息
4. 双重释放
描述
双重释放发生在堆区,对同一内存地址调用两次 free()。这会破坏堆分配器的内部数据结构,可能导致任意内存写入。
常见原因
- 具有多个错误处理路径的复杂控制流
- 共享指针被多个所有者释放(refcount 错误或者失效)
- 释放后缺少 NULL 赋值
- 多线程代码中的竞态条件
示例
void double_free_example(int error_condition) {
char *ptr = malloc(64);
if (error_condition) {
free(ptr);
// 缺少 return 或 ptr = NULL
}
// ... 更多代码 ...
free(ptr); // 如果 error_condition 为真,双重释放!
}
// 另一个常见模式
void shared_resource_bug() {
char *shared = malloc(64);
thread1_cleanup(shared); // 释放 shared
thread2_cleanup(shared); // 双重释放!
}
安全影响
- 堆元数据损坏
- 任意写入原语(write-what-where)
- 通过损坏的分配器结构执行代码
- 通常与堆喷射结合以实现可靠利用
5. 整数溢出/下溢
描述
整数溢出发生在算术运算产生的值超出整数类型可表示的范围时。对于无符号整数会发生回绕,对于 C 语言中的有符号整数则导致未定义行为。
常见原因
- 乘以用户控制的值时未检查溢出
- 加法运算时未考虑最大边界
- 不同整数大小之间的类型转换
- 对大小/长度使用有符号整数
示例
void integer_overflow_example(uint32_t user_count) {
// 如果 user_count = 0x40000001,乘法溢出
// 0x40000001 * 4 = 0x100000004,但被截断为 0x4
uint32_t size = user_count * sizeof(int);
int *array = malloc(size); // 只分配了 4 字节!
for (uint32_t i = 0; i < user_count; i++) {
array[i] = 0; // 大规模堆溢出!
}
}
安全影响
- 导致缓冲区分配过小
- 引发缓冲区溢出/下溢
- 绕过安全检查
- 可能影响财务计算和访问控制决策
6. 竞态条件(包括 TOCTOU)
描述
竞态条件(Race Condition)发生在软件的行为依赖于不可控事件的时序或顺序时。
Race condition 存在 3 个必要的触发条件: 1. 存在共享资源(文件、内存、设备等) 2. 多个线程/进程可以访问该资源 3. 访问该资源的操作之间存在可利用时间窗口
TOCTOU (Time Of Check To Time Of Use)是一种特定类型,其中存在共享资源,并且有多个线程/进程可以访问。对该资源的检查操作和使用操作之间存在时间差,攻击者可能在这段时间内改变资源状态,导致安全漏洞。
常见原因
- 对共享资源的非同步访问
- 没有适当锁定的文件操作
- 没有原子性的先检查后执行模式
- 信号处理程序修改全局状态
示例
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <unistd.h>
#define BUFFER_LIMIT 64
// Shared global variables - the vulnerability
volatile size_t size = 32;
char *user_data = NULL;
// Simulated "secure" destination buffer
char secure_buffer[BUFFER_LIMIT];
// Flag to coordinate the attack
volatile int check_passed = 0;
void *victim_thread(void *arg) {
printf("[VICTIM] Starting secure copy operation...\n");
// TIME-OF-CHECK: Validate size is within bounds
if (size <= BUFFER_LIMIT) {
printf("[VICTIM] Size check PASSED: %zu <= %d\n", size, BUFFER_LIMIT);
check_passed = 1; // Signal to attacker
// ======== RACE WINDOW ========
// Simulate some processing delay (realistic: context switch,
// preemption, or just unlucky scheduling)
usleep(100000); // 100ms gap for demonstration
// ==============================
// TIME-OF-USE: Copy using the (now potentially corrupted) size
printf("[VICTIM] Copying %zu bytes to secure_buffer...\n", size);
// VULNERABILITY: 'size' may have changed!
// This can overflow secure_buffer
memcpy(secure_buffer, user_data, size);
printf("[VICTIM] Copy complete. Buffer contents: \"%s\"\n", secure_buffer);
} else {
printf("[VICTIM] Size check FAILED: %zu > %d\n", size, BUFFER_LIMIT);
}
return NULL;
}
void *attacker_thread(void *arg) {
printf("[ATTACKER] Waiting for size check to pass...\n");
// Wait for victim to pass the check
while (!check_passed) {
// Spin wait
}
// ATTACK: Modify size after check but before use
printf("[ATTACKER] Check passed! Corrupting size value...\n");
size = 256; // Overflow! Much larger than BUFFER_LIMIT
printf("[ATTACKER] Size changed to %zu (buffer overflow incoming!)\n", size);
return NULL;
}
int main(void) {
pthread_t victim, attacker;
// Allocate and initialize user-controlled data
user_data = malloc(512);
if (!user_data) {
perror("malloc");
return 1;
}
// Fill with recognizable pattern
memset(user_data, 'A', 511);
user_data[511] = '\0';
// Initialize secure buffer
memset(secure_buffer, 0, BUFFER_LIMIT);
printf("=== TOCTOU Race Condition Demo ===\n");
printf("secure_buffer size: %d bytes\n", BUFFER_LIMIT);
printf("Initial size value: %zu bytes\n\n", size);
// Create threads
pthread_create(&attacker, NULL, attacker_thread, NULL);
pthread_create(&victim, NULL, victim_thread, NULL);
// Wait for completion
pthread_join(victim, NULL);
pthread_join(attacker, NULL);
printf("\n=== Post-exploit Analysis ===\n");
printf("Final size value: %zu\n", size);
printf("Bytes written beyond buffer: %zu\n",
(size > BUFFER_LIMIT) ? size - BUFFER_LIMIT : 0);
// In a real scenario, this overflow would corrupt adjacent memory:
// - Stack canaries
// - Return addresses
// - Adjacent heap metadata
// - Function pointers
free(user_data);
return 0;
}
安全影响
- 权限提升
7. API 误用
描述
API 误用发生在开发者错误地使用库函数、系统调用或框架 API,违反其预期契约或安全假设时。
常见原因
- 误解 API 文档
- 忽略返回值和错误码
- 参数顺序错误
- 对默认行为的错误假设
- 未遵循所需的初始化/清理顺序
示例
// 误用 strncpy - 如果 src >= n 不会添加空终止符
void strncpy_misuse(char *input) {
char buffer[32];
strncpy(buffer, input, sizeof(buffer));
// buffer 可能没有空终止符!
printf("%s", buffer); // 读取超出缓冲区
}
// 忽略返回值
void unchecked_return() {
char *ptr = malloc(HUGE_SIZE);
// 没有 NULL 检查 - ptr 可能是 NULL
memcpy(ptr, data, size); // 崩溃或更糟
}
// 错误的 OpenSSL 用法
void weak_crypto() {
// 使用已弃用/弱加密算法
EVP_des_ecb(); // DES 已被破解,ECB 模式不安全
// 未检查证书验证
SSL_CTX_set_verify(ctx, SSL_VERIFY_NONE, NULL); // 可能遭受中间人攻击!
}
安全影响
- 不安全函数使用导致的缓冲区溢出
- 空指针解引用
- 认证绕过
- 内存泄漏导致拒绝服务
8. 逻辑漏洞
描述
逻辑漏洞是程序逻辑中的缺陷,导致程序行为不正确。与内存损坏漏洞不同,它们不违反内存安全,但违反了预期的安全策略。
这种漏洞很难自动化检测,至少是在 AI 时代来临之前。
常见原因
- 错误的条件表达式
- 运算符使用错误(= 与 ==,& 与 &&)
- 缺少或错误的状态验证
- 有缺陷的业务逻辑实现
- 不完整的输入验证
示例
真实世界中的逻辑漏洞会复杂得多,这里是一些简化的例子:
// 通过逻辑错误绕过认证
int authenticate(char *username, char *password) {
if (strcmp(username, "admin") == 0 || check_password(password)) {
return 1; // 错误:应该是 && 而不是 ||
}
return 0;
}
// 授权缺陷
int check_access(int user_role, int required_role) {
// 错误:使用赋值而不是比较
if (user_role = required_role) { // 总是为真,提升用户权限!
return 1;
}
return 0;
}
// 访问控制中的差一错误
int is_admin(int user_id) {
int admin_ids[] = {1, 5, 10};
for (int i = 0; i <= 3; i++) { // 错误:读取超出数组
if (admin_ids[i] == user_id) return 1;
}
return 0;
}
安全影响
- 认证绕过
- 授权失败
- 业务逻辑利用
- 数据完整性违规
- 通常比内存损坏漏洞更难检测
9. 使用危险函数
描述
某些函数本质上是不安全的,因为它们不执行边界检查或具有其他危险行为。使用这些函数,特别是处理不受信任的输入时,经常导致漏洞。
危险函数及更安全的替代方案
| 危险函数 | 问题 | 更安全的替代方案 |
|---|---|---|
gets() |
完全没有边界检查 | fgets() |
strcpy() |
没有边界检查 | strncpy()、strlcpy() |
strcat() |
没有边界检查 | strncat()、strlcat() |
scanf("%s") |
没有边界检查 | 带宽度的 scanf("%Ns") |
vsprintf() |
没有边界检查 | vsnprintf() |
示例
// gets() - 永远不要使用
void dangerous_gets() {
char buffer[64];
gets(buffer); // 已从 C11 标准中移除 - 总是有漏洞
}
// 无边界的 strcpy
void dangerous_strcpy(char *user_input) {
char dest[32];
strcpy(dest, user_input); // 如果输入 > 31 字符,溢出
}
// 无边界的 sprintf
void dangerous_sprintf(char *name) {
char greeting[64];
sprintf(greeting, "Hello, %s! Welcome to our service.", name);
// 长名字可能导致溢出
}
// 更安全的版本
void safer_alternatives(char *user_input) {
char dest[32];
// 使用 snprintf
snprintf(dest, sizeof(dest), "%s", user_input);
// 使用 fgets 进行输入
fgets(dest, sizeof(dest), stdin);
// 使用 strncpy(但记得添加空终止符!)
strncpy(dest, user_input, sizeof(dest) - 1);
dest[sizeof(dest) - 1] = '\0';
}
安全影响
- 导致代码执行的缓冲区溢出
- 栈破坏
- 信息泄露
- 拒绝服务
10. 其他漏洞类型
空指针解引用
void null_deref(struct Config *config) {
// 缺少 NULL 检查
int value = config->setting; // 如果 config 是 NULL,崩溃
}
影响: 拒绝服务,在某些情况下可利用来执行代码(特别是在内核中)。
使用未初始化内存
void uninitialized_var() {
int is_admin; // 未初始化
// ... 可能不会设置 is_admin 的代码路径 ...
if (is_admin) { // 使用垃圾值
grant_access();
}
}
影响: 信息泄露、不可预测的行为、认证绕过。
类型混淆
struct Animal { int type; };
struct Dog { int type; char name[32]; };
struct Cat { int type; int lives; };
void type_confusion(struct Animal *a) {
if (a->type == DOG) {
struct Dog *d = (struct Dog *)a;
printf("%s", d->name);
}
// 如果攻击者使 type=DOG 但对象是 Cat 会怎样?
// d->name 会读取 cat->lives 及之后的内存 - 信息泄露
}
影响: 内存损坏、代码执行、信息泄露。
权限误用
描述
这种问题是相当普遍的,
就像下面的例子中,Windows 内核中许多 API 使用 AccessMode 参数区分请求来源。当处理用户态输入时错误使用 KernelMode,会跳过关键的安全检查,允许用户直接操作内核内存。
常见的 AccessMode 敏感 API
| API | KernelMode 行为 | UserMode 行为 |
|---|---|---|
MmProbeAndLockPages |
不验证地址范围 | 检查地址 < 0x7FFFFFFF0000 |
ProbeForRead/Write |
跳过地址验证 | 验证地址在用户空间 |
Irp->RequestorMode |
信任请求来源 | 执行额外权限检查 |
示例:CVE-2023-29360
以下为简要实例,完整解析请参考 漏洞分析文章
// mskssrv.sys 中的漏洞函数
NTSTATUS FsAllocAndLockMdl(
PVOID AddressPtr, // 用户可控!
ULONG Length, // 用户可控!
PMDL *OutputMdl
)
{
PMDL mdl;
if (!AddressPtr || !Length || !OutputMdl)
return STATUS_INVALID_PARAMETER;
mdl = IoAllocateMdl(AddressPtr, Length, FALSE, FALSE, NULL);
if (!mdl)
return STATUS_INSUFFICIENT_RESOURCES;
// ❌ 漏洞:使用 KernelMode 不检查地址范围
// 攻击者可传入内核地址,创建指向内核内存的 MDL
MmProbeAndLockPages(mdl, KernelMode, IoWriteAccess);
*OutputMdl = mdl;
return STATUS_SUCCESS;
}
// ✅ 修复:改为 UserMode
MmProbeAndLockPages(mdl, UserMode, IoWriteAccess);
// 现在传入内核地址会抛出异常
安全影响
- 敏感信息泄露
- 内核任意地址读写
- 绕过 KASLR(配合信息泄露)
- 本地权限提升 (LPE)
Your Tasks
看到这里,你已经了解了常见的漏洞类型及其成因和影响。在完成接下来的 workshop 之前,你可以尝试这两个简单的 Tasks 来巩固自己对本 workshop 内容的理解:
-
将每个漏洞类型对应的示例代码补充完整至可编译,注意编译成的程序不一定会出现 segmentation fault 或者崩溃,但你可以通过调试器(如 gdb)观察内存状态来验证漏洞的存在,抑或是利用 sanitizers(如 ASAN)进行检测
-
尝试对栈溢出漏洞编写一个简单的利用代码(exploit)