From f2445c0398e3969b594a369feac3c5e14c11578d Mon Sep 17 00:00:00 2001 From: Karan Desai Date: Wed, 18 Mar 2026 19:00:22 +0530 Subject: [PATCH 1/5] Add `Interface` socket option for binding to a network interface Adds an 'Interface' option to Rex::Socket::Parameters so callers can bind a socket to a specific network interface by name: Rex::Socket::Udp.create('Interface' => 'eth0', ...) Rex::Socket::Tcp.create('Interface' => 'eth0', ...) Rex::Socket::TcpServer.create('Interface' => 'eth0', ...) This is needed for broadcast-capable sockets (e.g. DHCP server) where binding by IP address alone is insufficient to receive broadcast frames. Changes: - Add 'Interface' param to Rex::Socket::Parameters - Call setsockopt(SO_BINDTODEVICE) in Comm::Local after bind, before connect/listen (Linux only; raises BindFailed on other platforms) - Raise Rex::BindFailed if Interface is combined with a proxy - Raise Rex::BindFailed on invalid interface name (ENODEV) or insufficient permissions (EPERM) - Add RSpec tests for all new behaviors See: rapid7/metasploit-framework#21114 --- lib/rex/socket/comm/local.rb | 21 +++++++++++ lib/rex/socket/parameters.rb | 10 ++++++ spec/rex/socket/comm/local_spec.rb | 58 +++++++++++++++++++++++++++++- spec/rex/socket/parameters_spec.rb | 23 ++++++++++++ 4 files changed, 111 insertions(+), 1 deletion(-) diff --git a/lib/rex/socket/comm/local.rb b/lib/rex/socket/comm/local.rb index dd8485d..4c5afaa 100644 --- a/lib/rex/socket/comm/local.rb +++ b/lib/rex/socket/comm/local.rb @@ -158,6 +158,10 @@ def self.create_by_type(param, type, proto = 0) # Notify handlers of the before socket create event. self.instance.notify_before_socket_create(self, param) + if param.interface && !param.interface.empty? && param.proxies? + raise Rex::BindFailed.new(param.localhost, param.localport), caller + end + # Create the socket sock = nil if param.v6 @@ -185,6 +189,23 @@ def self.create_by_type(param, type, proto = 0) end end + if param.interface && !param.interface.empty? + if Rex::Compat.is_linux + begin + sock.setsockopt(::Socket::SOL_SOCKET, ::Socket::SO_BINDTODEVICE, param.interface) + rescue ::Errno::ENODEV, ::Errno::ENXIO + sock.close + raise Rex::BindFailed.new(param.localhost, param.localport), caller + rescue ::Errno::EPERM + sock.close + raise Rex::BindFailed.new(param.localhost, param.localport), caller + end + else + sock.close + raise Rex::BindFailed.new(param.localhost, param.localport), caller + end + end + # Configure broadcast support for all datagram sockets if type == ::Socket::SOCK_DGRAM sock.setsockopt(::Socket::SOL_SOCKET, ::Socket::SO_BROADCAST, true) diff --git a/lib/rex/socket/parameters.rb b/lib/rex/socket/parameters.rb index c161e2a..a0cd585 100644 --- a/lib/rex/socket/parameters.rb +++ b/lib/rex/socket/parameters.rb @@ -81,6 +81,9 @@ def self.from_hash(hash) # retried. # @option hash [Fixnum] 'Timeout' The number of seconds before a connection # should time out + # @option hash [String] 'Interface' The network interface name to bind the socket to + # (e.g. 'eth0'). Only honoured by the local Comm; raises Rex::BindFailed if combined + # with a proxy. def initialize(hash = {}) if (hash['PeerHost']) self.peerhost = hash['PeerHost'] @@ -203,6 +206,9 @@ def initialize(hash = {}) self.timeout = hash['Timeout'].to_i end + self.interface = hash['Interface'].to_s.strip if hash['Interface'] + + # Whether to force IPv6 addressing if hash['IPv6'].nil? # if IPv6 isn't specified and at least one host is an IPv6 address and the @@ -486,6 +492,10 @@ def v6 # @return [Array] attr_accessor :proxies + # The network interface name to bind the socket to (e.g. 'eth0'). nil means no binding. + # @return [String, nil] + attr_accessor :interface + def proxies? proxies && !proxies.empty? end diff --git a/spec/rex/socket/comm/local_spec.rb b/spec/rex/socket/comm/local_spec.rb index 59df386..d825660 100644 --- a/spec/rex/socket/comm/local_spec.rb +++ b/spec/rex/socket/comm/local_spec.rb @@ -107,4 +107,60 @@ end end end -end \ No newline at end of file + + describe 'Interface option' do + context 'when Interface is set and a proxy is also configured' do + it 'raises Rex::BindFailed before creating a socket' do + params = Rex::Socket::Parameters.new( + 'Proto' => 'udp', + 'Interface' => 'lo', + 'Proxies' => 'socks5:127.0.0.1:1080' + ) + expect { described_class.create(params) }.to raise_error(Rex::BindFailed) + end + end + + context 'when Interface is set to a name that cannot exist' do + it 'raises Rex::BindFailed' do + skip 'Linux only' unless Rex::Compat.is_linux + skip 'requires root (SO_BINDTODEVICE)' unless Process.uid == 0 + params = Rex::Socket::Parameters.new( + 'Proto' => 'udp', + 'LocalHost' => '127.0.0.1', + 'LocalPort' => 0, + 'Interface' => 'xX_no_such_iface_Xx' + ) + expect { described_class.create(params) }.to raise_error(Rex::BindFailed) + end + end + + context 'when Interface is set to loopback on Linux' do + it 'creates the socket successfully' do + skip 'Linux only' unless Rex::Compat.is_linux + skip 'requires root (SO_BINDTODEVICE)' unless Process.uid == 0 + params = Rex::Socket::Parameters.new( + 'Proto' => 'udp', + 'LocalHost' => '127.0.0.1', + 'LocalPort' => 0, + 'Interface' => 'lo' + ) + sock = described_class.create(params) + expect(sock).not_to be_nil + sock.close + end + end + + context 'when running on a non-Linux platform' do + it 'raises Rex::BindFailed' do + skip 'non-Linux only' if Rex::Compat.is_linux + params = Rex::Socket::Parameters.new( + 'Proto' => 'udp', + 'LocalHost' => '127.0.0.1', + 'LocalPort' => 0, + 'Interface' => 'lo' + ) + expect { described_class.create(params) }.to raise_error(Rex::BindFailed) + end + end + end +end diff --git a/spec/rex/socket/parameters_spec.rb b/spec/rex/socket/parameters_spec.rb index 3d29196..5a14445 100644 --- a/spec/rex/socket/parameters_spec.rb +++ b/spec/rex/socket/parameters_spec.rb @@ -125,4 +125,27 @@ end end + describe '#interface' do + it 'is nil by default' do + params = described_class.new({}) + expect(params.interface).to be_nil + end + + it 'is set from the Interface hash key' do + params = described_class.new('Interface' => 'eth0') + expect(params.interface).to eq('eth0') + end + + it 'strips leading and trailing whitespace' do + params = described_class.new('Interface' => ' lo ') + expect(params.interface).to eq('lo') + end + + it 'is nil when the key is absent, not an empty string' do + params = described_class.new({}) + expect(params.interface).to be_nil + expect(params.interface).not_to eq('') + end + end + end From 5769bf7ab3a67cccf24f62042300d7bc68857e88 Mon Sep 17 00:00:00 2001 From: Karan Desai Date: Wed, 18 Mar 2026 19:49:00 +0530 Subject: [PATCH 2/5] Add descriptive reason messages to BindFailed and macOS support - Adds reason: keyword to all BindFailed raises for clearer errors - Adds macOS support using IP_BOUND_IF (guarded by defined?) - Improves developer experience when debugging interface binding issues Addresses review feedback on PR #80. --- lib/rex/socket/comm/local.rb | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/lib/rex/socket/comm/local.rb b/lib/rex/socket/comm/local.rb index 4c5afaa..2fb60c2 100644 --- a/lib/rex/socket/comm/local.rb +++ b/lib/rex/socket/comm/local.rb @@ -159,7 +159,8 @@ def self.create_by_type(param, type, proto = 0) self.instance.notify_before_socket_create(self, param) if param.interface && !param.interface.empty? && param.proxies? - raise Rex::BindFailed.new(param.localhost, param.localport), caller + raise Rex::BindFailed.new(param.localhost, param.localport, + reason: 'Interface option is incompatible with proxy use'), caller end # Create the socket @@ -195,14 +196,26 @@ def self.create_by_type(param, type, proto = 0) sock.setsockopt(::Socket::SOL_SOCKET, ::Socket::SO_BINDTODEVICE, param.interface) rescue ::Errno::ENODEV, ::Errno::ENXIO sock.close - raise Rex::BindFailed.new(param.localhost, param.localport), caller + raise Rex::BindFailed.new(param.localhost, param.localport, + reason: "Interface #{param.interface} not found"), caller rescue ::Errno::EPERM sock.close - raise Rex::BindFailed.new(param.localhost, param.localport), caller + raise Rex::BindFailed.new(param.localhost, param.localport, + reason: "Binding to interface #{param.interface} requires elevated privileges"), caller + end + elsif Rex::Compat.is_osx && defined?(::Socket::IP_BOUND_IF) + begin + idx = ::Socket.if_nametoindex(param.interface) + sock.setsockopt(::Socket::IPPROTO_IP, ::Socket::IP_BOUND_IF, [idx].pack('I')) + rescue ::SocketError, ::Errno::ENXIO + sock.close + raise Rex::BindFailed.new(param.localhost, param.localport, + reason: "Interface #{param.interface} not found"), caller end else sock.close - raise Rex::BindFailed.new(param.localhost, param.localport), caller + raise Rex::BindFailed.new(param.localhost, param.localport, + reason: 'Interface binding is not supported on this platform'), caller end end From 932a664c8f173ece482e0f5bd3b207ffacfc5c4a Mon Sep 17 00:00:00 2001 From: Karan Desai Date: Wed, 18 Mar 2026 20:42:24 +0530 Subject: [PATCH 3/5] Explicitly mark Windows as unsupported for Interface binding - Adds clear BindFailed reason for Windows - Keeps fallback for other unsupported platforms Addresses maintainer feedback --- lib/rex/socket/comm/local.rb | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/rex/socket/comm/local.rb b/lib/rex/socket/comm/local.rb index 2fb60c2..11cb3cd 100644 --- a/lib/rex/socket/comm/local.rb +++ b/lib/rex/socket/comm/local.rb @@ -214,8 +214,13 @@ def self.create_by_type(param, type, proto = 0) end else sock.close - raise Rex::BindFailed.new(param.localhost, param.localport, - reason: 'Interface binding is not supported on this platform'), caller + if Rex::Compat.is_windows + raise Rex::BindFailed.new(param.localhost, param.localport, + reason: 'Interface binding is not supported on Windows'), caller + else + raise Rex::BindFailed.new(param.localhost, param.localport, + reason: 'Interface binding is not supported on this platform'), caller + end end end From 125e231a2ddd362cc7236d70b7f27690eba5cc8d Mon Sep 17 00:00:00 2001 From: Karan Desai Date: Wed, 1 Apr 2026 13:52:09 +0530 Subject: [PATCH 4/5] Add Windows support and harden Interface option - Resolve interface name to IP via Socket.getifaddrs on Windows, overriding param.localhost so existing bind() handles the rest - Replace Socket.if_nametoindex on macOS with getifaddrs + ifindex since if_nametoindex is not available in all Ruby builds - Add rescue SystemCallError to Linux and macOS setsockopt blocks to prevent socket leaks on unexpected errors - Distinguish between interface not found vs interface has no IPv4 address in the Windows getifaddrs lookup - Add rescue for getifaddrs enumeration failures on Windows --- lib/rex/socket/comm/local.rb | 51 +++++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/lib/rex/socket/comm/local.rb b/lib/rex/socket/comm/local.rb index 11cb3cd..6a80035 100644 --- a/lib/rex/socket/comm/local.rb +++ b/lib/rex/socket/comm/local.rb @@ -163,6 +163,33 @@ def self.create_by_type(param, type, proto = 0) reason: 'Interface option is incompatible with proxy use'), caller end + if param.interface && !param.interface.empty? && Rex::Compat.is_windows + iface_ip = nil + iface_found = false + begin + ::Socket.getifaddrs.each do |ifaddr| + next unless ifaddr.name == param.interface + iface_found = true + next unless ifaddr.addr&.ipv4? + iface_ip = ifaddr.addr.ip_address + break + end + rescue ::SystemCallError, ::SocketError => e + raise Rex::BindFailed.new(param.localhost, param.localport, + reason: "Failed to enumerate interfaces: #{e.message}"), caller + end + if iface_ip.nil? + reason = if iface_found + "Interface #{param.interface} has no IPv4 address" + else + "Interface #{param.interface} not found" + end + raise Rex::BindFailed.new(param.localhost, param.localport, + reason: reason), caller + end + param.localhost = iface_ip + end + # Create the socket sock = nil if param.v6 @@ -202,25 +229,31 @@ def self.create_by_type(param, type, proto = 0) sock.close raise Rex::BindFailed.new(param.localhost, param.localport, reason: "Binding to interface #{param.interface} requires elevated privileges"), caller + rescue ::SystemCallError + sock.close + raise end elsif Rex::Compat.is_osx && defined?(::Socket::IP_BOUND_IF) begin - idx = ::Socket.if_nametoindex(param.interface) + idx = ::Socket.getifaddrs.find { |ifaddr| ifaddr.name == param.interface }&.ifindex + if idx.nil? + sock.close + raise Rex::BindFailed.new(param.localhost, param.localport, + reason: "Interface #{param.interface} not found"), caller + end sock.setsockopt(::Socket::IPPROTO_IP, ::Socket::IP_BOUND_IF, [idx].pack('I')) rescue ::SocketError, ::Errno::ENXIO sock.close raise Rex::BindFailed.new(param.localhost, param.localport, reason: "Interface #{param.interface} not found"), caller + rescue ::SystemCallError + sock.close + raise end - else + elsif !Rex::Compat.is_windows sock.close - if Rex::Compat.is_windows - raise Rex::BindFailed.new(param.localhost, param.localport, - reason: 'Interface binding is not supported on Windows'), caller - else - raise Rex::BindFailed.new(param.localhost, param.localport, - reason: 'Interface binding is not supported on this platform'), caller - end + raise Rex::BindFailed.new(param.localhost, param.localport, + reason: 'Interface binding is not supported on this platform'), caller end end From d42e115222b36efde8c44d1ade73491eee3227b7 Mon Sep 17 00:00:00 2001 From: Karan Desai Date: Thu, 2 Apr 2026 09:23:36 +0530 Subject: [PATCH 5/5] Address review feedback from smcintyre-r7 - Guard interface param against whitespace-only strings - Add comment explaining why Interface + proxy raises immediately - Use param.dup before mutating localhost to avoid side effects on the caller's instance - Select IPv6 address when param.v6 is true, IPv4 otherwise --- lib/rex/socket/comm/local.rb | 13 +++++++++++-- lib/rex/socket/parameters.rb | 3 +-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/rex/socket/comm/local.rb b/lib/rex/socket/comm/local.rb index 6a80035..b8fc8d1 100644 --- a/lib/rex/socket/comm/local.rb +++ b/lib/rex/socket/comm/local.rb @@ -158,6 +158,10 @@ def self.create_by_type(param, type, proto = 0) # Notify handlers of the before socket create event. self.instance.notify_before_socket_create(self, param) + # Binding to a specific interface while routing through a proxy is not + # supported. The proxy comm handles its own socket creation and ignores + # the interface option entirely, so we fail fast here rather than + # silently binding to the wrong interface. if param.interface && !param.interface.empty? && param.proxies? raise Rex::BindFailed.new(param.localhost, param.localport, reason: 'Interface option is incompatible with proxy use'), caller @@ -170,7 +174,11 @@ def self.create_by_type(param, type, proto = 0) ::Socket.getifaddrs.each do |ifaddr| next unless ifaddr.name == param.interface iface_found = true - next unless ifaddr.addr&.ipv4? + if param.v6 + next unless ifaddr.addr&.ipv6? + else + next unless ifaddr.addr&.ipv4? + end iface_ip = ifaddr.addr.ip_address break end @@ -180,13 +188,14 @@ def self.create_by_type(param, type, proto = 0) end if iface_ip.nil? reason = if iface_found - "Interface #{param.interface} has no IPv4 address" + "Interface #{param.interface} has no #{param.v6 ? 'IPv6' : 'IPv4'} address" else "Interface #{param.interface} not found" end raise Rex::BindFailed.new(param.localhost, param.localport, reason: reason), caller end + param = param.dup # avoid mutating the caller's instance param.localhost = iface_ip end diff --git a/lib/rex/socket/parameters.rb b/lib/rex/socket/parameters.rb index a0cd585..1f3a9a2 100644 --- a/lib/rex/socket/parameters.rb +++ b/lib/rex/socket/parameters.rb @@ -206,8 +206,7 @@ def initialize(hash = {}) self.timeout = hash['Timeout'].to_i end - self.interface = hash['Interface'].to_s.strip if hash['Interface'] - + self.interface = hash['Interface'].to_s.strip if hash['Interface'] && !hash['Interface'].strip.empty? # Whether to force IPv6 addressing if hash['IPv6'].nil?