Skip to content

Commit

Permalink
Detect ip spoofing in upnp gateway searching and related code
Browse files Browse the repository at this point in the history
  • Loading branch information
tomaszklak committed Dec 4, 2024
1 parent 94edc64 commit f7e52c0
Show file tree
Hide file tree
Showing 4 changed files with 198 additions and 19 deletions.
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ optional = true
version = "0.14"

[dev-dependencies]
http-body-util = "0.1.2"
hyper-new = { package = "hyper", version = "1", features = ["server", "http1"] }
hyper-util = { version = "0.1.10", features = ["tokio"] }
simplelog = "0.9"
test-log = "0.2"
tokio = {version = "1", features = ["full"]}
Expand Down
195 changes: 178 additions & 17 deletions src/aio/search.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
use std::collections::HashMap;
use std::net::SocketAddr;
use std::net::{IpAddr, SocketAddr};
use std::str::FromStr;
use std::time::Duration;

use futures::prelude::*;
use hyper::Client;
use hyper::client::Client;
use tokio::net::UdpSocket;
use tokio::time::timeout;

Expand All @@ -24,6 +25,7 @@ pub async fn search_gateway(options: SearchOptions) -> Result<Gateway, SearchErr

let (addr, root_url) = handle_broadcast_resp(&from, &response_body)?;

// TODO: wrap in a validation function
match (&from, &addr) {
(SocketAddr::V4(src_ip), SocketAddr::V4(url_ip)) => {
if src_ip.ip() != url_ip.ip() {
Expand Down Expand Up @@ -60,21 +62,27 @@ pub async fn search_gateway(options: SearchOptions) -> Result<Gateway, SearchErr
let control_schema =
run_with_timeout(options.http_timeout, get_control_schemas(&addr, &control_schema_url)).await??;

let addr = match addr {
let addr_v4 = match addr {
SocketAddr::V4(a) => Ok(a),
_ => {
warn!("unsupported IPv6 gateway response from addr: {}", addr);
Err(SearchError::InvalidResponse)
}
}?;

Ok(Gateway {
addr,
let gateway = Gateway {
addr: addr_v4,
root_url,
control_url,
control_schema_url,
control_schema,
})
};

let gateway_url = hyper::Uri::from_str(&format!("{gateway}"))?;

validate_url(addr.ip(), &gateway_url)?;

Ok(gateway)
}

async fn run_with_timeout<F>(timeout_value: Option<Duration>, fut: F) -> Result<F::Output, SearchError>
Expand Down Expand Up @@ -142,11 +150,13 @@ async fn get_control_schemas(
addr: &SocketAddr,
control_schema_url: &str,
) -> Result<HashMap<String, Vec<String>>, SearchError> {
let uri = match format!("http://{}{}", addr, control_schema_url).parse() {
let uri: hyper::Uri = match format!("http://{}{}", addr, control_schema_url).parse() {
Ok(uri) => uri,
Err(err) => return Err(SearchError::from(err)),
};

validate_url(addr.ip(), &uri)?;

debug!("requesting control schema from: {}", uri);
let client = Client::new();
let resp = hyper::body::to_bytes(client.get(uri).await?.into_body())
Expand All @@ -158,39 +168,64 @@ async fn get_control_schemas(
parsing::parse_schemas(c)
}

fn validate_url(src_ip: IpAddr, url: &hyper::Uri) -> Result<(), SearchError> {
match url.host() {
Some(url_host) if url_host != src_ip.to_string() => Err(SearchError::SpoofedUrl {
src_ip,
url_host: url_host.to_owned(),
}),
None => Err(SearchError::UriMissingHost(url.clone())),
_ => Ok(()),
}
}

#[cfg(test)]
mod tests {
use super::*;
use http_body_util::Full;
use hyper_new::{body::Bytes, service::service_fn, Request, Response};
use hyper_util::rt::TokioIo;
use std::convert::Infallible;
use std::{
net::{Ipv4Addr, SocketAddrV4},
time::Duration,
};
use test_log::test;
use tokio::net::TcpListener;

#[test(tokio::test)]
async fn ip_spoofing_in_broadcast_response() {
async fn start_broadcast_reply_sender(location: String) -> u16 {
let port = {
// Not 100% reliable way to find a free port number, but should be good enough
let sock = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await.unwrap();
sock.local_addr().unwrap().port()
};

let options = SearchOptions {
bind_addr: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, port)),
timeout: Some(Duration::from_secs(5)),
http_timeout: Some(Duration::from_secs(1)),
..Default::default()
};

tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(1)).await;

let sock = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();

sock.send_to(b"location: http://1.2.3.4:5/test", (Ipv4Addr::LOCALHOST, port))
sock.send_to(format!("location: {location}").as_bytes(), (Ipv4Addr::LOCALHOST, port))
.await
.unwrap();
});
port
}

fn default_options_with_using_free_port(port: u16) -> SearchOptions {
SearchOptions {
bind_addr: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, port)),
timeout: Some(Duration::from_secs(5)),
http_timeout: Some(Duration::from_secs(1)),
..Default::default()
}
}

