本文主要介绍如何构建一套基于安全基础库所制定的安全编码规范以及如何在DevSecOps中进行落地,从而在日常研发中增强业务整体的安全性。

1、为什么需要安全编码规范+安全基础库?

从当前公开的漏洞来看,大部分安全漏洞很多程度上是由于代码编写风格较为随意、编程语言特性理解不当、相关API使用不当造成的,特别是动态脚本语言,如PHP,如果不了解其中的某些特性,很容易写出安全漏洞。

为了解决这类问题,甲方安全部门往往都会整理一份安全编码规范文档来指导业务线规避不安全的代码写法,或者尝试使用静态代码扫描来识别代码漏洞。不过,在各大公司中存在的“安全编码规范”大多数情况下仅仅是一份文档, 没有融入到研发链条中,无法有效地进行落地。同样的,单纯的静态代码漏洞扫描的局限性也有很多,比如在实践过程中难以维持误报、漏报的平衡,而且业务线的同学看到漏洞报警后,往往只关注如何处理漏洞报警本身,无法达到改变不安全编码习惯的目的。

此外,从甲方DevSecOps建设经验来看,编写安全漏洞的防护代码往往是需要研发工程师具备一定的安全知识,否则很容易造成修复不当再次被绕过利用的风险。因此单纯在安全编码规范中给出文字性的修复建议描述是达不到良好效果的,由于理解不一致、缺乏必备的安全知识,研发人员有可能写出新的”漏洞代码”。

由于传统安全编码规范仅凭罗列错误的代码写法、漏洞修复建议无法真正意义上指导业务线写出安全的业务代码。因此在我们DevSecOps安全实践中,将安全编码规范与安全基础库两者进行融合,同时配合使用代码静态分析手段对不符合规范的代码进行提交阻断,将安全编码规范检查落地到研发链条中,实现效果最大化。

2、构建有效的安全编码规范检查机制

传统的安全编码规范往往都是枚举一些存在漏洞的代码编写方式,比如拼接导致SQL注入、命令执行漏洞等,然后给出修复建议以及正确的写法,但是从实践来看,单纯的文档指导存在诸多问题,无法使得效果最大化。而且修复建议本身过于笼统,无法覆盖所有的业务场景,研发人员按照编码规范描述很难写出安全的代码。

此外,传统的安全基础库往往只是提供简单的过滤转义功能,从DevSecOps实践来看往往效果不佳。因为很多情况下,业务方研发人员并没有很高的安全素养,很难判断到底哪些场景应该执行哪些过滤操作,总会因为疏漏导致出现问题。

因此,我们的思路是将原生语言中较为危险的调用方式进行拦截,在尽可能减少修改成本的情况下,让业务方使用相应的安全基础库中的API取而代之。这种拦截策略还需要嵌入到代码提交环节中进行检查,保证入库的代码符合安全编码规范。

这就要求安全基础库需要在实现业务功能的同时执行安全防护逻辑。比如安全网络资源请求需要即完成业务功能(请求网络资源),又要完成安全防护(对变量进行判断,避免SSRF漏洞产生)。这样既可以实现业务功能,又引入了安全加固代码,保证业务线使用安全基础库写出的业务代码就是相对安全的代码。

如上图,在安全基础库实现过程中,我们将相关的安全过滤、防护代码融入到具体的功能实现中去,甚至直接在开发框架、开发组件层面进行安全加固,如在Mybatis中内置安全机制。

这些安全防护手段对于业务方的研发人员来说是近乎透明的,比如调用安全基础库中的SafeHttpGet来替代原生的curl来实现网络资源请求功能,由于SafeHttpGet中自带了SSRF防护功能,业务方在实现网络资源请求逻辑时,只需要调用SafeHttpGet来替换原生CURL的形式,就能引入安全加固代码,从而避免出现SSRF的问题。因此,我们的安全编码规范看起来是这样的:

[强制][PHPSEC017]

在请求网络资源时,不得拼接和带入外部变量。如需带入的,使用安全基础库的SafeCurl类完成相关功能。

在DevSecOps实践中,我们还需要在代码入库阶段与静态分析技术进行结合,自动识别出不符合安全编码规范的高危API调用、不安全的拼接写法等,影响其代码入库的动作,提示业务方使用安全基础库的相关API进行替换,将安全编码规范的作用发挥到最大。

