Add Interface socket option for binding to a network interface#80
Conversation
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
- 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 rapid7#80.
|
I've pushed an update addressing both points:
Would love your thoughts on whether this covers the macOS case, and any suggestions for Windows support as well. |
- Adds clear BindFailed reason for Windows - Keeps fallback for other unsupported platforms Addresses maintainer feedback
|
pushed an update addressing your feedback:
Also re-ran the full test suite with elevated permissions:
|
|
@zeroSteiner following up on our Slack discussion — pushed the Platform matrix is now complete:
Also did a hardening pass:
Manually verified on Linux (all three cases pass), Windows approach 178 tests, 0 failures. Ready for final review whenever you get a chance. |
- 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
549a3a5 to
125e231
Compare
| self.timeout = hash['Timeout'].to_i | ||
| end | ||
|
|
||
| self.interface = hash['Interface'].to_s.strip if hash['Interface'] |
There was a problem hiding this comment.
| self.interface = hash['Interface'].to_s.strip if hash['Interface'] | |
| self.interface = hash['Interface'].to_s.strip if hash['Interface'] && !hash['Interface'].strip.empty? |
| ::Socket.getifaddrs.each do |ifaddr| | ||
| next unless ifaddr.name == param.interface | ||
| iface_found = true | ||
| next unless ifaddr.addr&.ipv4? |
There was a problem hiding this comment.
The parameters has a #v6 attribute
rex-socket/lib/rex/socket/parameters.rb
Lines 206 to 215 in 146bc6b
When that's set the address should be IPv6 and we'd want to select a v6 address not an v4 address. At this time, it's safe to assume that if it's not IPv6 that it's IPv4 because in practice we only support AF_INET and AF_INET6 nothing like AF_UNIX but maybe someday.
- 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
|
@smcintyre-r7 addressed all four points:
178 tests, 0 failures. |
smcintyre-r7
left a comment
There was a problem hiding this comment.
Found a bug while testing, .is_osx should be .is_macosx.
Co-authored-by: Spencer McIntyre <58950994+smcintyre-r7@users.noreply.github.com>
| sock.close | ||
| raise | ||
| end | ||
| elsif Rex::Compat.is_macosx && defined?(::Socket::IP_BOUND_IF) |
There was a problem hiding this comment.
Hey @karandesai2005 - thanks for the contribution. I was just testing on macOS Sequoia 15.7.5 (24G624) and I'm running into the following issue:
>> sock = Rex::Socket::Tcp.create('Port'=>4321, 'Server' => true, 'Interface' => 'bridge101')
/Users/jheysel/rapid7/rex-socket/lib/rex/socket/comm/local.rb:264:in `create_by_type': The address is already in use or unavailable: (0.0.0.0:0). Interface binding is not supported on this platform (Rex::BindFailed)
from /Users/jheysel/rapid7/rex-socket/lib/rex/socket/comm/local.rb:37:in `create'
from /Users/jheysel/rapid7/rex-socket/lib/rex/socket.rb:52:in `create_param'
from /Users/jheysel/rapid7/rex-socket/lib/rex/socket/tcp.rb:37:in `create_param'
from /Users/jheysel/rapid7/rex-socket/lib/rex/socket/tcp.rb:28:in `create'
from (irb):3:in `<main>'
from <internal:kernel>:187:in `loop'
It's due to this conditional statement evaluating to false due to Socket::IP_BOUND_IF not being defined:
[1] pry(Rex::Socket::Comm::Local)> n
From: /Users/jheysel/rapid7/rex-socket/lib/rex/socket/comm/local.rb:246 Rex::Socket::Comm::Local.create_by_type:
241: reason: "Binding to interface #{param.interface} requires elevated privileges"), caller
242: rescue ::SystemCallError
243: sock.close
244: raise
245: end
=> 246: elsif Rex::Compat.is_macosx && defined?(::Socket::IP_BOUND_IF)
247: begin
248: idx = ::Socket.getifaddrs.find { |ifaddr| ifaddr.name == param.interface }&.ifindex
249: if idx.nil?
250: sock.close
251: raise Rex::BindFailed.new(param.localhost, param.localport,
[1] pry(Rex::Socket::Comm::Local)> Rex::Compat.is_macosx
=> true
[2] pry(Rex::Socket::Comm::Local)> defined?(::Socket::IP_BOUND_IF)
=> nil
There was a problem hiding this comment.
Thanks for testing this on macOS — that’s really helpful.
You're absolutely right, the current guard is too strict. I was relying on Socket::IP_BOUND_IF being defined, but it seems Ruby doesn't expose it on your setup even though the OS supports it.
I'll update the implementation to fall back to the raw constant value (IP_BOUND_IF = 25) when it's not defined in Ruby, so the macOS path still executes correctly.
Will push a fix shortly.
- Clarify Windows implementation via localhost override (no setsockopt) - Add macOS fallback for IP_BOUND_IF (raw value 25) - Improve readability for interface lookup logic - Maintain consistent error handling across platforms # Conflicts: # lib/rex/socket/comm/local.rb
karandesai2005
left a comment
There was a problem hiding this comment.
hey @jheysel-r7
I’ve updated the macOS branch to use Rex::Compat.is_macosx and removed the defined?(::Socket::IP_BOUND_IF) guard, since the constant is not consistently exposed in Ruby builds.
Instead, we now fall back to the raw socket option value (IP_BOUND_IF = 25), which is stable across macOS versions even when the Ruby constant is missing.
Additionally:
Improved interface lookup readability (iface → idx)
Clarified Windows behavior (interface → IP resolution with localhost override)
Kept error handling consistent across platforms
This should ensure the macOS path executes correctly regardless of Ruby’s constant exposure.
|
Hey @karandesai2005 - thanks for making that change. I'm no longer seeing the failure on macOS, however I'm not seeing the socket listening when i search for it using netstat. I noticed the same thing testing on linux on the latest commit as well. macOSUbuntuI'm wondering if there might be something wrong with my testing? |
…sibility SO_BINDTODEVICE (Linux) and IP_BOUND_IF (macOS) must be set before bind() for the kernel to properly restrict the socket to the specified interface. Previously, interface binding was applied after sock.bind(), which caused: - Sockets to not appear in netstat/ss when using the Interface option - Connections to fail on the bound interface - Silent failures that were difficult to debug This fix reorders the setsockopt calls to execute immediately after socket creation but before bind(), ensuring the kernel enforces interface binding correctly. Tested on Linux (Arch) with wlo1 interface - socket now correctly appears in 'ss' output as '0.0.0.0%wlo1:4321' and accepts connections. Fixes rapid7#80 (interface binding order issue)
karandesai2005
left a comment
There was a problem hiding this comment.
@jheysel-r7 So I've fixed the interface binding order issue. The problem was that setsockopt calls (both SO_BINDTODEVICE on Linux and IP_BOUND_IF on macOS) were being applied after sock.bind(), but the kernel expects them before bind() for proper interface restriction.
✅ Linux Testing Complete
Tested on Arch Linux with the wlo1 interface. The socket now:
✅ Appears correctly in ss/netstat bound to the interface
✅ Accepts connections on the interface IP
✅ Shows interface-specific binding (%wlo1 suffix)
--->> macOS Testing Needed
The same fix applies to macOS - the IP_BOUND_IF setsockopt call is now applied before bind(). Could you re-test on macOS Sequoia when you get a chance?
|
Hey @karandesai2005 - thanks for the update! I got it working on macOS. I kept trying to create the socket with the following command: And it wasn't honoring the port I was passing in - it kept randomizing it to some high port in the 60xxx range. I realized I needed to be setting The above works great: |
|
@jheysel-r7 Awesome, thanks for confirming macOS works! 🎉 Great catch on Since this is now verified on: I think this PR is ready to merge! @zeroSteiner Thanks again for the thorough testing and feedback. Let me know if there's anything else needed from my side! 🙏 |

Summary
Adds an
Interfaceoption toRex::Socket::Parametersso callers canbind a socket to a specific network interface by name:
This is needed for broadcast-capable sockets (e.g. the DHCP server module)
where binding by IP address alone is insufficient — the socket must be bound
to a specific interface to send and receive broadcast frames correctly.
Changes
lib/rex/socket/parameters.rb— addsinterfaceattribute, parsed fromthe
'Interface'hash key, whitespace-stripped,nilby defaultlib/rex/socket/comm/local.rb— two additions insidecreate_by_type:Rex::BindFailedimmediately ifInterfaceis combinedwith a proxy (incompatible by design)
setsockopt(SOL_SOCKET, SO_BINDTODEVICE, interface)after thenormal bind block, before
SO_BROADCAST— Linux only. Non-Linuxplatforms raise
Rex::BindFailed(macOSIP_BOUND_IFis not availablein the current Ruby stdlib and can be added as a follow-up)
spec/rex/socket/parameters_spec.rb— new#interfacedescribe blockspec/rex/socket/comm/local_spec.rb— newInterface optiondescribeblock covering proxy guard, invalid interface name, loopback success
(root-guarded), and non-Linux platform
Testing
Notes
IP_BOUND_IFavailability across Ruby versions is confirmed