Skip to content

Add Interface socket option for binding to a network interface#80

Merged
smcintyre-r7 merged 8 commits intorapid7:masterfrom
karandesai2005:feature/interface-param-21114
Apr 21, 2026
Merged

Add Interface socket option for binding to a network interface#80
smcintyre-r7 merged 8 commits intorapid7:masterfrom
karandesai2005:feature/interface-param-21114

Conversation

@karandesai2005
Copy link
Copy Markdown
Contributor

Summary

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. 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 — adds interface attribute, parsed from
    the 'Interface' hash key, whitespace-stripped, nil by default
  • lib/rex/socket/comm/local.rb — two additions inside create_by_type:
    1. Guard: raises Rex::BindFailed immediately if Interface is combined
      with a proxy (incompatible by design)
    2. Calls setsockopt(SOL_SOCKET, SO_BINDTODEVICE, interface) after the
      normal bind block, before SO_BROADCAST — Linux only. Non-Linux
      platforms raise Rex::BindFailed (macOS IP_BOUND_IF is not available
      in the current Ruby stdlib and can be added as a follow-up)
  • spec/rex/socket/parameters_spec.rb — new #interface describe block
  • spec/rex/socket/comm/local_spec.rb — new Interface option describe
    block covering proxy guard, invalid interface name, loopback success
    (root-guarded), and non-Linux platform

Testing

bundle exec rspec spec/ --format documentation
# 178 examples, 0 failures, 3 pending (root-only / platform-specific skips)

Notes

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.
@karandesai2005
Copy link
Copy Markdown
Contributor Author

I've pushed an update addressing both points:

  • Added descriptive reason: messages to all Rex::BindFailed raises so failures are clearer (proxy conflict, invalid interface, permissions, unsupported platform)
  • Added a macOS branch using IP_BOUND_IF, guarded by defined?(::Socket::IP_BOUND_IF)

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
@karandesai2005
Copy link
Copy Markdown
Contributor Author

pushed an update addressing your feedback:

  • Added descriptive reason: messages to all Rex::BindFailed raises
    (proxy conflict, interface not found, insufficient permissions, unsupported platform)
  • Explicitly handle Windows as unsupported with a clear error message
  • Retained macOS support using IP_BOUND_IF, guarded with defined?

Also re-ran the full test suite with elevated permissions:

  • 178 examples, 0 failures (1 pending due to platform-specific skip)

@karandesai2005
Copy link
Copy Markdown
Contributor Author

@zeroSteiner following up on our Slack discussion — pushed the
Windows implementation as agreed.

Platform matrix is now complete:

Platform Mechanism Root required
Linux SO_BINDTODEVICE Yes
macOS IP_BOUND_IF via getifaddrs + ifindex No
Windows resolve interface name → IP via getifaddrs, override param.localhost No

Also did a hardening pass:

  • Replaced Socket.if_nametoindex with getifaddrs + ifindex since
    the former isn't available in all Ruby builds
  • Added rescue SystemCallError to prevent socket leaks on unexpected errors
  • Windows error messages now distinguish between interface not found vs
    interface exists but has no IPv4 address

Manually verified on Linux (all three cases pass), Windows approach
confirmed working via your test results.

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
@karandesai2005 karandesai2005 force-pushed the feature/interface-param-21114 branch from 549a3a5 to 125e231 Compare April 1, 2026 08:28
@smcintyre-r7 smcintyre-r7 self-assigned this Apr 1, 2026
@smcintyre-r7 smcintyre-r7 moved this from Todo to In Progress in Metasploit Kanban Apr 1, 2026
Comment thread lib/rex/socket/parameters.rb Outdated
self.timeout = hash['Timeout'].to_i
end

self.interface = hash['Interface'].to_s.strip if hash['Interface']
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
self.interface = hash['Interface'].to_s.strip if hash['Interface']
self.interface = hash['Interface'].to_s.strip if hash['Interface'] && !hash['Interface'].strip.empty?

Comment thread lib/rex/socket/comm/local.rb Outdated
::Socket.getifaddrs.each do |ifaddr|
next unless ifaddr.name == param.interface
iface_found = true
next unless ifaddr.addr&.ipv4?
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parameters has a #v6 attribute

