Skip to content

Add rogue IPv6 RA + RDNSS DNS-takeover coercion (CVE-2026-20929) - #21725

Open
Pushpenderrathore wants to merge 11 commits into
rapid7:masterfrom
Pushpenderrathore:feature/ipv6-ra-dns-takeover
Open

Add rogue IPv6 RA + RDNSS DNS-takeover coercion (CVE-2026-20929)#21725
Pushpenderrathore wants to merge 11 commits into
rapid7:masterfrom
Pushpenderrathore:feature/ipv6-ra-dns-takeover

Conversation

@Pushpenderrathore

@Pushpenderrathore Pushpenderrathore commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Part of #21693.

Third of a stacked series adding native Kerberos authentication relay to Metasploit. #21709 contributes the relay stack and the ESC8 target, #21724 contributes the DHCPv6 coercion half, and this PR adds the Router Advertisement coercion half.

Stacked on #21724. This branch contains that PR's commits, so the diff here shows both until #21724 lands. The RA specific work is the top commits. Review #21724 first.

Description

auxiliary/spoof/ipv6/ipv6_ra_dns_takeover runs a rogue IPv6 router that advertises the attacker as the recursive DNS server via the RDNSS option (RFC 8106) inside Router Advertisements, paired with the same DNS poisoner used by the DHCPv6 module.

It is the Router Advertisement equivalent of the mitm6 style DHCPv6 takeover. Rather than answering DHCPv6 Solicits it multicasts RAs carrying an RDNSS option, which modern Windows and other RFC 8106 clients adopt as their IPv6 resolver. Two behaviours worth calling out:

  • It listens for Router Solicitations and replies with a unicast RA immediately, so a client is coerced when it boots or refreshes rather than waiting for the next unsolicited advertisement.
  • By default the RA advertises a router lifetime of 0, so it does not claim to be a default router. Routing is left alone and only DNS is taken over. That keeps the victim network functional and the footprint small.

Having both coercion paths matters because they fail in different places. DHCPv6 needs the client to actually run a DHCPv6 client, and RDNSS needs the client to honour RFC 8106. Between them the coverage across Windows versions and configurations is much better than either alone.

Related Issue: #21693

Breaking Changes

None.

Reviewer Notes

Shared code with #21724: Msf::Exploit::Remote::DNS::NamePoisoner holds the poison-under-a-domain plus forward-everything-else behaviour, and both modules use it. It was extracted in #21724 rather than duplicated here.

lib/msf/core/exploit/remote/ipv6.rb gains the RA and RDNSS construction used by this module.

Unit coverage is green, and the end to end coerce to relay to certificate chain has been validated in #21709 against a live domain. Correcting an earlier note: that validation does not need a two host lab with the CA separate from the KDC. The coercion introduces a new name rather than poisoning an existing one, so the victim keeps reaching the KDC and a single host is sufficient. This module has now also been validated against a live RFC 8106 Windows client: it adopted the rogue RDNSS advertisement (confirmed with netsh interface ipv6 show dnsservers), the module answered a triggered Router Solicitation with an immediate unicast RA, and the victim's default route was unchanged (router lifetime 0, so DNS is taken over while routing is left alone).

Verification Steps

    • bundle exec rspec spec/lib/rex/proto/dhcpv6 spec/lib/msf/core/exploit/remote/ipv6_spec.rb spec/modules/auxiliary/spoof and confirm no failures.
    • use auxiliary/spoof/ipv6/ipv6_ra_dns_takeover, set DOMAIN to a lab domain, run. Confirm the DNS server reports it is poisoning names under that domain.
    • From an RFC 8106 capable client on the same segment, confirm the attacker appears as an IPv6 DNS server after an RA is received (netsh interface ipv6 show dnsservers on Windows).
    • Send a Router Solicitation from the client, by disabling and re-enabling its interface, and confirm a unicast RA is answered immediately rather than after the next multicast interval.
    • Confirm the default route is unchanged, since router lifetime is 0 by default.

Test Evidence

$ bundle exec rspec spec/lib/rex/proto/dhcpv6 \
    spec/lib/msf/core/exploit/remote/ipv6_spec.rb \
    spec/modules/auxiliary/spoof
62 examples, 0 failures

$ ruby tools/dev/msftidy.rb modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover.rb
1 file inspected, no offenses detected

Environment

Field Details
Operating System macOS 15 (Darwin 25.5.0), Ruby 3.3.8
Target Software/Hardware RFC 8106 capable IPv6 client on the same segment

Pre-Submission Checklist

  • Included a corresponding documentation markdown file in documentation/modules (new modules only)
  • No sensitive information (IP addresses, credentials, API keys, hashes) in code or documentation
  • Tested on the target environment specified in the Environment section above (unit coverage green; RDNSS adoption, immediate RS reply, and unchanged default route all verified against a live RFC 8106 Windows client, see Reviewer Notes)
  • Included RSpec tests for library changes (encouraged for lib/ changes)
  • Read the CONTRIBUTING.md and module acceptance guidelines

