你写的SSRF防护,可能一行都挡不住:SpringBoot4.1InetAddressFilter源码级拆解"

admin 2026-11-01 01:21:34 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

你写的 SSRF 防护,可能一行都挡不住:Spring Boot 4.1 InetAddressFilter 源码级拆解

原创

杠宝
杠宝

山水一程丿指路为码

2026年10月25日 12:33
湖南

去阅读

我们可以慢慢向前走,但有些漏洞,一步都不能退。


前阵子翻自己以前写的一篇排障记录,讲的是给自建博客配 Nginx 反向代理,页面一直 502。查 error.log 才看到一行不起眼的 13: Permission denied。当时的第一反应是防火墙,改了一圈规则,还是 502。最后根因是 SELinux——我一直在错误的那一层上使劲。

这件事后来成了我的一个习惯:每次加防护之前,先问一句\"这层防护,到底生效在哪一层\"。

今年给 Agent 加工具调用之后,这个习惯救了我一次。因为 Agent 会根据模型的输出,去请求一个它自己编出来的 URL。那一刻我意识到,困扰后端多年的那个老问题又回来了,而且变本加厉:以前是用户输入 URL,现在是模型替你输入 URL。

这个问题叫 SSRF。而 Spring Boot 4.1 给它准备了一个官方答案:InetAddressFilter。

今天我们把它拆到底。


01 — 一个\"看起来完全没问题\"的接口

先看这段代码。需求很常见:用户传一个 URL,后端拉回来解析。

java

@RestController

