安全函数与边界检查
00:00
C11 Annex K安全函数
概述
C语言的传统标准库函数(如 strcpy、sprintf、gets 等)缺乏边界检查,是缓冲区溢出漏洞的主要来源。C11 标准在 Annex K(边界检查接口)中引入了一系列带 _s 后缀的安全函数,要求调用者显式传入缓冲区大小,从而在编译期和运行期防止越界访问。此外,即使不使用 Annex K 函数,也可以通过 snprintf、strnlen 等替代方案实现安全的字符串操作。
基础概念
为什么需要安全函数
传统C函数的安全隐患:
// 危险:不检查目标缓冲区大小,可能导致溢出
char buf[10];
strcpy(buf, "这是一个很长的字符串"); // 缓冲区溢出!
sprintf(buf, "%d", 1234567890); // 可能溢出
gets(buf); // 最危险的函数,无法限制输入长度
Annex K 安全函数的设计原则
- 所有涉及缓冲区写入的函数都增加一个表示缓冲区大小的参数
- 函数返回
errno_t类型(通常是int),成功返回 0,失败返回非零错误码 - 如果检测到运行时约束违反(如空指针、缓冲区太小),会调用约束处理函数
- 约束处理函数可通过
set_constraint_handler_s自定义
安全函数与原函数对照
| 原函数 | 安全替代 | 说明 |
|---|---|---|
strcpy | strcpy_s | 字符串复制,需指定目标缓冲区大小 |
strcat | strcat_s | 字符串追加,需指定目标缓冲区大小 |
sprintf | sprintf_s | 格式化写入,需指定缓冲区大小 |
fopen | fopen_s | 打开文件,返回错误码而非指针 |
gets | gets_s | 读取输入,需指定缓冲区大小 |
scanf | scanf_s | 格式化输入,%s、%c、%[ 需指定长度 |
strncpy | strncpy_s | 带长度限制的复制 |
memcpy | memcpy_s | 内存复制,需指定目标和源大小 |
快速上手
使用安全函数的基本模式
#define __STDC_WANT_LIB_EXT1__ 1 // 启用 Annex K 扩展
#include <stdio.h>
#include <string.h>
int main(void) {
char dest[20];
const char *src = "Hello, World!";
// 使用 strcpy_s 替代 strcpy
errno_t err = strcpy_s(dest, sizeof(dest), src);
if (err != 0) {
printf("复制失败,错误码: %d\n", err);
return 1;
}
printf("结果: %s\n", dest); // 输出: Hello, World!
return 0;
}
使用 snprintf 替代 sprintf
在不支持 Annex K 的编译器上,snprintf 是最通用的安全替代方案:
#include <stdio.h>
int main(void) {
char buf[32];
int value = 42;
// snprintf 会自动截断,保证不会溢出
int len = snprintf(buf, sizeof(buf), "数值是: %d", value);
if (len < 0 || len >= (int)sizeof(buf)) {
printf("输出被截断\n");
}
printf("%s\n", buf);
return 0;
}
详细用法
strcpy_s 详细用法
#define __STDC_WANT_LIB_EXT1__ 1
#include <stdio.h>
#include <string.h>
int main(void) {
char dest[10];
// 正常复制
errno_t err = strcpy_s(dest, sizeof(dest), "Hi");
if (err == 0) {
printf("复制成功: %s\n", dest);
}
// 源字符串太长,目标缓冲区不够
err = strcpy_s(dest, sizeof(dest), "这是一个很长的字符串");
if (err != 0) {
printf("复制失败: 缓冲区太小\n");
}
return 0;
}
strcat_s 详细用法
#define __STDC_WANT_LIB_EXT1__ 1
#include <stdio.h>
#include <string.h>
int main(void) {
char buf[20] = "Hello";
// 安全追加字符串
errno_t err = strcat_s(buf, sizeof(buf), ", World!");
if (err == 0) {
printf("追加成功: %s\n", buf);
}
// 追加后超出缓冲区大小
err = strcat_s(buf, sizeof(buf), " 这是一段额外的文字");
if (err != 0) {
printf("追加失败: 缓冲区不足\n");
}
return 0;
}
sprintf_s 详细用法
#define __STDC_WANT_LIB_EXT1__ 1
#include <stdio.h>
int main(void) {
char buf[50];
int id = 1001;
double score = 95.5;
// 安全格式化写入
errno_t err = sprintf_s(buf, sizeof(buf), "学号: %d, 成绩: %.1f", id, score);
if (err == 0) {
printf("%s\n", buf);
}
return 0;
}
fopen_s 详细用法
#define __STDC_WANT_LIB_EXT1__ 1
#include <stdio.h>
int main(void) {
FILE *fp = NULL;
// 安全打开文件
errno_t err = fopen_s(&fp, "data.txt", "r");
if (err != 0) {
printf("文件打开失败,错误码: %d\n", err);
return 1;
}
// 正常读写操作
char line[256];
while (fgets(line, sizeof(line), fp)) {
printf("%s", line);
}
fclose(fp);
return 0;
}
scanf_s 详细用法
#define __STDC_WANT_LIB_EXT1__ 1
#include <stdio.h>
int main(void) {
char name[32];
int age;
// scanf_s 中 %s、%c、%[ 需要额外传入缓冲区大小
printf("请输入姓名和年龄: ");
int result = scanf_s("%31s", name, (unsigned)sizeof(name), "%d", &age);
if (result == 2) {
printf("姓名: %s, 年龄: %d\n", name, age);
} else {
printf("输入格式错误\n");
}
return 0;
}
常见场景
场景一:安全读取用户输入
#include <stdio.h>
#include <string.h>
// 安全读取一行输入,防止缓冲区溢出
int safe_read_line(char *buf, size_t buf_size) {
if (!buf || buf_size == 0) return -1;
if (fgets(buf, (int)buf_size, stdin) == NULL) {
return -1; // 读取失败或EOF
}
// 去除末尾的换行符
size_t len = strnlen(buf, buf_size);
if (len > 0 && buf[len - 1] == '\n') {
buf[len - 1] = '\0';
}
// 检查是否输入被截断
if (len == buf_size - 1) {
// 可能还有未读取的字符,清空输入缓冲区
int c;
while ((c = getchar()) != '\n' && c != EOF);
return 1; // 表示输入被截断
}
return 0;
}
int main(void) {
char input[64];
printf("请输入: ");
int ret = safe_read_line(input, sizeof(input));
if (ret < 0) {
printf("读取失败\n");
} else if (ret == 1) {
printf("输入过长,已截断为: %s\n", input);
} else {
printf("你输入了: %s\n", input);
}
return 0;
}
场景二:安全拼接路径
#include <stdio.h>
#include <string.h>
// 安全拼接目录路径和文件名
int join_path(char *buf, size_t buf_size, const char *dir, const char *file) {
if (!buf || !dir || !file || buf_size == 0) return -1;
size_t dir_len = strnlen(dir, buf_size);
size_t file_len = strnlen(file, buf_size);
// 检查总长度是否超出缓冲区
int need_sep = (dir_len > 0 && dir[dir_len - 1] != '/');
size_t total = dir_len + (need_sep ? 1 : 0) + file_len + 1;
if (total > buf_size) return -1;
// 使用 snprintf 安全拼接
if (need_sep) {
snprintf(buf, buf_size, "%s/%s", dir, file);
} else {
snprintf(buf, buf_size, "%s%s", dir, file);
}
return 0;
}
int main(void) {
char path[256];
if (join_path(path, sizeof(path), "/home/user", "document.txt") == 0) {
printf("路径: %s\n", path);
}
return 0;
}
场景三:安全复制结构体字符串字段
#include <stdio.h>
#include <string.h>
typedef struct {
char name[32];
char email[64];
} Person;
// 安全设置姓名字段
int set_name(Person *p, const char *name) {
if (!p || !name) return -1;
// 使用 strncpy + 手动添加终止符
strncpy(p->name, name, sizeof(p->name) - 1);
p->name[sizeof(p->name) - 1] = '\0'; // 确保以 null 结尾
return 0;
}
// 安全设置邮箱字段
int set_email(Person *p, const char *email) {
if (!p || !email) return -1;
// 使用 snprintf 更安全
snprintf(p->email, sizeof(p->email), "%s", email);
return 0;
}
int main(void) {
Person person = {0};
set_name(&person, "张三");
set_email(&person, "zhangsan@example.com");
printf("姓名: %s, 邮箱: %s\n", person.name, person.email);
return 0;
}
注意事项
Annex K 的可移植性问题
C11 的 Annex K 是可选的,编译器不一定要实现。目前的主要支持情况:
- MSVC(Visual Studio):完整支持
_s函数 - GCC / Clang:默认不支持 Annex K,但部分函数可通过
__STDC_WANT_LIB_EXT1__宏启用 - 嵌入式平台:通常不支持
在不支持 Annex K 的环境中,应使用 snprintf、strnlen、fgets 等替代方案。
strncpy 的陷阱
strncpy 并不是 strcpy 的安全替代品,它有以下问题:
char buf[10];
// 问题一:如果源字符串长度 >= n,strncpy 不会添加终止符
strncpy(buf, "ABCDEFGHIJ", sizeof(buf)); // buf 没有 '\0'!
// 问题二:如果源字符串很短,strncpy 会用 '\0' 填充剩余空间,效率低
strncpy(buf, "Hi", sizeof(buf)); // 后面8个字节都被填为 '\0'
// 正确做法:手动添加终止符
strncpy(buf, src, sizeof(buf) - 1);
buf[sizeof(buf) - 1] = '\0';
约束处理函数
当安全函数检测到运行时约束违反时,会调用约束处理函数。默认行为通常是终止程序,可以通过 set_constraint_handler_s 自定义:
#define __STDC_WANT_LIB_EXT1__ 1
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// 自定义约束处理函数
void my_handler(const char *msg, void *ptr, errno_t err) {
fprintf(stderr, "约束违反: %s (错误码: %d)\n", msg, err);
// 可以选择不终止程序,而是返回错误
}
int main(void) {
// 设置自定义处理函数
set_constraint_handler_s(my_handler);
char buf[5];
// 这会触发约束处理函数,因为源字符串太长
strcpy_s(buf, sizeof(buf), "Hello, World!");
return 0;
}
缓冲区大小参数的类型
安全函数的缓冲区大小参数类型为 rsize_t(通常是 size_t),且不能为零。传入零大小会导致运行时约束违反。
进阶用法
封装安全的字符串操作库
#include <stdio.h>
#include <string.h>
#include <stdarg.h>
// 安全的字符串复制
int safe_strcpy(char *dst, size_t dst_size, const char *src) {
if (!dst || !src || dst_size == 0) return -1;
size_t src_len = strnlen(src, dst_size);
if (src_len >= dst_size) return -2; // 源字符串太长
memcpy(dst, src, src_len + 1); // 包含终止符
return 0;
}
// 安全的字符串追加
int safe_strcat(char *dst, size_t dst_size, const char *src) {
if (!dst || !src || dst_size == 0) return -1;
size_t dst_len = strnlen(dst, dst_size);
size_t src_len = strnlen(src, dst_size);
if (dst_len + src_len >= dst_size) return -2; // 空间不足
memcpy(dst + dst_len, src, src_len + 1);
return 0;
}
// 安全的格式化写入
int safe_sprintf(char *dst, size_t dst_size, const char *fmt, ...) {
if (!dst || !fmt || dst_size == 0) return -1;
va_list ap;
va_start(ap, fmt);
int len = vsnprintf(dst, dst_size, fmt, ap);
va_end(ap);
if (len < 0 || (size_t)len >= dst_size) return -2;
return 0;
}
int main(void) {
char buf[64] = "";
safe_strcpy(buf, sizeof(buf), "Hello");
safe_strcat(buf, sizeof(buf), ", ");
safe_strcat(buf, sizeof(buf), "World!");
char detail[64];
safe_sprintf(detail, sizeof(detail), "内容: %s (长度: %zu)", buf, strnlen(buf, sizeof(buf)));
printf("%s\n", detail);
return 0;
}
使用静态分析工具辅助安全检查
// 编译时启用警告选项辅助发现不安全函数调用
// gcc -Wall -Wextra -Wformat=2 -Wformat-security -D_FORTIFY_SOURCE=2
#include <stdio.h>
#include <string.h>
int main(void) {
char buf[16];
// 编译器会在 -D_FORTIFY_SOURCE=2 时将 printf 检查为 __printf_chk
// 并在运行时检测格式字符串攻击
snprintf(buf, sizeof(buf), "%s", "安全输出");
// 使用 strnlen 替代 strlen,防止未终止字符串导致越界
const char *unknown_str = "测试字符串";
size_t len = strnlen(unknown_str, 100); // 最多检查100个字符
printf("长度: %zu\n", len);
return 0;
}
运行时缓冲区溢出检测
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// 带溢出检测的缓冲区结构
typedef struct {
char *data;
size_t size;
size_t capacity;
const char *tag; // 用于调试标识
} SafeBuffer;
// 创建安全缓冲区
SafeBuffer *sbuf_create(size_t capacity, const char *tag) {
SafeBuffer *sb = malloc(sizeof(SafeBuffer));
if (!sb) return NULL;
sb->data = calloc(capacity, 1);
if (!sb->data) { free(sb); return NULL; }
sb->size = 0;
sb->capacity = capacity;
sb->tag = tag;
return sb;
}
// 安全写入数据
int sbuf_write(SafeBuffer *sb, const char *data, size_t len) {
if (!sb || !data) return -1;
if (sb->size + len >= sb->capacity) {
fprintf(stderr, "[%s] 缓冲区溢出风险: 需要 %zu, 容量 %zu\n",
sb->tag, sb->size + len, sb->capacity);
return -2;
}
memcpy(sb->data + sb->size, data, len);
sb->size += len;
sb->data[sb->size] = '\0';
return 0;
}
// 释放安全缓冲区
void sbuf_free(SafeBuffer *sb) {
if (sb) {
free(sb->data);
free(sb);
}
}
int main(void) {
SafeBuffer *sb = sbuf_create(32, "用户输入");
sbuf_write(sb, "Hello", 5);
sbuf_write(sb, ", World!", 8);
printf("内容: %s\n", sb->data);
// 这会触发溢出警告
sbuf_write(sb, " 这是一段很长的额外文字会导致溢出", 30);
sbuf_free(sb);
return 0;
}