Thinkphp5 SQL注入漏洞引发的思考

文章首发于先知社区:Thinkphp5 SQL注入漏洞引发的思考

Thinkphp5

ThinkPHP,是为了简化企业级应用开发和敏捷WEB应用开发而诞生的开源轻量级PHP框架。

最早诞生于2006年初,2007年元旦正式更名为ThinkPHP,并且遵循Apache2开源协议发布。ThinkPHP从诞生以来一直秉承简洁实用的设计原则,在保持出色的性能和至简的代码的同时,也注重易用性。并且拥有众多原创功能和特性,在社区团队的积极参与下,在易用性、扩展性和性能方面不断优化和改进。

某些版本的Thinkphp存在一些漏洞,比如Thinkphp 5.1.(16-22) sql注入漏洞

POC

http://********/index/index/index?orderby[id`|updatexml(1,concat(0x7,user(),0x7e),1)%23]=1 

漏洞描述

在ThinkPHP 5.1.23之前的版本中存在SQL注入漏洞,该漏洞是由于程序在处理order by 后的参数时,未正确过滤处理数组的key值所造成。如果该参数用户可控,且当传递的数据为数组时,会导致漏洞的产生。(CVE-2018-16385)

一点思考

这个漏洞虽然是sql注入,但是比较鸡肋。。。

为什么这么讲呢?我们测试一下,就会发现报错注入的时候,我们只能爆出类似于user()、database()这类最基础的信息,而不能进行子查询,获取不到更加关键的信息。

这样的原因是用参数化查询PDO,将参数与查询语句分离,进而降低了漏洞风险。

PDO分析

下面将会针对thinkphp 5.1.17框架的PDO进行分析。

PDO介绍

我们可以把它看作是想要运行的 SQL 的一种编译过的模板,它可以使用变量参数进行定制。预处理语句可以带来两大好处:

  • 查询仅需解析(或预处理)一次,但可以用相同或不同的参数执行多次。当查询准备好后,数据库将分析、编译和优化执行该查询的计划。对于复杂的查询,此过程要花费较长的时间,如果需要以不同参数多次重复相同的查询,那么该过程将大大降低应用程序的速度。通过使用预处理语句,可以避免重复分析/编译/优化周期。简而言之,预处理语句占用更少的资源,因而运行得更快。
  • 提供给预处理语句的参数不需要用引号括起来,驱动程序会自动处理。如果应用程序只使用预处理语句,可以确保不会发生SQL 注入。(然而,如果查询的其他部分是由未转义的输入来构建的,则仍存在 SQL 注入的风险)。

预处理语句如此有用,以至于它们唯一的特性是在驱动程序不支持 PDO 将模拟处理。这样可以确保不管数据库是否具有这样的功能,都可以确保应用程序可以用相同的数据访问模式。

如果还不理解的话,我们可以看看PDO预编译执行过程

  1. prepare($SQL) 编译SQL语句
  2. bindValue($param,$value) 将value绑定到param的位置上

    prepare("INSERT INTO REGISTRY (name, value) VALUES (?, ?)");
    $stmt->bindParam(1, $name);
    $stmt->bindParam(2, $value);
    
    // 插入一行
    $name = 'one';
    $value = 1;
    $stmt->execute();
    
    // 用不同的值插入另一行
    $name = 'two';
    $value = 2;
    $stmt->execute();
    ?>
  3. execute() 执行

    prepare("CALL sp_returns_string(?)");
    $stmt->bindParam(1, $return_value, PDO::PARAM_STR, 4000); 
    
    // 调用存储过程
    $stmt->execute();
    
    print "procedure returned $return_value\n";
    ?>

报错原因

预编译SQL语句的时候发生错误,从而产生报错

当 prepare() 时,查询语句已经发送给了数据库服务器,此时只有占位符 ? 发送过去,没有用户提交的数据;当调用到 execute()时,用户提交过来的值才会传送给数据库,他们是分开传送的,所以理论上确保不会发生SQL注入。

这个漏洞实际上就是控制了第二步的$param变量,这个变量如果是一个SQL语句的话,那么在第二步的时候是会抛出错误使得报错(单纯的语句报错)既然如此我们实际上报错利用点在哪里呢?

实际上,在预编译的时候,也就是第一步即可利用

<?php
$params = [
    PDO::ATTR_ERRMODE           => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_EMULATE_PREPARES  => false,
];

$db = new PDO('mysql:dbname=tpdemo;host=127.0.0.1;', 'root', 'root', $params);