public class FetchController {

private final RestClient restClient;

public FetchController(RestClient.Builder builder) {

this.restClient = builder.build();

}

@GetMapping(\"/fetch\")

public String fetch(@RequestParam String url) {

return restClient.get()

.uri(url)

.retrieve()

.body(String.class);

}

}

三行代码,干净利落,跑起来也没问题。

问题在于:你的服务跑在内网里。

它能访问数据库、缓存、内部管理后台,还有云厂商的元数据服务。这个接口等于把\"服务器发起请求的能力\",开放给了任何一个能传参数的人。攻击者不需要突破你的鉴权,他只需要让你自己的机器去敲自己家的门。

这就是为什么 SSRF 在云上格外危险——攻击面从来不在模型里,在权限里。

很多人的第一反应是:那我加个黑名单。

java

// 大多数人写的第一版防护

if (url.startsWith(\"http://10.\")

|| url.startsWith(\"http://192.168.\")

|| url.startsWith(\"http://127.\")) {

throw new RuntimeException(\"禁止访问内网\");

}

这行代码,一行都挡不住。下面说为什么。


02 — 为什么\"校验 URL 字符串\"必然失守

因为你校验的是一串文字,而攻击者控制的是这段文字最终会解析成什么。两者之间隔着一整套 DNS 和 URL 规范。

失守方式 实际发生了什么 字符串黑名单为什么看不见
进制变形 http://2130706433 就是 127.0.0.1(十进制写法),十六进制、八进制同理 字符串里根本没有 127.
域名解析 http://internal-api.mycompany.com 解析到 10.0.0.5 校验时看到的是合法域名
短域名指向 任意域名把 A 记录指到 169.254.169.254 同上,且随时可改
302 重定向 请求先到公网域名,返回 302 跳到内网地址 校验发生在第一次请求前
DNS 重绑定 校验时解析成公网 IP,连接时解析成 127.0.0.1 两次解析结果不同
IPv6 http://[::1] 也是本机;还有 64:ff9b:: 这种 NAT64 映射写法 黑名单通常只写 IPv4

结论很硬:正确的 SSRF 防护,不是校验 URL 字符串,而是在 DNS 解析之后、TCP 连接之前,校验那个真实的 IP 地址。

InetAddressFilter 做的就是这件事。


03 — 先看清全貌:InetAddressFilter 的 API

它在包 org.springframework.boot.http.client 下,从 4.1.0 开始提供,是一个函数式接口:

java

@FunctionalInterface

public interface InetAddressFilter {

// 唯一抽象方法:这个地址允许通过吗?

boolean matches(InetAddress address);

}

就一个方法。剩下的都是工厂方法和组合器:

类型 方法 作用
抽象 matches(InetAddress) 判断地址是否允许
默认 matches(InetSocketAddress) 便捷重载
工厂 externalAddresses() 只允许外部(公网)地址
工厂 internalAddresses() 只允许内部(私有)地址
工厂 of(String...) / of(Filter...) 白名单:匹配给定 IP 或 CIDR 段
工厂 not(String...) / not(Filter...) 黑名单:排除给定 IP 或 CIDR 段
工厂 routable() / multicast() / specialPurpose() 按地址类别匹配
工厂 all() / none() 全放行 / 全拒绝
工厂 adapt(Predicate) 把一个 Predicate 适配成过滤器
组合 and(...) / or(...) / andNot(...) / negate() 逻辑组合,可链式

被拦下的请求不会静默失败,而是抛出 FilteredHostException(RuntimeException 的子类),带 getHost() 和 getFilter() 两个方法,方便你打日志时知道\"是哪个地址、被哪条规则拦了\"。


04 — 源码剖析:externalAddresses() 到底拦住了什么

真正值得看的是它的定义,一共三行:

java

static InetAddressFilter externalAddresses() {

// 可路由的地址 减去 组播地址 减去 特殊用途地址

return routable().andNot(multicast(), specialPurpose());

}

先看 routable():

java

static InetAddressFilter routable() {

return (address) -> {

Assert.notNull(address, \"'address' must not be null\");

byte[] bytes = address.getAddress();

for (byte b : bytes) {

if (b != 0) {

return true;   // 只要有一个字节不是 0,就算可路由

}

}

return false;          // 全 0:0.0.0.0 或 ::,直接拒绝

};

}

是不是很简单!这就是第一道闸门——把 0.0.0.0 和 :: 这种\"本机所有网卡\"的写法挡掉。

再看核心的 specialPurpose(),它是一个 25 条 CIDR 的清单:

java

static InetAddressFilter specialPurpose() {

return of(\"0.0.0.0/8\", \"100.64.0.0/10\", \"127.0.0.0/8\", \"169.254.0.0/16\",

\"192.0.0.0/24\", \"192.0.0.0/29\", \"192.0.2.0/24\", \"192.88.99.0/24\",

\"198.18.0.0/15\", \"198.51.100.0/24\", \"203.0.113.0/24\", \"240.0.0.0/4\",

\"255.255.255.255/32\", \"::1/128\", \"::/128\", \"64:ff9b::/96\", \"100::/64\",

\"2001::/23\", \"2001::/32\", \"2001:2::/48\", \"2001:db8::/32\", \"2001:10::/28\",

\"2002::/16\", \"fc00::/7\", \"fe80::/10\")

.or(InternalInetAddressFilter.instance);   // 注意这一行

}

or(InternalInetAddressFilter.instance) 这一行是双保险——即使 CIDR 清单漏了什么,内部类还会再兜一遍。

这 25 条按防御意义分成几类:

类别 网段 挡住的风险
回环 127.0.0.0/8 、::1/128 本机 Redis、actuator 管理端点
私有地址 由 InternalInetAddressFilter 覆盖(10/8、172.16/12、192.168/16) 内网数据库、内部服务
链路本地 169.254.0.0/16 、fe80::/10 云元数据服务,可窃取临时凭证
运营商级 NAT 100.64.0.0/10 阿里云元数据 100.100.100.200 就在这个段内
NAT64 映射 64:ff9b::/96 IPv6 写法绕过 IPv4 黑名单
IPv6 唯一本地 fc00::/7 内网 IPv6 服务
组播 / 保留 / 文档段 224.0.0.0/4 、240.0.0.0/4、192.0.2.0/24 等 扫描与探测

这里有个流传很广的错误说法:有人说阿里云的 100.100.100.200 不在标准 RFC 1918 私有段里,所以需要额外配置才能挡住。

我们顺着源码走一遍就清楚了:100.100.100.200 属于 100.64.0.0/10,而这一条明确写在specialPurpose() 的清单里,externalAddresses() 又 andNot 了 specialPurpose()。所以它默认就会被拦,不需要你额外写任何东西。

(顺带一提:这正是\"读源码\"比\"读二手结论\"靠谱的地方。)


05 — 一个反直觉的坑:negate() 之后就不是同一个东西了

既然有 externalAddresses(),那 internalAddresses().negate() 是不是等价?

不是。我们看 internalAddresses() 的定义:

java

static InetAddressFilter internalAddresses() {

return routable().and(InternalInetAddressFilter.instance);

}

它只由两部分构成:routable() 和\"内部地址判定\"。而 externalAddresses() 是 routable().andNot(multicast(), specialPurpose())。

specialPurpose() 那 25 条 CIDR 里,除了私有地址,还包含 CGNAT、文档段、组播等一票东西——这些不在internalAddresses() 的判定范围内。取反之后,它们就全被放行了。

地址 externalAddresses() internalAddresses().negate() 差异原因
10.0.0.1 ❌ 拒绝 ❌ 拒绝 一致
100.64.0.1 (CGNAT) ❌ 拒绝 ✅ 放行 在 specialPurpose 清单里,但不属于\"内部地址\"
0.0.0.0 ❌ 拒绝 ✅ 放行 不可路由 → internal 为 false → 取反为 true
224.0.0.1 (组播) ❌ 拒绝 ✅ 放行 同上逻辑
192.0.2.1 (TEST-NET-1) ❌ 拒绝 ✅ 放行 文档保留段

结论:想要\"只允许访问公网\",就老老实实用 externalAddresses(),不要用 negate() 自己拼。


06 — 源码里最值钱的那段注释:别信 JDK 的 isSiteLocalAddress()

内部类 InternalInetAddressFilter 是整个过滤器里最值得读的 60 行。先看主干:

java

@Override

public boolean matches(InetAddress address) {

Assert.notNull(address, \"'address' must not be null\");

// 两条路:IPv4 本地判定,或 IPv6 私网判定

return isLocal(address) || isSiteLocalIpv6Address(address.getAddress());

}

private boolean isLocal(InetAddress address) {

return address.isLoopbackAddress()     // 回环

|| address.isLinkLocalAddress()    // 链路本地(含云元数据)

|| address.isSiteLocalAddress();   // 站点本地(RFC 1918)

}

然后是这个方法的注释,我直接贴原文:

java

/\\

\* Check for Unique Local IPv6 Addresses. We cannot rely on

\* {@code Inet6Address.isSiteLocalAddress()} because the JVM implementation dictates

\* that {@code fec0::/10} is the only site-local IPv6 address space, based on the

\* outdated RFC 2373. That RFC was deprecated by the IETF in 2004 in favor of

\* {@code fc00::/7} (RFC 4193). To keep our private network checking accurate to

\* modern subnets, we maintain manual parsing.

\*/

这段话讲了一件很现实的事:JDK 的 Inet6Address.isSiteLocalAddress() 认的网段是 fec0::/10,而这个标准(RFC 2373)在 2004 年就被 IETF 废弃了,现在的 IPv6 私网地址是 fc00::/7(RFC 4193)。

也就是说,如果你图省事直接调 JDK 的 API 判 IPv6 私网,那么 fc00::1、fd00::1 这两个最常用的 IPv6 内网地址会被当成公网放行。

所以 Spring 选择手动按首字节判断:

java

private boolean isSiteLocalIpv6Address(byte[] address) {

return (address.length == 16)

&& (address[0] == (byte) 0xfc    // fc00::/7 的前半段

|| address[0] == (byte) 0xfd    // fc00::/7 的后半段

|| isNat64Local(address));      // NAT64 映射,单独处理

}

private boolean isNat64Local(byte[] address) {

if (!Arrays.equals(address, 0, NAT64\_PREFIX.length, NAT64\_PREFIX, 0, NAT64\_PREFIX.length)) {

return false;

}

try { // IPv4/IPv6 转换前缀 64:ff9b

// 取出最后 4 个字节——也就是被嵌进去的那个 IPv4 地址

return isLocal(InetAddress.getByAddress(Arrays.copyOfRange(address, 12, 16)));

}

catch (UnknownHostException ex) {

return false;

}

}

NAT64 这一段尤其漂亮:它先比对前缀 64:ff9b,命中后把地址里内嵌的 IPv4 抠出来,再递归判一次。因为攻击者完全可以用 64:ff9b::7f00:1 这种写法去够本机的 127.0.0.1。

这就是我一直喜欢的那种代码:注释里写清楚\"为什么不这么做\",而不只是\"这么做了什么\"。


07 — 它没挡住的事,比挡住的更值得记住

过滤器很强,但它不是防火墙。以下五条边界,官方文档里写得明明白白,我逐条翻译一遍。

① 一旦走了代理,过滤器就失效了

官方原文:

The InetAddressFilter can only be used to filter requests that originate from your Spring Boot application. If your HTTP client is configured to use a proxy ... the filter will not restrict calls that the proxy makes on your behalf.

原因是:过滤器只能过滤 IP 地址。走代理时,目标主机是放在 HTTP 头里发出去的,DNS 解析由代理来做,你的应用根本看不到最终 IP。

官方给的建议很实在:代理场景请把限制加在代理服务器上;应用侧则用过滤器确保\"只能连到这台代理\"。

② 过滤不等于连接时还是那个 IP(DNS 重绑定)

过滤发生在解析之后。但如果客户端在真正建立连接时又解析了一次,两次结果可能不同。Boot 在 JDK 客户端上专门处理了这一点——JdkFilteredProxySelector 里有这么一段注释:

java

private @Nullable InetAddress resolve(String host) {

try {

// We follow the same resolution logic as

// jdk.internal.net.http.HttpRequestImpl.getAddress()

return InetAddress.getByName(host);

}

catch (UnknownHostException ex) {

return null;

}

}

它刻意复刻了 JDK 内部的解析逻辑,为的就是保证\"我过滤的地址 == 我连接的地址\"。 这个细节是整套设计里我最欣赏的地方——过滤的正确性,取决于两条路径是否一致。

③ 重定向之后的落点

302 一跳,就是一个新连接。落点是不是还要再过一遍过滤器,取决于你用的是哪个客户端、怎么配的。这一条官方没有给出统一承诺,建议按你自己选的客户端实测一遍再说。

④ 你自己 new 出来的客户端,bean 管不着

定义为 @Bean 的过滤器,只作用于自动配置的 HTTP 客户端 builder。代码里手动 new 出来的客户端、第三方库内部的客户端,统统不在覆盖范围内。

⑤ 它只管 IP,不管协议

file://、gopher:// 这类 scheme 不在 IP 过滤的射程内。scheme 白名单要你自己加。


08 — 生产落地:两份可以直接抄的配置

依赖(Boot 4.1.x + Java 17 起):

xml

org.springframework.boot

spring-boot-starter-restclient

写法一:全局生效(推荐)

声明一个 InetAddressFilter bean,自动配置的所有 HTTP 客户端 builder 都会用上它。

java

@Configuration(proxyBeanMethods = false)

public class HttpClientSecurityConfig {

@Bean

public InetAddressFilter httpClientInetAddressFilter() {

// 只放行公网;需要调内网特定服务时,用 or(...) 显式加进来

return InetAddressFilter.externalAddresses();

}

}

这样配完,下面这个 bean 不用多写一行,就已经自带防护了:

java

@Bean

RestClient restClient(RestClient.Builder builder) {

return builder.build();

}

写法二:按客户端单独配

同一个应用既要调公网 API、又要调内部服务时,用这种更精细的写法。

java

@Bean

RestClient outboundRestClient() {

InetAddressFilter onlyExternal = InetAddressFilter.externalAddresses();

// HttpClientSettings 是不可变 record,withXxx 返回新副本,可以链式叠加

HttpClientSettings settings = HttpClientSettings.defaults()

.withInetAddressFilter(onlyExternal)

.withReadTimeout(Duration.ofSeconds(10));

ClientHttpRequestFactory requestFactory =

ClientHttpRequestFactoryBuilder.jdk().build(settings);

return RestClient.builder().requestFactory(requestFactory).build();

}

响应式路径形状一样,只是换成 ClientHttpConnectorBuilder:

java

@Bean

WebClient outboundWebClient() {

HttpClientSettings settings = HttpClientSettings.defaults()

.withInetAddressFilter(InetAddressFilter.externalAddresses());

ClientHttpConnector connector = ClientHttpConnectorBuilder.reactor().build(settings);

return WebClient.builder().clientConnector(connector).build();

}

兜底逻辑就一行:解析出来的地址不匹配,直接抛 FilteredHostException。

java

public WebhookResult fetch(String userSuppliedUrl) {

// 若解析到内网地址,这里会抛 FilteredHostException

return outboundRestClient.get()

.uri(userSuppliedUrl)

.retrieve()

.body(WebhookResult.class);

}

怎么确认它真的生效了?

别靠猜,起一个本地端口,直接断言异常:

java

@Test

void shouldBlockLoopback() {

assertThatThrownBy(() -> fetchService.fetch(\"http://127.0.0.1:8080/actuator\"))

.isInstanceOf(FilteredHostException.class)

.hasMessageContaining(\"Filtered host\");

}


09 — 最后:把防护放在正确的那一层

回到开头那次 502。当时的教训不是\"SELinux 很坑\",而是:我在防火墙那一层折腾了半天,真正的开关在更下面一层。

SSRF 是一模一样的病。你校验 URL 字符串、你在网关上加正则、你在代码里写 startsWith(\"http://10.\")——每一层看着都像防护,但没有一层落在\"解析之后、连接之前\"这个唯一正确的位置上。

InetAddressFilter 的价值不只是省了你几十行代码,而是它替你把位置定对了:

  • 它过滤的是 IP,不是字符串;
  • 它覆盖了 IPv4 + IPv6 + NAT64,不是你记得住的那几个段;
  • 它连 JDK 里那段过时标准的坑都替你绕过去了;
  • 它明确告诉了你边界在哪(代理场景失效、自建客户端不覆盖、scheme 要自己管)。

所以这次的收尾建议只有一句:

别再手写 URL 黑名单了。把它换成一行 externalAddresses(),然后把精力花在它明确告诉你的那几个边界上。

希望大家看完能够有所收获,如果有任何问题,欢迎后台私信讨论!

你写的SSRF防护,可能一行都挡不住:SpringBoot4.1InetAddressFilter源码级拆解" 网络安全文章

你写的SSRF防护,可能一行都挡不住:SpringBoot4.1InetAddressFilter源码级拆解"

本文源码级拆解SpringBoot4.1新增的InetAddressFilter组件,用于防护SSRF攻击。文章指出传统URL字符串黑名单防护因进制变形、DNS重绑定等手段必然失守,正确防护应在DNS解析后TCP连接前校验真实IP。深入分析externalAddresses()实现原理、specialPurpose()中25条CIDR清单及阿里云元数据地址默认拦截逻辑,并揭示negate()不等价于externalAddresses()的反直觉陷阱。
【CTF】ciscn_2019_n_1" 网络安全文章

【CTF】ciscn_2019_n_1"

本文为CTF题目ciscn_2019_n\\_1的解题writeup,属于PWN类型栈溢出利用。通过checksec和IDA分析发现gets函数存在栈溢出漏洞,可覆盖浮点数变量v2为11.28125,从而执行system(cat/flag)获取flag。文章提供了完整exp脚本,步骤清晰,可操作性强,对CTF学习者具有参考价值。
【CTF】jarvisoj_level0" 网络安全文章

【CTF】jarvisoj_level0"

本文为CTFPWN题目jarvisoj_level0的解题记录。题目开启了NX保护,通过IDA分析发现vulnerable_function存在栈溢出漏洞,可利用read函数覆盖返回地址跳转到后门函数callsystem执行system(‘/bin/sh’)。文章提供了详细的栈帧分析和利用SSL连接的完整payload构造方法,可操作性强,适合CTF入门学习。
评论:0   参与:  0