这里需要注意的是,安全编码规范层面的静态分析检测命中条件不同于静态代码漏洞扫描,应该是比较松散的一种识别规则。比如针对PHP中的system函数,传统的静态代码漏洞扫描往往需要判定system中确实拼接了外部变量(如$_GET、$_POST)才会报警拦截。而从安全编码规范的角度来说,可以认为拼接的写法或者调用某些较为危险的API实现业务功能本身就不是安全的代码写法,因为很容易因为这种编码习惯出现命令注入问题,因此要拦截的场景不仅仅是一个漏洞,而是这一种拼接变量的代码写法都会被拦截,要求相关的研发人员进行修正后才能入库。

3、如何设计安全基础库

一个好的安全基础库需要满足以下条件:

(1)安全防护对研发透明:业务线无需主动判断在何处进行主动调用,最好将安全防护机制嵌入到框架层面

(2)覆盖的业务场景全面:由于安全机制需要融入到正常功能实现中,因此需要覆盖尽可能多的框架、类库、业务场景

(3)具备良好的兼容性:为了将安全基础库大规模推广,需要与现有的开发环境进行适配,具备良好的兼容性与持续升级保障

(4)具备先进的漏洞防护思路:安全防护机制应该做到健壮、全面、严密

(5)保证良好的调用风格:安全基础库中的API调用风格需要避免误用导致漏洞的情况,比如以绑定参数的键值对数组取代直接传入字符串的形式

本节以防护SQL注入为例,探讨一个基于上述思路的SQL安全基础库该如何设计与实现。

3.1 PHP SQL安全基础库

PHP原生方法如mysqli_query()、PDO::query()等简单查询可以直接拼接SQL字符串。这种方式在代码风格上不优雅,而且直接组装SQL语句,在安全性上也难以保证。所以会首先在编码规范中对此类简单查询作出限制,拦截粒度是只允许传入硬编码的内容。

保证只出现:

或拼接的只是一个函数内变量,且变量已经明确赋值:

这样可以保证在简单查询中是绝对安全的。

形式2需要进行语义分析,在此前的文章中有过介绍,在这里可以使用PHP-Parser等工具得到抽象语法树,进而确定是否是已赋值的函数内变量。如不具备这一条件,可以传入变量就作拦截,要求切换成安全基础库。

既然进行了拦截,就要提供对应的替代方法。在主流PHP框架中,通常也是依赖各种数据访问层(Database Access Objects,DAO)同数据库进行交互,带来了安全性的提升。但在主流框架中,安全性往往只体现在where处,甚至有的在where处就存在缺陷,还是会造成注入发生,安全基础库应当避免这些坑点。

以流行框架Yii2为例,来看看Yii2 DAO有哪些设计缺陷,以及一个安全的SDK应当如何避免这类问题。

3.1.1 危险的字符串拼接

在Yii2下,可用查询构建器QueryBuilder和活动记录ActiveRecord两种方式来访问和控制数据库。

在QB中,用操作符方式或者哈希方式来执行SQL语句通常是安全的:

分析源码可知,两种方式都以数组形式参与到buildCondition过程,并进入到createConditionFromArray方法内,在createConditionFromArray内会执行多步操作,最终会把这段where子句拼接成population =:qp0,然后传给mysql进行预编译和绑定执行。

但也可以发现,在buildCondition方法中,当传入的不是一个数组时,不会进入到if逻辑内,而是直接return原字符串。

也就是说,下面这种拼接形式也是合法的:

并且在底层则不作任何处理,直接return原句,造成注入发生。

在设计安全基础库时,也无法避免语句的拼接。但对于这种明显的对外接口,应当完全禁止掉。所以安全基础库处理方式是,只允许这样的操作符形式,对外不再提供字符串拼接的接口:

3.1.2 k/v形式下的key注入

上点说到在Yii2中操作符形式和哈希形式在底层采用了预编译机制。

这看起来是安全的。但进一步分析可以发现即使用了这两种方式还是会带来注入问题。

关键就在下面这段代码中。可以看到,在build语句时,只对$phName做了绑定处理,对于$column,会传递给quoteColumnName处理,而这个函数只是在两边简单添加了下反引号。