#[test(tokio::test)]
async fn ip_spoofing_in_broadcast_response() {
let port = start_broadcast_reply_sender("http://1.2.3.4:5".to_owned()).await;

let options = default_options_with_using_free_port(port);

let result = search_gateway(options).await;
if let Err(SearchError::SpoofedIp { src_ip, url_ip }) = result {
Expand All @@ -200,4 +235,130 @@ mod tests {
panic!("Unexpected result: {result:?}");
}
}

const RESP: &'static str = r#"<?xml version="1.0" ?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<device>
<deviceList>
<device>
<deviceList>
<device>
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:WANIPConnection:1</serviceType>
<controlURL>/igdupnp/control/WANIPConn1</controlURL>
<SCPDURL>:[email protected]/exec_cmd?cmd=touch%20%2ftmp%2frce</SCPDURL>
</service>
</serviceList>
</device>
</deviceList>
</device>
</deviceList>
</device>
</root>
"#;
const RESP2: &'static str = r#"<?xml version="1.0" ?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<device>
<deviceList>
<device>
<deviceList>
<device>
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:WANIPConnection:1</serviceType>
<controlURL>:[email protected]/exec_cmd?cmd=touch%20%2ftmp%2frce</controlURL>
<SCPDURL>/igdupnp/control/WANIPConn1</SCPDURL>
</service>
</serviceList>
</device>
</deviceList>
</device>
</deviceList>
</device>
</root>
"#;
const CONTROL_SCHEMA: &'static str = r#"<?xml version="1.0" ?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<actionList>
<action>
</action>
</actionList>
</root>
"#;

async fn start_http_server(responses: Vec<String>) -> u16 {
let addr = SocketAddr::from(([0, 0, 0, 0], 0));

// We create a TcpListener and bind it to 127.0.0.1:3000
let listener = TcpListener::bind(addr).await.unwrap();

let ret = listener.local_addr().unwrap().port();

tokio::task::spawn(async move {
for resp in responses {
let (stream, _) = listener.accept().await.unwrap();

// Use an adapter to access something implementing `tokio::io` traits as if they implement
// `hyper::rt` IO traits.
let io = TokioIo::new(stream);

let hello_fn =
move |r: Request<hyper_new::body::Incoming>| -> Result<Response<Full<Bytes>>, Infallible> {
println!("Request: {r:?}");
Ok(Response::new(Full::new(Bytes::from(resp.clone()))))
};

// Finally, we bind the incoming connection to our `hello` service
if let Err(err) = hyper_new::server::conn::http1::Builder::new()
// `service_fn` converts our function in a `Service`
.serve_connection(io, service_fn(|r| async { hello_fn(r) }))
.await
{
eprintln!("Error serving connection: {:?}", err);
}
}
});

ret
}

#[test(tokio::test)]
async fn ip_spoofing_in_getxml_body() {
let http_port = start_http_server(vec![RESP.to_owned()]).await;

let port = start_broadcast_reply_sender(format!("http://127.0.0.1:{http_port}")).await;

println!("http server port: {http_port}, udp port: {port}");

let options = default_options_with_using_free_port(port);

let result = search_gateway(options).await;
if let Err(SearchError::SpoofedUrl { src_ip, url_host }) = result {
assert_eq!(src_ip, Ipv4Addr::LOCALHOST);
assert_eq!(url_host, "example.com");
} else {
panic!("Unexpected result: {result:?}");
}
}

#[test(tokio::test)]
async fn ip_spoofing_in_getxml_body_control_url() {
let http_port = start_http_server(vec![RESP2.to_owned(), CONTROL_SCHEMA.to_owned()]).await;

let port = start_broadcast_reply_sender(format!("http://127.0.0.1:{http_port}")).await;

println!("http server port: {http_port}, udp port: {port}");

let options = default_options_with_using_free_port(port);

let result = search_gateway(options).await;

if let Err(SearchError::SpoofedUrl { src_ip, url_host }) = result {
assert_eq!(src_ip, Ipv4Addr::LOCALHOST);
assert_eq!(url_host, "example.com");
} else {
panic!("Unexpected result: {result:?}");
}
}
}
17 changes: 17 additions & 0 deletions src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -314,13 +314,23 @@ pub enum SearchError {
/// Error parsing URI
#[cfg(feature = "aio")]
InvalidUri(hyper::http::uri::InvalidUri),
#[cfg(feature = "aio")]
/// The uri is missing the host
UriMissingHost(hyper::Uri),
/// Ip spoofing detected error
SpoofedIp {
/// The IP from which packet was actually received
src_ip: IpAddr,
/// The IP which the receiving packet pretended to be from
url_ip: IpAddr,
},
/// Uri spoofing detected error
SpoofedUrl {
/// The IP from which packet was actually received
src_ip: IpAddr,
/// The IP which the receiving packet pretended to be from
url_host: String,
},
}

impl From<attohttpc::Error> for SearchError {
Expand Down Expand Up @@ -380,6 +390,11 @@ impl fmt::Display for SearchError {
#[cfg(feature = "aio")]
SearchError::InvalidUri(ref e) => write!(f, "InvalidUri Error: {}", e),
SearchError::SpoofedIp { src_ip, url_ip } => write!(f, "Spoofed IP from {src_ip} as {url_ip}"),
SearchError::UriMissingHost(ref uri) => write!(f, "Host part of '{uri} is missing"),
SearchError::SpoofedUrl {
ref src_ip,
ref url_host,
} => write!(f, "Spoofed IP from {src_ip} as {url_host}"),
}
}
}
Expand All @@ -397,6 +412,8 @@ impl error::Error for SearchError {
#[cfg(feature = "aio")]
SearchError::InvalidUri(ref e) => Some(e),
SearchError::SpoofedIp { .. } => None,
SearchError::UriMissingHost { .. } => None,
SearchError::SpoofedUrl { .. } => None,
}
}
}
Expand Down
2 changes: 0 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@ extern crate futures;
#[cfg(feature = "aio")]
extern crate http;
#[cfg(feature = "aio")]
extern crate hyper;
#[cfg(feature = "aio")]
extern crate tokio;

// data structures
Expand Down

0 comments on commit f7e52c0

Please sign in to comment.