@Pushpenderrathore

Copy link
Copy Markdown
Contributor Author

The one red check here (Verify / Ruby 3.4) is the framework-wide PacketFu bug tracked in #21721, not this change.

PacketFu 2.0.0's StructFu#typecast parses the setter name out of caller[0] with a regex that assumes the pre-3.4 backtrace format, so any packet construction raises undefined method '[]' for nil on Ruby 3.4 (PacketFu::IPv6Packet.new here, ARP in #21721). The Ruby 3.2 and 3.3 legs pass, and it reproduces on master. Root cause and a one-line fix are on #21721; this PR's IPv6 code is unaffected once that lands.

@Pushpenderrathore
Pushpenderrathore marked this pull request as ready for review August 14, 2026 15:55
@Pushpenderrathore
Pushpenderrathore force-pushed the feature/ipv6-ra-dns-takeover branch from 21bba62 to 51d212c Compare August 18, 2026 17:01
@jheysel-r7 jheysel-r7 self-assigned this Aug 18, 2026
@jheysel-r7 jheysel-r7 added the GSoC Google Summer of Code project PRs label Aug 18, 2026
@jheysel-r7
jheysel-r7 requested a lite review from Copilot August 19, 2026 20:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR (stacked on #21724) adds native IPv6 DNS-takeover coercion primitives for CVE-2026-20929 by introducing a rogue Router Advertisement (RDNSS/DNSSL) module, a rogue DHCPv6 module, and the supporting DHCPv6 + IPv6 RA packet-construction library code so Metasploit can coerce clients onto an attacker-controlled DNS server without external tooling.

Changes:

  • Adds auxiliary/spoof/ipv6/ipv6_ra_dns_takeover to coerce RFC 8106 clients via rogue RAs (RDNSS) and optionally reply immediately to Router Solicitations.
  • Adds native DHCPv6 parsing/server code (Rex::Proto::DHCPv6) and auxiliary/spoof/dhcp/dhcpv6_dns_takeover for mitm6-style coercion.
  • Extracts shared selective DNS poisoning/forwarding behavior into Msf::Exploit::Remote::DNS::NamePoisoner and expands the IPv6 mixin with RA/RDNSS/DNSSL builders, with RSpec coverage and module docs.

Impact Analysis:

  • Blast radius: medium — introduces new auxiliary modules plus new library/mixin APIs (Msf::Exploit::Remote::Ipv6, Msf::Exploit::Remote::DNS::NamePoisoner, Rex::Proto::DHCPv6) that can be reused by future modules.
  • Data and contract effects: adds new public methods/constants and a new autoload inflection (dhcpv6 -> DHCPv6); no schema/storage changes.
  • Rollback and test focus: rollback is straightforward (new files + small mixin/autoload additions); focus testing on RA packet validity (RDNSS/DNSSL encoding), DNS poisoning behavior (scope + forwarding), and DHCPv6 server multicast receive/reply behavior.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover.rb New rogue RA (RDNSS/DNSSL) coercion module with optional RS handling.
modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover.rb New rogue DHCPv6 DNS-takeover module using shared NamePoisoner behavior.
lib/msf/core/exploit/remote/ipv6.rb Adds RDNSS/DNSSL builders and RA packet construction + RS helpers.
lib/msf/core/exploit/remote/dns/name_poisoner.rb New shared selective DNS poisoning + forwarding mixin for coercion modules.
lib/rex/proto/dhcpv6/constants.rb DHCPv6 constants for the new Rex DHCPv6 implementation.
lib/rex/proto/dhcpv6/packet.rb DHCPv6 message/option encoding + response builder helpers.
lib/rex/proto/dhcpv6/server.rb Minimal rogue DHCPv6 server with multicast join and request/response loop.
lib/msf_autoload.rb Adds Zeitwerk inflection for DHCPv6.
spec/lib/msf/core/exploit/remote/ipv6_spec.rb Unit tests for RA/RDNSS/DNSSL packet construction and RS logic.
spec/lib/rex/proto/dhcpv6/packet_spec.rb Unit tests for DHCPv6 wire encoding and response construction.
spec/lib/rex/proto/dhcpv6/server_spec.rb Unit tests for DHCPv6 server request handling behavior.
spec/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover_spec.rb Module-level specs for RA takeover behavior and DNS poisoning integration.
spec/modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover_spec.rb Module-level specs for DHCPv6 takeover DNS poisoning integration.
documentation/modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover.md New module documentation for RA/RDNSS coercion workflow.
documentation/modules/auxiliary/spoof/dhcp/dhcpv6_dns_takeover.md New module documentation for DHCPv6 coercion workflow.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread lib/msf/core/exploit/remote/dns/name_poisoner.rb
Comment thread modules/auxiliary/spoof/ipv6/ipv6_ra_dns_takeover.rb
Comment thread lib/msf/core/exploit/remote/ipv6.rb
Pushpenderrathore added a commit to Pushpenderrathore/metasploit-framework that referenced this pull request Aug 20, 2026
name_poisoner.rb built the logged peer as a bare "#{host}:#{port}"
string, which is ambiguous for IPv6 clients (colon-separated) and
goes against the framework convention of using
Rex::Socket.to_authority, which brackets IPv6 hosts.

Copilot flagged this on rapid7#21725, which stacks on this branch and
shares this file; porting the same fix here so both branches carry
it independently of the eventual rebase.
@Pushpenderrathore

Copy link
Copy Markdown
Contributor Author

Live-lab finding: with defaults, the IPv6 coercion here never reaches the relay

Testing the full coerce-to-relay-to-cert chain against a live domain, I isolated why the same setup succeeds with one coercion name and silently fails with another. The cause is a bind-family mismatch between this coercion half and the relay half (#21709), not the resolver.

The relay listens IPv4-only. auxiliary/server/relay/esc8_kerberos binds SRVHOST, which defaults to 0.0.0.0 (lib/msf/core/exploit/remote/socket_server.rb:21, lib/msf/core/exploit/remote/smb/relay_server.rb:91). 0.0.0.0 is the IPv4 wildcard and cannot accept IPv6 connections.

This coercion hands the victim an AAAA. NamePoisoner#poison_answers_for answers AAAA with SPOOF_IP6, and by design returns no A record when the module SRVHOST is IPv6, so the poisoned name resolves to IPv6 only:

when 'AAAA'
  [Dnsruby::RR.create(name: "#{name}.", type: 'AAAA', address: datastore['SPOOF_IP6'])]
else
  # 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)] : []

So the victim connects to SPOOF_IP6 over IPv6, and the relay is not listening on IPv6. The connection never arrives.

Reproduced deterministically with a listener bound exactly like the relay (lsof confirmed IPv4 *:445, no [::]:445); victim is a domain-joined Windows client, attacker is the relay host:

victim connects to relay bind victim TCP state relay received it
attacker IPv4 literal 0.0.0.0 (IPv4-only) Established yes
attacker IPv6 literal 0.0.0.0 (IPv4-only) SynSent (hung) no
dual-stack name (A+AAAA) 0.0.0.0 (IPv4-only) System error 64 no
attacker IPv6 literal :: (dual-stack) Established yes

Row 3 is the tell: that name's A record points at the same IPv4 listener that accepts row 1, yet it fails, because Windows prefers the AAAA and does not fall back to the working A record. Row 4 is the fix.

Why one name worked and another did not: a hand-made A-only record (for example Add-DnsServerResourceRecordA) gives the victim IPv4, which the IPv4-only relay accepts. Any name carrying an AAAA, whether from this poisoner or a real host that auto-registered one, steers the victim to IPv6, which the relay never hears.

Fix (committed to the relay stack, #21709 as 7d32656): the relay module now defaults SRVHOST to :: so it listens dual-stack. On Linux and macOS :: also accepts IPv4, so A-record coercion keeps working; the module notes document that a Windows relay host binds :: IPv6-only and should set SRVHOST to the attacker IPv6 (SPOOF_IP6) there. This matches the convention this coercion module already follows: its own SRVHOST defaults to :: for the same reason. The doc transcript that showed Listening on 0.0.0.0:445 under an IPv6 takeover is corrected in the same commit.

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.
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.
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.
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.
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.
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.
- 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.
…inistically

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.
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.
@Pushpenderrathore
Pushpenderrathore force-pushed the feature/ipv6-ra-dns-takeover branch from f3d7743 to 1302e65 Compare August 31, 2026 16:14
Pushpenderrathore added a commit to Pushpenderrathore/metasploit-framework that referenced this pull request Aug 31, 2026
name_poisoner.rb built the logged peer as a bare "#{host}:#{port}"
string, which is ambiguous for IPv6 clients (colon-separated) and
goes against the framework convention of using
Rex::Socket.to_authority, which brackets IPv6 hosts.

Copilot flagged this on rapid7#21725, which stacks on this branch and
shares this file; porting the same fix here so both branches carry
it independently of the eventual rebase.
@Pushpenderrathore

Copy link
Copy Markdown
Contributor Author

Update after rebasing onto master: this branch now carries the Ruby 3.4 PacketFu fix directly, so all Verify Ruby 3.4 legs are green. The two remaining red checks are the mettle native-build flakes, unrelated to this change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

GSoC Google Summer of Code project PRs

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

3 participants