Featured image of post Reversing & understanding Android native binders

Reversing & understanding Android native binders

How I reversed a custom vendor binder on an Android TV, uncovered a parallel IPC universe built on /dev/sbinder, and hit every security layer in the way.

Context & introduction

Disclosure notice: Throughout this article, all vendor-specific identifiers (vendor name, binary names, service names, package names) have been replaced with generic placeholders. The vendor has been anonymized as [VENDOR], and affected names follow the convention [VENDOR]tvservice, com.[VENDOR].REDACTED, etc.

I decided to take a closer look at my Android TV after noticing it was unusually slow. Beyond the performance issue, the device came with a significant amount of pre-installed vendor software that could not be removed without root access. This sparked an interest in the security of the vendor layer running on the device.

This article documents the investigation of vendor-specific native services and their IPC interfaces. The goal is to explain what Android Binders are, how vendors extend them, and what the attack surface looks like from an offensive perspective.

Initial reconnaissance

The first step when assessing an Android IoT device is to enumerate processes running as root. Vendor binaries are a particularly interesting target: they often implement complex functionality with less security scrutiny than the Android framework itself, and they run with the highest privilege level on the system.

1
2
3
4
5
6
7
$ ps -A | grep root
root            324      1   42780   5600 0                   0 S rtkd
root            329      1   12320   1784 0                   0 S motionclient
root            361    332  247284  11852 0                   0 S [VENDOR]tvservice
root            363    332   21620   1520 0                   0 S application_manager
root            377    324   38636   1420 0                   0 S rtkd
root            385    363   60836   2868 0                   0 S PlayerServer

Several non-standard vendor binaries are running as root. The main target here is [VENDOR]tvservice, a 25 MB stripped ARM ELF binary.

1
2
$ cat /proc/361/cmdline
/vendor/tvos/bin/[VENDOR]tvservice all

my little secret

1
2
$ file [VENDOR]tvservice
[VENDOR]tvservice: ELF 32-bit LSB shared object, ARM, EABI5 version 1 (SYSV), dynamically linked, interpreter /system/bin/linker, stripped

Opening the binary in Ghidra and searching for interesting imports, system immediately stands out.

sniffing some interesting functions

system imported

system is used in multiple places, but one occurrence is particularly interesting:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
sbinder_system_service::onTransact(uint param_1,Parcel *param_2,Parcel *param_3,uint param_4)
{
  [...]
  pIVar5 = (IPCThreadState *)sita_android::IPCThreadState::self();
  iVar4 = sita_android::IPCThreadState::getCallingPid(pIVar5);
  iVar4 = tos_system_verify(iVar4,"sbinder_system",param_2);
  if (iVar4 == 0) {
    return 0x80000008;
  }
  [...]
  switch(param_2) {
      case (Parcel *)0x18:
    sita_android::String16::String16((String16 *)&local_3c,"sbinder_system");
    sita_android::Parcel::enforceInterface(param_3,(String16 *)&local_3c,(IPCThreadState *)0x0);
    [...]
    uVar7 = sita_android::Parcel::readInt32(param_3);           // command size, from caller
    sita_android::Parcel::readBlob(param_3,uVar7,(ReadableBlob *)&local_3c);  // command, from caller
    [...]
    iVar4 = tos_system_execute_cmd(local_38,uVar7);             // β†’ system()
    sita_android::Parcel::writeInt32(param_4,iVar4);
    break;
  }
}

This handler reads a command string directly from the caller-supplied Parcel and passes it to tos_system_execute_cmd, which calls system() β€” a direct OS shell execution. Since [VENDOR]tvservice runs as root, any caller that reaches this code path with an arbitrary command achieves root command execution. The only protection in place is the tos_system_verify check at the top. Understanding what that means requires some background on Android IPC.

Background: Android IPC

System services

Android separates application code from privileged system functionality through system services β€” long-running background processes that expose well-defined interfaces to clients. Unlike Android application services (declared in a manifest), system services run outside the application lifecycle and are managed by the platform itself.

