Flask 中的 XSS
在 Flask 应用中,XSS 攻击通常发生在模板引擎渲染阶段,特别是在使用 {{ … }} 语法输出用户数据的时候,下面是 flask 导致 XSS 的利用场景。
- 使用危险模板渲染函数
- render_template_string
- flask.Markup
- 绕过模板引擎
- 路由信息带入返回值
- 模板语法的错误使用
- safe 过滤器
- 关闭 autoescape
- 不带引号的模板变量渲染到 HTML 属性中
- 模板变量渲染到 href 属性中
- 模板变量渲染到
<script>
标签中
- 异常处理导致的 XSS
使用危险模板渲染函数
使用 render_template_string 进行渲染
render_template_string 函数用于直接渲染传递的字符串作为模板,使用这个函数可以从用户输入中动态创建模板,但用户输入值接拼接进模板会造成 SSTI 或 XSS。
@web.route('/test1', methods=['GET'])
def test1():
return render_template_string("<div>%s</div>" % request.args.get("name"))
例如输入:
name=<script>alert(%27XSS%27)</script>
使用 flask.Markup 进行渲染
在 Flask 中,为了防止 XSS,模板引擎会默认对输出进行转义,但 flask.Markup 会将字符串以 HTML 原始形式输出,不会进行转义,在一些情况下会导致 XSS。
from flask import Markup
@web.route('/test3', methods=['GET'])
def test3():
template_string = '<h1><script>alert("XSS");</script></h1>'
return Markup(template_string)
绕过模板引擎
路由信息带入返回值
直接从路由中获取输入来返回内容,这种方式会绕过模板渲染引擎,不会进行任何转义,例如:
@app.route("/index/<msg>")
def index(msg):
return "Hello! " + msg
模板语法的错误使用
使用了 safe 过滤器
safe 过滤器会禁用 HTML 转义,容易导致 XSS,例如 TFC CTF 2023 BABY DUCKY NOTES 中的漏洞代码:
{% for post in posts %}
<li>
<div class="blog_post">
<div class="container_copy">
<h1> {{post.get('title')}} </h1>
<h3> {{post.get('username')}} </h3>
<p> {{post.get('content') | safe}} </p>
</div>
</div>
</li>
{% endfor %}
题目在 content 属性中使用了 safe 过滤器,导致内容不会被转义,从而产生 XSS。
关闭 autoescape
在 Flask 中,autoescape 是模板引擎的一个配置选项,用于控制模板渲染时的自动转义行为。默认情况下,autoescape 设置为 True,即自动对输出进行转义。如果将 autoescape 设置为 False ,就有可能造成 XSS。
设置 autoescape 的方式有两种:
- 应用全局配置: 可以在 Flask 应用的配置中设置 autoescape,这会影响所有的模板渲染。
app = Flask(__name__) app.config['TEMPLATES_AUTO_RELOAD'] = True # 全局设置自动转义
-
模板级别配置: 可以在单个模板文件中设置 autoescape,这会仅影响当前模板的渲染。
{%- autoescape false %} <p>{{ user_input }}</p> {%- endautoescape %}
不带引号的模板变量渲染到 HTML 属性中
如果模板变量需要拼接到 html 标签的属性中,但又没有加上引号,就有可能造成标签属性的注入,例如:
<div class={{ classes }}></div>
模板变量渲染到 href 属性中
如果模板变量需要拼接到 html 标签的属性中,并且加上链引号,在属性为 href 时仍有可能造成 XSS。
<a href="{{ link }}"></a>
原因在于 href 值可以接收 javascript:URI
模板变量渲染到 <script>
标签中
如果模板变量值接插入到 script 标签中,会造成 JavaScript 代码的注入。例如:
<script>var name = {{ name }};</script>
异常处理造成的 XSS
当服务器发生异常并返回错误信息给客户端时,如果这些错误信息未经过适当的转义,就有可能导致 XSS。但异常处理需要与服务本身的逻辑结合分析,因此没有一个较为固定的形式,一般可以从异常处理的代码片段开始着手。
以 TFC CTF 2023 DUCKY NOTES: PART 3 为例:
这道题提供了一个类似向管理员发送帖子的页面。其中存在如下的一个异常处理函数。
@app.errorhandler(Exception)
def handle_error(e):
if not e.args or "username" not in e.args[0].keys():
return e, 500
error_date = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
post = e.args[0]
log_message = f'"{error_date}" {post["username"]} {post["content"]}'
with open(f"{app.config['LOG_DIR']}{error_date}.txt", 'w') as f:
f.write(log_message)
return log_message, 500
这个函数会将用户输入的 content 值接打印出来,没有进行任何转义,那么就有可能造成 XSS。
为了利用这个异常处理,还需要找到一个可以触发异常的地方。作为客户端输入,python 中可利用的一些异常大多为:
- KeyError(键错误): 当访问字典中不存在的键时引发的错误。(用户输入的键名被应用使用)
- FileNotFoundError(文件未找到错误): 在尝试打开不存在的文件时引发的错误。
- ValueError(值错误): 当函数接收到正确类型的参数,但参数值不合适时引发的错误。
- TypeError(类型错误): 当操作或函数应用于不支持的数据类型时引发的错误。
- NameError(名称错误): 当尝试访问一个不存在的变量或名称时引发的错误。
因为 handle_error 函数处理的异常信息并不是系统异常,因此利用点出现在题目自生抛出的异常中,搜索 Exception 就可以发现两处抛出异常的地方。
- posts_view 函数。
- posts 函数。
最终这道题的思路是触发 posts 函数中的 KeyError。
在创建帖子时如果输入的 title 如果为 null。
{
"title":null,
"content":"<img src=x onerror=alert(document.domain)>",
"hidden":false
}
则可以将 tilte 值赋值为 None,导致插入 SQLite 数据库中的值变为 NULL。当管理员进行查看时,需要从数据库中取出数据,代码如下:
@web.route('/posts/', methods=['GET'])
@auth_required
def posts(username):
if username != 'admin':
return jsonify('You must be admin to see all posts!'), 401
frontend_posts = []
posts = db_get_all_users_posts()
for post in posts:
try:
frontend_posts += [{'username': post['username'],
'title': post['title'],
'content': post['content']}]
except:
raise Exception(post)
return render_template('posts.html', posts=frontend_posts)
db_get_all_users_posts 会从数据库中获取所有记录。
def db_get_all_users_posts():
con = sqlite3.connect('database/data.db')
posts = query(con, 'SELECT users.username as username, title, content, hidden from posts INNER JOIN users ON users.id = posts.user_id ')
return posts
但其中的 query 函数对 sqlite fectchall 进行了包装,过滤了其中为 None 的部分,导致 title 为 None 时,整个 title 键值对缺失。
def query(con, query, args=(), one=False):
c = con.cursor()
c.execute(query, args)
rv = [dict((c.description[idx][0], value)
for idx, value in enumerate(row) if value != None) for row in c.fetchall()]
return (rv[0] if rv else None) if one else rv
最终引发 KeyError。