diff options
| author | Joakim Hulthe <joakim@hulthe.net> | 2024-07-05 16:25:32 +0200 |
|---|---|---|
| committer | Joakim Hulthe <joakim@hulthe.net> | 2024-07-16 15:46:33 +0200 |
| commit | 71ef4709732d9b0ba052d957a6483c743d5746aa (patch) | |
| tree | 14dc9da32f3e8ae0249a4b4109eb086b939b7a4e /test/test-manager | |
| parent | 28a3776b3a7a35c4e1f964e9dcb9c4698afd7a68 (diff) | |
| download | mullvadvpn-71ef4709732d9b0ba052d957a6483c743d5746aa.tar.xz mullvadvpn-71ef4709732d9b0ba052d957a6483c743d5746aa.zip | |
Add E2E test of CVE-2019-14899 mitigation
Diffstat (limited to 'test/test-manager')
| -rw-r--r-- | test/test-manager/Cargo.toml | 6 | ||||
| -rw-r--r-- | test/test-manager/src/tests/cve_2019_14899.rs | 319 | ||||
| -rw-r--r-- | test/test-manager/src/tests/helpers.rs | 49 | ||||
| -rw-r--r-- | test/test-manager/src/tests/mod.rs | 1 |
4 files changed, 373 insertions, 2 deletions
diff --git a/test/test-manager/Cargo.toml b/test/test-manager/Cargo.toml index fa86db329e..7285a9e837 100644 --- a/test/test-manager/Cargo.toml +++ b/test/test-manager/Cargo.toml @@ -40,7 +40,8 @@ tokio-serde = { workspace = true } log = { workspace = true } pcap = { version = "1.3", features = ["capture-stream"] } -pnet_packet = "0.31.0" +pnet_packet = "0.34.0" +pnet_base = "0.34.0" test-rpc = { path = "../test-rpc" } socks-server = { path = "../socks-server" } @@ -59,7 +60,8 @@ talpid-types = { path = "../../talpid-types" } ssh2 = "0.9.4" -nix = { version = "0.25", features = ["net"] } +nix = { workspace = true } +socket2 = "0.5.6" [target.'cfg(target_os = "macos")'.dependencies] tun = "0.5.1" diff --git a/test/test-manager/src/tests/cve_2019_14899.rs b/test/test-manager/src/tests/cve_2019_14899.rs new file mode 100644 index 0000000000..d194451066 --- /dev/null +++ b/test/test-manager/src/tests/cve_2019_14899.rs @@ -0,0 +1,319 @@ +#![cfg(target_os = "linux")] + +use std::{ + convert::Infallible, + ffi::{c_int, c_uint, c_void}, + mem::size_of, + net::{IpAddr, Ipv4Addr}, + os::fd::AsRawFd, + time::Duration, +}; + +use anyhow::{anyhow, bail, Context}; +use futures::{select, FutureExt}; +use mullvad_management_interface::MullvadProxyClient; +use nix::{ + errno::Errno, + sys::socket::{self, MsgFlags, SockProtocol}, +}; +use pnet_base::MacAddr; +use pnet_packet::{ + ethernet::{EtherTypes, EthernetPacket, MutableEthernetPacket}, + ip::IpNextHeaderProtocols, + ipv4::{Ipv4Packet, MutableIpv4Packet}, + tcp::{MutableTcpPacket, TcpFlags, TcpPacket}, + MutablePacket, Packet, +}; +use socket2::Socket; +use test_macro::test_function; +use test_rpc::ServiceClient; +use tokio::{task::yield_now, time::sleep}; + +use crate::{ + tests::helpers, + vm::network::{linux::TAP_NAME, NON_TUN_GATEWAY}, +}; + +use super::TestContext; + +/// The port number we set in the malicious packet. +const MALICIOUS_PACKET_PORT: u16 = 12345; + +/// Test mitigation for cve-2019-14899. +/// +/// The vulnerability allowed a malicious router to learn the victims private mullvad tunnel IP. +/// It is performed by sending a TCP packet to the victim with SYN and ACK flags set. +/// +/// If the destination_addr of the packet was the same as the private IP, the victims computer +/// would respond to the packet with the RST flag set. +/// +/// This test simply gets the private tunnel IP from the test runner and sends the SYN/ACK packet +/// targeted to that address. If the guest does not respond, the test passes. +/// +/// Note that only linux was susceptible to this vulnerability. +#[test_function(target_os = "linux")] +pub async fn test_cve_2019_14899_mitigation( + _: TestContext, + rpc: ServiceClient, + mut mullvad_client: MullvadProxyClient, +) -> anyhow::Result<()> { + // The vulnerability required local network sharing to be enabled + mullvad_client + .set_allow_lan(true) + .await + .context("Failed to allow local network sharing")?; + + helpers::connect_and_wait(&mut mullvad_client).await?; + + let host_interface = TAP_NAME; + let victim_tunnel_interface = "wg0-mullvad"; + let victim_gateway_ip = NON_TUN_GATEWAY; + + // Create a raw socket which let's us send custom ethernet packets + log::info!("Creating raw socket"); + let socket = Socket::new( + socket2::Domain::PACKET, + socket2::Type::RAW, + Some(socket2::Protocol::from(SockProtocol::EthAll as c_int)), + ) + .with_context(|| "Failed to create raw socket")?; + + log::info!("Binding raw socket to tap interface"); + socket + .bind_device(Some(host_interface.as_bytes())) + .with_context(|| anyhow!("Failed to bind the socket to {host_interface:?}"))?; + + // Get the private IP address of the victims VPN tunnel + let victim_tunnel_ip = rpc + .get_interface_ip(victim_tunnel_interface.to_string()) + .await + .with_context(|| { + anyhow!("Failed to get ip of guest tunnel interface {victim_tunnel_interface:?}") + })?; + + let IpAddr::V4(victim_tunnel_ip) = victim_tunnel_ip else { + bail!("I didn't ask for IPv6!"); + }; + + let victim_default_interface = rpc + .get_default_interface() + .await + .context("failed to get guest default interface")?; + + let victim_default_interface_mac = rpc + .get_interface_mac(victim_default_interface.clone()) + .await + .with_context(|| { + anyhow!("Failed to get ip of guest default interface {victim_default_interface:?}") + })? + .ok_or(anyhow!( + "No mac address for guest default interface {victim_default_interface:?}" + ))?; + + // Get the MAC address and index of the tap interface + let host_interface_index = helpers::get_interface_index(host_interface)?; + let host_interface_mac = helpers::get_interface_mac(host_interface)?.ok_or(anyhow!( + "No mac address for host interface {host_interface:?}" + ))?; + + let malicious_packet = craft_malicious_packet( + MacAddr::from(host_interface_mac), + MacAddr::from(victim_default_interface_mac), + victim_gateway_ip, + victim_tunnel_ip, + ); + + let filter = |tcp: &TcpPacket<'_>| { + let reset_flag_set = (tcp.get_flags() & TcpFlags::RST) != 0; + let correct_source_port = tcp.get_source() == MALICIOUS_PACKET_PORT; + let correct_destination_port = tcp.get_destination() == MALICIOUS_PACKET_PORT; + + reset_flag_set && correct_source_port && correct_destination_port + }; + + let rst_packet = select! { + result = filter_for_packet(&socket, filter, Duration::from_secs(5)).fuse() => result?, + + result = spam_packet(&socket, host_interface_index, &malicious_packet).fuse() => match result { + Err(e) => return Err(e), + Ok(never) => match never {}, // I dream of ! being stabilized + }, + }; + + if let Some(rst_packet) = rst_packet { + log::warn!("Victim responded with an RST packet: {rst_packet:?}"); + bail!("Managed to leak private tunnel IP"); + } + + Ok(()) +} + +/// Read from the socket and return the first packet that passes the filter. +/// Returns `None` if we don't see such a packet within the timeout. +async fn filter_for_packet( + socket: &Socket, + filter: impl Fn(&TcpPacket<'_>) -> bool, + timeout: Duration, +) -> anyhow::Result<Option<TcpPacket<'static>>> { + let mut buf = vec![0u8; usize::from(u16::MAX)]; + + let result = tokio::time::timeout(timeout, async { + loop { + let packet = poll_for_packet(socket, &mut buf).await?; + if filter(&packet) { + return anyhow::Ok(packet); + } + } + }); + + match result.await { + Ok(packet) => Ok(Some(packet?)), + Err(_timed_out) => Ok(None), + } +} + +/// Repeatedly poll the raw socket until we receives an Ethernet/IPv4/TCP packet. +/// Drops any non-TCP packets. +/// +/// # Returns +/// - `Err` if the `read` system call failed. +/// - A single TCP packet otherwise. +async fn poll_for_packet(socket: &Socket, buf: &mut [u8]) -> anyhow::Result<TcpPacket<'static>> { + loop { + // yield so we don't end up hogging the runtime while polling the socket + yield_now().await; + + let result = socket::recv(socket.as_raw_fd(), &mut buf[..], MsgFlags::MSG_DONTWAIT); + + let n = match result { + Ok(0) | Err(Errno::EWOULDBLOCK) => { + sleep(Duration::from_millis(10)).await; + continue; + } + Err(e) => return Err(e).context("Failed to read from socket"), + Ok(n) => n, + }; + + let packet = &buf[..n]; + + let Some(eth_packet) = EthernetPacket::new(packet) else { + continue; + }; + + if eth_packet.get_ethertype() != EtherTypes::Ipv4 { + continue; + } + + let Some(ipv4_packet) = Ipv4Packet::new(eth_packet.payload()) else { + continue; + }; + + let valid_ip_version = ipv4_packet.get_version() == 4; + let protocol_is_tcp = ipv4_packet.get_next_level_protocol() == IpNextHeaderProtocols::Tcp; + + if !valid_ip_version || !protocol_is_tcp { + continue; + } + + if let Some(tcp_packet) = TcpPacket::owned(ipv4_packet.payload().to_vec()) { + return Ok(tcp_packet); + }; + } +} + +/// Send `packet` on the socket in a loop. +// NOTE: Replace return type with ! if/when stable. +async fn spam_packet( + socket: &Socket, + interface_index: c_uint, + packet: &EthernetPacket<'_>, +) -> anyhow::Result<Infallible> { + loop { + send_packet(socket, interface_index, packet)?; + sleep(Duration::from_millis(50)).await; + } +} + +/// Send an ethernet packet on the raw socket. +fn send_packet( + socket: &Socket, + interface_index: c_uint, + packet: &EthernetPacket<'_>, +) -> anyhow::Result<()> { + let result = { + let mut destination = libc::sockaddr_ll { + sll_family: 0, + sll_protocol: 0, + sll_ifindex: interface_index as c_int, + sll_hatype: 0, + sll_pkttype: 0, + sll_halen: size_of::<MacAddr>() as u8, + sll_addr: [0; 8], + }; + destination.sll_addr[..6].copy_from_slice(&packet.get_destination().octets()); + unsafe { + // NOTE: since you're reading this, consider using https://docs.rs/pnet_datalink + // instead of whatever you're planning... + libc::sendto( + socket.as_raw_fd(), + packet.packet().as_ptr() as *const c_void, + packet.packet().len(), + 0, + (&destination as *const libc::sockaddr_ll).cast(), + size_of::<libc::sockaddr_ll>() as u32, + ) + } + }; + + if result < 0 { + let err = Errno::last(); + bail!("Failed to send ethernet packet: {err}"); + } + + Ok(()) +} + +fn craft_malicious_packet( + source_mac: MacAddr, + destination_mac: MacAddr, + source_ip: Ipv4Addr, + destination_ip: Ipv4Addr, +) -> EthernetPacket<'static> { + // length of the various parts of the malicious packet we'll be crafting. + const TCP_LEN: usize = 20; // a TCP packet is 20 bytes + const IPV4_LEN: usize = 20 + TCP_LEN; // an IPv4 packet is 20 bytes + payload + const ETH_LEN: usize = 14 + IPV4_LEN; // an ethernet packet is 14 bytes + payload + + let mut eth_packet = + MutableEthernetPacket::owned(vec![0u8; ETH_LEN]).expect("ETH_LEN bytes is enough"); + eth_packet.set_destination(destination_mac); + eth_packet.set_source(source_mac); + eth_packet.set_ethertype(EtherTypes::Ipv4); + + let mut ipv4_packet = + MutableIpv4Packet::new(eth_packet.payload_mut()).expect("IPV4_LEN bytes is enough"); + ipv4_packet.set_version(4); + ipv4_packet.set_header_length(5); + ipv4_packet.set_total_length(IPV4_LEN as u16); + ipv4_packet.set_identification(0x77); + ipv4_packet.set_ttl(0xff); + ipv4_packet.set_next_level_protocol(IpNextHeaderProtocols::Tcp); + ipv4_packet.set_source(source_ip); + ipv4_packet.set_destination(destination_ip); + ipv4_packet.set_checksum(pnet_packet::ipv4::checksum(&ipv4_packet.to_immutable())); + + let mut tcp_packet = + MutableTcpPacket::new(ipv4_packet.payload_mut()).expect("TCP_LEN bytes is enough"); + tcp_packet.set_source(MALICIOUS_PACKET_PORT); + tcp_packet.set_destination(MALICIOUS_PACKET_PORT); + tcp_packet.set_data_offset(5); // 5 is smallest possible value + tcp_packet.set_window(0xff); + tcp_packet.set_flags(TcpFlags::SYN | TcpFlags::ACK); + tcp_packet.set_checksum(pnet_packet::tcp::ipv4_checksum( + &tcp_packet.to_immutable(), + &source_ip, + &destination_ip, + )); + + eth_packet.consume_to_immutable() +} diff --git a/test/test-manager/src/tests/helpers.rs b/test/test-manager/src/tests/helpers.rs index d6a59bd33c..adefedcf98 100644 --- a/test/test-manager/src/tests/helpers.rs +++ b/test/test-manager/src/tests/helpers.rs @@ -253,6 +253,55 @@ pub async fn resolve_hostname_with_retries( } } +/// Get the mac address (if any) of a network interface (on the test-manager machine). +#[cfg(target_os = "linux")] // not used on macos +pub fn get_interface_mac(interface: &str) -> anyhow::Result<Option<[u8; 6]>> { + let addrs = nix::ifaddrs::getifaddrs().map_err(|error| { + log::error!("Failed to obtain interfaces: {}", error); + test_rpc::Error::Syscall + })?; + + let mut interface_exists = false; + + let mac_addr = addrs + .filter(|addr| addr.interface_name == interface) + .find_map(|addr| { + // sadly, the only way of distinguishing between "iface doesn't exist" and + // "iface has no mac addr" is to check if the interface appears anywhere in the list. + interface_exists = true; + + let addr = addr.address.as_ref()?; + let link_addr = addr.as_link_addr()?; + let mac_addr = link_addr.addr()?; + Some(mac_addr) + }); + + if interface_exists { + Ok(mac_addr) + } else { + bail!("Interface not found: {interface:?}") + } +} + +/// Get the index of a network interface (on the test-manager machine). +#[cfg(target_os = "linux")] // not used on macos +pub fn get_interface_index(interface: &str) -> anyhow::Result<std::ffi::c_uint> { + use nix::errno::Errno; + use std::ffi::CString; + + let interface = CString::new(interface).context(anyhow!( + "Failed to turn interface name {interface:?} into cstr" + ))?; + + match unsafe { libc::if_nametoindex(interface.as_ptr()) } { + 0 => { + let err = Errno::last(); + Err(err).context("Failed to get interface index") + } + i => Ok(i), + } +} + /// Log in and retry if it fails due to throttling pub async fn login_with_retries( mullvad_client: &mut MullvadProxyClient, diff --git a/test/test-manager/src/tests/mod.rs b/test/test-manager/src/tests/mod.rs index 25811f7d45..255efb5941 100644 --- a/test/test-manager/src/tests/mod.rs +++ b/test/test-manager/src/tests/mod.rs @@ -1,6 +1,7 @@ mod access_methods; mod account; pub mod config; +mod cve_2019_14899; mod dns; mod helpers; mod install; |