Examples include WindowManagerService, ActivityManagerService, or hardware abstraction layers for audio and sensors. On vendor-customized devices, additional proprietary system services are layered on top to expose device-specific functionality.

Binder: the kernel IPC driver

All processes on Android run in isolated sandboxes. Direct memory sharing between processes is not allowed. The Binder [1] is the kernel-level IPC mechanism that bridges this isolation. It is exposed as a character device, /dev/binder, which every participating process opens and mmap()s.

When a client sends a call to a service, the kernel driver copies the data from the client’s address space directly into the service’s mapped region, without an intermediate copy through a kernel buffer. This zero-copy design is the main performance advantage of Binder over traditional Unix IPC.

Binder internal working

Android ships with four standard binder devices:

DeviceUsage
/dev/binderStandard Android framework IPC
/dev/hwbinderHardware HAL communication
/dev/vndbinderVendor services (AOSP-standard)

On the device under analysis, a fourth device is present:

1
2
3
4
5
$ ls -la /dev/binder /dev/hwbinder /dev/vndbinder /dev/sbinder
crw-rw-rw- 1 root root 10, 60 2018-01-01 01:00 /dev/binder
crw-rw-rw- 1 root root 10, 59 2018-01-01 01:00 /dev/hwbinder
crw-rw-rw- 1 root root 10, 57 2018-01-01 01:00 /dev/sbinder
crw-rw-rw- 1 root root 10, 58 2018-01-01 01:00 /dev/vndbinder

/dev/sbinder is not part of the AOSP standard. We will come back to it.

Not you

ServiceManager

Since multiple services can coexist on the same binder device, there needs to be a name registry. The ServiceManager [2] fulfills this role: it runs at handle 0 (the reserved context manager handle) and maintains a mapping between service names and their binder handles. Services register themselves at startup; clients query by name to obtain a handle before sending calls.

1
2
3
4
5
$ service list
2    TVGlobalService: [com.[VENDOR].TVGlobalService.ITVGlobalService]
29   blockmonitor: [com.[VENDOR].resource.IBlockMonitorManager]
35   com.[VENDOR].factory.service: []
...

The object model

The Binder framework exposes three core abstractions [3]:

  • IBinder: The common interface implemented by all binder objects.
  • BBinder: The server-side base class. Services subclass it and override onTransact() to handle incoming calls.
  • BpBinder (Binder Proxy): The client-side stub. It holds a remote handle and forwards calls through the kernel driver.

When a client calls a method on a remote service, it invokes transact() on a BpBinder. The kernel delivers a BR_TRANSACTION event to the server thread, which dispatches it to onTransact() on the corresponding BBinder.

Parcels

Data is exchanged through Parcel objects [4] β€” flat byte buffers with typed read/write accessors (readInt32(), readString16(), readBlob(), etc.) and bookkeeping for embedded binder references.

The first field written into any Parcel is the interface token β€” a UTF-16LE string that identifies the target interface. The server calls enforceInterface() on receipt to verify the token matches, preventing a client from sending a call meant for interface A to a service implementing interface B.

Going back to the Ghidra output above: onTransact() is the server-side handler. param_2 is the transaction code (which method is being called), param_3 is the input Parcel (caller-supplied data), and param_4 is the reply Parcel. param_1 is the implicit this pointer, typed as uint by Ghidra since the class type was not resolved.

The vendor sbinder ecosystem

Going back to the binary, [VENDOR]tvservice does not register its services on the standard /dev/binder. Instead, it opens a completely separate device: /dev/sbinder.

1
2
3
4
5
6
7
8
int *binder_open(size_t param_1)
{
  [...]
  iVar1 = open("/dev/sbinder", 2);  // not /dev/binder
  [...]
  pvVar2 = mmap((void *)0x0, param_1, 1, 2, *__ptr, 0);
  [...]
}

