前言

SQL注入是Web安全界地位很高的一个漏洞,它把矛头对准网站最为重要的数据库,利用成本低而危害巨大。若说一个普通开发人员有那么点儿安全意识,那一定和SQL注入有关。如今随着Web安全逐渐被重视,大家安全意识提升,同时各种预编译框架、ORM层出不穷,SQL注入已不像10年前那么泛滥,那么SQL注入的前世今生是怎样的?在这个预编译时代,SQL注入为何仍未销声匿迹?而预编译的底层又有哪些细节需要安全工程师知晓?这将是本文所重点探究之处。

注入原理剖析

SQL注入的本质

SQL注入的本质是『注入』,注入类攻击可被称为Web安全漏洞第一家族,内含SQL注入、命令注入、代码注入等,连XSS漏洞的本质其实都是HTML注入。而SQL注入自然是发生在SQL语句中的注入攻击。马三立先生有一段很著名的相声:

妈妈:『看着衣服,有人偷告诉妈妈』

小偷:『小孩你认识我吗?我叫逗你玩』

小孩:『妈妈有人偷咱家衣服』

妈妈:『谁啊?』

小孩:『逗你玩!』

妈妈:『介孩子!』

原本妈妈(数据库)想获取的是一个人名,即『数据』。但经过小偷(黑客)精心构造,小孩(SQL语句)将『逗你玩』(Payload)直接反馈给妈妈,导致妈妈理解成了一个『动作』。由数据到动作便是SQL注入的精髓所在,这会使黑客在数据库中任意执行SQL命令,不论后续有多少的奇技淫巧与绕过姿势,这样的本质总是不变的。一个简单的例子:

1
mysql_query("SELECT * FROM nowcoder WHERE id = $id");

当黑客传入精心构造的数据 1 UNION SELECT version(), user(),整条语句会变为

1
mysql_query("SELECT * FROM nowcoder WHERE id = 1 UNION SELECT version(), user()");

导致变为一个联合查询,将数据库的版本与当前用户泄露。

如果注入点被包裹在引号内,就传入引号,闭合SQL语句中原本的引号,使得攻击使用的Payload逃逸到引号外,成为SQL语法的一部分。

因此,SQL注入漏洞防御的核心就是阻止用户输入由『数据』变为『动作』。如使用反斜杠将用户输入中的引号转义,使其不能闭合SQL语句中原有的引号,无法影响引号外的部分,也就只能作为数据;又如PHP对付数字型SQL注入常用的intval,使得无论用户输入什么,最终都只能变为数字,或是使用addslashes转义引号,防止SQL语句中原本的引号被闭合使Payload逃逸到引号外;而预编译更是做到了极致,通过预先生成SQL语句语法树的方式,使得传入的数据永远也无法成为一个动作,关于其原理与对抗手段,文章将会在后续详细探讨。

SQL注入的类别

关于SQL注入的分类是仁者见仁,智者见智的事,很多人喜欢将其分为字符型、数字型和搜索型。而我个人更喜欢布尔盲注、时间盲注、报错注入与联合查询的分类方式。

布尔盲注可谓是最基础的一种注入,其本质就是使SQL语句永真或永假,使页面上显示的内容不同,然后逐个字符的去判断,以此来得到数据库中的所有数据。

时间盲注是从布尔盲注的基础上发展而来,即如果存在盲注,但不论SQL语句是用真还是永假,页面都没有回显或没有明显变化,如何去判断SQL语句此时是真是假呢?可以使用sleep函数使SQL语句延迟一段时间后再返回结果,如果sleep函数与整个语句是且(and)的关系,那么为真的语句就会延时,为假的语句就不会延时。其他的部分与布尔盲注其实是相同的。在面试中,经常会被问到『如果sleep函数被禁用,如何进行时间盲注?』答案是使用benchmark函数,它的本意是向用户报告执行某个表达式的时间,使用它大批量执行一个任意表达式,也可达到延时的效果。

报错注入则是通过floor、extractvalue、updatexml等函数来实现的,这些函数有一个共同的特点,便是完成相应的功能需要进行两次及以上的操作。因此我们使其成功查询数据后进行处理的那次操作出错,也就将查出的数据暴露在了错误信息中,从而使我们得到想要的数据。

联合查询导致的注入则是原本的select语句后可以通过union关键字来拼接一个自定义select,从而为所欲为,查询任意想查询的数据。并且只需要自定义select所查询的列数与原select语句查询的列数相同,即可将数据正常显示在页面上。

从实战来看,这四种类型的注入点查询数据的速度为联合查询>报错注入>布尔盲注>时间盲注,因为前两者可以直接查询想要的数据,而后两者则需要大量的试错,尤其是时间盲注,会将整个过程拉的很长,但时间盲注并不是没有优点。

我曾在面试中遇到过这样一个问题:如果你只有一次试验的机会,如何判定一个数据输入点是否存在SQL注入?答案是使用时间盲注,如sleep(4),如果真的存在注入,则肯定会延时4秒再显示结果。其他类型的注入都非一次试验而能确定存在的,并且一个数据输入点只要存在注入,必定存在时间盲注。

注入功守道

WAF的本质

如果说我在SQL注入的本质中提到的三种SQL注入防御方式是对症下药,那么使用WAF进行防御就像是隔靴搔痒,无法根治漏洞。WAF的目的是在不可信数据被传给应用程序处理前,先将其过滤为可信数据。这里要提到一个概念:信任边界,即应用程序内的已定义的数据是处于信任边界内的,而任何来自用户输入的数据都处于信任边界外,属于不可信数据。华为的安全编码规范曾明确提出任何跨越信任边界传递的数据都需要被校验,因为所有的不可信数据都可能来自黑客攻击。

WAF通过过滤或拦截用户输入中的SQL片段来防御SQL注入,如黑客输入的数据为1 AND 1=1,WAF侦测到SQL关键字AND,就会将用户输入过滤为1 1=1,或干脆直接拦截掉本次请求。这将会导致一个问题,便是当正常用户输入YOU AND ME时,请求也会被过滤和拦截,这将会严重破坏用户体验。但如果将校验的关键字减少,又会带来许多绕过WAF进行攻击的方式。

由此可见,企业Web安全其实不是纯粹的Web安全技术,会夹杂很多用户体验与安全之间的权衡,甚至会考虑到程序猿们的开发体验。很多规模庞大复杂的祖传代码由于年久失修,会有各种各样的神奇BUG,如果重构,可能会带来彻底的崩溃。这个时候,游离在整个应用程序之外的WAF便是保证祖传代码安全性的最好选择。

综上,WAF就是个既有用也没用的矛盾***体,让安全工程师们又爱又恨,绕过层出不穷却不得不用,只好缝缝补补又一年。

来自HTTP头的注入

从上述防御姿势可以看出,很多网站要么使用WAF,要么在每处用户输入做严格过滤。

很多WAF其实都是按照用户输入的传入形式来配置防御的,比如GET请求,POST请求可能是两套不同的规则,都会专注于对传入参数的防御,那么如果此时攻击来自于Cookie呢?WAF不会过滤Cookie中的危险字符,但Web应用在进行数据库操作时,确实可能使用到Cookie中的值,此时我们只需使用Cookie值进行注入即可绕过WAF。

把这个概念推广开来,当HTTP请求中的其他请求头的值开始参与数据库操作时,都是存在注入风险的。比如Web应用将访问者的IP地址存入数据库,会对HTTP请求中的x-forwarded-for、x-remote-ip、x-client-ip获取后进行INSERT操作,一般的开发者可能只会将来自$\_GET、\$\_POST的变量使用addsla