JohannesLks/CVE-2025-14558: How an IPv6 Router Advertisement Became a Shell Command
A FreeBSD rtsold proof of concept shows how a valid-looking DNS search list can cross from C parsing into shell execution, turning local network presence into code execution.
- This exploit is interesting because a standards-compliant IPv6 Router Advertisement can cross from network parsing into shell execution.
- The real bug is the trust boundary between FreeBSD's C-based router daemon and the shell-script machinery behind `resolvconf(8)`.
- The payload is carefully shaped to survive DNS label rules, which makes the attack look legitimate until the shell sees it.
- This PoC is less about brute force than about protocol literacy, packet precision, and legacy glue that was never meant to meet.
The surprising part of this PoC is not that a packet arrives. It is that a packet that looks like ordinary IPv6 housekeeping can survive several layers of parsing and still reach a shell expansion point. That is the whole story in one sentence: legitimacy is the delivery vehicle.
The repository is tiny by design. It centers on one Python script, `exploit.py`, plus enough documentation to explain the target, the packet shape, and the execution path. That minimalism matters, because it makes the exploit chain easier to study than a sprawling framework would.
The packet that should have been harmless
The attack starts with a Router Advertisement carrying a DNSSL option. On paper, that is routine IPv6 neighbor discovery behavior. In practice, it is a carefully chosen trust path: `rtsold` reads the option, then passes the result to `resolvconf(8)`, where shell semantics take over.
The key weakness is not memory corruption. It is impedance mismatch. A C parser handles structured network data, then a shell script consumes that data as text. If the boundary is not quoted and sanitized correctly, syntax can cross the line as payload.
How the payload survives DNS rules
The exploit script does not smash its way through DNS constraints. It respects them. The payload is encoded in wire format, then split into labels so it can pass basic validation before the dangerous characters reappear at the shell layer.
def encode_domain(domain):
parts = domain.split('.')
out = b''
for part in parts:
out += bytes([len(part)]) + part.encode()
return out + b'\x00'
def encode_payload(cmd):
wrapped = f'$( {cmd} )'
labels = []
while wrapped:
labels.append(wrapped[:63])
wrapped = wrapped[63:]
return encode_domain('.'.join(labels))
That label splitting is the quiet cleverness in the PoC. DNS has rules, and the script uses those rules as camouflage. The payload stays within the protocol long enough to become somebody else's problem.
Assembling the death packet
The rest of the script is a packet composer. It builds the DNSSL option, then wraps it in an ICMPv6 Router Advertisement inside an Ethernet frame aimed at the multicast address that nearby IPv6 listeners will notice. The result is not a scan, but a precise delivery mechanism.
def build_dnssl(payload):
# Type 31 DNSSL option, padded to 8-byte alignment.
return struct.pack('!BBH', 31, length_units, lifetime) + payload + padding
def build_ra(dnssl):
eth = Ether(dst='33:33:00:00:00:01')
ipv6 = IPv6(dst='ff02::1')
ra = ICMPv6ND_RA()
return eth / ipv6 / ra / dnssl
This is why the exploit reads as polished rather than noisy. It is standards-aware. It knows the label rules, the alignment rules, and the neighbor discovery shape the target expects to see.
Why this is a Layer 2 problem before it is a CVE problem
| Mental model | Classic exploit | This PoC |
|---|---|---|
| Input shape | Malformed bytes | Standards-compliant IPv6 control traffic |
| Failure mode | Crash or memory corruption | Shell command execution |
| Required position | Internet reachability | Same-segment Layer 2 adjacency |
| Defender blind spot | Application logs | Local network trust assumptions |
| Signal | Loud and irregular | Quiet and protocol-shaped |
That adjacency requirement is the operational lesson. A Wi-Fi neighbor, an Ethernet neighbor, or a VLAN peer can be enough. If IPv6 discovery traffic is treated as harmless housekeeping, the attack surface stays hidden until it matters.
What this PoC gets right
The repository is sparse, but that is part of its value. It does one thing cleanly: encode the payload, build a valid packet, and transmit it repeatedly. There is no accidental complexity to distract from the exploit chain itself.
As a study object, that makes it better than a larger toolkit would be. You can read the code in one sitting and see the attack boundary clearly: packet construction on one side, shell execution on the other.
What the exploit is really teaching
The broader lesson is not specific to FreeBSD. Any system that moves data from one trust domain to another without rethinking the representation is vulnerable to the same class of mistake. Structure is not a permanent property. It can be lost at a handoff.
That is why this PoC is memorable. It does not depend on exotic memory tricks. It depends on a simple mistake that still survives in modern systems: treating data as safe because it was safe somewhere else.