This function is used to bootstrap a full ServiceManager loop embedded inside the binary itself:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
undefined4 _serviceManager_loop(void)
{
  int iVar1 = binder_open(0x20000);
  [...]
  iVar2 = binder_become_context_manager();  // register as handle 0 on sbinder
  [...]
  binder_loop(iVar1, svcmgr_handler);       // start receiving service lookups
  [...]
}

int ServiceManager_start(void)
{
  os_thread_create(_serviceManager_loop, 0, 0, 0x32, "service_manager_task");
  [...]
}

[VENDOR]tvservice is both a service host and a ServiceManager for /dev/sbinder. It establishes a fully parallel IPC universe β€” one that is completely invisible to the standard service list command, which only queries /dev/binder.

Registered services

The function proxy_add_service() registers services on sbinder at startup, driven by a bitmask:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
void proxy_add_service(uint param_1)
{
  if ((param_1 & 1) != 0)    sbinder_message_service_add();
  if ((param_1 & 2) != 0)    sbinder_hotel_service_add();
  if ((param_1 & 4) != 0)    sbinder_factory_service_add();
  if ((param_1 & 8) != 0)    sbinder_dm_system_service_add();
  if ((param_1 & 0x10) != 0) sbinder_dm_channel_service_add();
  if ((param_1 & 0x20) != 0) remote_property_module_service_add();
  if ((param_1 & 0x40) != 0) { sbinder_tifhal_helper_service_add(); remote_tifhal_helper_service_add(); }
  if ((param_1 & 0x80) != 0) sbinder_sound_setting_service_add();
  if ((param_1 & 0x100) != 0) sbinder_function_setting_service_add();
  [...]
}

// In main():
proxy_add_service(0b0000000100100110);

Among others, remote_property_module is registered (bit 5 of 0x126 is set). sbinder_system_service β€” the handler containing the system() call β€” is also registered, separately from this function.

Additionally, the application_manager binary exposes remote_application_manager_sbinder, and ntvi_server exposes over 40 distinct sbinder services covering audio, video, CEC, Bluetooth, network settings, EPG, PVR, and more.

SELinux and sbinder access

The device file has world-readable/writable permissions but carries a SELinux label:

1
2
$ ls -laZ /dev/sbinder
crw-rw-rw- 1 root root u:object_r:sbinder_device:s0 10, 57 /dev/sbinder

Inspection of the firmware’s SELinux policy (vendor/etc/selinux/vendor_sepolicy.cil) reveals which domains can access it:

1
2
3
4
5
(allow ntv_[VENDOR]tvservice sbinder_device (chr_file (ioctl read write getattr lock append map open ...)))
(allow system_server_30_0 sbinder_device (chr_file (ioctl read write getattr lock append map open ...)))
(allow untrusted_app_30_0 sbinder_device (chr_file (ioctl read write map open)))
(allow shell_30_0 sbinder_device (chr_file (ioctl read write open)))
(allow shell_30_0 sbinder_device (chr_file (map)))

Notably, both untrusted_app (any third-party application) and shell (ADB) appear to be allowed to open and map the device. In practice, whether this is sufficient to communicate with the services depends on a second security layer.

The security layer: tos_system_verify

Allowing unprivileged processes to open /dev/sbinder does not directly expose the methods offered by the services registered on it. Every onTransact() handler in [VENDOR]tvservice begins with an authorization check:

1
2
3
4
callingPid = sita_android::IPCThreadState::getCallingPid();
if (tos_system_verify(callingPid, "sbinder_system", code) == 0) {
    return 0x80000008;  // PERMISSION_DENIED
}

tos_system_verify maintains an in-memory whitelist of authorized PIDs. The calling PID is obtained through IPCThreadState::getCallingPid(), which reads the value set by the kernel at the time of the transaction β€” it cannot be forged by the caller. If the PID is not on the whitelist, the call is rejected before any service logic runs. Any process whose PID is absent from this list will receive PERMISSION_DENIED regardless of the method it is trying to invoke.

How does a process get authorized?