try {
    $link = $db->prepare('SELECT * FROM users WHERE id in (:where_id, updatexml(0,concat(0xa,user()),0))');
} catch (\PDOException $e) {
    var_dump($e);
}

执行发现,虽然只调用prepare(),但原SQL语句中的报错已经成功执行:

是因为这里设置了PDO::ATTR_EMULATE_PREPARES => false

这个选项涉及到PDO的“预处理”机制:因为不是所有数据库驱动都支持SQL预编译,所以PDO存在“模拟预处理机制”。如果说开启了模拟预处理,那么PDO内部会模拟参数绑定的过程,SQL语句是在最后execute()的时候才发送给数据库执行;如果我这里设置了PDO::ATTR_EMULATE_PREPARES => false,那么PDO不会模拟预处理,参数化绑定的整个过程都是和Mysql交互进行的。

非模拟预处理的情况下,参数化绑定过程分两步:第一步是prepare阶段,发送带有占位符的sql语句到mysql服务器(parsing->resolution),第二步是多次发送占位符参数给mysql服务器进行执行(多次执行optimization->execution)。

这时,假设在第一步执行prepare($SQL)的时候我的SQL语句就出现错误了,那么就会直接由mysql那边抛出异常,不会再执行第二步。

而在thinkphp 5.1.17中的默认配置

// PDO连接参数
protected $params = [
    PDO::ATTR_CASE              => PDO::CASE_NATURAL,
    PDO::ATTR_ERRMODE           => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_ORACLE_NULLS      => PDO::NULL_NATURAL,
    PDO::ATTR_STRINGIFY_FETCHES => false,
    PDO::ATTR_EMULATE_PREPARES  => false,
];

但是,在这个POC中

/public/index.php/index/index?username[0]=point&username[1]=1&username[2]=updatexml(1,concat(0x7,user(),0x7e),1)^&username[3]=0

如果你将user()改成一个子查询语句,那么结果又会爆出Invalid parameter number: parameter was not defined的错误。应该是预编译在mysql服务端进行的,但是预编译的过程是不接触数据的 ,也就是说不会从表中将真实数据取出来,所以使用子查询的情况下不会触发报错;虽然预编译的过程不接触数据,但user()数据库函数的值还是将会编译进SQL语句,所以这里执行并爆了出来。

修改子查询语句

如果我们把user()改成一个子查询语句呢?

<?php
$params = [
    PDO::ATTR_ERRMODE           => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_EMULATE_PREPARES  => false,
];

$db = new PDO('mysql:dbname=tpdemo;host=127.0.0.1;', 'root', 'root', $params);

try {
       $link = $db->prepare('SELECT * FROM `users` WHERE  `id` IN (:where_id_in_0,updatexml(0,concat(0xa,(select username from users limit 1)),0)) ');

} catch (\PDOException $e) {
    var_dump($e);
}

虽然我们使用了updatexml函数,但是他可能不接触数据:预编译的确是mysql服务端进行的,但是预编译的过程是不接触数据的 ,也就是说不会从表中将真实数据取出来,所以使用子查询的情况下不会触发报错;虽然预编译的过程不接触数据,但类似user()这样的数据库函数的值还是将会编译进SQL语句,所以这里执行并爆了出来。

把updatexml函数去掉

<?php
$params = [
    PDO::ATTR_ERRMODE           => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_EMULATE_PREPARES  => false,
];

$db = new PDO('mysql:dbname=tpdemo;host=127.0.0.1;', 'root', 'root', $params);

try {
    $link = $db->prepare('SELECT * FROM users WHERE  id IN (:where_id_in_0)union(select~1,2)');
    var_dump($link);
    $link->bindValue(':where_id_in_0)union(select~1,2)','1','1');
} catch (\PDOException $e) {
    var_dump($e);
}

这样就会报Invalid parameter number: parameter was not defined

在上面绑定的变量中,让:符号后面的字符串中不出现空格。但是在PDO的prepare()编译sql语句这个过程中,pdo已经把(:)内的内容认为时PDO绑定的变量,所以在第二步bindValue()步骤中,才会报错parameter was not defined

也就说这两步数据不匹配,导致无法正常执行第三步查询我们想要得字段

总结

Thinkphp5 框架采用的PDO机制可以说从根本上已经解决了一大堆SQL方面的安全问题,但过多的信任导致这里是在参数绑定的过程中产生了注入,不过采用的PDO机制也可以说是将危害降到了最小。

发布者

AndyNoel

一杯未尽,离怀多少。

发表回复