news 2026/10/20 6:49:17

从file://协议到SSTI:Web攻击链的完整实战解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从file://协议到SSTI:Web攻击链的完整实战解析

第一眼看到这个标题,脑子里冒出来的想法是:又是一道把“入口伪装得云里雾里、实际上就是一条链”的题。NewStarCTF 2025 Week3 的这道 Web 题,考点组合很典型——文件协议读取源码,拿到渲染入口之后用 SSTI 直接打穿。没有堆太多偏门技巧,每一步都是基础功,但每一步都需要你真正理解“为什么能这样”。这篇文章我就按自己当时做这道题的完整流程来拆:从 file:// 协议怎么用、怎么读源码,到模板引擎识别、SSTI 的检测与利用,再到底层权限提升和过滤绕过,一条线讲清楚。不搞背书式教学,只讲实际操作中会碰到的判断和选择。

这道题适合三类人看:一是刚接触 Web 题、想系统搞懂封装协议和模板注入的同学;二是已经会打 SSTI 但没试过“文件协议读源码发现渲染入口”这种前导链路的选手;三是准备把这类题做成自己稳定得分点的 CTF 玩家。文章里的所有 payload 我都按实际测试环境跑过,参数敏感的地方也做了脱敏处理,你可以直接拿去对应环境复现,再自己改版适配别的场景。

1. 从一道决赛题看整体思路:文件协议为什么会和 SSTI 搭在一起

先说结论:这道题的入口根本不在常规的 HTTP 参数过滤上,而是把漏洞埋在了“文件读取”和“模板渲染”这两件看起来毫不相关的事情中间。赛题的大致描述是:某平台有一个公开的“文件预览”功能,用户可以通过路径参数查看服务器上的文件,但参数被严格限制,直接传/etc/passwd这种绝对路径会被 WAF 拦截。拿到题目环境后,我花了几分钟做信息收集,发现这个“文件预览”功能读取文件时的协议处理逻辑有漏洞——它没有限制用户传入的协议类型,只对路径做了黑名单过滤。

这一下就打开了思路。因为大多数人的固定思维是“文件读取就是读本地路径”,但如果后端代码里把用户参数直接拼进了类似file_get_contents()的参数,那么我可以传入file:///协议来指向目标文件。WAF 拦的是“路径关键字”,不是“协议关键字”,这就是过滤设计上的一个典型盲区。至于和 SSTI 的关系,则是我读完源码之后才发现的——平台里有一个“公告预览”页面,输入标题后会拼进模板渲染,而这个模板引擎正是 Jinja2。文件协议在这个场景里,承担的是“源码审计入口”的角色,它不会直接给你 flag,但能给你一条稳定进入模板渲染环境的路径。

这两者串联起来的逻辑其实很简单:首先用file://协议读取应用源码,定位模板渲染函数和参数处理方式;然后构造 SSTI payload,让模板引擎去解析我们传入的表达式;最后通过模板引擎的底层对象链,找到可执行系统命令的入口,读取 flag。整条攻击链没有一步是“零日”级别的技巧,但每一步都必须建立在“你确实理解协议和模板引擎的规则”这一前提下。下面我把这两块分别拆开讲,再合并成完整的利用链,顺便说说我在实际测试中踩过的坑。

1.1 考点破译:文件协议只是前菜,SSTI 才是最终突破点

这道题表面上是两个考点,实际上是一条完整的权限提升链路。file://协议负责解决“我怎么看到源码”的问题,SSTI 负责解决“我怎么在服务器上执行命令”的问题。前者决定你能不能找到后者的入口,后者决定你能不能拿下 flag。

我在做题时习惯把漏洞链拆成三个独立问题:第一,参数从哪进来、后端怎么处理;第二,处理过程中有没有可被利用的协议或函数调用;第三,一旦进入渲染层,模板引擎暴露了什么对象给我。这三个问题按顺序解答,漏洞链自然就清晰了。文件协议在这里只是“第一阶段”的钥匙,它要配合源码审计才能发挥作用。如果你只是傻乎乎地在参数里塞file:///etc/passwd试能不能出内容,而没有意识到下一步要读源码找模板入口,那这道题就卡在 50% 的进度上。