The whitelist is populated through a dedicated bootstrap service: remote_property_module. This service exposes a method β€” TRANSACTION_tos_system_authorized (transaction code 6) β€” whose sole purpose is to add a PID to the whitelist. The call requires a signed authorization key:

1
2
3
4
5
6
Parcel layout for transaction code 6:
  enforceInterface("remote_property_module")
  int32:  pid               <- PID to authorize
  int32:  blobLen           <- key blob length
  blob:   authorized_key    <- signed authorization string
  int32:  keyLen            <- key length

The authorized_key field follows the format:

1
<process_cmdline>|[VENDOR]#<base64_RSA_signature>

This key is verified by fpi_system_security_authorized, a function within [VENDOR]tvservice that is invoked by tos_system_authorized when processing this transaction. It performs the following steps:

  1. Base64-decode the signature
  2. RSA-decrypt it using a public key embedded in the binary
  3. Verify that the SHA-1 of the cmdline field matches the decrypted hash
  4. Verify that /proc/<pid>/cmdline matches the declared cmdline
  5. On success, add the PID to the licensed app list

IMAGE

To authorize any process, a valid RSA signature from the vendor’s private key is required.

The bypass: method codes 1–7 skip verification

A closer look at the onTransact() header in remote_property_module reveals an unusual condition:

1
2
3
4
5
6
if (1 < ((code & 0xfffffffb) - 2)) {
    callingPid = sita_android::IPCThreadState::getCallingPid();
    if (tos_system_verify(callingPid, "remote_property_module", code) == 0) {
        return PERMISSION_DENIED;
    }
}

The mask 0xfffffffb equals ~0x4, clearing bit 2. Evaluating this condition for each method code:

Code& 0xfffffffb- 2> 1Verification?
11-1NoBypassed
220NoBypassed
331NoBypassed
40-2NoBypassed
51-1NoBypassed
620NoBypassed
731NoBypassed
886YesVerified
9+>1YesVerified

Method codes 1 through 7 bypass tos_system_verify. The intent is clear: the authorization method (code 6) must be reachable by any process before it is whitelisted, so verification is intentionally skipped for the bootstrap range. The security is supposed to come from the RSA signature, not from the access control check.

PID spoofing vulnerability

Inside the handler for code 6, the PID to be authorized is read from the client-supplied Parcel:

1
2
3
uVar8 = sita_android::Parcel::readInt32(param_3);  // PID read from caller data
// ...
iVar1 = tos_system_authorized(uVar8, pvVar11, uVar4);

The PID is not taken from IPCThreadState::getCallingPid(). It is an arbitrary integer supplied by the caller. This means an attacker can request authorization for any PID of their choice β€” for instance, the PID of [VENDOR]tvservice itself β€” without needing to run code in that process.

The exploit flow would be:

  1. Open /dev/sbinder from ADB
  2. Send method code 6 to remote_property_module
  3. Supply any PID in the Parcel
  4. If the signature check passes, that PID is whitelisted for all privileged operations

Step 4 is where the attack stalls.

The RSA wall

fpi_system_security_authorized validates the authorization key against a 1024-bit RSA public key embedded in [VENDOR]tvservice:

1
2
3
4
5
6
-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDnZ+xn2Frxikpvjc2XrwnNVspB
KvAnNZNpOn9fASR26xfMar2Pke+2xxdbkY4n9XYueqvcINsv9BdasefCEaYrnv4A
vp0Hz8xI/eMmcc5Uoyu6Ka3d4dLUj/FH6YcIeeAhIrZg0hQ22w6InQLaR8Z7iKiw
8/HJ2Hd3MRmEfK76KwIDAQAB
-----END PUBLIC KEY-----

The debug strings in the binary confirm the verification steps:

1
2
3
4
5
TVSDK - AUTHORIZATION : BASE64 DECRYPTION FAILED
TVSDK - AUTHORIZATION : RSA DECRYPTION FAILED
TVSDK - AUTHORIZATION : SHA1 VERIFICATION FAILED
TVSDK - AUTHORIZATION : Invalid Proc Name : name = %s
TVSDK - AUTHORIZATION : pid(%d) add in licensed app list ret = %d

