Skip to content

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

Open
karandesai2005 wants to merge 5 commits intorapid7:masterfrom
karandesai2005:feature/interface-param-21114
Open

Add Interface socket option for binding to a network interface#80
karandesai2005 wants to merge 5 commits intorapid7:masterfrom
karandesai2005:feature/interface-param-21114

Conversation

@karandesai2005
Copy link
Copy Markdown

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

::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.

# Notify handlers of the before socket create event.
self.instance.notify_before_socket_create(self, param)

if param.interface && !param.interface.empty? && param.proxies?
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.

Would you mind please adding a comment here summarizing why we're doing this? We/I will surely forget in the future how we landed on this solution.

raise Rex::BindFailed.new(param.localhost, param.localport,
reason: reason), caller
end
param.localhost = iface_ip
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.

I think this approach may need to be modified ever so slightly. If we do this, then the param the caller passed us will be mutated and after this function has returned, the #localhost address the caller had originally set will be different. I think the easiest fix might be to update this to:

Suggested change
param.localhost = iface_ip
param = param.dup # avoid mutating the caller's instance
param.localhost = iface_ip

Alternatively you'd need to use a localhost local variable in this scope, defaulting to param.localhost and bind to that directly.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Waiting on Contributor

Development

Successfully merging this pull request may close these issues.

2 participants