代码审计的角度分析sql注入
一、sql注入起源
- SQL注⼊第⼀次为公众所知,是在1998年的著名⿊客杂志《Phrack》第54期上,⼀位名叫rfp的⿊客发表了⼀篇题为“NT Web Technology Vulnerabilities”的⽂章,在⽂章中,第⼀次向公众介绍了这种新型攻击。下⾯是当时给出的例⼦,现在看已经很古⽼了。
var ShipCity;
ShipCity = Request.from("ShipCity");
var sql = "select * from OrderTable where ShipCity = '" + ShipCity +"'";
- 变量ShipCity为用户所提交,正常情况下,假如用户输入“glc”,那么SQL语句会执行
select * from OrderTable where ShipCity = 'glc';
- 若用户输入一段有特别含义的SQL语句,比如:
glc' ;select version ();--
- 那么SQL语句在实际执行时就会如下
select * from OrderTable where ShipCity = 'glc' ;select version ();--';
- 现在变成了查询后,再执行一个查询数据库版本的操作,属于用户非法行为
- 近⼏年,因为规范化的框架使⽤、orm的普及,基本在框架层⾯就解决了⼤部分普通sql注⼊的问题,sql注⼊越来越难,所以表现就是sql注⼊不那么受关注了。但需要注意的是,sql注⼊仍然是危害巨⼤(拿数据库、什么拿shell、拿系统权限)的攻击⼿法。
二、sql注入的基础代码样例
1.调用原生数据库操作模块
-
php
<?php $db = init_db(); $username = $_GET['username']; $db->query("select * from table where username = '$username'"); ?>
-
.net/aspx
string connectionstring = "xxx"; SqlConnection con = new SqlConnection(connectionstring); con.Open(); string username = Request.QueryString["username"]; string sql = "select * from table where username = '" + username + "'"; SqlDataAdapter adapter = new SqlDataAdapter(sql, con); DataSet dataSet = new System.Data.DataSet(); adapter.Fill(dataSet); con.Close()
-
java
conn = DBHerpel.getConnection(); if (conn == null) return; String username = request.getParameter("username"); String Sql = "select * from table where username = '" + username + "'"; stt = conn.createStatement(); set = stt.executeQuery(Sql);
-
python
@app.route("/", methods=["GET"]) def test(): username = request.args.get('username') sql = "select * from table where username = '" + username + "'" conn = connect(host='localhost',port=3306,user='root',password='',database='test',charset='utf8') cs1 = conn.cursor() count = cs1.execute(sql)
-
以上全都是不同语⾔在不调⽤orm框架,直接调⽤原⽣数据库操作函数时的⽤例。其实orm框架的底层也是调⽤了原⽣数据库操作函数,只是orm帮开发者做了封装和对象映射的步骤,这种显⽽易⻅有漏洞的
-
数据流⼊侵控制流
产⽣的⻛险点,在于不同层⾯组件的交汇处,如:代码层与数据库层 -
关于orm相关知识可以查看这里
2.调用orm的错误写法
-
php
- php没有啥⽐较出名的orm框架,基本是各web框架或者cms⾃⾏去实现⾃⼰的orm,⼀套闭源cms的orm可能实现得并不规范,从而产⽣漏洞,即使⾃⼰写了⼀套orm,还有防注⼊,但还是被绕过了
-
java/MyBatis
-
java的持久层技术解决⽅案可以发展历程与MyBatis的详解看
-
MyBatis需要有⼀个xml配置⽂件来来绑定映射关系
<select id="findUserByName" parameterType="java.lang.String" resultType="cn.itcast.mybatis.po.User"> <!-- 拼接 MySQL,引起 SQL 注⼊ --> SELECT * FROM table WHERE username = '${value}' </select>
@Test public void testFindUserByName() throws Exception{ SqlSession sqlSession=sqlSessionFactory.openSession(); //创建UserMapper代理对象 UserMapper userMapper=sqlSession.getMapper(UserMapper.class); //调⽤userMapper的⽅法 List<User> list=userMapper.findUserByName("fuckdada' and 1=1#"); sqlSession.close(); System.out.println(list); }
-
简单讲就是MyBatis有两种变量绑定⽅式,分别是:#{}和${}
#是绑定变量的形式,底层会⽤#{}会被替换为?号,有参数映射,会在DefaultParameterHandler中进⾏设置占位符的操作 -->预编译 $也是绑定变量的形式,{value}是直接被替换为了对应的值,没有参数映射,不会进⾏设置占位符的操作 -->拼接
所以这也是很多代码审计初学者上来就去找**$符号**的原因。⼤家后续遇到MyBatis也确实可以这么搞,对于⼩cms简单有效
-
-
python/flask/sqlalchemy
-
sqlalchemy是flask最经常配套的orm框架,在许多django项⽬中也常常看到身影,也是⽬前python上⽤得最⽕的orm框架,详情请见这里
-
注⼊漏洞的例⼦
@app.route("/", methods=["GET"]) def test(): username = request.args.get('username') res = db.session.query(table).filter("username={}".format(username))
原因跟上⾯MyBatis类似,依然是⽤户的输⼊其实是拼接后才导⼊sqlalchemy层的(不同层⾯组件的交汇处)
-
正确的写法
@app.route("/", methods=["GET"]) def test(): username = request.args.get('username') res = db.session.query(table).filter(table.username == username)
-
三、sql注入到底在注入什么
-
通常会把sql注入分为sql联合查询注⼊、sql堆叠注⼊、sql报错注⼊、sql时间盲注、sql布尔盲注、sql带外数据、sql注⼊执⾏命令等等,但是在这里我们要思考一个问题,那就是他们的共性是什么,也就是总结出一个sql注入的核心思维
-
核心思维就是想法设法去执行⼀条完全的sql语句,把数据带出来或把命令传进去。归根结底sql注入就是在执⾏⼀段sql语句,那么我们的关注点就应该放在数据库类型。
-
其他次要关注点
-
编程语言
不同编程语⾔最终的⽬的都是为了将我们的payload送⼊数据库层进⾏执⾏,能看到注⼊点即可,编程语⾔没那么重要。
-
注⼊类型
不需要特别关注分类,所有的分类都是sql语句的不同写法⽽已,⽐编程语⾔相对重要⼀些
⼀条sql语句,最终会被解析成⼀段控制序列
action(动作): select object(对象): table subject(⽬标客体): * condition(条件): key: username value: $username // ⽤户输⼊
如果能够使⽤堆叠注⼊,我们就能跳出action动作,执⾏其他sql语句,肯定危害更⼤。
⽬前来看,堆叠注⼊越来越少。经验上来看,mssql数据库的堆叠注⼊最多;oracle数量基本差不多,稍微少⼀点;mysql不多爆数据的速度上:报错>联合查询>带外数据>布尔盲注>时间盲注
-
产⽣注⼊的输⼊点
输⼊点⽐上⾯相对⼜重要⼀些,因为输⼊点决定了我们能⽤什么样的Vector(攻击向量),以及是否需要绕过
select $username$,password from $table$ where $username2$ = '$dada$' order by $username3$ desc limit $0$,1
-
四、特殊的sql注入
1.宽字节注入
-
宽字节概念
- 单字节字符集:所有的字符都使用一个字节来表示,比如 ASCII 编码(0-127)
- 多字节字符集:在多字节字符集中,一部分字节用多个字节来表示,另一部分(可能没有)用单个字节来表示
-
我们知道字节是计算机存储世界中最⼩的衡量单位,1Byte = 8bits。所以⼀个字节最⼤能够表示2^8=256个字符。
对ascii编码⽽⾔,⼀个字符⽤⼀个字节就可以表示,所以ascii编码最多可以表示256个字符。
对GBK编码⽽⾔,⼀个汉字字符需要⽤两个字节表示,所以gbk编码理论上最多可以表示256*256个字符。
-
利用
- 因为宽字符的存在,导致⼀些防注⼊的⽅式会被绕过,我们以代码的形式来讲解
<?php $db = init_db(); $db->query("set SET NAMES 'gbk'); //设置gbk字符集 $username = addslashes($_GET['username']); //input: glc' and 1=1# $db->query("select * from table where username = '$username'"); ?>
- 我们来考虑下sql语句会是怎么样的呈现,输⼊glc’ and 1=1#,在这种场景下,我们没办法进⾏sql注⼊,因为我们的单引号被转义了,我们没办法侵⼊到控制流
select * from table where username = 'glc\' and 1=1#'
-
输⼊glc%df’ and 1=1#
数据在存储的时候⼀定是以字节存储的,但是数据解读的时候,都是以字符的标准去解读的。所以,在连接数据库进⾏sql执⾏(执⾏就是⼀种对存储的解读)时,会按照字符集编码的规范去解读,所以遇到了\xDF\x5C的时候,会解读成⼀个字符,此时的数据流转过程为
url输⼊-->php字符串变量-->addslashes-->sql语句-->数据库 glc%DF' and 1=1# --> glc\xDF' and 1=1# --> fuckdada\xDF\' and 1=1# --> select * from table where username = 'glc運' and 1=1#' //数据流成功⼊侵到控制流
-
所以代码审计中审阅宽字节注⼊的⽅式,就是查看数据库连接⽂件(⼀般名字类似conn.php),检查其字符集类型是什么,如果不是utf-8(因为脚本的字符集默认是utf-8)就可能有注⼊产⽣
2.hql注入
- Hibernate:⼀种ORM框架,⽤来映射与tables相关的类定义(代码),并包含⼀些⾼级特性,包括缓存以及继承,通常在Java与.NET中使⽤,但在Java⽣态系统中更受欢迎。近来似乎逐渐有被Mybatis取代的趋势。
- 与Mybatis不同的是,Hibernate虽然也⽤了xml作为映射模型。但是他构成了⼀套⾃⼰的解析引擎和语法,也就是HQL
- ⽤户的输⼊作为HQL的⼀部分,先经过Hibernate解析引擎,渲染成sql语句,然后再进⼊到数据库层进⾏查询
- 利⽤
- HQL注⼊的漏洞产⽣与SQL注⼊并⽆差异,也是直接通过拼接字符串导致的注⼊。即代码层的输⼊破坏了HQL层的结构,最终蔓延到SQL数据库层,改变了控制流。
- 相对于SQL注⼊⽽⾔,HQL增加的挑战就是我们插⼊的数据,⾸先必须经过HQL渲染通过才能进⼊SQL数据库获取数据。相当于我们需要构造⼀条payload,既符合当前的HQL结构,HQL渲染后的sql⼜能正常的在数据库执⾏
- ⼀般的利⽤⽅式是,不考虑sql能够完全执⾏成功,⽽是利⽤sql报错注⼊+框架开启报错,将有⽤的数据直接在错误回显中爆出来
五、预编译下的注入
-
看个例子
<?php $username = $_GET['username']; $db = "mysql:host=127.0.0.1;dbname=test;charset=gbk"; $dbname = "root"; $passwd = "root"; $conn = new PDO($dbs, $dbname, $passwd); $conn->query('SET NAMES GBK'); $stmt = $conn->prepare("select * from table where username = :username"); $stmt->bindParam(":username",$username); $stmt->execute(); ?>
在这种情况下,是⽆法解决宽字符注⼊的问题,因为
$stmt = $conn->prepare("select * from table where username =:username"); ==> $stmt->sql = "select * from table where username = '${addslashes($input)}'"
-
⽆法预编译的输⼊点
-
like关键字
我们知道sql语句的模糊查找⾥⾯⽤的关键字like,⽽like关键字默认是不会预编译的(如果使⽤Mybatis则是预编译报错)。数据库⽅给出的原因好像是like预编译会造成慢查询和DOS,所以只能⼿动去添加预编译
<?php $username = $_GET['username']; $db = "mysql:host=127.0.0.1;dbname=test;"; $dbname = "root"; $passwd = "root"; $conn = new PDO($dbs, $dbname, $passwd); $conn->query('SET NAMES GBK'); $stmt = $conn->prepare("select * from table where username like '%:username%'"); //不⽣效 $stmt = $conn->prepare("select * from table where username like concat('%',:username,'%'"); //⽣效 $stmt->bindParam(":username",$username); $stmt->execute(); ?>
-
可能很多开发会遗漏这个点,导致存在注⼊。或者⼀些java的开发,Mybatis编译报错,然后他们⾃⼰去添加过滤(过滤没写好)或者不过滤(使⽤原⽣语句),导致GG。
-
与之类似的还有IN关键字,该位置也默认不能预编译,需要在预编译语法中去for循环,有些程序员为了⽅便,可能也会在这边偷⼯减料
-
-
不能加引号的关键字
-
我们刚才分析了预编译模式下的宽字节注⼊,我们可以发现预编译+绑定变量的效果,有点类似于做了两个步骤
$newInput = addslashes($input) //内容转义 sql = select * from table where column = '$newInput' //强制⽤单引号包裹
那么,结合我们刚才讲的,产⽣注⼊的输⼊点
select $username$,password from $table$ where $username2$ = '$glc$' order by $username3$ $desc$ limit $0$,1
-
是否有输入点是一定不能加单引号的呢,如果不能加单引号,那么就不能预编译,于是我们找到了
$username$,$username2$,$table$,$username3$,$desc$,$0$
-
这些地⽅都是不能加单引号的,总结就是表名、列名、limit子句、order by[desc/asc]
-
跟刚才⼀样,可能很多开发会遗漏这个点,导致存在注⼊。或者⼀些java的开发,Mybatis编译报错,然后他们⾃⼰去添加过滤(过滤没写好)或者不过滤(使⽤原⽣语句),导致凉凉
-