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 in one paragraph
- 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:
procioctl- 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.
netlink features
- Full-duplex, asynchronous communication.
- User space uses the standard BSD socket API (though netlink doesn’t hide protocol-packet construction/parsing — a third-party lib like
libnlis recommended).- The kernel side uses a dedicated kernel API.
- Multicast support (hence “bus”-style communication and message subscription).
- On the kernel side, usable from both process context and interrupt context.
netlink advantages
- 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.
Getting NIC info 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 intorecvthrough it. It handles 32/64-bit compatibility better, but thesyscallentry 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