二、依赖分析
依赖分析是c语言静态代码分析中一个非常重要的环节,它的分析准确与否,关系到了后续的漏洞分析的准确性。
什么是依赖分析
依赖图是源代码文件与其依赖库之间的依赖关系的一种图形表示。我们知道,在c语言中,项目真正用到的一些组件库一般只有在编译的时候才能够确定,它不像java项目,一份代码,到处运行,而是一份代码,多次编译,因为很多时候,我们为了跨平台的需要,需要给同一个组件准备不同的适配方案,这也是c语言项目被一些开发人员长期诟病的地方。在c/c++项目的分析工作中,我们首要解决的就是程序文件之间的依赖关系,因为这直接影响到了后续我们静态代码分析的准确性。
依赖图的作用
根据生成的文件依赖图,我们可以清晰而准确的知道整体项目的组织脉络。在后续的符号识别和函数调用链生成阶段,需要依靠依赖图来找到目标库文件,从而保证符号识别和函数调用链构建的准确性。
如何进行依赖分析
针对c/c++项目文件依赖图的生成,业内的主流办法是通过解析compile_commands.json文件中记录的编译命令来进行生成。详情可参考pvs-stdio这款商业sca扫描器的技术说明文档(pvs-studio.com)。而compile_commands.json文件可以通过编译工具(make、ninja等)生成,具体的办法步骤是:
1.收集编译过程的输出信息。
2.将输出信息重定向到解析器,通过正则匹配的方式,生成compile_commands.json文件。
当然,cmake等变异工具本身也支持生产compile_commands.json文件。具体是在构建方案的时候,指定参数:-DCMAKE_EXPORT_COMPILE_COMMANDS=1。
那么,有了compile_commands.json文件之后要如何进行依赖分析呢?
我们先看下compile_commands.json文件的数据结构:
[
{
"directory": "/Users/pony/work/sourcehub/cmake-examples/01-basic/B-hello-headers/build",
"command": "/Library/Developer/CommandLineTools/usr/bin/cc -I/Users/pony/work/sourcehub/cmake-examples/01-basic/B-hello-headers/include -isysroot /Library/Developer/CommandLineTools/SDKs/MacOSX10.15.sdk -o CMakeFiles/hello_headers.dir/src/Hello.c.o -c /Users/pony/work/sourcehub/cmake-examples/01-basic/B-hello-headers/src/Hello.c",
"file": "/Users/pony/work/sourcehub/cmake-examples/01-basic/B-hello-headers/src/Hello.c"
},
{
"directory": "/Users/pony/work/sourcehub/cmake-examples/01-basic/B-hello-headers/build",
"command": "/Library/Developer/CommandLineTools/usr/bin/cc -I/Users/pony/work/sourcehub/cmake-examples/01-basic/B-hello-headers/include -isysroot /Library/Developer/CommandLineTools/SDKs/MacOSX10.15.sdk -o CMakeFiles/hello_headers.dir/src/main.c.o -c /Users/pony/work/sourcehub/cmake-examples/01-basic/B-hello-headers/src/main.c",
"file": "/Users/pony/work/sourcehub/cmake-examples/01-basic/B-hello-headers/src/main.c"
}
]
其中directory表示当前编译目录,command表示当前执行的编译命令,file表示待编译的源码文件。其中,在command命令中,参数I后面跟的是当前源码所依赖的头文件目录的路径,编译器在编译的时候会在给定的这个目录下搜索相关的头文件。
但是,显然只找到头文件是不够的,我们还需要找到函数的定义位置,这样我们才能够真正建立起函数调用所在文件和函数定义所在文件之间的关联关系。下面我们为了更好的说明aurora代码安全分析引擎是如何对c项目进行依赖分析的,下面,我们以一个c代码项目为示例进行阐述。
项目目录:
.
├── CMakeLists.txt
├── README.adoc
├── include
│ └── Hello.h
└── src
├── Hello.c
└── main.c
2 directories, 5 files
CMakeLists.txt
# Set the minimum version of CMake that can be used
# To find the cmake version run
# $ cmake --version
cmake_minimum_required(VERSION 3.5)
# Set the project name
project (hello_headers)
# Create a sources variable with a link to all c files to compile
set(SOURCES
src/Hello.c
src/main.c
)
# Add an executable with the above sources
add_executable(hello_headers ${SOURCES})
# Set the directories that should be included in the build command for this target
# when running g++ these will be included as -I/directory/path/
target_include_directories(hello_headers
PRIVATE
${PROJECT_SOURCE_DIR}/include
)
include/Hello.h
#ifndef __HELLO_H__
#define __HELLO_H__
void print();
#endif
src/Hello.c
#include "Hello.h"
void print()
{
printf("hello world.");
}
src/main.c
#include "Hello.h"
int main(int argc, char *argv[])
{
print();
return 0;
}
这个项目主要由一个主程序和一个组件构成,主程序中通过头文件Hello.h对组件中定义的print函数进行调用。那么,首先明确,我们期望建立起来的依赖关系是main.c和Hello.c之间的依赖关系。通过对compile_commands文件的分析,我们已经可以知道main.c和Hello.c都和Hello.h是有依赖关系的,只不过一个是API调用,一个是API定义。那么我们是否可以直接说明这两个文件是有依赖的呢?那显然是不行的,因为我们无法确定Hello.c中是否是定义了main.c中调用的print函数。只有同时满足在main.c中调用了Hello.h中声明的print函数,且在Hello.c中定义了Hello.h中声明的print函数,那么我们才能够认为,main.c和Hello.c之间是存在依赖关系的,这样,我们后续才能够据此建立起函数之间的调用关系,即函数调用链(这对于全局数据流构建分析很重要)。
我们可以用下面这个示意图来表示这个推导过程。
其中,依赖确定模块一,通过对compile_commands.json文件及相关源码的AST的分析,确定两个源码文件引入了同一个头文件,依赖确定模块二,通过分析是否同时导入相同头文件且一方为函数调用,另一方为函数定义,确定main.c文件到Hello.c之间的依赖关系,而调用链确定模块,通过分析main函数中是否包含对函数print的调用,进而确定main.c中的main函数和src/Hello.c中的print函数的调用关系。
三、代码数据库构建
代码数据库,顾名思义,存储的代码相关的一些数据。这里的代码数据库可以认为是第一层代码属性图,我们一般会在其中存储源代码的AST表示形式。至于源代码的AST表示形式,我们可以通过一些AST提取工具来获取,比如eclipse提供的cdt工具,亦或者一些开源的前端解析工具,如antlr,均可以完成这部分工作。当然,如果我们利用这些工具提取ast,那么获得的仅是一个ast unit class的集合,而为了后续展示方便,同时也为了能够通过分布式架构进行高效的分析,我们还需要对这些ast unit类进行必要的初步解析工作,然后将其转为json格式进行持久化存储,这部分的处理流程可用下图表示:
四、函数调用图构建
什么是函数调用图
函数调用图是函数之间的调用关系的一种图形表示形式。在进行过程间数据流分析的时候,我们需要依赖函数调用图求解函数调用链,以便于沿着这个调用链进行过程间数据流分析。
如何进行函数调用图构建
函数调用链构建需要建立在依赖分析的基础之上。在依赖分析中其实已经对这部分进行了形象的阐述,这里就不做过多的赘述。总的来说,依托依赖分析的结果,我们可以获得两个源码文件之间的依赖关系,然后基于此,再对这些源码中的函数定义信息进行遍历分析,判断下函数调用语句的函数签名和函数定义的函数签名是否一致,即可构建我们后续分析所需的函数调用链。
五、控制流及数据流分析
什么是控制流及数据流
程序控制流表示的是程序的各个语句结构之间的控制关系,总的来说有两种形式,一种是基本块控制关系图,另一种是表达式控制关系图。前者注重在基本块之间的控制关系,后者注重在表达式之间的控制关系。而数据流表示的是程序中数据的流动关系,其中,我们会比较关注变量的定义和引用关系,即def-use链,以及一些赋值语句引起的数据流动关系。
为什么要进行控制流和数据流分析
控制流分析和数据流分析是程序静态分析中比较核心的分析环节,我们在进行代码安全审计的时候,我们认定某一处存在漏洞,如sql注入漏洞,一般需要有比较完整的污点传播路径,才能够比较有充分的理由其漏洞进行判定,而污点传播路径依赖于数据流分析,而数据流分析也依赖于控制流分析。总的来说,java和c的数据流分析的方法大同小异,针对java方面的控制流及数据流分析方法,我已在前一篇文章中进行了详细的描述,这里就不做过多的赘述了,详情见:《DevSecOps建设之白盒续篇 - FreeBuf网络安全行业门户》。
六、代码安全分析
栈溢出漏洞
那么,我们如何将静态代码分析技术应用在代码安全分析上呢?我们以pwnable.kr上的一个栈溢出靶场bof为例,详细阐述aurora白盒引擎是如何进行c代码安全分析的。漏洞代码如下:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
void func(int key){
char overflowme[32];
printf("overflow me : ");
gets(overflowme); // smash me!
if(key == 0xcafebabe){
system("/bin/sh");
}
else{
printf("Nah..\n");
}
}
int main(int argc, char* argv[]){
func(0xdeadbeef);
return 0;
}
简要说明:这是一段比较典型的栈溢出漏洞代码,在函数func中,我们声明并定义了一个字符数组overflowme,并给其分配了32个字节的内存空间。程序中调用gets函数,获取命令行输入流,如果字符流长度在overflowme这个变量定义的合法区间内,那么这个程序将按照正常的逻辑走,如果超过了overlfowme定义的合法区间,那么,在没有对这个输入流长度进行合法性校验的前提下,将会造成栈溢出漏洞,覆盖变量key的内存空间,影响预期的分支流程走势,即走到if的then逻辑部分,执行system函数,反弹一个bash窗口。这个调用过程发生时,内存的栈帧分布示意图如下:
其中,下层为func栈帧,上层为main栈帧,栈帧的排布由高地址向低地址排布,先调用的函数占据高地址,后调用的函数占据低地址,而数据写入规则则是由低地址到高地址。我们在实际利用的时候,如果要改变if分支中的逻辑,可通过输入以下payload实现攻击:
payload="a"*52+ chr(0xbe) + chr(0xba) + chr(0xfe) +chr(0xca);
其中,chr(0xbe) + chr(0xba) + chr(0xfe) +chr(0xca)即为0xdeadbeef,而52表示key和overflow之间的距离,这些数据可以通过gdb调试获得。通过给定overflowme超出其合法值范围的payload,我们即可实现对原有逻辑的篡改。
那么,针对这种漏洞,我们要怎样通过静态分析方法进行检测呢?
我们可以从栈溢出的原理出发,来思考我们的防护策略。我们知道,栈溢发生的本质原因是在对变量进行传值的时候,未对输入数据的字节长度进行合法性校验。那么,我们在检测的时候可以枚举源代码中的内存写入操作的函数调用表达式(如gets、memcpy等),通过分析其在进行内存写入的时候,写入数据的值的大小是否比声明的区间大来判定其是否存在漏洞。那么,问题就集中在了对两者值的大小分析(区间分析)上了。针对缓冲区大小,我们可以根据变量对应的def-use链找到变量定义的位置,然后结合其变量定义表达式中和变量区间定义相关的ast数据,进行综合分析判定。针对输入的数据的区间大小,我们也可以用类似方式进行分析。当然,区间的值可能并不一定是常量,如果要追求分析的准确性,那么就要对区间范围进行进一步的约束求解。
七、区间分析
什么是区间分析
区间分析是指通过约束求解算法,对变量和表达式的取值范围进行跟踪,为进一步的程序分析提供精确的数据支持。
为什么要做区间分析
通过上文可知,我们在做一些代码安全分析的时候,比如栈溢出分析,如果我们不能够比较准确的分析缓冲区和输入数据的区间大小,那么我们是无法准确地判断是否存在栈溢出漏洞的。
如何做区间分析
学术上,针对区间分析早有很多相关的方法分析方法,如王雅文、宫云战等在第五届中国测试学术会议上发表的论文《区间运算在软件缺陷检测中的应用》中就提到了一种区间分析方法。论文中提出,可针对不同的变量类型设置不同的初始区间值,然后通过表达式区间分析、条件区间分析、控制流区间分析三个不同纬度对变量的取值范围进行约束求解。
1. 表达式区间分析
如表达式3*(++i),如果i的取值范围是[1,1],那么执行完3*(++i)之后,其取将范围将变成:[6,6]。
2. 条件区间分析
例如:
if(x>2){
}
else{
}
区间分析在代码质量领域的应用
区间分析,可以应用于程序中的不可达代码块检测、代码覆盖率分析等。例如:
void func(){
int i=5;
if(i<0){
i++;
}
}
初始控制流图:
转为带区间信息的控制流图:
从带区间的控制流图中可以发现,在执行到stmt_3语句时,因为i<0这个条件的取值范围是[-∞,-1]n[5,5]=Ø,所以i<0分支下的所有表达式语句上i的区间范围会被设置为null,表示不可达到。统计出所有执行的语句数量及总语句数量即可计算得语句覆盖率,计算公式为:可执行语句数量/语句总数。统计所有可达分支数量及总的分支数量即可计算得分支覆盖率,计算公式为:可达分支/分支总数。
区间分析在代码安全领域的应用
比如上面的那个溢出漏洞案例。我们可以得到其初始控制流图为:
转化为区间为带区间信息的控制流图:
其中在stmt_3(即语句gets(overflowme))中,overflowme的区间范围易求得为[0,32],而用户输入的数据的范围是[0,+∞],这里用1000作为缺省值,当然为了以防万一,也可以设置大一点,显而易见这里用户输入的数据的大小的区间范围远大于overflowme最大的取值32,那么这里是肯定存在溢出漏洞的。当然,要比较精确的判定溢出漏洞,我们还可以结合compile_commands.json中的编译命令(如一些编译时设定的堆栈溢出的防护策略)进行综合判定。
八、CI流程自动化
一款静态代码安全分析工具,要好用,不仅要引擎能力足够硬核,在流程方面也应该自动化,让使用者能够很方便的配置,很容易地集成到一些主流的CI piplines中。下面我们以gitlab ci,详细阐述我们是如何实现c代码自动化安全、代码质量检测。
以下是示例ci例子
# This file is a template, and might need editing before it works on your project.
# This is a sample GitLab CI/CD configuration file that should run without any modifications.
# It demonstrates a basic 3 stage CI/CD pipeline. Instead of real tests or scripts,
# it uses echo commands to simulate the pipeline execution.
#
# A pipeline is composed of independent jobs that run scripts, grouped into stages.
# Stages run in sequential order, but jobs within stages run in parallel.
#
# For more information, see: https://docs.gitlab.com/ee/ci/yaml/README.html#stages
stages: # List of stages for jobs, and their order of execution
- build
- test
- deploy
build-job: # This job runs in the build stage, which runs first.
image: ci_vtsmap:latest
stage: build
script:
- echo "Compiling the code..."
- mkdir build && cd build
- cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=1 ..
- curl -F "file=@compile_commands.json" -F "user=${GITLAB_USER_NAME}" -F "projectname=${CI_PROJECT_NAME}" -F "language=c" -F "email=xxx@gmail.com" -F "type=git"-F "branch=${CI_COMMIT_BRANCH}" -F "commitid=${CI_COMMIT_SHA}" -F "gitaddr=${CI_PROJECT_URL}" -F “token=xxx” -x POST http://[domain]/pushjob/
- make .
- echo "Compile complete."
deploy-job: # This job runs in the deploy stage.
stage: deploy # It only runs when *both* jobs in the test stage complete successfully.
script:
- echo "Deploying application..."
- echo "Application successfully deployed."
其中,我们会在原有的ci脚本中加入两行命令,一行时在原有的cmake命令上加上参数-DCMAKE_EXPORT_COMPILE_COMMANDS=1 来生成compile_commands.json文件,以便于后端的静态分析引擎进行依赖分析。以下命令用于提交任务信息:
curl -F "file=@compile_commands.json" -F "user=${GITLAB_USER_NAME}" -F "projectname=${CI_PROJECT_NAME}" -F "language=c" -F "email=xxx@gmail.com" -F "type=git" -F "branch=${CI_COMMIT_BRANCH}" -F "commitid=${CI_COMMIT_SHA}" -F "gitaddr=${CI_PROJECT_URL}" -F “token=xxx” -x POST http://[domain]/pushjob/
九、总结
综合来看,c代码静态分析目前最主要的难点在于区间分析,这部分分析的准确性对后续的漏洞分析的准确性有很大的影响。所以在c代码静态分析方面,区间分析方面需要花比较大的功夫去钻研,不仅要保证分析的分析的准确性,同时也要考虑到分析的效率,因为很多c代码项目,如linux内核等,代码量非常庞大,如果没有一个比较合理的算法,加快代码分析速度,那么接入到企业内部使用,其体验感也是非常差的。当然,精度和速度两者一般情况下是一种此消彼长的关系的,如何从中达到一个平衡,还需要不断的进行测试和实践。
王雅文,宫云战,杨朝红,肖庆,区间运算在软件缺陷检测中的应用,第五届中国测试学术会议论文集,2008,51-52。