代码审计:C 语言静态分析规则编写实践
C 语言历经数十载发展,始终在编程语言领域占据重要地位,其设计理念深刻影响了后续众多编程语言的演进。在编写 C 语言安全规则时可以发现,其漏洞更多源于对语言本身特性的不当使用,而非网络层面的安全问题。许多著名的 CVE 漏洞都源于一些特定的编程场景:
遗憾的是,当前 IRify 对控制流的信息处理非常有限,不能像 Fortify 那样进行指针别名分析和深入的污点传播,因此像内存重复释放和空指针解引用这种需要精确控制流信息的漏洞就很难使用 Syntaxflow 规则精确定位。
目前正在做的工作是尽可能完善 C 语言的底层逻辑,让它尽可能和当前的编译逻辑相兼容。
指针与副作用
在指针处理这篇文章 (点击这里可查看) 的末尾,我们提到了为指针生成 side-effect 这样的功能:
#include<stdio.h>
inttest(int a,int b,int* c){
*c = a + b;
return 0;
}
intmain()
{
int a=1,b=2,c;
test(a,b,&c);
printf("%d\n",c);
return 0;
}
使用printf(* #-> as $a)进行分析时数据流,由于c和 test(a,b,&c)的返回值没有关系,因此会使用 side-effect 来连接数据流,这里就是side-effect(add(Parameter-a, Parameter-b), c)。
当我们自己定义函数时,能够清楚地知道它们的完整函数签名(即返回值类型、函数名、参数类型等信息)。然而,当代码中使用#include导入外部库函数(如 C 标准库函数)时,要准确获取其签名就变得复杂起来。
为了在静态分析时能够正确处理这些外部函数,我们利用 IRify 工具编译了一批常见的基础库,并将这些库函数的签名信息预先定义在了 c2ssa 的预处理阶段中。当前的做法是采用一种硬编码的方式,在编译时强制注入与目标库函数签名完全一致的空函数(桩函数)。
例如,下面代码的 sprintf 就会生成一个格外的空函数:
#include<stdio.h>
intsprintf(char *str, constchar *format, ...){
}
voidvulnerable_file_list(constchar *directory){
char command[256];
sprintf(command, "ls -la %s", directory);
println(command); // side-effect(Parameter-directory, command)
}
当时在硬编码 C 库的函数签名时就想到,可以使用宏扩展来一劳永逸解决问题。
宏扩展
C 语言宏扩展预处理系统是 YAK C2SSA 模块的核心组件之一,负责在 C 代码编译前自动处理宏定义、条件编译和头文件包含等预处理指令。该系统通过文件系统钩子机制,在文件读取时透明地进行宏扩展,确保后续的 SSA 转换能够处理已展开的宏代码。
系统采用**文件系统钩子(Hook)**的设计模式,通过拦截文件读取操作,在返回源代码之前自动执行宏预处理。
文件读取请求
↓
文件系统钩子拦截(.c/.h 文件)
↓
提取 #include 指令
↓
确保头文件存在(创建临时头文件)
↓
调用 C 预处理器(gcc/clang -E)
↓
返回预处理后的代码
funcnewCPreprocessFS(underlying fi.FileSystem) fi.FileSystem {
// 1. 设置头文件环境
if err := setupHeaderFiles(underlying); err != nil {
log.Warnf("setupHeaderFiles failed: %v", err)
return underlying
}
// 2. 创建钩子文件系统
hookFS := filesys.NewHookFS(underlying)
// 3. 注册读取钩子
hookFS.AddReadHook(&filesys.ReadHook{
Matcher: filesys.SuffixMatcher(".c", ".h"),
AfterRead: func(ctx *filesys.ReadHookContext, data []byte) ([]byte, error) {
// 宏预处理逻辑
},
})
return hookFS
}
临时目录管理
在宏扩展过程中,为了保持用户源代码的完整性不被修改,我们选择在临时目录中完成所有宏扩展的相关操作:
funcinitTemp() error {
tmpDir, err := os.MkdirTemp("", "c_headers_*")
if err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
globalTempDir = tmpDir
return nil
}
系统在启动时创建临时目录,用于存放:
- 项目中的头文件(
.h、.in) - 自动创建的系统头文件占位符
- 预处理过程中的临时源文件
系统会扫描底层文件系统中的所有头文件,并将用户自定义的头文件复制到对应目录。在处理代码中的#include<...>指令时,我们提供了两种配置选项:一是拷贝并使用系统 C 库文件,二是创建空文件占位符。
目前,我们将"创建空文件占位符"作为默认配置项,这是因为我们发现部分用户在进行测试时可能没有完整的 C 库环境,或者当前机器权限不足无法拷贝系统 C 库文件。
将 C 标准库函数也纳入宏扩展,虽然能提供最精确的分析结果(因为它包含了函数最真实的签名和行为),但代价是会在前端生成并显示大量复杂的内部宏展开代码,导致代码视图变得臃肿,不利于开发者快速浏览和理解核心逻辑。因此,在通常的代码阅读和调试场景下,隐藏库函数的宏展开可以保持界面的简洁。
宏扩展工具
系统按优先级检测可用的 C 预处理器:
-
gcc:GNU 编译器集合(优先)
-
clang:LLVM 编译器
-
cc:系统默认 C 编译器
简单宏定义
输入代码:
#define MAX_SIZE 1024
intmain() {
int size = MAX_SIZE;
return 0;
}
处理流程:
1、文件系统钩子拦截main.c的读取
2、提取 include 指令(无)
3、创建临时文件并写入源代码
4、调用gcc -E -P -nostdinc ...
5、返回预处理结果
输出代码:
intmain() {
int size = 1024;
return0;
}
示例2:函数式宏
输入代码:
#define MIN(a,b) ((a)<(b)?(a):(b))
intmain() {
int x = 10, y = 20;
int min = MIN(x, y);
return 0;
}
输出代码:
intmain() {
int x = 10, y = 20;
int min = ((x)<(y)?(x):(y));
return 0;
}
头文件包含
输入代码:
#include<myheader.h>
#define VALUE 42
intmain(){
return VALUE;
}
处理流程:
1、提取#include <myheader.h>
2、创建globalTempDir/include/myheader.h空文件
3、添加 include 路径-I globalTempDir/include
4、执行预处理
Syntaxflow 规则编写
目前 syntaxflow 对 C 语言的支持非常有限,例如:目标是查找一个溢出漏洞,按照 Syntaxflow 的思路就应该去查找污点函数的使用位置,然后查找 top def 分析其是否与可控数据流有交集。
但有时候 C 语言的拷贝让人无从入手,例如下面案例:
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
void vulnerable_function() {
char local_buf[100];
unsigned char input_len;
input_len = get_input_length();
char *input_str = get_input_string();
if (input_len >= 4 && input_len <= 8) {
for (int i = 0; i < input_len && i < sizeof(local_buf) - 1; i++) {
local_buf[i] = input_str[i];
}
if (input_len < sizeof(local_buf)) {
local_buf[input_len] = '\0';
} else {
local_buf[sizeof(local_buf) - 1] = '\0';
}
}
}
在 C 语言中,使用 for 循环配合数组索引 arr[i] 是一种非常基础且高效的编程模式。然而,其安全性并非由语言本身保证,而是完全依赖于程序员的谨慎编码。
这样的代码让 Syntaxflow 完全没有着手点,因为当前的 Syntaxflow 规则非常依赖代码中的各种符号。目前我们正在积极学习 Fortify 在处理相关问题时的解决方案,在今后的版本中可能会解决问题。
除了较为常见的溢出问题以外,C 语言中有许多危险的函数,这些函数一旦使用就可以认定为高危:
gets() as $high;
scanf(*<slice(index=0)>?{have: "%s"} as $high);
vscanf(*<slice(index=1)>?{have: "%s"} as $high);
fscanf(*<slice(index=1)>?{have: "%s"} as $high);
vfscanf(*<slice(index=1)>?{have: "%s"} as $high);
sscanf(*<slice(index=1)>?{have: "%s"} as $high);
vsscanf(*<slice(index=1)>?{have: "%s"} as $high);
对于类似于printf的函数,需要检测其调用点是否有格式化字符串,如果可以被用户控制则标记为高危:
printf(*<slice(index=0)> #-> as $sink);
fprintf(*<slice(index=1)> #-> as $sink);
sprintf(*<slice(index=1)> #-> as $sink);
snprintf(*<slice(index=2)> #-> as $sink);
syslog(*<slice(index=1)> #-> as $sink);
$sink?{!opcode:const} as $high
总结
目前,SyntaxFlow 在分析内存泄漏、UAF(释放后使用)和 Double Free(双重释放)等复杂内存漏洞时,其能力还存在局限。尽管底层的 YAK 基础设施已经能够表达较为复杂的控制流信息,但 SyntaxFlow 的前端目前主要聚焦于数据流的展示与追踪,尚未充分利用这些控制流信息。
未来的计划是引入以基本块为单位的控制流分析机制,旨在最终实现类似于 IDA 中的可交互的控制流图,从而更直观地揭示程序执行的路径和分支逻辑。
本文首发于 Yak Project 公众号,阅读原文。
