防注入

2019年,防注入某电商平台的防注入安全团队在例行巡检时发现,一个订单查询接口的防注入响应时间突然从平均80毫秒飙升到3秒。日志里没什么异常,防注入没有大量并发,防注入没有硬件告警。防注入工程师顺着请求链路查下去,防注入发现数据库的防注入慢查询日志里躺着一条被拼接出来的SQL语句——用户输入的数字ID位置,被人塞进了一段精心构造的防注入字符串。数据没丢,防注入但攻击者已经通过布尔盲注,防注入一个字符一个字符地摸清了数据库里所有表的防注入名字。

这就是防注入注入攻击的典型面目。它不靠暴力破解,防注入不靠漏洞扫描器的防注入大嗓门,而是利用程序对“数据”和“代码”边界的模糊处理。你把用户输入当数据,它偏要当代码执行。SQL注入是最老的一种,老到1998年就被写进Phrack杂志,可到今天它依然在OWASP Top 10里赖着不走。

为什么?因为拼接字符串太顺手了。

我见过一个刚入行的后端写的登录逻辑,大概是这样的:"SELECT * FROM users WHERE name='" + name + "' AND pwd='" + pwd + "'"。他跟我说测试通过了,正常用户能登录。我说你试试在用户名里输入一个单引号。他试了,报错。再输入' OR '1'='1,密码随便填,直接进了后台。他愣了半天,说这怎么可能。没什么不可能的,数据库看到的是WHERE name='' OR '1'='1' AND pwd='xxx',运算符优先级一算,条件恒真。

防注入的核心就一句话:别让用户输入的东西变成指令的一部分。做到这一点,办法其实不复杂。参数化查询是首选,把SQL语句的骨架先交给数据库预编译,用户输入的值再填进去,数据库压根不把值当代码解析。Java里的PreparedStatement,Python里的cursor.execute带占位符,PHP的PDO,都是干这个的。可惜很多人嫌麻烦,或者被老代码绑住了手脚,继续用字符串拼接。

还有一种情况更隐蔽:LIKE语句。有人觉得"WHERE title LIKE '%" + keyword + "%'"没法参数化,因为百分号得动态加。其实可以,把百分号拼在参数值里,而不是拼在SQL字符串里。这个弯绕不过来,就给了注入可乘之机。

注入不止SQL。命令注入、LDAP注入、XPath注入,原理都一样——解释器把本该是数据的部分当成了语法。比如一个ping工具,用户输入IP地址,程序直接Runtime.exec("ping " + ip)。攻击者输入127.0.0.1; rm -rf /,分号后面的命令就跟着跑了。防御手段是白名单校验,只允许数字和点,或者用参数化的API调用,别碰shell。

我自己的习惯是,任何来自外部的数据,在进入解释器之前,先问三个问题:它的类型对吗?它的长度合理吗?它有没有可能被解释成特殊字符?前两个问题用校验解决,第三个问题用参数化或转义解决。转义要小心,不同数据库的转义规则不一样,MySQL的mysql_real_escape_string在GBK编码下曾经出过宽字节注入的经典漏洞,一个%df%27就能绕过。所以参数化永远优先于手动转义。

还有一个经常被忽略的点:ORM不是万能药。Hibernate、MyBatis这些框架,如果用HQL或注解拼字符串,照样注入。我见过一个项目用MyBatis的${ }而不是#{ },结果用户输入直接拼进SQL。框架只是工具,用错了照样翻车。

最后说个真实的教训。某次代码审计,我发现一个下载接口用文件名拼路径,new File(baseDir + fileName)。攻击者输入../../../../etc/passwd,就能读系统文件。这叫路径穿越,也算注入的一种。修复方案很简单,fileName只取new File(fileName).getName(),把目录部分剥掉。一行代码的事,但没意识到的人就是会写错。

防注入不是背几条规则就完事。它要求写代码的人时刻保持一种警觉:用户输入永远是脏的,永远不要相信它。这种警觉变成肌肉记忆之后,你写出来的代码自然就安全了。工具在进步,语言在进化,但“数据和代码分离”这个原则,从没变过。