Fix LHOST validation rejecting tunnel hostnames when DNS lookup fails#21552
Fix LHOST validation rejecting tunnel hostnames when DNS lookup fails#21552stzifkas wants to merge 14 commits into
Conversation
|
I'm pretty sure this is a regression I added with the work I did in #20989. I understand that payloads need a way to connect to a hostname, but having I think what we need here is possibly a new option that permits the hostname resolution to be deferred. It shouldn't be |
|
@smcintyre-r7 does this change make sense as is as a first step and we can create an issue to address the root issue more in depth? or are there specific issues you're concerned this will introduce that need to be addressed first? Just thinking since this addresses a real problem now we could link the issue in a comment by the change while we work on a more holistic fix |
|
If that ticket explicitly called out reverting the changes contained herein as part of the work and we pulled it into the next sprint then sure. Ideally though, we'd just fix it correctly the first time so it's up to @stzifkas on if they're up for the additional work that's necessary. |
1fe205f to
12708c2
Compare
|
@smcintyre-r7 , @dwelch-r7 thanks for the feedback. Went ahead with the architectural approach you outlined. Here's what changed:
|
| # resolving LHOST (LHOST may be a tunnel hostname not locally resolvable). | ||
| bind_addr = datastore['ReverseListenerBindAddress'] | ||
| any = Rex::Socket.is_ipv6?(bind_addr) ? "::0" : "0.0.0.0" | ||
| return [ (bind_addr == "0.0.0.0" || bind_addr == "::0") ? any : bind_addr ] |
There was a problem hiding this comment.
These checks for the any address should always normalize with addr_atoi then compare to zero. I know it's a preexisting issue but we shouldn't propagate it. The reason is actually apparent here, :: is typically the bind-any IPv6 address not ::0, but they're equivalent since per the RFC, the 0s can be omitted.
This comparison should always be:
Rex::Socket.addr_atoi(bind_addr) == 0 not bind_addr == "0.0.0.0" || bind_addr == "::0"). This is not the only location that needs to be updated with the correct pattern.
There was a problem hiding this comment.
Done, using Rex::Socket.addr_atoi(bind_addr) == 0 throughout.
| addr = Rex::Socket.resolv_nbo(datastore['LHOST']) | ||
| any = (addr.length == 4) ? "0.0.0.0" : "::0" |
There was a problem hiding this comment.
Use Rex::Socket.is_ipv4? not Rex::Socket.resolv_nbo(datastore['LHOST']).length == 4
There was a problem hiding this comment.
Using Rex::Socket.is_ipv4? now.
| # syntactically valid hostname. Unlike OptAddress, DNS resolution is NOT | ||
| # performed during validation, so tunnel service hostnames (ngrok, pinggy, | ||
| # etc.) that are not resolvable from the local machine are accepted. |
There was a problem hiding this comment.
This would do well to allow an option like resolve_names: false and allow the caller to specify whether or not we should be resolving hostnames to IP addresses as part of the validation process. If we're performing a connection to the value or binding to it, that would allow us to fail earlier which would be extremely helpful.
There was a problem hiding this comment.
resolve_names: is already there, defaults to false.
|
Addressed in the latest push
|
| register_options( | ||
| [ | ||
| OptAddressLocal.new('LHOST', [true, 'The local listener hostname']), | ||
| OptAddressOrHostname.new('LHOST', [true, 'The local listener hostname']), |
There was a problem hiding this comment.
For R7 reviewers: Going forward, we might look into modifying the option name here.
| # @return [OptAddressOrHostname] | ||
| def self.LHOST(default=nil, required=true, desc="The listen address (an interface may be specified)") | ||
| Msf::OptAddressLocal.new(__method__.to_s, [ required, desc, default ]) | ||
| Msf::OptAddressOrHostname.new(__method__.to_s, [ required, desc, default ]) |
There was a problem hiding this comment.
I think we don't want to change the default type for this option across all of MSF.
There was a problem hiding this comment.
Reverted. Opt::LHOST is back to OptAddress. OptAddressOrHostname is only applied in the Reverse and ReverseUdp handlers.
| # a locally-resolvable address. | ||
| # | ||
| ### | ||
| class OptAddressOrHostname < OptBase |
There was a problem hiding this comment.
Can we deduplicate the validation and normalisation here and other options? Modifying the validation logic here to e.g. fix a bug, means other options don't get the same fix applied.
There was a problem hiding this comment.
Now inherits from OptAddressRoutable. Duplicate interfaces, normalize_interface, and normalize_ip methods removed.
| private | ||
|
|
||
| def normalize_interface(value) | ||
| addrs = NetworkInterface.addresses(value).values.flatten |
There was a problem hiding this comment.
This looks to be copy-pasted from opt_address_routable. Can we do any code deduplication here?
There was a problem hiding this comment.
Removed, inherited from OptAddressRoutable now.
| end | ||
| end | ||
| end | ||
|
|
There was a problem hiding this comment.
Looks like we can restore this change
| { value: 5 }, | ||
| { value: [] }, | ||
| { value: [1, 2] }, | ||
| { value: {} }, |
There was a problem hiding this comment.
Can we test nil as well?
There was a problem hiding this comment.
Added nil to the invalid values list.
There was a problem hiding this comment.
Removed nil from invalid_values - OptBase defaults required to false when attrs is empty, so nil is valid for that subject. The shared example already covers nil for both required and non-required cases.
| # XXX: We repurpose OptAddressLocal#interfaces, so we can't put this in Rex | ||
| def tab_complete_source_interface(o) | ||
| return [] unless o.is_a?(Msf::OptAddressLocal) | ||
| return [] unless o.respond_to?(:interfaces) |
There was a problem hiding this comment.
We need to confirm if this change makes sense; or should we only tab complete when the option is an OptAddressLocal explicitly
There was a problem hiding this comment.
Changed to is_a?(Msf::OptAddressRoutable) so it covers both OptAddressLocal and OptAddressOrHostname explicitly.
| context 'with tunnel hostnames' do | ||
| it 'accepts without requiring DNS resolution' do | ||
| allow(::Rex::Socket).to receive(:getaddress).and_raise(::SocketError) | ||
| expect(opt.valid?('qhuoq-106-219-171-165.run.pinggy-free.link')).to be_truthy |
There was a problem hiding this comment.
Can we use any placeholder addresses here such as example.com as opposed to what looks like a real live link?
There was a problem hiding this comment.
Replaced with tunnel.example.com throughout the spec.
| # a locally-resolvable address. | ||
| # | ||
| ### | ||
| class OptAddressOrHostname < OptBase |
There was a problem hiding this comment.
Could we either:
Inherit from OptAddress
or
Have the OptAddress as a member variable
in an effort to deduplicate?
There was a problem hiding this comment.
Went with inheriting from OptAddressRoutable (which itself extends OptAddress). Deduplicates interfaces, normalize_interface, and normalize_ip_address.
|
Reverted |
|
@stzifkas I'm gonna be looking at this PR shortly but just to let you know you've got some failing tests that will need to be addressed |
…on in bind_addresses
01061b6 to
8bb40b3
Compare
|
Rebased on master. The python 3.8 macos build failure is pre-existing and unrelated to this PR. |
There was a problem hiding this comment.
Pull request overview
This PR introduces a new option type to allow LHOST values that are syntactically valid hostnames even when they don’t resolve locally (common with tunnel providers), and updates reverse handlers to better separate “callback host” (LHOST) from “bind address” (ReverseListenerBindAddress).
Changes:
- Added
Msf::OptAddressOrHostname(accepts IP/interface/hostname without DNS by default, with an opt-in DNS resolution mode). - Updated reverse handlers to use the new option and to avoid resolving
LHOSTwhenReverseListenerBindAddressis explicitly set. - Added RSpec coverage for the new option behavior (including tunnel-style hostnames) and updated interface tab completion to work with the new option’s superclass.
Impact Analysis:
- Blast radius: medium; affects option validation for reverse handlers (and potentially any code using
Opt::LHOST), impacting payload generation (msfvenom) and listener startup paths. - Data and contract effects: option validation semantics change for
LHOST(hostname acceptance and when DNS is consulted); handler binding behavior changes whenReverseListenerBindAddressis set. - Rollback and test focus: validate
msfvenomgeneration for staged and single reverse payloads, and validate listener bind behavior for IPv4/IPv6 + interface-nameLHOSTwith/withoutReverseListenerBindAddress.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| spec/lib/msf/core/opt_address_or_hostname_spec.rb | Adds specs covering tunnel hostname validation and normalization behaviors. |
| lib/msf/ui/console/module_option_tab_completion.rb | Broadens interface-name tab completion to OptAddressRoutable-derived options. |
| lib/msf/core/option_container.rb | Autoloads the new OptAddressOrHostname option type. |
| lib/msf/core/opt.rb | Changes the framework Opt::LHOST shortcut implementation. |
| lib/msf/core/opt_address_or_hostname.rb | Introduces the new option class implementing hostname-without-DNS validation. |
| lib/msf/core/handler/reverse.rb | Uses the new option for LHOST and adjusts bind address selection logic. |
| lib/msf/core/handler/reverse_udp.rb | Uses the new option for LHOST and adjusts bind address selection logic. |
| lib/msf/core/handler/reverse_http.rb | Switches LHOST option type to accept non-resolving hostnames. |
| # @return [OptAddress] | ||
| def self.LHOST(default=nil, required=true, desc="The listen address (an interface may be specified)") | ||
| Msf::OptAddressLocal.new(__method__.to_s, [ required, desc, default ]) | ||
| Msf::OptAddress.new(__method__.to_s, [ required, desc, default ]) | ||
| end |
There was a problem hiding this comment.
Fixed - now calls for IP addresses, so they go through OptAddressRoutable's multicast/reserved/broadcast checks. Wildcard IPs (0.0.0.0, 0::0) are now invalid for this option.
| if value =~ /^\d+(\.\d+)+$/ | ||
| return Rex::Socket.is_ipv4?(value) | ||
| end | ||
|
|
||
| return true if Rex::Socket.is_ip_addr?(value) | ||
|
|
||
| if Rex::Socket.is_name?(value) | ||
| return true unless @resolve_names | ||
| begin | ||
| ::Rex::Socket.getaddress(value, true) | ||
| return true | ||
| rescue | ||
| return false | ||
| end | ||
| end |
There was a problem hiding this comment.
I think you just need to call super to address this, copilot is a bit wordy, but will need to verify that's all that's needed
There was a problem hiding this comment.
Done. IP addresses now call super so they go through OptAddressRoutable for the multicast/reserved/broadcast checks. 0.0.0.0 and 0::0 are now invalid as a side effect (removed from the spec valid_values, added to invalid_values).
| # -*- coding: binary -*- | ||
| require 'network_interface' | ||
|
|
| # @return [OptAddressLocal] | ||
| # @return [OptAddress] | ||
| def self.LHOST(default=nil, required=true, desc="The listen address (an interface may be specified)") | ||
| Msf::OptAddressLocal.new(__method__.to_s, [ required, desc, default ]) | ||
| Msf::OptAddress.new(__method__.to_s, [ required, desc, default ]) |
There was a problem hiding this comment.
I think we want to revert this change, if LHOST needs to be something else it should be up to the individual modules to override that like like you're doing elsewhere in the PR
There was a problem hiding this comment.
Already done - Opt::LHOST is back to OptAddress. OptAddressOrHostname is only on the reverse handlers.
There was a problem hiding this comment.
This has still changed? I think it needs to be reverted back to OptAddressLocal
| if value =~ /^\d+(\.\d+)+$/ | ||
| return Rex::Socket.is_ipv4?(value) | ||
| end | ||
|
|
||
| return true if Rex::Socket.is_ip_addr?(value) | ||
|
|
||
| if Rex::Socket.is_name?(value) | ||
| return true unless @resolve_names | ||
| begin | ||
| ::Rex::Socket.getaddress(value, true) | ||
| return true | ||
| rescue | ||
| return false | ||
| end | ||
| end |
There was a problem hiding this comment.
I think you just need to call super to address this, copilot is a bit wordy, but will need to verify that's all that's needed
|
|
||
| # No ReverseListenerBindAddress set — resolve LHOST to determine the | ||
| # bind address and whether to use IPv4 or IPv6 ANY as fallback. | ||
| addr_nbo = Rex::Socket.resolv_nbo(datastore['LHOST']) |
There was a problem hiding this comment.
Won't this hit the same issue if LHOST is a domain name?
There was a problem hiding this comment.
Good catch. Added a rescue around the resolv_nbo call in both reverse.rb and reverse_udp.rb - if LHOST is a hostname that cannot be resolved locally, it falls back to binding on 0.0.0.0 and prints a warning suggesting ReverseListenerBindAddress.
There was a problem hiding this comment.
I think we're missing tests for when resolve_names is true
There was a problem hiding this comment.
Added - two tests: one where getaddress succeeds (hostname accepted) and one where it raises SocketError (hostname rejected).
| # Switch to IPv6 ANY address if the LHOST is also IPv6 | ||
| addr = Rex::Socket.resolv_nbo(datastore['LHOST']) | ||
| # First attempt to bind LHOST. If that fails, the user probably has | ||
| # something else listening on that interface. Try again with ANY_ADDR. | ||
| any = (addr.length == 4) ? "0.0.0.0" : "::0" | ||
|
|
||
| addrs = [ Rex::Socket.addr_ntoa(addr), any ] | ||
|
|
||
| if not datastore['ReverseListenerBindAddress'].to_s.empty? | ||
| # Only try to bind to this specific interface | ||
| addrs = [ datastore['ReverseListenerBindAddress'] ] | ||
|
|
||
| # Pick the right "any" address if either wildcard is used | ||
| addrs[0] = any if (addrs[0] == "0.0.0.0" or addrs == "::0") | ||
| bind_addr = datastore['ReverseListenerBindAddress'] | ||
| any = Rex::Socket.is_ipv6?(bind_addr) ? "::0" : "0.0.0.0" | ||
| return [ Rex::Socket.addr_atoi(bind_addr) == 0 ? any : bind_addr ] | ||
| end | ||
|
|
||
| addrs | ||
| addr_nbo = Rex::Socket.resolv_nbo(datastore['LHOST']) | ||
| addr = Rex::Socket.addr_ntoa(addr_nbo) | ||
| any = Rex::Socket.is_ipv4?(addr) ? "0.0.0.0" : "::0" |
There was a problem hiding this comment.
This is missing some of the functionality of the bind_addresses method, namely missing is_loopback_address?
There was a problem hiding this comment.
Added - bind_address now warns if LHOST resolves to a loopback address. ReverseUdp does not include the Reverse module so is_loopback_address? is not available there; inlined the equivalent check.
| # Reject anything that looks like a dotted-decimal number sequence | ||
| # (e.g. "192.0.2", "192.0.2.0.0") - Rex::Socket.is_name? accepts numeric | ||
| # DNS labels, so these would pass without this guard. Require it to be a | ||
| # valid IPv4 address, then delegate to parent for routable checks. | ||
| if value =~ /^\d+(\.\d+)+$/ | ||
| return false unless Rex::Socket.is_ipv4?(value) | ||
| return super | ||
| end | ||
|
|
||
| return super if Rex::Socket.is_ip_addr?(value) |
There was a problem hiding this comment.
| # Reject anything that looks like a dotted-decimal number sequence | |
| # (e.g. "192.0.2", "192.0.2.0.0") - Rex::Socket.is_name? accepts numeric | |
| # DNS labels, so these would pass without this guard. Require it to be a | |
| # valid IPv4 address, then delegate to parent for routable checks. | |
| if value =~ /^\d+(\.\d+)+$/ | |
| return false unless Rex::Socket.is_ipv4?(value) | |
| return super | |
| end | |
| return super if Rex::Socket.is_ip_addr?(value) | |
| return super if Rex::Socket.is_ip_addr?(value) |
I think most of this is redundant
There was a problem hiding this comment.
Done, removed the regex block. Also dropped 192.0.2 / 192.0.2.0.0 / 1.2.3.4.5 from invalid_values in the spec since those now pass as hostnames.
|
@stzifkas seems like we have another test failure here, I think we're close to getting this landed though once that's passing I can give it one more manual test and we should be good to go |
Summary
OptAddress#valid?was catching all DNS lookup failures and immediately returningfalseRex::Socket.is_name?for hostname syntax validation — syntactically valid hostnames are accepted, garbage is still rejectedVerification steps
Reproduce with the installed framework before the fix:
After the fix, the same command generates the APK successfully.
IPs, invalid hostnames, and garbage strings still fail validation as expected.
Related issues
See #21539