← Back to all posts

Retrieving the ARP Table on Android SDK 30+ via Netlink

April 8, 2026 · by Alessandro Sangiorgi · 6 min read

Starting with Android 11 (API level 30), Google removed the ability for apps to run ip neigh or bind netlink sockets. This broke every app that relied on reading the ARP table — network scanners, device discovery tools, local network diagnostics. The change was intentional: Google argued that exposing the neighbor table leaks information about other devices on the local network, which is a privacy concern.

The problem is that there was no replacement API. Apps that needed neighbor discovery — for example, to find smart home devices, printers, or other hosts on the LAN — were simply out of luck. So I wrote a native library that retrieves the ARP table via RTNetlink without binding the socket, shipped it as an Android library, and open-sourced it.

Netlink is the Linux kernel’s IPC mechanism for communication between the kernel and user-space processes. It’s a socket-based interface (AF_NETLINK) that replaces older ioctl-based approaches for configuring networking, routing, and other kernel subsystems. Netlink sockets support multicast (subscribing to kernel events) and request-response patterns (dumping tables, modifying routes).

RTNetlink (NETLINK_ROUTE) is the most commonly used netlink family. It handles everything related to network configuration: routes, addresses, links, neighbors (ARP/NDP), traffic control. When you run ip neigh show, iproute2 opens an AF_NETLINK socket with the NETLINK_ROUTE protocol, sends an RTM_GETNEIGH message, and parses the kernel’s response.

The standard flow looks like this:

  1. Create a socket: socket(AF_NETLINK, SOCK_DGRAM, NETLINK_ROUTE)
  2. Bind it to a netlink address with your PID
  3. Send a dump request (NLM_F_REQUEST | NLM_F_DUMP)
  4. Receive and parse the response

Google’s restriction in SDK 30 targets step 2 — apps can no longer bind() a netlink socket. Without binding, the traditional flow fails.

The Workaround: Skip the Bind

The key insight is that bind() is not strictly required. You can send() on an unbound netlink socket and the kernel will still route the response back to you. The socket gets an implicit binding when you send the first message. This is the core of the workaround.

The implementation lives in a JNI (Java Native Interface) layer — C code compiled with the Android NDK and called from Java/Kotlin. The Java side opens a ParcelFileDescriptor pipe and passes the write end’s file descriptor down to native code. The native code does the netlink work and writes the results back through the pipe, which the Java side reads.

Creating the Socket and Sending the Request

struct sockaddr_nl saddr;
int s = socket(AF_NETLINK, SOCK_DGRAM, NETLINK_ROUTE);

memset(&saddr, 0, sizeof(saddr));
saddr.nl_family = AF_NETLINK;
saddr.nl_pid = getpid();
// No bind() call — this is the whole trick

The dump request asks the kernel for the neighbor table. The message type is RTM_GETNEIGH (value 30), and the flags include NLM_F_DUMP to request the full table:

int do_route_dump_request(int sock)
{
    struct {
        struct nlmsghdr nlh;
        struct rtmsg rtm;
    } nl_request;

    nl_request.nlh.nlmsg_type = 30;  // RTM_GETNEIGH
    nl_request.nlh.nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP;
    nl_request.nlh.nlmsg_pid = getpid();
    nl_request.nlh.nlmsg_len = sizeof(nl_request);
    nl_request.nlh.nlmsg_seq = 0;
    nl_request.rtm.rtm_family = AF_INET;

    return send(sock, &nl_request, sizeof(nl_request), 0);
}

The nlmsghdr is the standard netlink message header — every netlink message starts with one. It specifies the message type, flags, the sender’s PID, and the total message length. The rtmsg payload that follows tells the kernel we want IPv4 neighbors (AF_INET).

Receiving and Parsing the Response

The response arrives as a stream of nlmsghdr messages, each containing a neighbor entry. The receive logic uses a two-pass approach — first peek to get the total message size, then allocate and read:

static int rtnl_recvmsg(int fd, struct msghdr *msg, char **answer)
{
    struct iovec *iov = msg->msg_iov;
    char *buf;
    int len;

    iov->iov_base = NULL;
    iov->iov_len = 0;

    len = rtnl_receive(fd, msg, MSG_PEEK | MSG_TRUNC);
    if (len < 0)
        return len;

    buf = malloc(len);
    if (!buf)
        return -ENOMEM;

    iov->iov_base = buf;
    iov->iov_len = len;

    len = rtnl_receive(fd, msg, 0);
    if (len < 0) {
        free(buf);
        return len;
    }

    *answer = buf;
    return len;
}

MSG_PEEK | MSG_TRUNC is a useful trick: it returns the total message size without consuming the data, so we know exactly how much to malloc() before the real read.

Extracting Neighbor Entries

Each netlink message with type RTM_GETNEIGH (28 in the response) contains a ndmsg structure followed by route attributes. The parsing loop walks the message chain using the standard NLMSG_OK / NLMSG_NEXT macros and extracts the IP address, MAC address, interface name, and NUD (Neighbor Unreachability Detection) state:

while (NLMSG_OK(h, msglen)) {
    if (h->nlmsg_type == 28) {
        route_entry = (struct rtmsg *) NLMSG_DATA(h);
        route_attribute = (struct rtattr *) RTM_RTA(route_entry);

        if (route_attribute->rta_type == RTA_DST) {
            inet_ntop(AF_INET, RTA_DATA(route_attribute),
                destination_address, sizeof(destination_address));
        }

        inf_msg_ptr = (struct ifinfomsg *) NLMSG_DATA(h);
        rta_ptr = (struct rtattr *) IFLA_RTA(inf_msg_ptr);
        memcpy(mac_buf, RTA_DATA(rta_ptr), 10);
        rtmp = (struct ndmsg *) NLMSG_DATA(h);

        char ifname[1024];
        if_indextoname(inf_msg_ptr->ifi_index, ifname);

        // Output depends on NUD state
        switch (rtmp->ndm_state) {
        case NUD_REACHABLE:
            fprintf(fd, "%s dev %s lladdr %02x:%02x:%02x:%02x:%02x:%02x REACHABLE\n",
                    destination_address, ifname,
                    mac_buf[4], mac_buf[5], mac_buf[6],
                    mac_buf[7], mac_buf[8], mac_buf[9]);
            break;
        case NUD_STALE:
            // ... same format, STALE state
            break;
        case NUD_DELAY:
        case NUD_PROBE:
        case NUD_FAILED:
            // ... handle each state
            break;
        }
    }
    h = NLMSG_NEXT(h, msglen);
}

The NUD states map directly to what ip neigh shows: REACHABLE means the entry was recently confirmed, STALE means it hasn’t been used recently, DELAY and PROBE are transitional states during revalidation, and FAILED means resolution timed out. The output format intentionally mirrors ip neigh show so that existing parsing code works unchanged.

The output is written to the file descriptor passed from Java, making it available to the app through the ParcelFileDescriptor pipe.

SELinux Closes the Door

This workaround worked well for a while. But Google eventually patched it — not at the socket API level, but through SELinux policy.

Android’s SELinux policies were updated to deny nlmsg_read permissions on netlink_route_socket for untrusted app domains. This means that even if you manage to create and use a netlink socket without binding, the kernel’s SELinux hooks will block the RTM_GETNEIGH dump request at the security policy level. The send() succeeds but the response never comes — or the socket operation itself is denied with EACCES.

SELinux operates at a layer below the system call interface. There is no user-space workaround for a mandatory access control denial. The library stopped working on devices with updated SELinux policies, which rolled out progressively across Android 12 and later.

The Ubiquiti Story

Before I open-sourced the library, Ubiquiti reached out. They wanted to buy the workaround for their app WiFiMan, a popular network scanner that was affected by the same SDK 30 restriction. They offered to pay for a license.

I declined the offer. I preferred to release the code as open source under CC0-1.0 (public domain). Shortly after, Ubiquiti was able to implement their own solution independently.

Source Code

The library is available at github.com/fulvius31/ip-neigh-sdk30, licensed under CC0-1.0.

It’s no longer effective on modern Android due to the SELinux changes, but it documents a real technique for working with RTNetlink from Android native code and the cat-and-mouse game between app developers and platform restrictions. It was a fun workaround while it lasted.