DEV Community

Cover image for Ping, traceroute and Path MTU on Android without root or the NDK
Dmytro Tishchenko
Dmytro Tishchenko

Posted on

Ping, traceroute and Path MTU on Android without root or the NDK

When I started porting my iPhone network tools to Android, I assumed ping, traceroute and Path MTU were off the table. ICMP needs a raw socket, a raw socket needs root, and without root the options I could see were shelling out to /system/bin/ping or timing TCP connects and calling it ping.

Then a simple question stopped me: ping uses ICMP, which has no ports, so why was I planning around TCP ports for it? I had no good answer, so I went and checked:

$ adb shell cat /proc/sys/net/ipv4/ping_group_range
0   2147483647
Enter fullscreen mode Exit fullscreen mode

That range covers every group ID. On every Android device I've checked, that means any app can open an unprivileged ICMP socket, with no root and no NDK. This post is what I learned building ping, traceroute, MTR and Path MTU on top of that in plain Kotlin, and where it bites.

Ping sockets: ICMP without a raw socket

Linux has had "ping sockets" since 3.0: socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP). You send an ICMP echo request as the datagram payload, and the kernel delivers echo replies back to you. Which groups may open one is set by the sysctl net.ipv4.ping_group_range. The upstream kernel default, 1 0, lets nobody in. On the Android devices I've checked it is 0 2147483647, every group, and since each app runs under its own user ID, that means every app.

android.system.Os wraps enough syscalls to do it without a line of C:

val fd = Os.socket(OsConstants.AF_INET, OsConstants.SOCK_DGRAM, OsConstants.IPPROTO_ICMP)
Os.setsockoptTimeval(fd, OsConstants.SOL_SOCKET, OsConstants.SO_RCVTIMEO,
                     StructTimeval.fromMillis(timeoutMs))
Os.sendto(fd, packet, 0, packet.size, 0, address, 0)
val n = Os.recvfrom(fd, buf, 0, buf.size, 0, null)   // throws ErrnoException(EAGAIN) on timeout
Enter fullscreen mode Exit fullscreen mode

The packet is an ordinary 8-byte ICMP header plus payload. Two details differ from a raw socket:

  • The identifier is the kernel's, not yours. A ping socket gets its own identifier, much like a UDP socket gets a port, and the kernel writes it into every request. So I leave the field at zero and match replies on the sequence number.
  • Replies are filtered for you. The kernel only hands a socket the echo replies that carry its identifier. Two pings running at once never see each other's replies.

That second point is a real difference from iOS. On iPhone I use the same kind of SOCK_DGRAM ICMP socket, but Darwin delivered echo replies to other ICMP sockets in the same app as well. When I added a second pinger, every ping row in my app read 0 ms until I filtered by identifier myself. I wrote that one up in an earlier post. On Android the kernel does that filtering for me.

setsockoptTimeval arrived in API 29, and it's the reason my minSdk is 29: without a receive timeout, a lost reply blocks the thread forever. On older versions, Os.poll before recvfrom should do the same job; I didn't need to go below 29.

Traceroute: the hops arrive on the error queue

Traceroute sends echo requests with a growing TTL. Each router that drops a packet because its TTL ran out answers with ICMP Time Exceeded. With a raw socket you simply read those. With a ping socket you don't: Linux treats them as errors on your socket, and recvfrom() never returns the packet itself.

To see them, you turn on IP_RECVERR and read the socket's error queue with recvmsg(MSG_ERRQUEUE). Each queued error carries a sock_extended_err structure in a control message, followed by the address of the router that sent it.

android.system.Os has recvmsg, StructMsghdr and StructCmsghdr (API 31), but not the constants. IP_RECVERR and MSG_ERRQUEUE are missing from OsConstants, so I pass the raw Linux values:

const val IP_RECVERR = 11
const val MSG_ERRQUEUE = 0x2000

Os.setsockoptInt(fd, OsConstants.IPPROTO_IP, IP_RECVERR, 1)
Os.setsockoptInt(fd, OsConstants.IPPROTO_IP, OsConstants.IP_TTL, ttl)

// after sending, if no echo reply arrived:
val iov = arrayOf(ByteBuffer.allocate(1500))
val control = arrayOf(StructCmsghdr(OsConstants.IPPROTO_IP, IP_RECVERR, ByteArray(512)))
val msg = StructMsghdr(null, iov, control, 0)
Os.recvmsg(fd, msg, MSG_ERRQUEUE)   // throws ErrnoException(EAGAIN) if nothing is queued
for (cmsg in msg.msg_control ?: emptyArray()) {
    if (cmsg.cmsg_level != OsConstants.IPPROTO_IP || cmsg.cmsg_type != IP_RECVERR) continue
    val d = cmsg.cmsg_data ?: continue
    if (d.size < 24) continue
    if ((d[4].toInt() and 0xFF) != 2) continue          // ee_origin must be SO_EE_ORIGIN_ICMP
    val type = d[5].toInt() and 0xFF                     // ee_type: 11 Time Exceeded, 3 Unreachable
    if (type != 11 && type != 3) continue
    val hop = (0 until 4).joinToString(".") { (d[20 + it].toInt() and 0xFF).toString() }
    // hop = the router that answered
}
Enter fullscreen mode Exit fullscreen mode