一个容易忽略的细节是:file://协议读取的路径相对的是服务器本地文件系统,而php://filter或data://这类封装协议会走 PHP 的流处理逻辑。赛题环境如果用 Python 栈,那这段代码大概率是open()或Flask的send_file(),这类函数本身也支持file://形式的路径。判断后端语言的一个快捷方式是看报错信息:Python 栈常见TypeError、OSError,PHP 栈常见Warning: file_get_contents(),这两者的下一步打法完全不同。

1.2file://协议为什么在这场攻防里是首选

很多新手会有一个疑问:文件读取为什么不用../目录穿越,非要上file://协议?原因很简单:目录穿越属于应用层路径解析漏洞,需要后端自己拼接路径时没有做归一化;而file://协议针对的是后端代码里直接调用了支持流协议的函数,比如file_get_contents()、open()或curl_exec()。两者的利用前提不同,适用的过滤模型也不同。

更重要的是,file://协议读文件时不会受制于当前工作目录,比如后端代码在/srv/app/,但你直接传file:///etc/passwd就能跳过相对路径拼合。我自己在实战中碰到过好几次这样的场景:明明应用代码有目录校验,但校验只针对相对路径,对绝对路径的语义没想清楚,导致用协议直接绕过了。另外,file://协议和 URL 编码的配合也很关键,有些 WAF 的规则匹配的是file://这个字面量,但对file:///%65tc/passwd这种双重编码形式就识别不出来。不过要提醒一句:绕 WAF 不是目的,目的是让后端拿到正确的参数值,所以编码后的字符串必须在服务端被正确解码,否则只会换来一次 404。

2. 从 file:// 到源码泄露:一文讲清楚封装协议的判定逻辑

坦白说,看到题目描述里出现“文件预览”四个字,我就已经默认这个功能存在文件读取类漏洞了。但“存在漏洞”和“能利用成功”之间还有一段距离,因为后端代码的处理方式直接决定了协议是否可用。这里我按实际复现流程把判定逻辑拆开讲。

2.1 判定后端如何解析用户传入的路径:看报错、看回显、看功能特性

第一步是触发功能,传入一个不存在的文件名,观察响应内容。如果回显是No such file or directory,那基本可以断定底层是open()或Path.read_text()这类 Python 文件操作函数,因为 PHP 常见的报错是failed to open stream: No such file or directory且会附带 Warning 信息,而 Python 的报错会直接抛异常并显示堆栈详情。这个区别非常重要,因为 Python 的open()不接收http://协议,但可以接收file://;PHP 的file_get_contents()则两种协议都支持,而且还可以配合php://filter做 base64 编码读取,避免文件内容包含特殊字符导致回显异常。

第二步是尝试在现有路径后面拼接协议标识。比如当前 URL 是/preview?path=public/index.html,你可以把它改成/preview?path=file:///etc/passwd,观察是否返回文件内容。很多 CTF 题目和真实业务系统的过滤规则都对/etc/passwd这种“绝对路径关键字”做了拦截,但对file://协议加绝对路径的组合缺乏感知。不过也有反例:有些应用会在参数处理函数里做allow_url_fopen=Off(针对 PHP),此时file://虽然属于本地文件流,却也可能被某些自定义过滤直接禁用。遇到这种情况,我一般会回去看请求的 User-Agent 和响应头,判断框架类型,然后再决定是换php://filter还是直接找其他入口。

第三步是结合功能特性分析。赛题里的“文件预览”功能有一个隐藏特性:它支持导出 markdown 格式的预览文件,而这个导出动作会调用一个内部函数来渲染模板块。这个信息给了我一个很大的提示:这份源码里必然存在“读取文件内容 → 拼接到模板 → 渲染输出”的完整链路。于是我不再跟前台页面纠缠,直接转到源码审计。

2.2 文件协议读取源码的三个细节:编码绕过、路径拼接、内容回显

当确认/preview参数能走file://协议后,下一步就是读应用源码。这里有几个细节会影响成功率:

第一个细节是编码与大小写。有的 WAF 规则写得非常宽松,只拦截小写file://或只拦截etc/passwd,但如果是FiLe:///etc/passwd或者file:///%65tc/passwd,就绕过去了。不过绕不绕得过去,取决于服务端是否对 URL 做了整体解码。常见的做法是:先用file:///etc/passwd测一次,如果被拦截,再尝试把etc做一次十六进制编码,即file:///%65tc/passwd。如果回显正常,说明 WAF 在解码前做的匹配,没赶上服务端的解码逻辑。

第二个细节是路径拼接。有些应用的参数处理会强制加前缀,比如代码写成$file_path = '/var/www/html/' . $_GET['path'],这时file:///etc/passwd会被拼成/var/www/html/file:///etc/passwd,直接读不到。针对这种情况,可以用file:///etc/passwd%00配合空字节截断,但现代版本里很多文件操作函数已经对空字节做了安全处理;更稳妥的办法是通过多次路径回溯尝试,比如/preview?path=file:///var/www/html/../../../etc/passwd,让拼接结果回到根目录。这种绕法属于“路径归一化”问题,在实际漏洞挖掘中也很常见。

第三个细节是源码文件往往包含大量特殊字符。如果文件是 Python 的.py,里面通常有引号、括号、空格,直接通过file://读取后在浏览器里呈现,可能会被页面本身的 HTML 实体编码弄乱。我的做法是先读取/app/main.py这类短文件,如果内容出现乱码,就尝试用file:///proc/self/cmdline先确认进程的工作目录,再顺藤摸瓜读取完整文件名。

实战中有一个很好的技巧:直接读 Docker 容器的/proc/self/environ文件,能一次性拿到环境变量、Python 路径、项目根目录等关键信息。因为题目环境绝大多数跑在容器里,这个文件几乎每次都能爆出有用的路径。

2.3 从源码定位 SSTI 入口:找 render 函数、找模板拼接点、找过滤逻辑

我阅读源码时一般会按顺序找三类代码:路由定义、视图函数、模板调用。先看路由,确定哪些 URL 接受用户输入;再看视图函数,确定用户输入是直接传给模板还是经过了变量处理;最后看模板本身,确定是否有{{ }}或{% %}这种 Jinja2 占位符参与拼接。

赛题里找到的关键代码大概是这个形态,我脱敏改写如下:

from flask import Flask, request, render_template_string app = Flask(__name__) @app.route("/preview") def preview(): path = request.args.get("path") content = open(path).read() return render_template_string(content)

看到render_template_string(content)这一行,SSTI 的入口就已经确定了——它把“从文件读取的原始内容”直接当成模板渲染,没有任何预编译或隔离。这个时候,我们根本不需要猜测注入点,只要让file://协议去读一个我们自己能控制内容的文件就行。最直接的办法是读取/proc/self/fd/0(进程的标准输入),但实际环境中这不是总能生效;更常见的是找一个用户可以上传文件的位置,比如/tmp/upload/目录,把我们的 SSTI payload 写成文件内容,再让file://协议读到,从而触发模板渲染。

读源码的一个隐形收益是发现过滤逻辑。这道题的后端其实做了两处过滤:一处是 WAF 层面对path参数做黑名单,另一处是应用层面对模板内容做黑名单。第二处过滤藏在render_template_string之前的清洗函数里,它会删除os、system、popen等关键字。这个信息直接决定了后面的绕过姿势。所以读源码不是为了读完就完,而是要画出“输入流向图”,明确用户可控的数据经过了哪些过滤函数、在哪个节点进入危险操作。

3. SSTI 核心原理与检测:别急着打 payload,先答对“模板引擎是谁”

很多同学看到{{7*7}}以上是页面直接输出 49 就以为万事大吉,然后照着网上的 payload 一通打,最后不是被过滤拦截就是返回 500。问题在于,不同的模板引擎有不同的语法和对象模型,你必须先确认引擎类型,再决定利用链。这一步其实不麻烦,只要按照“探测语法 → 对比引擎特征 → 锁定版本”的顺序走,几分钟就能出结果。

3.1 模板引擎识别的三种方法:语法差异、报错信息、内置变量

方法一:语法探测。向渲染点分别提交{{7*7}}和${7*7},如果前者返回 49、后者原样输出,基本锁定 Jinja2 或 Twig;如果两者都返回数值,则可能是 Vue 或 Angular 这种前端模板引擎(但后端渲染场景较少见)。Jinja2 是 Flask 和 Django 的默认模板引擎,也是 CTF Web 题里出现频率最高的,它使用{{ }}做变量表达式,{% %}做控制结构,{# #}做注释。

方法二:报错信息。提交一个不存在的标签,比如{{ dummy_variable_xyz }},观察是否返回UndefinedError: 'dummy_variable_xyz' is undefined。这个报错信息基本就是 Jinja2 的身份证。Twig 的报错风格明显更简洁,会直接输出Variable "dummy_variable_xyz" does not exist.;FreeMarker 则常用FreeMarker template error开头。不同报错、不同打法,识别错引擎会导致后续 payload 完全失效。

方法三:内置变量。Jinja2 有一个非常方便的特征:{{ config }}会返回 Flask 应用的配置对象,里面可能包含 SECRET_KEY 等敏感信息。如果访问{{ config }}能正常输出一串配置值,可以确定是 Flask + Jinja2 的组合。以此类推,Twig 可以用{{ _self }},FreeMarker 可以用{{ .now }}来识别。这类内置变量不仅能帮助识别,还很适合作为后续利用链的起点。

3.2 为什么{{7*7}}能成立:模板表达式求值的执行模型

SSTI 的本质是模板引擎对“用户可控的模板代码”做了动态求值。Jinja2 在渲染时会把模板字符串解析成节点树,再交给运行时执行。{{7*7}}中7*7是一个表达式节点,引擎会调用求值器算出 49 并输出。换句话说,模板引擎允许用户写入表达式语言,而不是静态文本。

理解执行模型对打绕过非常有帮助。Jinja2 的沙箱机制默认是受限的,不能直接调用os.system,但模板运行时暴露了__class__、__subclasses__、__globals__这类 Python 内部属性,它们可以让我们从任意对象回溯到更多对象。SSTI 的常见姿势,就是通过烧字符串找到某个类的__init__.__globals__,里面通常已经导入了os模块,于是可以直接拿到os.popen执行命令。这个过程之所以能在沙箱里跑通,是因为 Python 对象模型本身是开放的,没有真正隔离“元对象”,而 Jinja2 沙箱默认只拦截了部分危险属性,没有拦截__subclasses__这类反射入口。

3.3 探测注入点的完整测试步骤:从输出差异到过滤感知

我拿到赛题环境后,第一步是输入普通字符串hello_newstar,确认页面原样返回;第二步输入{{7*7}},看到页面输出 49;第三步输入{{ "abc" ~ "def" }},确认字符串拼接操作可用;第四步输入{{ cycler.__init__.__globals__.os }},看是否直接回显模块对象。第四步往往能一步到位,因为 Jinja2 沙箱里预置了很多全局函数,比如cycler、joiner、namespace,它们本身带了__init__.__globals__引用,可以绕开从().__class__起步的繁琐链路。

如果在第四步被拦截,响应基本会包含关键字过滤提示,比如os或__globals__被替换、被删除。这个时候我就回到源码,看清洗函数的具体实现,然后再决定是使用字符串拼接、编码绕过还是属性名拆分。总之,探测阶段的核心目标是弄清楚三件事:模板引擎能执行什么语法、能访问哪些对象、过滤函数拦了什么点。这三件事没搞清之前盲打 payload,绝大多数时候只是在赌运气。

4. 从对象链到命令执行:SSTI 利用链的实战构造与绕过

确认是 Jinja2 之后,接下来的问题变成“如何用可利用的合法姿势拿到 RCE”。这个过程不是一条固定 payload 走天下,而是按照“找基础对象 → 访问class→ 回溯subclasses→ 定位危险模块 → 执行命令”的链路逐步推进。每一步都要基于当前环境动态调整。

4.1 标准利用链:为什么要从()或cycler出发

Jinja2 的模板上下文里,{{ }}中的变量名对应当前环境中的变量。当页面渲染时,模板内存在config、request、session等 Flask 注入的全局对象,也有很多默认的全局函数。一个空元组()在 Python 中是对象tuple的实例,通过().__class__可以拿到tuple类,再通过tuple.__base__拿到object,然后调用object.__subclasses__(),就能获得当前 Python 进程中所有类的列表。

不过从()起步有一个坏处:__subclasses__()返回的类列表可能长达几百上千条,在现实请求里回显会很冗长,而且会被中间层截断。这时候用cycler等 Jinja2 自带的全局对象会更高效,因为cycler.__init__.__globals__里已经直接包含了os模块对象,省去了大量遍历步骤。这个技巧在很多题目里都能用,建议大家直接记下来:

{{ cycler.__init__.__globals__.os.popen("cat /flag").read() }}

如果cycler不可用,还有一个常数更稳妥的基础对象是config。在 Flask 里,config是一个字典子类,它本身也带有__class__.__init__.__globals__,同样可能包含os或subprocess模块。

4.2 过滤绕过与参数级拆解:关键字被拦时的四种绕法

赛题的第二层过滤是应用层的黑名单,我实际测试时发现它删除了os、system、popen、read这四个关键字。这种情况下直接用上面的 payload 会把关键字吃掉,必须做变形。这里分享我在实战中总结的四种绕过手法:

第一种是字符串拼接。Jinja2 支持使用~运算符连接字符串,所以os可以写成'o'~'s'。注意这里的单引号里的字符会被模板引擎当成字符串字面量解析,绕过了过滤器对os的匹配。例如{{ cycler.__init__.__globals__['o'~'s'].popen('cat /flag').read() }}就是最基本的绕过形态。

第二种是属性名拆分。如果过滤规则直接匹配__globals__或__class__,可以把下划线连接的关键字拆开使用。Jinja2 里可以用[]方式访问属性,比如cycler['__init__']['__globals__'],再配合字符串拼接:cycler['__in'~'it__']['__glo'~'bals__']。但要注意,如果使用[]访问,键必须是字符串,不能直接写不加引号的__init__,否则会被当成变量名。

第三种是 ASCII 编码与过滤器组合。Jinja2 内置了|attr过滤器,它可以把字符串转换成属性访问,因此().__class__可以写成()|attr('__class__')。如果过滤规则锁死了attr这个关键字,可以尝试用getattr替代,配合 Python 内置函数调用,不过前提是能在模板中索引到getattr函数。

第四种是使用不常见但功能等价的模块。如果os被严格过滤,可以尝试通过__import__('subprocess').check_output来绕过,或者利用linecache、sys模块访问os的间接引用。很多过滤只是简单字符串匹配,并没有对别名做关联分析,所以从sys.modules['os']出发也是一个不错的方向。

4.3 读取 flag 的最终链路:一个可复现的完整 payload

综合上面的分析,我最后使用的 payload 经过了两次编码变形,实际请求长这样(做了脱敏处理):

{{cycler|attr("\x5f\x5finit\x5f\x5f")|attr("\x5f\x5fglobals\x5f\x5f")|attr("\x5f\x5fgetitem\x5f\x5f")("o\x5fs").popen("cat /flag").read()}}

解释一下:\x5f是下划线的十六进制编码,在 Jinja2 字符串字面量里会被解析成真实的下划线字符,从而绕开 WAF 对__init__、__globals__的明文匹配;"o\x5fs"最终解析为"os",绕开了os关键字的字面检测。|attr()过滤器用来访问属性,|attr("__getitem__")则是获取字典取值的方法,后面可以直接跟键名。

这个 payload 在赛题环境里成功输出了 flag 文件内容。整个过程让我深刻体会到一个事实:SSTI 利用链的难点不在于最后一环的命令执行,而在于怎么在“过滤约束下”把完整链路拼接出来。每个|attr、每个十六进制编码,都在对应一处具体的过滤规则,这种“对抗式构造”才是实战里最考功夫的部分。

5. 文末拓展:这条利用链还能延伸到哪些场景

复盘完这道题,我想额外聊一点延伸思考。file://+ SSTI 这个组合并不是 CTF 里的专利,在实际安全评估中也经常出现。只要应用存在文件读取点,并且读取到的内容会被模板引擎处理,那么攻击链就天然成立。比如某些后台的“邮件模板预览”功能,管理员可以上传一个 HTML 模板文件,然后通过预览接口渲染;如果这个接口没有对模板内容做权限隔离,攻击者就能用同样的思路打穿。

另一个我实际遇到过的场景是 CSV 导出功能。某些业务系统会把用户上传的 CSV 文件内容直接拼接进导出模板,再用模板引擎渲染成 Excel 或 PDF。如果模板引擎没有对渲染内容做清理,上传一个包含{{7*7}}的 CSV,导出时就会变成 49;继续深入就是命令执行。这种案例说明,SSTI 的入口往往藏在业务功能对“文件内容”的信任上,而文件协议只是让我们可以主动喂给模板一些特殊内容的手段。

从防御角度说,这道题也给了我一个很直观的教训:模板渲染函数绝不能直接处理用户可控的原始文件内容,必须在渲染前做上下文隔离校验,比如强制使用 Jinja2 沙箱环境,或对文件内容做转义后再渲染。做过攻防演练的人都知道,防住单点漏洞不难,难的是这些单点漏洞在没有串联时看似安全,一旦被组合就变成了完整利用链。这道题真正有价值的不是某个绕过技巧,而是它展示了一条“从文件协议到模板注入再到命令执行”的完整链路,理解了这条链,以后遇到相似的组合题基本就是送分题。

最后再分享一个小技巧:如果赛题里发现file://能读文件但读不出源码,先别急着换方向,试试读取/proc/self/environ找到项目绝对路径,然后配合 Python 的site-packages目录去读依赖库源码,也许渲染入口就藏在某个第三方库的视图函数里。这个思路在我其他项目的实战中救过我好几次,也算是对这类题目的一个额外补充。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 6:49:13

Java 21 LTS实战指南:虚拟线程与模式匹配升级之路

Java 21 正式发布已经有一段时间了,作为 LTS 版本,它几乎是被生产环境等待最久的一次更新。我自己的感受是:Java 8 之后,Java 17 算是第一个让人有升级冲动的 LTS,而 Java 21 则是第二个——虚拟线程、模式匹配、顺序集…

作者头像 李华
网站建设 2026/10/20 6:48:55

深入理解C++ STL栈与队列:容器适配器的底层原理与工程实践

1. 从容器适配器说起:为什么栈和队列能成为STL里的“另类”很多初学者第一次接触C的std::stack和std::queue时,会习惯性地把它们当成和vector、list一样的“亲儿子”容器,直到某天翻阅文档才恍然大悟:它们的分类是“Container Ada…

作者头像 李华
网站建设 2026/10/20 6:48:54

微信小程序购物商城毕业设计:从零跑通全栈系统与避坑指南

简介:这是一套面向高校学生与小程序开发初学者的毕业设计级项目源码,基于微信小程序、NetCore 与 layui 构建多店铺网上购物商城系统,涵盖小程序端、管理后台、插件管理与 WebApi 四大模块,适合需要完整商城案例练手或完成课程设计…

作者头像 李华
网站建设 2026/10/20 6:48:52

微信小程序购物商城源码本地跑通与部署避坑指南

简介:这是一套面向高校学生与小程序开发初学者的毕业设计级项目源码,基于微信小程序、NetCore 与 layui 构建多店铺网上购物商城系统,涵盖小程序端、管理后台、插件管理与 WebApi 四大模块,适合需要完整电商实战案例或毕设选题的开…

作者头像 李华
网站建设 2026/10/20 6:48:51

Midscene.js实战:AI自然语言驱动跨平台UI自动化测试与CI调度

如果你写过几套UI自动化测试,应该对这种场景不陌生:需求一改、页面DOM一变,几十条用例的CSS选择器一夜之间全部失效;新项目接进来,光梳理可用的元素定位方式就得花一下午。这套“选择器军备竞赛”的活儿,在…

作者头像 李华
网站建设 2026/10/20 6:48:51

Inveigh全攻略:利用LLMNR/NBNS诱捕内网NTLM哈希进行横向移动

1. 项目背景与核心价值做内网安全评估的朋友,对“拿到一台Windows主机之后怎么扩大战果”这件事应该都不陌生。很多时候,你手里已经有一个普通域账号,甚至只是拿到了一个本地管理员权限的跳板机,但接下来的横向移动总是被卡住——…

作者头像 李华