这就造成,即使使用的是操作符形式或者哈希形式这种基于预编译的形式,当业务线用到如下写法时,

$value是安全的,但只要控制了$population,也就是key/value形式的key部分时,注入语句还是会被带入。闭合一次反引号,就可以注入任何evil code。

几乎所有的框架都存在这一安全隐患,安全基础库在这里的做法是:

会对$column做出限制,只允许数字、字母和_@$.符号传入,并用反引号包裹。需要说明的是,一个安全基础库通常需要对接多种数据库,如mysql、oracle、ms server等,不同的厂商对表名和列名的要求并不完全相同。但基本上,无论是从代码风格还是安全上来说,表名和列名通常只要数字字母和下划线就可以表示清楚,这里最好在研发流程中就加以约定,避免表名列名中出现特殊字符,也避免像笔者一样在某国企项目时遭遇中文表名这样的尴尬情景。

3.1.3 like、in注入问题

like、in等语句下,参数绑定通常需要在内部拼接一次,甚至借助concat语句才能通过编译,往往需要单独处理。一些框架对这些操作支持并不友好,造成注入发生。

在Yii2中,like语句构建的关键代码如下

会将传入的参数绑定,得到类似’`name` LIKE :qp0’的结果,并参数化查询。

这样是并不会带来注入问题的。

同样,In操作下

也都会被当作参数进行参数化查询,是相对安全的。

但如果存在第1、2点说的问题,传入的是字符串形式,或者传入的key处被外界控制。依然造成注入的发生。

而offset、limit等语句则粗暴的采用了ctype_digit方法完成验证:

传入非法字符串会返回空,直接移除limit、offset语句,这样虽然不会带来注入问题,但过于粗暴,连limi 0,1的形式都禁用了。相比之下,intval转换一下侵入性会更小。

3.1.4 orderby注入

Yii2中,orderby的构建方法是

可见,没有对orderby后的内容作任何有效的转义处理。

这意味着在下面这样的调用方式时,

只要令$order= CASE WHEN (1=1) THEN name ELSE code END)

则可以看到最终编译的语句是:

即使底层是预编译方式,还是造成了SQL注入的发生。

同样的点还有groupby、having等,市面上的主流框架很少能对这些子句做出正确的处理。一旦这些子句后的内容被外界控制,还是会带来各种SQL注入问题。对此安全基础库的做法和第3点相同,对该表名和列名出现的地方,都进行一次check,确保传入的列表名是安全且符合规范的。

基本上,在设计安全基础库时只要注意到上述4点问题,并在底层全部使用预编译接口,参数部分使用参数化查询。就能得到一个安全的db接口。同时配合编码规范对PHP原生和主流框架的简单查询进行限制,基本可以杜绝PHP下的SQL注入问题。

3.2 Java SQL安全基础库

Java与PHP不同,Java语言开发和第三方生态联系更加紧密,比如流行于数据库操作与对象映射的库很多,从原生JDBC到JDBC高层次封装到JPA,每个场景下的研发漏洞都不尽相同,SQL安全基础库应该至少覆盖以下场景:

(1)原生JDBC操作

(2)JDBC高层封装:Spring jdbcTemplate

(3)SQLMapper:Mybatis

(4)JPA框架:Hibernate

(5)JPA高层封装:Spring Data JPA

这里以Mybatis为例,来介绍安全基础库如何进行设计。

众所周知,在Mybatis的XML Mapper中使用$绑定变量会导致SQL注入,因为$的底层处理是直接拼接,#的底层处理才是参数化查询,但#并不适用于所有情况。Mybatis在处理XML Mapper时,会将#所在的位置替换为预编译占位符?,以MySQL为例,其本身不支持LIKE "%?%"或是IN ?这样的预编译形式,更不支持表名/列名/排序方式的预编译,可想而知这些场景下也就不支持使用#来绑定变量了。

在常规的安全解决方案中,Mybatis的LIKE后的#需要CONCAT来辅助编写,IN后则需结合foreach标签。

对于ORDER BY后列名及排序方式(ASC/DESC)的动态传入,则是通过来自Java层面的映射完成的,如将所有涉及到的列名及排序方式定义为常量,将用户传入的值匹配为常量后进行传入,若不匹配就使用默认值。