A search across the entire firmware for keys matching this public key’s modulus, and for any pre-signed authorization tokens, returned no results. The private key is held on the vendor’s build infrastructure. No application in the firmware embeds a hardcoded signed token.

I hope to have a leak soon.

This blocks exploitation of sbinder_system_service via the authorization path: even with the PID spoofing primitive and the bypass on method code 6, generating a valid signature requires the private key.

application_manager: a different beast

While [VENDOR]tvservice enforces tos_system_verify on all its services, the application_manager binary does not implement this check at all.

1
2
3
$ cat /proc/363/cmdline
/vendor/tvos/bin/application_manager --app-path=/product/am_apps:/product/am_apps/tplayer \
  --log=logcat --memory-limit-file=/vendor/tvos/bin/am_config.ini

Inspection of application_manager’s onTransact() in Ghidra confirms: there is no call to tos_system_verify. The service accepts calls from any process without any authorization check.

The service descriptor is remote_application_manager_sbinder. Its interface, derived from libtaf_client.so (the vendor’s Application Framework client library), exposes the following operations among others:

CodeMethodNotes
22PushKeyeventInject key events
34InstallAppInstall a .tpk application
35UninstallAppUninstall an application
38GetInstalledAppsEnumerate installed apps

Method 34 β€” InstallApp β€” is the most significant. The installation path leads to AppRunner::StartApp:

1
2
3
4
5
6
7
8
9
int AppRunner::StartApp(AppInfo *app_info)
{
  pid_t pid = vfork();
  if (pid == 0) {
    execve(app_info->so_path, argv, envp);
    exit(1);
  }
  return pid;
}

The binary at the caller-supplied path is executed via fork + execve. Since application_manager runs as root, the spawned process inherits root privileges. Any caller that can reach this method can execute arbitrary binaries as root, with no RSA signature required.

Maybe??

The Parcel format for InstallApp (method code 34) is:

1
2
3
4
5
6
Interface token:  "remote_application_manager_sbinder" (UTF-16LE)
int32:            package name length
bytes:            package name (null-terminated, 4-byte aligned)
int32:            package path length
bytes:            package path (null-terminated, 4-byte aligned)
int32:            observer callback (0 = null)

I wrote a native proof-of-concept that implements the raw sbinder protocol to send this transaction:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
/* Open sbinder device */
binder_fd = open("/dev/sbinder", O_RDWR | O_CLOEXEC);

/* Check protocol version */
struct binder_version ver;
ioctl(binder_fd, BINDER_VERSION, &ver);

/* mmap the binder device */
binder_mmap = mmap(NULL, binder_mmap_size, PROT_READ,
                   MAP_PRIVATE | MAP_NORESERVE, binder_fd, 0);

/* Build InstallApp Parcel and send it */
build_install_app_parcel(&tx_data, "test.exploit", "/data/local/tmp/exploit.tpk");
binder_transact(handle, TX_INSTALL_APP, &tx_data, NULL, 0);

Result:

1
2
3
4
5
6
7
8
$ adb push application_manager_exploit /data/local/tmp/
$ adb shell chmod +x /data/local/tmp/application_manager_exploit
$ adb shell /data/local/tmp/application_manager_exploit
=== application_manager sbinder exploit ===

[+] Opened /dev/sbinder (fd=3)
[-] mmap failed: Permission denied
[-] ioctl failed: Invalid argument

/dev/sbinder opened successfully. mmap() failed. SELinux intervened.

SELinux keeps me awake at night

SELinux: the real gatekeeper

Despite the SELinux policy granting shell_30_0 the map permission on sbinder_device, the kernel AVC log shows a denial:

1
2
3
4
avc: denied { map } for comm="application_manager_expl" path="/dev/sbinder"
     scontext=u:r:shell:s0
     tcontext=u:object_r:sbinder_device:s0
     tclass=chr_file

