一、白盒整体概括
在开始变量追踪的正式讲解之前,先概括的看一下目前各大厂对于白盒的使用情况,可以简单的概括为下图的模型。
上图列出了触发白盒扫描的几个关键节点,分别为开发、集成、发布阶段,目前大部分公司多在发布阶段进行强制卡点,如果发现存在严重漏洞,则会终止整个发布流程,确保应用不能带漏洞上线。发布阶段进行扫描是目前执行扫描最多的一个阶段,为了能够让开发者在发布之前就能够对漏洞进行修复,在代码集成,甚至本地开发阶段都会进行扫描。其实按照理想状态,在本地开发阶段进行扫描应该是开发者修复最好的时机,因为这个阶段开发者对于代码的印象最深,同时漏洞的展示效果也最好,能够通过IDE定位到具体的代码片段,方便直观。不过这个阶段的代码扫描,我目前接触的公司感觉没有运行的比较好的,个人感觉原因在于,这个阶段的扫描多是使用本地IDE安全插件的扫描能力,除了这个插件扫描能力有限外,更多的可能在于,扫描规则一旦多了,就会带来巨大的开销,跑着跑着,IDE卡死了,万一刚写的代码还没保存,岂不是要GG。
至于白盒的扫描能力,主要体现的其扫描引擎的能力上,一般而言,扫描能力可以分为三个层级,如上所示。三种扫描引擎的目标其实并不相同,1、2两种引擎更多的是为了保障安全规范的彻底实施,而3则是为了更加有效的发现代码中潜在的漏洞。相信大多数读者对于第三种引擎会更有兴趣,因此系列文章的开端将以多文件的污点追踪引擎作为开端,为大家系统的讲解整个变量追踪引擎的实现。由于目前本人当前正在进行Node变量追踪引擎的开发,因此文中的示例也将以Node作为示例进行讲解。
二、一次简单的扫描
现在大家都喜欢使用github上面的开源搭建自己的服务,而忽略了这个开源服务本身的安全性,在进行Node变量追踪引擎开发的过程中,为了测试工具的有效性,曾对GitHub上一个9.8K的Node项目进行了扫描,结果还真扫出了问题。
/restaurants ### rce
file_path: /x/program/controller/shopping/shop.js,
file_line: 305,
taint_params: geohash, keyword
file_code: const {geohash, keyword} = req.query;
file_path: /x/program/controller/shopping/shop.js,
file_line: 323,
taint_params: geohash, keyword
file_code: const restaurants = await ShopModel.find({name: eval('/' + keyword + '/gi')}, '-_id').limit(50);`
如上为扫描工具的输出,我们发现其将用户的输入keyword直接放到了eval函数中,感觉上是想生成一个正则表达式,加入我们将keyword设置为:
** 123 */ console.log(require("child_process").exec("ls -al")); /** *
最后会不会执行成功呢?留给大家自行测试吧,具体代码库的信息及代码分析我会在公众号的文章中给出。
三、变量追踪引擎
上图为大家详细的拆解了一个变量追踪引擎实现过程中的一些关键节点,从图中可以看出实现一个变量追踪引擎的实现可能需要经历6个关键节点:
1. 文件语法解构解析及提取;
2. 解析后的语法解构的合理存储;
3. 危险函数(Sink)、污染数据(Source)的定义;
4. 调用流图的构建,Source与Sink之间函数调用链的生成;
5. 代码的模拟执行,实现具体的变量追踪;
6. 扫描出来了很多漏洞,发现了大量的误报,应该怎么优化。
同时就每个步骤中可能会遇到的难题也罗列在了下方,系列文章将会详细的为大家拆解每个步骤的实现,其中某些步骤可能因为涉及专利申请的原因暂时无法为大家展开,因此文章可能会略过某些节点,还请大家见谅理解。
四、语法结构的解析
4.1 为什么选用抽象语法树
进行源码分析是应该直接使用源码,还是使用进行结构解析后的抽象语法树(AST:Abstract Syntax Tree,后文中的抽象语法树将统一表示为AST),亦或是使用编译后生成的字节码呢?首先源码应该是可分析性最差的,比如下面一段简单代码:,想要分析“ls”是TemplateLiteral、Identifier还是StringLiteral,通过源码我觉得判断起来是非常麻烦的,需要判断“ls”是否被“`”包裹,而通过解析后的AST则是非常容易判断的。
shell.exec(`ls`);
"expression":{
"type":"CallExpression",
"start":33,
"end":49,
"loc":Object{...},
"callee":Object{...},
"arguments":[
{
"type":"TemplateLiteral",
"start":44,
"end":48,
"loc":Object{...},
"expressions":Array[0],
"quasis":[
{
"type":"TemplateElement",
"start":45,
"end":47,
"loc":Object{...},
"value":{
"raw":"ls",
"cooked":"ls"
},
"tail":true,
"range":Array[2],
"_babelType":"TemplateElement"
}
],
"range":Array[2],
"_babelType":"TemplateLiteral"
}
],
当然抽象语法树也有其缺点:
语法结构比较复杂,从上面的示例代码可以明显看出,一个代码行被解析为上百行AST语法结构,识别起来确实非常繁琐,尤其是结构呈现递归的形式。
包含的节点太多,目前针对Node的支持,需要解析的指令集多达150多个,囊括所有的指令集确实不是个轻松的工作。
如果使用字节码的形式并不会帮我们解决这个问题,因为字节码的指令集也是不少。其实真正解决指令集这个问题,需要通过相关工具将源码解析为专门用于代码分析的中间语言(IR),如Soot将Java源码解析为Jimple,该IR将指令集缩减到了15个,非常便于分析。不过目前这种开源工具还是太少,一般商业化扫描工具进行编译都会进行这步操作,当然这个是人家的核心,是不可能开源的啦。鉴于上述情况,还是老(苦)老(逼)实实(哈哈)进行AST的解析吧。
4.2 AST解析工具
每种语言用于获取AST的工具都比较多,如针对JS的解析工具有acorn、babel、espree、recast等,因此在进行选择时必须考虑全面,针对JS你需要考虑工具需要支持ES5、ES6规范,同时也需要支持JavaScript、TypeScript、JSX语言的解析,综合以上因素最终选择了babel工具进行解析,可以astexplorer上体验不同工具对于AST语法树解析的异同及支持程度。针对babel的解析的解析配置,作者采用的配置如下,仅供参考。
this.config = {
loc: true,
strictMode: false,
sourceType: 'module',
allowImportExportEverywhere: true,
allowUndeclaredExports: true,
errorRecovery: true,
plugins: ['typescript', 'classProperties', 'dynamicImport', 'decorators-legacy', 'jsx', 'optionalChaining', 'exportDefaultFrom']
};
其他语言的AST解析在这里简单罗列下,仅供参考。
Java:Eclipse JDT、Soot。
Python:自带的ast.parse。
Go:go/parser包。
4.3 AST节点学习
使用AST进行代码分析,必须熟悉AST的指令集,这个是个体力活,只能慢慢去看,理解相关节点的结构,这里提供JavaScript的相关指令集的学习文档,就不一一展开了。
ESTree:JS的相关规范的定义,里面对相关节点进行了详细的介绍,对于快速熟悉JavaScript语法结构非常有帮助。
babel-spec:babel针对某些节点进行的更详细的定义,如Literal被拆解为:RegExpLiteral、NullLiteral、StringLiteral、BooleanLiteral、NumericLiteral、BigIntLiteral,使用babel进行解析,需要仔细阅读。
其他:比如针对TypeScript、JSX语言的节点解析,目前未发现比较好的文档介绍,只能根据代码的解析信息从babel-types中抽取,如果大家有比较好的文档,欢迎分享给我,在此表示感谢。
五、关键信息的快速提取
5.1 快速定位关键节点
在进行AST分析时,面临的第一个问题就是如何实现对某个关键节点的快速定位,比如对变量声明节点(VariableDeclarator)进行快速定位,这个问题其实不需要我们担心,相关解析工具已经基本帮我们实现了该功能,比如在babel中可以通过babel/traverse对某个节点进行快速定位,我们需要做的就是在相关参数中定义要收集的节点,比如在infoCollect中定义要收集的节点信息及操作。
const babelTraverse = require('@babel/traverse').default;
babelTraverse(astData, infoCollect(results));
infoCollect(results) {
VariableDeclarator({ node }) {......},
}
5.2 单个节点信息的快速解析
完成关键节点的定位后,下一步就是如何提取关键节点的关键数据了,比如提取CallExpression中的调用函数的信息,比如对shell.exec(ls
);这个语句,我们需要提取的信息就是shell.exec。但是我们发现解析后的AST信息并没有那么简单,如下为示例代码的AST。
{
"type":"Program",
"start":0,
"end":17,
"loc":Object{...},
"comments":Array[0],
"range":Array[2],
"sourceType":"module",
"body":[
{
"type":"ExpressionStatement",
"start":0,
"end":17,
"loc":Object{...},
"expression":{
"type":"CallExpression",
"start":0,
"end":16,
"loc":Object{...},
"callee":{
"type":"MemberExpression",
"start":0,
"end":10,
"loc":Object{...},
"object":{
"type":"Identifier",
"start":0,
"end":5,
"loc":Object{...},
"name":"shell",
"range":Array[2],
"_babelType":"Identifier"
},
"property":{
"type":"Identifier",
"start":6,
"end":10,
"loc":Object{...},
"name":"exec",
"range":Array[2],
"_babelType":"Identifier"
},
"computed":false,
"range":Array[2],
"_babelType":"MemberExpression"
},
"arguments":Array[1],
"range":Array[2],
"_babelType":"CallExpression"
},
"range":Array[2],
"_babelType":"ExpressionStatement"
}
]
}
针对CallExpression提取callee仔细观察AST结构好像并不是很难,只要按照它的基本结构进行一步步的解析不就可以了嘛。不过问题在于这里只是针对CallExpression的callee进行解析,还有parameters的解析,还有剩下150多个节点也需要解析,如果按照这种方式解析下去,估计代码会爆炸,开发人员也会被逼疯。那么有没有比较好的解析办法呢,我这里说一下我使用的解析方法:递归解析。
首先设定好自己需要解析的关键信息,比如希望解析CallExpression的调用函数,根据节点信息,你需要锚定的点即为callee、Identifier、name三个属性,再进行CallExpression解析时,将该函数以递归的方式传递下去,该函数将会自动判断该子节点或子类型是否服务设定的要求,最终返回符合条件的节点信息。
单个节点的解析后面专门抽一篇文章进行详细的解析,这里只是先想大家明确递归解析的概念。
六、危险函数及污染源的定义
对于漏洞的触发,一个很朴素的说法就是,用户可控的参数,传递给了某个危险函数,并作为该函数的参数进行执行。理解了这个漏洞触发的定义,那么对于污染源的定义就比较简单了,就是用户可以控制的任何数据都应该被定义污染源。当然工程项目中配置文件严格意义上来讲,也是用户可以控制数据,但是通常并不把它作为污染源,因为非项目开发者很难进行控制更改。但是某些商业化工具会把配置文件中的数据也作为污染源之一,比如Coverity,这个不知道说些啥,一般情况下我都会把它作为误报处理。
用户可控制的数据,对于Web应用而言就是通过HTTP请求发送到服务端的数据,最典型的就是请求参数,比如对于Node中的express、koa框架,其污染源的定义如下所示。
// Express:
MemberExpression: [
['req', 'params'],
['req', 'body'],
['req', 'cookies'],
['req', 'query'],
],
CallExpression: [
['req', 'get'],
['req', 'param'],
]
// Koa:
MemberExpression: [
['ctx', 'header'],
['ctx', 'headers'],
['ctx', 'origin'],
['ctx', 'href'],
['ctx', 'query'],
['ctx', 'querystring'],
['ctx', 'cookies'],
['ctx', 'body'],
['ctx', 'params'],
]
CallExpression: [
['ctx', 'query'],
['ctx', 'query', 'get'],
['ctx', 'request', 'get'],
['ctx', 'cookies', 'get'],
['ctx', 'get'],
]
至于危险函数理解起来就更加简单了,就是易造成风险的函数,比如执行命令的函数、发起请求的函数、触发数据库操作的函数等,都应该被定义为危险函数,如下为Node中定义的部分危险函数,其中index参数表示该危险函数的第index个参数被污染将会触发漏洞,用于模拟执行的时候进行更精准的判断。
// 命令执行:
CallExpression: [
{
index: 0,
value: ['exec']
},
{
index: 0,
value: ['execSync']
},
{
index: 0,
value: ['child_process', 'exec']
},
{
index: 0,
value: ['child_process', 'execSync']
},
{
index: 0,
value: ['eval']
},
{
index: 0,
value: ['shell', 'exec']
},
]
// SQL注入:
CallExpression: [
{
index: 0,
value: ['connection', 'query']
},
{
index: 0,
value: ['conn', 'query']
},
{
index: 0,
value: ['db', 'query']
},
{
index: 0,
value: ['mysql', 'createConnection', 'query']
},
{
index: 0,
value: ['sequelize', 'query']
},
{
index: 0,
value: ['db', 'driver', 'execQuery']
},
]
// SSRF:
CallExpression: [
{
index: 0,
value: ['http', 'get']
},
{
index: 0,
value: ['http', 'request']
},
{
index: 0,
value: ['axios']
},
{
index: 0,
value: ['fetch']
},
{
index: 0,
value: ['request']
},
{
index: 0,
value: ['request', 'get']
},
{
index: 0,
value: ['request', 'post']
},
]
七、调用链的构建
在完成污染源数据(Source)与危险函数(Sink)的定义后,下一步就讲讲怎么构建起Source与Sink之间的调用链。
7.1 全局调用流图 VS 局部调用流图
在开始Source与Sink的调用链构建前,我们需要思考一个问题,用于构建调用关系到底是构建起全局调用流图,还是根据需要构建局部调用流图呢?这里我只针对Web应用进行讨论,移动端的情况可能会复杂些。
全局调用流图是对项目中所有需要解析的文件进行抽象语法树解析,然后根据文件中的引用关系,比如import、require等,建立起所有文件之间的关联关系,然后再根据文件内各函数的调用关系,构建起所有方法之间的一个调用关系图。由于牵扯到项目中所有文件及方法之间调用关系的构建,因此一般构建全局调用流图的时间会非常长,一般通过这个方式进行变量追踪的扫描,基本无法做到发布代码的实时扫描,除非提前构建整个调用流图并进行存储复用。
局部调用流图对Web应用而言就是以接收HTTP请求的方法为起点,根据该方法中函数的调用关系,按需对被调用文件中的方法进行解析,从而建立起一个局部的调用关系。由于局部调用流图是按需进行调用关系的构建,因此其构建速度非常的快,如果在加上对解析后文件的合理的解构存储,基本能够实现对项目的实时扫描。目前我这边对Node项目变量追踪扫描,使用的就是局部调用流图的方式,基本小的项目扫描能够控制在几秒之内,对于比较大型的项目,扫描时间也能够控制在一分钟左右。
由于局部调用流图在扫描时间上更具优势,而且针对Web应用的扫描未见与全局调用流图存在明显的差异,因此在构建调用流图的选择上,一般选择局部调用流图。
7.2 调用示例
在这里为方便大家更好理解,我们模拟一个调用关系,如下图所示。
如上图所示为一个假设调用图,其中路由节点用于标识处理HTTP请求的方法,比如在Koa中,路由节点一般形式为:
router.get('/api/ranking/ssrf', controller.xx.ssrf);
,其中第一个参数url path,第二个参数就是表示处理该请求的方法,上图中表示为Source方法。其中调用1-1、调用1-2、调用1-3及Sink-1是Source方法中包含的调用语句,这一部分假设统一包含在文件1中。另外调用-调用的其他的方法,这里我们统一假设为调用文件2中的相关方法,每个方法体中又包含不同的调用关系。
7.3 局部调用树
对于局部调用树的构建,我们以方法体作为树的节点,使用一个空节点作为根节点进行构建,在每个节点中使用其最后的调用语句作为其重要标识,方便后续调用链路的构建。
根据调用图我们构建起了如上图所示的一个局部的调用树,除根节点外,每个节点必须包含两个关键信息:方法体的相关信息、调用点的相关信息。
7.4 调用链路的获取
调用链获取的方式是通过遍历上述局部调用树的所有叶子节点,如果叶子节点符合我们所定义的Sink信息,则通过逆向查找的方式,还原其整个调用链路,从而获取该局部调用图上的所有的调用链路的数据。
如上图所示,整个局部调用树一共包含3条调用链路,分别使用1、2、3表示,至此我们就完成了Source与Sink之间调用链路的构建。
八、结语
阅读至此,相信读者在心中已经有了变量追踪引擎的一个大概的模型。实际上能够实现调用链的获取已基本上完成了变量追踪大半的工作,下一篇将为读者朋友们带来变量追踪最核心的部分,基于状态机的模拟执行。
*本文作者:nightmarelee,转载请注明来自FreeBuf.COM