上述解决方案为了确保安全性增加了额外的编码逻辑,如果安全基础库可以支持类似LIKE "%#{id}%"或是IN #{ids}这样简单直接的语法,就可以在编码成本与安全性上取得双赢了。想要达成这样的效果,就必须在执行前对SQL语句及其参数值进行拦截、解析以及修改。

提及拦截,Mybatis支持用户自定义插件,其本质上就是一种拦截器,可在已映射语句执行过程中的某一点进行拦截调用。以query拦截器为例,拦截到的args中共有4个参数:

其中args[0]与args[1]为修改SQL语句及其对应参数的关键。args[0]是一个MappedStatement对象,SQL语句储存在其内部的BoundSql.sql中,在单步调试中查看其值,发现#已经被处理为占位符?。args[1]本质上是一个Map,通过key-value对储存了对应的参数值。BoundSql中还有一个名为parameterMappings的List,其内部元素parameterMapping在List中的位置对应了SQL中预编译占位符?的位置,属性property储存的值便是args[1]中的key。parameterMappings通过这种方式关联了BoundSql.sql与args[1]中参数值的绑定关系。

明确修改SQL及对应参数的方法后,就可以开始着手对SQL语句中需要被替换的占位符进行捕获了。以支持IN #{ids}为例,拦截器进行拦截后,应在SQL语句中捕获字符串片段『IN ?』,这里不能直接使用正则匹配,因为『IN ?』极可能在SQL语句中的引号内作为常量出现,而非SQL语法的一部分,正则表达式无法对这种情况进行有效区分。为了保证捕获的精确性,可以参考PrepareStatement计算占位符?的方法:逐字符解析。在遍历SQL语句字符的过程中,需要排除注释带来的干扰,计算当前字符是否处于引号内,并以SQL关键字间的分隔符(如空格或制表符等)为界限记录前一个关键字。当当前字符为处于引号外的?,且前一个SQL关键字为IN时,『IN ?』即捕获成功。接着只需根据其对应的Array长度对SQL及参数进行修改即可。如IN ?对应的参数是长度为3的Array{1, 2, 3},则SQL中相应位置修改为IN (?, ?, ?),将Array拆分并与此三个占位符一一对应即可,其他场景的处理也大同小异。另外需要说明的是,query拦截器仅适用于对select语句的处理,对于insert、update等语句的处理原理相同,在此不再一一展开。

除Mybatis外,其他的ORM框架诸如Hibernate、Spring Data JPA都对『IN ?』的情况进行了特殊支持,使不得不拼接的场景减少了,这为编码安全提供了极大的帮助。

此外,在接触大量SQL注入的代码场景后,发现大多数问题的根源还是在于研发人员太过于随意地使用SQL语句的拼接造成的(实际上所有编程语言中SQL注入问题都是源于此)。Java中的SQL拼接的写法大多使用字符串连接符『+』或StringBuilder,通过深入调研业务场景可以发现,完全杜绝拼接是不可能的,因为SQL语句过长也是需要拼接的理由之一。本着堵不如疏的原则,可以提供一个与StringBuilder类似的SQL语句拼接器,配合安全编码规范和检测引擎对危险拼接行为进行限制,如append方法中只允许传入字符串常量,为表名/列名/排序方式等场景提供专用的内置严格过滤器的append方法等。

总之,不论对什么样的场景进行支持,安全基础库都应在兼顾安全性的同时尽可能降低使用成本。

4、总结

DevSecOps过程中的各种治理措施和工具一定是要向前靠拢,即尽可能将安全措施前置到研发周期最开始的阶段,问题发现的越早,修复成本就越小。而安全编码规范就是要作用于代码编写、代码入库这种比较靠前的研发阶段。通过安全编码规范+安全基础库+自动化静态代码检查相结合的形式,业务线可以在实现产品功能的过程中无感知地引入安全加固代码,显著提高代码安全性,从源头上解决大量的问题,值得甲方安全团队进行尝试。

声明:本文来自百度安全应急响应中心,版权归作者所有。文章内容仅代表作者独立观点,不代表安全内参立场,转载目的在于传递更多信息。如有侵权,请联系 anquanneican@163.com。