The ADB shell process runs in the u:r:shell:s0 domain, not u:r:shell_30_0:s0. These are distinct SELinux types, and shell (the context assigned to processes spawned via ADB) does not have map permission on sbinder_device.

Without mmap, the Binder protocol cannot function: the kernel driver requires a memory-mapped receive buffer to deliver reply data to the caller. The exploitation attempt is blocked before it can even resolve the service handle.

The situation across all binder devices:

DeviceADB shell accessReason
/dev/binderFullStandard Android, shell:s0 allowed
/dev/hwbinderFullHAL, shell:s0 allowed
/dev/vndbinderFullVendor, shell:s0 allowed
/dev/sbinderNo mmapSELinux denies map for shell:s0

What would have worked

The following summarizes each attempted attack path and the control that blocked it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
Path 1 β€” Direct sbinder access from ADB:
  open /dev/sbinder  βœ…
  mmap /dev/sbinder  ❌  SELinux: shell:s0 β‰  shell_30_0

Path 2 β€” Reach sbinder_system_service as an authorized process:
  tos_system_verify passes      βœ…  (if PID is whitelisted)
  tos_system_execute_cmd β†’ system() with caller-controlled command  βœ…
  Getting authorized requires RSA private key  ❌

Path 3 β€” Reach application_manager (no tos_system_verify):
  open /dev/sbinder  βœ…
  mmap /dev/sbinder  ❌  SELinux

Path 4 β€” PID spoofing via remote_property_module:
  Send method code 6 (bypasses tos_system_verify)  βœ…
  Spoof any PID in the Parcel  βœ…
  Requires valid RSA key in authorized_key field  ❌

Identified vulnerabilities:

  • PID spoofing in remote_property_module (method code 6): the PID to whitelist is read from caller-controlled data rather than from the kernel. An attacker can request whitelisting of any arbitrary PID.
  • application_manager has no tos_system_verify: any process able to communicate via sbinder can install an application that will be executed as root.
  • sbinder_system_service exposes arbitrary command execution: method code 0x18 passes a caller-controlled Parcel blob directly to system() as root. Any process that clears tos_system_verify can achieve root RCE by supplying an arbitrary command in the Parcel.

The defense-in-depth combination β€” SELinux blocking the mmap at the kernel level, and RSA signatures gating the authorization path β€” prevented full exploitation from the ADB shell context.

RSA and SELinux teaming up to ruin my article

To go further, one of the following would be needed:

  • Code execution in a process context that SELinux permits to mmap /dev/sbinder (e.g., through a vulnerability in an already-authorized process such as com.[VENDOR].systemserver)
  • Access to the RSA private key, or recovery of a signed token from the memory of a running authorized process
  • Exploitation of the TOCTOU window on /proc/<pid>/cmdline during PID spoofing

Conclusion

This investigation illustrates how vendor customizations can introduce significant complexity into the Android security model. The vendor here built a fully parallel IPC infrastructure on /dev/sbinder β€” with its own ServiceManager, its own service registry, and its own authorization scheme β€” largely outside the visibility of standard Android tooling.

Two design weaknesses stand out. First, using an attacker-controlled value as a security-sensitive identifier (the PID in the authorization call) violates a basic principle: security decisions must rely on kernel-verified data, not caller-supplied data. Second, exposing a service that executes arbitrary shell commands as root (application_manager) without any authorization check creates a critical primitive that is one SELinux policy gap away from full compromise.

References

[1] Android Binder IPC β€” https://source.android.com/docs/core/architecture/hidl/binder-ipc
[2] Android ServiceManager β€” https://cs.android.com/android/platform/superproject/+/main:frameworks/native/cmds/servicemanager/
[3] Binder object model β€” https://cs.android.com/android/platform/superproject/+/main:frameworks/native/libs/binder/
[4] Android Parcel β€” https://developer.android.com/reference/android/os/Parcel
[5] Binder transactions in the Linux kernel β€” https://www.synacktiv.com/en/publications/binder-transactions-in-the-bowels-of-the-linux-kernel

Further reading