# 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
# other is either nil, a hostname or an IPv6 address, then use IPv6
self.v6 = (Rex::Socket.is_ipv6?(self.localhost) || Rex::Socket.is_ipv6?(self.peerhost)) && \
(self.localhost.nil? || !Rex::Socket.is_ipv4?(self.localhost)) && \
(self.peerhost.nil? || !Rex::Socket.is_ipv4?(self.peerhost))
else
self.v6 = hash['IPv6']
end

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.

Comment thread lib/rex/socket/comm/local.rb
Comment thread lib/rex/socket/comm/local.rb
@github-project-automation github-project-automation Bot moved this from In Progress to Waiting on Contributor in Metasploit Kanban Apr 1, 2026
- 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
@karandesai2005
Copy link
Copy Markdown
Contributor Author

@smcintyre-r7 addressed all four points:

  1. Interface param now guards against whitespace-only strings
  2. Added comment explaining the proxy guard decision
  3. Added param.dup before mutating localhost
  4. Windows getifaddrs loop now selects IPv6 when param.v6
    is true, IPv4 otherwise — error message updated to match

178 tests, 0 failures.

Copy link
Copy Markdown
Contributor

@smcintyre-r7 smcintyre-r7 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found a bug while testing, .is_osx should be .is_macosx.

Comment thread lib/rex/socket/comm/local.rb Outdated
Co-authored-by: Spencer McIntyre <58950994+smcintyre-r7@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

@karandesai2005 karandesai2005 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

Comment thread lib/rex/socket/comm/local.rb Outdated
sock.close
raise
end
elsif Rex::Compat.is_macosx && defined?(::Socket::IP_BOUND_IF)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Contributor Author

@karandesai2005 karandesai2005 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@jheysel-r7
Copy link
Copy Markdown
Contributor

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.

macOS

>> sock = Rex::Socket::Tcp.create('Port'=>4321, 'Server' => true, 'Interface' => 'en0')
=> #<Socket:fd 12>
➜  rex-socket git:(5787730) netstat -anvp tcp | grep 4321
➜  rex-socket git:(5787730)

Ubuntu

>> sock = Rex::Socket::Tcp.create('Port'=>4321, 'Server' => true, 'Interface' => 'ens33')
=> #<Socket:fd 10>
(devbox) ➜  rex-socket git:(5787730) sudo netstat -tulpen | grep 4321
[sudo] password for msfuser:
(devbox) ➜  rex-socket git:(5787730) nc 172.16.199.159 4321
(devbox) ➜  rex-socket git:(5787730)

I'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)
Copy link
Copy Markdown
Contributor Author

@karandesai2005 karandesai2005 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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?

Image

@jheysel-r7
Copy link
Copy Markdown
Contributor

Hey @karandesai2005 - thanks for the update!

I got it working on macOS. I kept trying to create the socket with the following command:

sock = Rex::Socket::Tcp.create('Port'=>4321, 'Server' => true, 'Interface' => 'en0')

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 LocalPort instead (seems quite obvious now).

sock = Rex::Socket::Tcp.create('LocalPort' => 4321, 'Server' => true, 'Interface' => 'en0')

The above works great:

➜  rex-socket git:(5787730) lsof -nP -iTCP:4321 -sTCP:LISTEN
COMMAND   PID    USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
ruby    77664 jheysel   13u  IPv4 0xf30537f3ab36b039      0t0  TCP *:4321 (LISTEN)

@karandesai2005
Copy link
Copy Markdown
Contributor Author

@jheysel-r7 Awesome, thanks for confirming macOS works! 🎉

Great catch on LocalPort vs Port — that's a helpful clarification for anyone using this API. I'll make a note of that for future docs/examples.

Since this is now verified on:
✅ Linux (Arch, wlo1) — ss shows 0.0.0.0%wlo1:4321
✅ macOS (Sequoia, en0) — lsof shows TCP *:4321 (LISTEN)

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! 🙏

Copy link
Copy Markdown
Contributor

@smcintyre-r7 smcintyre-r7 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Retested the Linux path and everything is looking good now. I can see it raises on a bad interface name, and then I can force it to bind based on the interface name.

Image

@github-project-automation github-project-automation Bot moved this from Waiting on Contributor to In Progress in Metasploit Kanban Apr 21, 2026
@smcintyre-r7 smcintyre-r7 merged commit a852819 into rapid7:master Apr 21, 2026
21 checks passed
@github-project-automation github-project-automation Bot moved this from In Progress to Done in Metasploit Kanban Apr 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants