关于 sast-ssrf
用于代码库安全评估的 SSRF 检测 skill,面向安全审查人员。它分阶段发现出站网络调用、批量追踪用户输入并合并报告,运行前需要 `sast/architecture.md`。
主要功能
- 01发现出站网络调用点
- 02批量追踪用户输入
- 03检查 SSRF 防护模式
- 04合并分析结果报告
适用场景
- 01审查 Webhook 和 URL 抓取功能
- 02检查用户输入是否进入网络调用
- 03评估代码中的 SSRF 防护
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ utkusen/ sast-skills --skill sast-ssrf安装后在 Claude Code 中输入/sast-ssrf确认已加载。
详细介绍
服务端请求伪造(SSRF)检测
你正在进行有针对性的安全评估,以查找代码库中的 SSRF 漏洞。本技能采用包含子代理的三阶段方法:侦察(查找所有发起出站 TCP、DNS 或 HTTP 请求的位置)、分批验证(追踪用户提供的输入是否到达这些调用位置,每批并行处理 3 个)以及合并(将各批报告汇总到一个文件中)。
前提条件:必须存在 sast/architecture.md。如果不存在,请先运行分析技能。
什么是 SSRF
SSRF 是指攻击者通过提供或影响服务端请求所使用的 URL、主机名、IP 或端口,使服务器向任意目标发起出站网络请求,包括内部服务、云元数据端点或其他外部目标。
核心模式:未经验证、由用户控制的输入到达出站网络调用的目标参数。
什么情况属于 SSRF
- HTTP 客户端调用中,URL 或主机由用户输入构造:
requests.get(user_url) - 获取由客户端提供位置的资源:
fetch(req.body.webhook_url) - 对用户提供的主机名进行 DNS 查询:
dns.lookup(req.query.host) - 连接到由用户输入推导出的主机或端口的原始 TCP 连接:
socket.connect((user_host, user_port)) - 使用来自用户输入的 HTTP/FTP URL 调用文件获取函数:
file_get_contents($user_url) - 未经验证便将请求转发到用户提供目标的 URL 重定向器
- Webhook、从 URL 导入、截图服务、PDF 渲染器、图像代理——任何代表用户获取远程资源的功能
什么情况不属于 SSRF
以下情况不要报告:
- 开放重定向:将浏览器(HTTP 302)重定向到用户提供的 URL——这是客户端重定向,不是服务端请求
- 通过 URL 实现的 XSS:在未转义的
<a>标签中渲染用户提供的 URL——这是 XSS - IDOR:通过更改对象 ID 访问其他用户的数据——属于其他漏洞类别
- 硬编码的出站调用:向固定且完全硬编码的 URL 发起 HTTP 请求,没有用户输入影响——不属于 SSRF
可防止 SSRF 的模式
看到以下模式时,代码可能不存在漏洞:
1. 对允许目标进行严格的白名单校验
ALLOWED_HOSTS = {"api.example.com", "cdn.example.com"}
parsed = urlparse(user_url)
if parsed.hostname not in ALLOWED_HOSTS:
raise ValueError("Destination not allowed")
requests.get(user_url)
2. 对允许的 URL 前缀/方案进行白名单校验
ALLOWED_PREFIXES = ["https://api.example.com/", "https://cdn.example.com/"]
if not any(user_url.startswith(p) for p in ALLOWED_PREFIXES):
abort(400)
requests.get(user_url)
3. 目标不受用户输入影响
# Destination fully hardcoded — no user input involved
response = requests.get("https://api.thirdparty.com/data")
注意:IP 黑名单(屏蔽 169.254.0.0/16、10.0.0.0/8 等)并不足以提供保护——攻击者可以通过 DNS 重绑定、URL 编码、IPv6 表示法、十进制 IP 表示法或重定向链绕过。不要将黑名单视为能够保障网站安全的措施;应将其归类为可能存在漏洞。
漏洞与安全示例
Python — requests
# VULNERABLE: URL fully controlled by user
@app.route('/fetch')
def fetch():
url = request.args.get('url')
response = requests.get(url)
return response.text
# SECURE: strict allowlist on destination host
ALLOWED = {"api.example.com"}
@app.route('/fetch')
def fetch():
url = request.args.get('url')
if urlparse(url).hostname not in ALLOWED:
abort(403)
response = requests.get(url)
return response.text
Python — urllib
# VULNERABLE: user controls the URL passed to urlopen
def preview(request):
target = request.GET.get('target')
data = urllib.request.urlopen(target).read()
return HttpResponse(data)
# SECURE: only allow https scheme to a hardcoded host
def preview(request):
target = request.GET.get('target')
parsed = urlparse(target)
if parsed.scheme != 'https' or parsed.hostname != 'media.example.com':
return HttpResponse(status=400)
data = urllib.request.urlopen(target).read()
return HttpResponse(data)
Node.js — fetch / axios
// VULNERABLE: webhook URL comes directly from request body
app.post('/webhook/test', async (req, res) => {
const { url } = req.body;
const result = await fetch(url);
res.json(await result.json());
});
// SECURE: allowlist check before fetch
const ALLOWED_HOSTS = new Set(['hooks.example.com']);
app.post('/webhook/test', async (req, res) => {
const { url } = req.body;
const { hostname } = new URL(url);
if (!ALLOWED_HOSTS.has(hostname)) return res.status(403).send('Forbidden');
const result = await fetch(url);
res.json(await result.json());
});
Node.js — http.request
// VULNERABLE: host and path from query string
app.get('/proxy', (req, res) => {
const { host, path } = req.query;
http.get({ host, path }, (proxyRes) => proxyRes.pipe(res));
});
Ruby on Rails — Net::HTTP / OpenURI
# VULNERABLE: open() fetches arbitrary URL
def import
url = params[:url]
content = URI.open(url).read # also triggers for open(url) via Kernel#open
# ...
end
# SECURE: restrict scheme and host
def import
url = params[:url]
uri = URI.parse(url)
raise "Forbidden" unless uri.is_a?(URI::HTTPS) && uri.host == "data.example.com"
content = uri.open.read
# ...
end
PHP — cURL
// VULNERABLE: user-supplied URL piped into curl
function fetch_preview($url) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = curl_exec($ch);
curl_close($ch);
return $result;
}
// Called as: fetch_preview($_GET['url'])
// SECURE: validate URL against allowlist before curl
function fetch_preview($url) {
$allowed = ['https://cdn.example.com/'];
foreach ($allowed as $prefix) {
if (strpos($url, $prefix) === 0) {
// ... proceed with curl
}
}
throw new Exception("Destination not allowed");
}
PHP — file_get_contents
// VULNERABLE: file_get_contents with http:// wrapper and user input
$url = $_GET['source'];
$data = file_get_contents($url); // fetches remote URL if scheme is http/https/ftp
Java — Spring / OkHttp
// VULNERABLE: RestTemplate with user-controlled URL
@GetMapping("/proxy")
public ResponseEntity<String> proxy(@RequestParam String url) {
RestTemplate restTemplate = new RestTemplate();
return restTemplate.getForEntity(url, String.class);
}
// VULNERABLE: OkHttp with user-controlled host
public String fetch(String host, String path) {
Request request = new Request.Builder()
.url("https://" + host + path)
.build();
return client.newCall(request).execute().body().string();
}
Go — net/http
// VULNERABLE: user-supplied URL passed to http.Get
func proxyHandler(w http.ResponseWriter, r *http.Request) {
target := r.URL.Query().Get("url")
resp, err := http.Get(target)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
io.Copy(w, resp.Body)
}
// VULNERABLE: user controls host in net.Dial
func dialHandler(w http.ResponseWriter, r *http.Request) {
host := r.URL.Query().Get("host")
port := r.URL.Query().Get("port")
conn, _ := net.Dial("tcp", host+":"+port)
// ...
}
C# — HttpClient
// VULNERABLE: user-supplied URL passed to HttpClient
[HttpGet("proxy")]
public async Task<IActionResult> Proxy([FromQuery] string url)
{
var response = await _httpClient.GetAsync(url);
var content = await response.Content.ReadAsStringAsync();
return Content(content);
}
执行
本技能通过子代理分三个阶段运行。将 sast/architecture.md 的内容作为上下文传递给所有子代理。
阶段 1:查找所有出站网络调用位置
使用以下指令启动一个子代理:
目标:找出代码库中应用发起出站网络请求的所有位置,包括 HTTP、HTTPS、FTP、TCP 或 DNS 请求,无论目标是否由用户控制。将结果写入
sast/ssrf-recon.md。上下文:你将获得项目的架构摘要。请利用它了解技术栈、正在使用的 HTTP 客户端库,以及任何与网络或 Webhook 相关的组件。
搜索范围——出站请求调用点:
查找任何会打开网络连接或获取远程资源的代码。只要调用将非完全硬编码的 URL、主机或地址值作为参数传入,就标记出来。此阶段无需追踪该值是否由用户控制;那是第 2 阶段的工作。
- Python HTTP 客户端:
requests.get(url)、requests.post(url)、requests.put(url)、requests.request(method, url)、requests.Session().get(url)urllib.request.urlopen(url)、urllib2.urlopen(url)httpx.get(url)、httpx.post(url)、httpx.AsyncClient().get(url)aiohttp.ClientSession().get(url)、aiohttp.ClientSession().post(url)- Python 套接字 / DNS:
socket.connect((host, port))、socket.create_connection((host, port))dns.resolver.resolve(name)、socket.getaddrinfo(host, ...)- Python 使用远程协议的文件获取:
urllib.request.urlopen(url),其中 url 可能是 http/https/ftp- 通过
from urllib.request import urlopen或类似方式调用open(url)(若 url 可能是远程地址,则标记)- Node.js / JavaScript HTTP 客户端:
fetch(url)、node-fetch(url)axios.get(url)、axios.post(url)、axios.request({url})http.get(url)、https.get(url)、http.request(options)、https.request(options)got(url)、superagent.get(url)、needle.get(url)、undici.request(url)require('request')(options)- Node.js 套接字 / DNS:
net.createConnection({host, port})、net.connect(port, host)dns.lookup(hostname, ...)、dns.resolve(hostname, ...)、dns.resolve4(hostname)- Ruby HTTP 客户端:
Net::HTTP.get(uri)、Net::HTTP.start(host, ...)、Net::HTTP.get_response(url)URI.open(url)、open(url)(Kernel#open / OpenURI)RestClient.get(url)、RestClient::Resource.new(url)Faraday.new(url).get(path)、HTTParty.get(url)Typhoeus::Request.new(url)- PHP HTTP 客户端和文件函数:
curl_setopt($ch, CURLOPT_URL, $url),后接curl_exec($ch)file_get_contents($url)——当$url可能是 http/https/ftp URL 时标记- 使用远程 URL 协议的
fopen($url, 'r')Guzzle:$client->request('GET', $url)、$client->get($url)Symfony HttpClient:$client->request('GET', $url)- Java HTTP 客户端:
new URL(url).openConnection()、new URL(url).openStream()- 使用动态 URL 的
HttpURLConnection/HttpsURLConnectionOkHttpClient().newCall(new Request.Builder().url(url)...)RestTemplate.getForObject(url, ...)、RestTemplate.getForEntity(url, ...)WebClient.get().uri(url)、WebClient.create(url)Apache HttpClient:httpClient.execute(new HttpGet(url))- Go HTTP 客户端和网络拨号:
http.Get(url)、http.Post(url, ...)、http.NewRequest("GET", url, ...)net.Dial("tcp", addr)、net.DialTCP(...)、net.DialTimeout("tcp", addr, ...)net.LookupHost(hostname)、net.LookupAddr(addr)、net.ResolveIPAddr(...)net.ResolveTCPAddr("tcp", addr)- C# / .NET HTTP 客户端:
HttpClient.GetAsync(url)、HttpClient.PostAsync(url, ...)、HttpClient.SendAsync(request)WebRequest.Create(url)、WebClient.DownloadString(url)、WebClient.DownloadData(url)- 使用动态 URL 的
HttpWebRequest- 通过子进程、exec、system 等调用网络工具:
subprocess.run(["curl", url, ...])、subprocess.Popen(["wget", url, ...])os.system("curl " + url)、exec("wget " + url)- 目标为变量的任何
curl、wget、nc、ncat、nmap调用跳过的内容(这些是安全的——不要标记):
- URL 和主机名均为完整硬编码字符串字面量且不含动态部分的调用:
requests.get("https://api.example.com/data")- 明确属于服务间架构一部分的内部环回连接,例如连接到本地队列的
localhost或127.0.0.1连接——如果地址是动态的,则标记输出格式——写入
sast/ssrf-recon.md:# SSRF 侦察:[Project Name] ## 摘要 共找到 [N] 个出站网络调用点。 ## 出站调用点 ### 1. [描述性名称——例如“Webhook 分发器中的 HTTP GET”] - **文件**:`path/to/file.ext`(第 X-Y 行) - **函数 / 端点**:[函数名称或路由] - **调用类型**:[HTTP GET / HTTP POST / TCP 拨号 / DNS 查询 / 子进程 curl / 等] - **库 / 方法**:[requests.get / fetch / http.Get / curl_exec / 等] - **目标参数**:`var_name` 或 `url_expression`——[简短说明,例如“由查询参数拼接而成”或“路径部分硬编码,主机变量化”] - **代码片段**:[出站调用以及紧邻其前、用于构造目标的代码行]
[每个调用点重复上述格式]
第 1 阶段之后:继续前先检查候选项
第 1 阶段完成后,读取 sast/ssrf-recon.md。如果侦察结果发现零个出站调用点(摘要报告“Found 0”,或“Outbound Call Sites”部分为空或不存在),则完全跳过第 2 阶段和第 3 阶段。改为将以下内容写入 sast/ssrf-results.md,然后停止:
# SSRF Analysis Results
No vulnerabilities found.
只有在第 1 阶段发现至少一个出站调用点时,才继续进行第 2 阶段。
第 2 阶段:验证——追踪用户输入到出站调用点(分批处理)
第 1 阶段完成后,读取 sast/ssrf-recon.md,并将出站调用点分成每批最多 3 个调用点。为每批并行启动一个子 Agent。每个子 Agent 仅追踪分配给自己的调用点的污点,并将结果写入各自的批次文件。
分批流程(由你,即协调者执行,而非子 Agent):
- 读取
sast/ssrf-recon.md,统计“Outbound Call Sites”下编号的调用点章节(### 1.、### 2. 等)。 - 将它们分成每批最多 3 个调用点。例如,8 个调用点 → 3 批(1-3、4-6、7-8)。
- 从侦察文件中提取每批调用点章节的完整文本。
- 并行启动所有批次子 Agent,并且只向每个子 Agent提供分配给它的调用点。
- 每个子 Agent 将结果写入
sast/ssrf-batch-N.md,其中 N 为从 1 开始的批次编号。 - 从
sast/architecture.md中确定项目的主要语言/框架,并且只选择上文“Vulnerable vs. Secure Examples”部分中与之匹配的示例。例如,如果项目使用 Node.js 和 fetch/axios,则只包含“Node.js — fetch / axios”和“Node.js — http.request”示例。在下文[TECH-STACK EXAMPLES]所示的位置,将选定的示例加入每个子 Agent 的指令中。
向每个批次子 Agent 提供以下指令(替换为对应批次的值):
目标:对于每个分配的出站网络调用点,确定用户提供的值是否控制或影响目标地址(URL、主机、路径、端口或协议)。我们的目标是查找 SSRF 漏洞。将结果写入
sast/ssrf-batch-[N].md。分配给你的出站调用点(来自侦察阶段):
[在此处粘贴分配给你的各站点完整内容,并保留原始编号]
背景:你将获得项目的架构摘要。请利用它了解应用程序的入口点、中间件以及数据流。
SSRF 参考——检查要点:
当用户控制的输入到达服务器端出站网络调用的目标参数,且没有有效的允许列表限制服务器可连接的位置时,就会发生 SSRF。
以下情况不属于 SSRF——不要将其报告为 SSRF:
- 开放重定向:HTTP 302 重定向到用户提供的 URL——这是客户端重定向,不是服务器端请求
- 通过 URL 造成的 XSS:用户 URL 未经转义便渲染到 HTML 中——这是 XSS
- IDOR:篡改对象 ID——属于另一类问题
- 完全硬编码的出站 URL,不受用户影响——不属于 SSRF
对于每个出站调用点,沿数据流反向追踪目标参数的来源:
- 直接用户输入——目标地址直接从请求来源赋值,未经转换:
- HTTP 查询参数:
request.GET.get('url')、req.query.url、params[:url]、$_GET['url']、c.Query("url")- 请求正文 / JSON 字段:
request.json['webhook_url']、req.body.target、params[:source]- 路径参数:
req.params.host、params[:endpoint]- HTTP 标头:
request.headers.get('X-Forwarded-For')、req.headers['destination']- Cookie:
req.cookies.redirect_url- 间接 / 拼接的目标地址——URL 通过将硬编码前缀与用户提供的后缀或路径拼接而成:
"https://example.com/" + user_path——根据 HTTP 客户端的实现,仍可能因路径遍历或协议注入而被利用base_url + user_query——用户可控制查询字符串,可能造成注入- 将这些标记为 Likely Vulnerable,并注明用户控制的部分
- 先存储用户输入,之后再发起请求——目标地址之前从用户输入保存(例如,已存储的 webhook URL),现在从数据库中取出并用于发起请求:
- 查找存储值的写入位置——该值是否在写入时未经允许列表验证便被接受?
- 发起请求前,读取时是否进行了验证?
- 服务器端 / 硬编码值——目标地址来自配置、环境变量、硬编码常量或不受用户影响的服务器端逻辑——此调用点不可被利用。
对于每个调用点,还要检查是否存在缓解措施:
- 严格的主机 / 前缀允许列表:硬编码的许可主机名或 URL 前缀集合,并在发起请求前验证目标地址是否符合要求——这是有效的缓解措施。标记为 Not Vulnerable。
- 仅限制协议(例如,只允许
https://):部分缓解措施——可以降低影响,但无法阻止 SSRF 请求任意 HTTPS 主机。仍标记为 Likely Vulnerable。- 私有 IP 范围 / 元数据端点封锁列表:
169.254.169.254、10.0.0.0/8、192.168.0.0/16等——不足以防护。可通过 DNS 重绑定、其他 IP 表示形式和重定向链绕过。标记为 Likely Vulnerable。- DNS 解析 + IP 检查(先解析主机名,再根据封锁列表检查解析出的 IP):比单纯的封锁列表更强,但检查与请求之间仍可能遭受 DNS 重绑定攻击(TOCTOU)。除非明确固定同一解析 IP 并用于请求,否则标记为 Likely Vulnerable。
适用于此项目技术栈的易受攻击与安全示例:
[TECH-STACK EXAMPLES]
分类:
- Vulnerable:可证实用户输入到达出站请求的目标地址,且没有有效的缓解措施(没有允许列表,或仅有封锁列表 / 协议检查)。
- Likely Vulnerable:用户输入可能到达目标地址(存在间接数据流或部分拼接),或仅存在较弱的缓解措施(封锁列表、仅检查协议、部分 URL 前缀)。
- Not Vulnerable:目标地址完全由服务器端控制,或者发起请求前执行了严格的主机 / 前缀允许列表验证。
- Needs Manual Review:无法确定目标地址来源(辅助函数不透明、条件流程复杂,或外部库负责解析 URL)。
输出格式——写入
sast/ssrf-batch-[N].md:# SSRF 批次 [N] 结果 ## 发现 ### [VULNERABLE] 描述性名称 - **文件**:`path/to/file.ext`(第 X-Y 行) - **端点 / 函数**:[路由或函数名称] - **问题**:[例如,“HTTP 查询参数 `url` 直接流入 requests.get()”] - **污点追踪**:[从入口点到调用点的逐步追踪——例如,“request.args.get('url') → target_url → requests.get(target_url)”] - **影响**:[攻击者可采取的行动——访问云元数据 `169.254.169.254`、转向内部服务、扫描内部网络端口、窃取数据、绕过防火墙等] - **已存在的缓解措施**:[无 / 仅封锁列表 / 仅协议检查——说明其为何不足] - **修复建议**:[采用严格的主机允许列表,或完全移除用户对目标地址的控制] - **动态测试**:[用于确认该发现的 curl 命令或载荷。 展示参数、载荷以及需要观察的结果。 示例:curl "https://app.example.com/fetch?url=http://169.254.169.254/latest/meta-data/" 或用于转向内部网络:curl "https://app.example.com/fetch?url=http://internal-db:5432/"]
### [LIKELY VULNERABLE] 描述性名称 - **文件**:`path/to/file.ext`(第 X-Y 行) - **端点 / 函数**:[路由或函数名称] - **问题**:[例如,“用户控制部分硬编码 URL 的路径”或“已存储的 webhook URL 在写入时未经允许列表验证便被接受”] - **污点追踪**:[尽可能追踪,并指出不确定或部分受控的步骤] - **风险说明**:[说明其仍存在风险的原因——例如,“攻击者可能通过路径遍历将请求重定向到内部主机”或“封锁列表可通过 DNS 重绑定绕过”] - **修复建议**:[采用严格的允许列表或移除用户控制] - **动态测试**:[尝试使用的载荷——例如,路径遍历或 DNS 重绑定场景]
### [NOT VULNERABLE] 描述性名称 - **文件**:`path/to/file.ext`(第 X-Y 行) - **端点 / 函数**:[路由或函数名称] - **原因**:[例如,“URL 完全硬编码”或“发起请求前执行了严格的主机允许列表验证”] ### [NEEDS MANUAL REVIEW] 描述性名称 - **文件**:`path/to/file.ext`(第 X-Y 行) - **端点 / 函数**:[路由或函数名称] - **不确定之处**:[无法确定目标地址来源的原因] - **建议**:[需要手动追踪的内容——例如,“追踪 helpers.py 中的 `resolve_target()`,检查 URL 的来源”]
阶段 3:合并 — 汇总批次结果
所有阶段 2 批次子 Agent 全部完成后,读取每个 sast/ssrf-batch-*.md 文件,并将它们合并为一个 sast/ssrf-results.md。由你(协调者)直接完成此操作,无需子 Agent。
合并流程:
- 读取所有
sast/ssrf-batch-1.md、sast/ssrf-batch-2.md……文件。 - 收集每个批次文件中的所有发现并合并为一个列表,同时保留原始分类和所有详细字段。
- 汇总所有批次的数量,为执行摘要统计总数(分析的网站总数等于侦察阶段的数量/分配的网站总数)。
- 按照以下格式将合并后的报告写入
sast/ssrf-results.md:
# SSRF Analysis Results: [Project Name]
## Executive Summary
- Outbound call sites analyzed: [total across all batches]
- Vulnerable: [N]
- Likely Vulnerable: [N]
- Not Vulnerable: [N]
- Needs Manual Review: [N]
## Findings
[All findings from all batches, grouped by classification:
VULNERABLE first, then LIKELY VULNERABLE, then NEEDS MANUAL REVIEW, then NOT VULNERABLE.
Preserve every field from the batch results exactly as written.]
- 写入
sast/ssrf-results.md后,删除所有中间批次文件(sast/ssrf-batch-*.md)。
重要提醒
- 读取
sast/architecture.md,并将其内容作为上下文传递给所有子 Agent。 - 阶段 2 必须在阶段 1 完成后运行,因为它依赖侦察阶段的输出。
- 阶段 3 必须在所有阶段 2 批次完成后运行,因为它依赖所有批次的输出。
- 每个子 Agent 的批次大小为3 个出站调用点。如果总共只有 1–3 个调用点,则使用单个子 Agent。如果有 10 个,则使用 4 个子 Agent(3+3+3+1)。
- 并行启动所有批次子 Agent,不要按顺序运行。
- 每个批次子 Agent 只接收侦察文件中分配给它的调用点文本,而不是整个侦察文件。这样可以让每个子 Agent 的上下文保持精简且聚焦。
- 阶段 1 纯粹关注结构:标记任何目标参数为动态内容(变量、表达式或拼接字符串)的调用点,无论是否有用户输入流入其中。阶段 1 不要尝试追踪用户输入,这是阶段 2 的工作。
- 阶段 2 纯粹进行污点分析:对于其批次中的每个调用点,追踪目标参数的来源。如果它来自用户可控的来源,且没有有效的允许列表,则该调用点构成真实漏洞。
- 阻止列表不是缓解措施:针对私有地址段和云元数据端点的 IP 阻止列表很容易被绕过。始终将此类调用点分类为“存在漏洞”或“很可能存在漏洞”,而不是安全。
- 部分 URL 控制仍然危险:即使攻击者只能控制 URL 的路径或查询字符串部分,也要标记为“很可能存在漏洞”——根据 HTTP 客户端的行为、是否跟随重定向以及目标服务,部分控制也足以造成风险。
- 已存储的目标地址也带有污点:如果 URL 或主机名在写入时接受了用户输入,之后又用于发起出站请求,则应追踪写入时的接受过程。写入时缺少允许列表验证会导致 SSRF。
- 子进程中的 curl/wget 也属于 SSRF:使用用户提供的 URL 运行
curl或wget的 Shell 调用,与 HTTP 客户端调用同样危险。请检查此类情况,尤其是在图像处理、导入或下载功能中。 - 如有疑问,应分类为“需要人工审查”,而不是“没有漏洞”。在安全评估中,漏报比误报更糟。
- DNS 重绑定说明:对于仅存在 DNS 解析后再进行阻止列表检查的发现,应在发现内容中明确说明 TOCTOU 时间窗口。这是一种已知的绕过技术。
- 清理中间文件:写入最终的
sast/ssrf-results.md后,删除sast/ssrf-recon.md和所有sast/ssrf-batch-*.md文件。