The offsets come from sock_extended_err: origin at byte 4, ICMP type at 5, and the offender's sockaddr_in starting at byte 16, so its IPv4 address sits at bytes 20 to 23. The cmsg_level and ee_origin checks look like paranoia, but without them an unrelated control message or a local error would be read at the same offsets and could produce a plausible, completely fictitious hop.

The destination itself doesn't send an error; it answers with a normal echo reply. That's how the loop knows it has arrived. MTR is the same loop run over and over, with loss and latency kept per hop.

Path MTU: the don't-fragment bit, no raw socket needed

Path MTU is the largest packet that crosses the whole path without being fragmented. VPNs, PPPoE and some mobile networks make it smaller than 1500, and when the ICMP message that should report that gets filtered somewhere, you get the classic "some sites load, others hang" problem.

The method is to send echo requests with the don't-fragment bit set and binary-search the size. A ping socket can do that too:

const val IP_MTU_DISCOVER = 10
const val IP_PMTUDISC_DO = 2
Os.setsockoptInt(fd, OsConstants.IPPROTO_IP, IP_MTU_DISCOVER, IP_PMTUDISC_DO)
Enter fullscreen mode Exit fullscreen mode

With IP_PMTUDISC_DO the kernel sets DF on every packet and refuses to send anything larger than the MTU it knows about, failing sendto() with EMSGSIZE. So an echo that comes back means the size fits, and a send failure or a timeout means it doesn't. The sizes are whole IP packets, so the ICMP payload is the size minus 28 bytes of headers. The search runs from 552 to 1500 bytes, about ten probes.

One thing I couldn't do: read the kernel's own estimate back with getsockopt(IP_MTU) as a cross-check. android.system.Os has setsockoptInt but no getsockoptInt, only getsockoptTimeval. That is the one place where an NDK shim would buy something. The binary search stands on its own.

What bit me

1. My emulator couldn't ping the internet. In my setup, the emulator's default network carried TCP and UDP but not ICMP to external hosts, so even ping 1.1.1.1 from adb shell gave nothing useful. All my ICMP tools failed at once while every TCP tool worked, which looked exactly like a bug in my code. Pinging 10.0.2.2, the emulator's alias for the host machine, instead gave 0% loss, which showed the socket and the sequence matching were fine. Test ICMP on a real phone.

2. A documented API that isn't there. Os.recvmsg, StructMsghdr and StructCmsghdr are documented as API 31. On a physical device in Firebase Test Lab that reported a higher API level, traceroute crashed with NoClassDefFoundError: the class wasn't in that device's framework. So I probe once with Class.forName and cache the answer. And I learned the hard way that NoClassDefFoundError is an Error, not an Exception. My catch (e: Exception) let it straight through, so the app crashed instead of degrading gracefully. Below API 31, or when the probe fails, traceroute says plainly that it can only check whether the destination is reachable, rather than printing twenty * lines that look like a filtered network.

3. "All stars after hop 1" isn't always your code. One evening my traceroute showed the router at hop 1 and then * all the way, while the destination answered fine. A Mac on the same router, sending ICMP with ping -c 1 -m <ttl>, got Time Exceeded from most hops in the same minute, and twenty minutes later the phone matched the Mac hop for hop. My best guess: with a ping socket, the kernel matches an incoming error to your socket by the identifier inside the returned copy of your packet. If a NAT rewrites that identifier on the way out and doesn't map it back on errors, the error never reaches your queue, while the router's own Time Exceeded at hop 1 comes before any translation. macOS ping matches replies itself in user space, so it wouldn't be affected the same way. It doesn't explain why the problem went away twenty minutes later, so treat it as a hypothesis. But if you see the same pattern, test the same path from a laptop before you debug your parser.

Where this breaks

  • IPv4 only, in my implementation. Ping sockets exist for IPv6 too (IPPROTO_ICMPV6), but these tools resolve to an IPv4 address.
  • A filtered path looks like a dead one. If a network drops ICMP, Path MTU can't tell "too big" from "dropped", and traceroute shows *. That is a limit of ICMP itself, not of the socket type.
  • A vendor could close ping_group_range. I haven't met one that does, but if Os.socket fails with EACCES, that's the most likely reason, and the honest thing is to say so in the UI.

This is the ICMP core of the Android version of NetDiag+, my network diagnostics app (it's on Google Play and the App Store). If your Path MTU comes out below 1500, I keep a plain-language guide on where to change the MTU on common routers: https://netdiag.online/guides/router-mtu/

Has anyone met an Android build where ping_group_range is closed, or where StructCmsghdr is missing on a device that claims API 31 or higher? I'd like to know which vendors, so the app can say so instead of guessing.

Top comments (0)