Getting the MAC Address via netlink and Inline svc — Bypassing Android Permissions

About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.

This research is published for educational and defensive purposes. The target sample is anonymized throughout.


Getting the MAC Address via netlink and Inline svc

Why this approach

Device-fingerprint collection on Android has exploded into a zoo of techniques. The earliest were the system_property_get / system_property_find / system_property_read calls that hit the native layer directly. Later came the advanced moves: reading boot_id via inline svc, or reflecting the mValues map in memory to extract the Android ID (which requires bypassing the reflection restrictions). All decent fingerprint sources.

What I’m covering here is a different one: getting the device’s network-card MAC address by talking to the kernel over netlink. This bypasses Android’s permission model entirely — you can pull the NIC info even when the app has no granted permissions. Because it’s very hard to mock, a lot of the big apps use exactly this method.

I’ve extended the standard approach: when reading the kernel’s reply, I process the incoming packets with an inline svc rather than calling recv/recvfrom/recvmsg directly. That hardens the data path — it stops someone from inline-hooking recv and swapping the reply before you see it. In theory this defeats 99% of device-spoofing (“改机”) tools.

  • Netlink is Linux’s channel between kernel and user-space processes.
    • It’s mainly for user↔kernel communication, but it can also talk user↔user.
    • User↔user IPC has plenty of other options, so netlink is rarely used there — except when you want its broadcast feature.
    • Netlink is a special socket, Linux-only. Messages sit in the socket’s receive buffer and aren’t processed immediately by the receiver, so netlink is asynchronous — syscalls and ioctl are synchronous.

There are three classic user↔kernel channels:

  • proc
  • ioctl
  • netlink

The first two are one-way; netlink is full-duplex.

Netlink runs on BSD sockets with the AF_NETLINK address family. It addresses peers with a 32-bit port number (historically called a PID). Each netlink protocol (or “bus”, or “netlink family” in the man pages) is usually tied to one kernel service/component — NETLINK_ROUTE gets and sets routing/link info, NETLINK_KOBJECT_UEVENT sends notifications from the kernel to the udev daemon, and so on.

  1. Full-duplex, asynchronous communication.
  2. User space uses the standard BSD socket API (though netlink doesn’t hide protocol-packet construction/parsing — a third-party lib like libnl is recommended).
  3. The kernel side uses a dedicated kernel API.
  4. Multicast support (hence “bus”-style communication and message subscription).
  5. On the kernel side, usable from both process context and interrupt context.
  • It’s simple — add one protocol definition in include/linux/netlink.h (e.g. #define NETLINK_TEST 20) and both kernel and user space can immediately exchange data over that type via the socket API.
  • It’s asynchronous: messages between kernel and user space sit in a socket queue, so sending just enqueues to the receiver’s queue — no waiting for the receiver.
  • The kernel part can be a module; the user-space part and kernel part have no compile-time dependency.
  • It’s multicast: a kernel module or app can broadcast to a netlink group, and every member gets it. The kernel’s event-notification mechanism leans on this.
  • The kernel can initiate a session over netlink.

How Android does it

Whether it’s the ip command or Java’s NetworkInterface, everything bottoms out at ifaddrs.cpp → getifaddrs.

getifaddrs

Source: AOSP bionic libc/bionic/ifaddrs.cpp:

// Pass in the corresponding struct pointer
int getifaddrs(ifaddrs** out) {
  // We construct the result directly into `out`, so terminate the list.
  *out = nullptr;

  // Open the netlink socket and ask for all the links and addresses.
  NetlinkConnection nc;
  // Check whether get addresses and get link both succeed; return 0 on success
  bool okay = nc.SendRequest(RTM_GETLINK) && nc.ReadResponses(__getifaddrs_callback, out) &&
              nc.SendRequest(RTM_GETADDR) && nc.ReadResponses(__getifaddrs_callback, out);
  if (!okay) {
    out = nullptr;
    freeifaddrs(*out);
    // Ensure that callers crash if they forget to check for success.
    *out = nullptr;
    return -1;
  }

  return 0;
}

NetlinkConnection is a netlink wrapper class. The interesting part is ReadResponses, from bionic/libc/bionic/bionic_netlink.cpp:

/**
 * @param type  the type of the parameter to send; for the exact content
 * @see rtnetlink.h
 * @return
 */
bool NetlinkConnection::SendRequest(int type) {
  // Rather than force all callers to check for the unlikely event of being
  // unable to allocate 8KiB, check here.
  // 8KB data memory allocated in the NetlinkConnection constructor
  if (data_ == nullptr) return false;

  // Did we open a netlink socket yet?
  if (fd_ == -1) {
    // Try to open a netlink socket
    fd_ = socket(PF_NETLINK, SOCK_RAW | SOCK_CLOEXEC, NETLINK_ROUTE);
    if (fd_ == -1) return false;
  }

  // Construct and send the message.
  // Construct the message to send
  struct NetlinkMessage {
    nlmsghdr hdr;
    rtgenmsg msg;
  } request;

  memset(&request, 0, sizeof(request));
  request.hdr.nlmsg_flags = NLM_F_DUMP | NLM_F_REQUEST;
  request.hdr.nlmsg_type = type;
  request.hdr.nlmsg_len = sizeof(request);
  // All families
  request.msg.rtgen_family = AF_UNSPEC;
  // Send via the socket
  return (TEMP_FAILURE_RETRY(send(fd_, &request, sizeof(request), 0)) == sizeof(request));
}
/*
 * Get the socket's return result
 */
bool NetlinkConnection::ReadResponses(void callback(void*, nlmsghdr*), void* context) {
  // Read through all the responses, handing interesting ones to the callback.
  ssize_t bytes_read;
  while ((bytes_read = TEMP_FAILURE_RETRY(recv(fd_, data_, size_, 0))) > 0) {
    // Assign the received data
    auto* hdr = reinterpret_cast<nlmsghdr*>(data_);

    for (; NLMSG_OK(hdr, static_cast<size_t>(bytes_read)); hdr = NLMSG_NEXT(hdr, bytes_read)) {
      // Check if read is done; otherwise read into callback
      if (hdr->nlmsg_type == NLMSG_DONE) return true;
      if (hdr->nlmsg_type == NLMSG_ERROR) {
        auto* err = reinterpret_cast<nlmsgerr*>(NLMSG_DATA(hdr));
        errno = (hdr->nlmsg_len >= NLMSG_LENGTH(sizeof(nlmsgerr))) ? -err->error : EIO;
        return false;
      }
      // Handle the actual logic
      callback(context, hdr);
    }
  }

  // We only get here if recv fails before we see a NLMSG_DONE.
  return false;
}

Putting it to use

Walk the results and print what you need:

int listmacaddrs(void) {
    struct ifaddrs *ifap, *ifaptr;

    if (myGetifaddrs(&ifap) == 0) {
        for (ifaptr = ifap; ifaptr != NULL; ifaptr = (ifaptr)->ifa_next) {
            char macp[INET6_ADDRSTRLEN];
            if(ifaptr->ifa_addr!= nullptr) {
                if (((ifaptr)->ifa_addr)->sa_family == AF_PACKET) {
                    auto *sockadd = (struct sockaddr_ll *) (ifaptr->ifa_addr);
                    int i;
                    int len = 0;
                    for (i = 0; i < 6; i++) {
                        len += sprintf(macp + len, "%02X%s", sockadd->sll_addr[i],( i < 5 ? ":" : ""));
                    }
                    //LOGE("%s  %s  ",(ifaptr)->ifa_name,macp)
                    if(strcmp(ifaptr->ifa_name,"wlan0")== 0){
                        LOGE("%s  %s  ",(ifaptr)->ifa_name,macp)
                        freeifaddrs(ifap);
                        return 1;
                    }
                }
            }

        }
        freeifaddrs(ifap);
        return 0;
    } else {
        return 0;
    }
}

The svc inline wrapper

AOSP’s version receives with recv and loops until the end marker. The problem is that recv is trivially hookable — inline-hook recv/recvfrom/recvmsg and you can tamper with the reply (argument #2) right after the call returns.

Once you stop using the system’s recv directly, you have two options.

  • Option 1: call syscall() and route into recv through it. It handles 32/64-bit compatibility better, but the syscall entry point itself can be hooked.
  • Option 2: embed the syscall assembly directly in the target method. Since device-fingerprint functions are security-critical, this is the one I went with.

Option 2 means replacing recv with inline svc assembly — essentially reimplementing syscall yourself (code lifted from libc). Usage is simple: import the function header.

extern "C" {
    __inline__ __attribute__((always_inline))  long raw_syscall(long __number, ...);
}

32-bit

    .text
    .global raw_syscall
    .type raw_syscall,%function

raw_syscall:
        MOV             R12, SP
        STMFD           SP!, {R4-R7}
        MOV             R7, R0
        MOV             R0, R1
        MOV             R1, R2
        MOV             R2, R3
        LDMIA           R12, {R3-R6}
        SVC             0
        LDMFD           SP!, {R4-R7}
        mov             pc, lr

64-bit

    .text
    .global raw_syscall
    .type raw_syscall,@function

raw_syscall:
        MOV             X8, X0
        MOV             X0, X1
        MOV             X1, X2
        MOV             X2, X3
        MOV             X3, X4
        MOV             X4, X5
        MOV             X5, X6
        SVC             0
        RET

Swap the code to use it:

while ((bytes_read = TEMP_FAILURE_RETRY(raw_syscall(__NR_recv,fd_, data_, size_, 0, NULL,0))) > 0) {
    auto* hdr = reinterpret_cast<nlmsghdr*>(data_);
    for (; NLMSG_OK(hdr, static_cast<size_t>(bytes_read)); hdr = NLMSG_NEXT(hdr, bytes_read)) {
      // Check if read is done; otherwise read into callback
      if (hdr->nlmsg_type == NLMSG_DONE) return true;
      if (hdr->nlmsg_type == NLMSG_ERROR) {
        auto* err = reinterpret_cast<nlmsgerr*>(NLMSG_DATA(hdr));
        errno = (hdr->nlmsg_len >= NLMSG_LENGTH(sizeof(nlmsgerr))) ? -err->error : EIO;
        return false;
      }
      // Handle the actual logic
      callback(out, hdr);
    }
  }

  // We only get here if recv fails before we see a NLMSG_DONE.
  return false;
}

Unfortunately it crashes. On Android 8+ the kernel uses seccomp to block direct svc calls to recv:

2022-03-02 21:47:13.753 5867-5867/? A/DEBUG: Build fingerprint: 'Xiaomi/cmi/cmi:11/RKQ1.200826.002/21.11.3:user/release-keys'
2022-03-02 21:47:13.753 5867-5867/? A/DEBUG: Revision: '0'
2022-03-02 21:47:13.753 5867-5867/? A/DEBUG: ABI: 'arm'
2022-03-02 21:47:13.753 5867-5867/? A/DEBUG: Timestamp: 2022-03-02 21:47:13+0800
2022-03-02 21:47:13.756 5867-5867/? A/DEBUG: pid: 5773, tid: 5773, name: example.jnihook  >>> com.example.jnihook <<<
2022-03-02 21:47:13.756 5867-5867/? A/DEBUG: uid: 10019
2022-03-02 21:47:13.756 5867-5867/? A/DEBUG: signal 31 (SIGSYS), code 1 (SYS_SECCOMP), fault addr --------
2022-03-02 21:47:13.756 5867-5867/? A/DEBUG: Cause: seccomp prevented call to disallowed arm system call 291
2022-03-02 21:47:13.756 5867-5867/? A/DEBUG:     r0  0000004c  r1  da3ea000  r2  00002000  r3  00000000
2022-03-02 21:47:13.756 5867-5867/? A/DEBUG:     r4  00000000  r5  00000000  r6  00000000  r7  00000123
2022-03-02 21:47:13.756 5867-5867/? A/DEBUG:     r8  00000000  r9  f1ff2e00  r10 ffeedb20  r11 f1ff2e00
2022-03-02 21:47:13.756 5867-5867/? A/DEBUG:     ip  ffeed9f8  sp  ffeed9e8  lr  c4459b03  pc  c445a3a4
2022-03-02 21:47:13.757 5867-5867/? A/DEBUG: backtrace:
2022-03-02 21:47:13.757 5867-5867/? A/DEBUG:       #00 pc 0000c3a4  /data/app/~~O88Sqqnxjf7EjHid_THMIA==/com.example.jnihook-j2EVKCAjF3Cpu3p_RLym8A==/lib/arm/libhelloword.so (BuildId: 95d05421436486cc260cc32f813488b04b882b78)
....

The whole thing in one line:

seccomp prevented call to disallowed arm system call 291

seccomp in one paragraph

seccomp is a Linux security mechanism, and Android 8.1+ uses it to restrict calling certain syscalls directly. It has two filter modes — strict and filter.

strict only allows four syscalls; anything else gets a SIGKILL:

  • read()
  • write()
  • exit()
  • rt_sigreturn

Set it like this:

seccomp(SECCOMP_SET_MODE_STRICT)
prctl(PR_SET_SECCOMP, SECCOMP_MODE_STRICT)

strict

#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <linux/seccomp.h>
#include <sys/prctl.h>

int main(int argc, char **argv)
{
int output = open(“output.txt”, O_WRONLY);
const char *val = “test”;
// Set seccomp to strict mode via prctl
printf(“Calling prctl() to set seccomp strict mode…\n”);

prctl(PR_SET_SECCOMP, SECCOMP_MODE_STRICT);

printf(“Writing to an already open file…\n”);
// Try to write
write(output, val, strlen(val)+1);
printf(“Trying to open file for reading…\n”);

// Try to open again after setting seccomp (strict mode kills this with signal -9)
int input = open(“output.txt”, O_RDONLY);
printf(“You will not see this message — the process will be killed first\n”);
}

filter (BPF)

seccomp-bpf is the filter mode, present on newer Linux. When a process issues an svc, your BPF rules run first and decide whether that syscall is allowed.

A common combo is ptrace + seccomp to rewrite a syscall’s arguments and return value.

The fix

Back to the problem. On Android’s lower layer, recv is implemented in terms of recvfrom:

__BIONIC_FORTIFY_INLINE
ssize_t recv(int socket, void* const buf __pass_object_size0, size_t len, int flags)
    __overloadable
    __clang_error_if(__bos_unevaluated_lt(__bos0(buf), len),
                     "'recv' called with size bigger than buffer") {
  return recvfrom(socket, buf, len, flags, NULL, 0);
}

So switch the svc call number to recvfrom:

bool NetlinkConnection::ReadResponses(void callback(void*, nlmsghdr*), void* out) {
  // Read through all the responses, handing interesting ones to the callback.
  ssize_t bytes_read;

while ((bytes_read = TEMP_FAILURE_RETRY(raw_syscall(__NR_recvfrom,fd_, data_, size_, 0, NULL,0))) > 0) {
    auto* hdr = reinterpret_cast<nlmsghdr*>(data_);
    for (; NLMSG_OK(hdr, static_cast<size_t>(bytes_read)); hdr = NLMSG_NEXT(hdr, bytes_read)) {

      if (hdr->nlmsg_type == NLMSG_DONE) return true;
      if (hdr->nlmsg_type == NLMSG_ERROR) {
        auto* err = reinterpret_cast<nlmsgerr*>(NLMSG_DATA(hdr));
        errno = (hdr->nlmsg_len >= NLMSG_LENGTH(sizeof(nlmsgerr))) ? -err->error : EIO;
        return false;
      }
      // Handle the actual logic
      callback(out, hdr);
    }
  }

  // We only get here if recv fails before we see a NLMSG_DONE.
  return false;
}

It runs cleanly, and the NIC comes through:

2022-03-02 22:05:53.790 11145-11145/com.example.jnihook E/Netlink: wlan0  A4:4B:D5:0B:51:57

Reference


Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).

Get in touch