Vulnérabilité

So Close to Root: Reversing an Android TV's Secret Binder

Herard Thibault
Pentester - Audit
16/7/2026
How I reversed a custom vendor binder on an Android TV, uncovered a parallel IPC universe and hit every security layer in the way.OWN Security

This article is only availble in english.

> 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 than the Android framework itself, and they run with the highest privilege level on the system.

bash
$ 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.

bash
$ cat /proc/361/cmdline
/vendor/tvos/bin/[VENDOR]tvservice all
bash
$ 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.

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

cpp
sbinder_system_service::onTransact(uint32_t code, const Parcel &data, Parcel *reply, uint32_t flags)
{
  [...]
  ipc = (IPCThreadState *)sita_android::IPCThreadState::self();
  calling_pid = sita_android::IPCThreadState::getCallingPid(ipc);
  iVar4 = tos_system_verify(calling_pid,"sbinder_system",code);
  if (iVar4 == 0) {
    return 0x80000008;
  }
  [...]
  switch(code) {
      case 0x18:
    sita_android::String16::String16((String16 *)&local_3c,"sbinder_system");
    sita_android::Parcel::enforceInterface(data,(String16 *)&local_3c,(IPCThreadState *)0x0);
    [...]
    uVar7 = sita_android::Parcel::readInt32(data);           // command size, from caller
    sita_android::Parcel::readBlob(data,uVar7,(ReadableBlob *)&local_3c);  // command, from caller
    [...]
    result = tos_system_execute_cmd(local_38,uVar7);             // → system()
    sita_android::Parcel::writeInt32(reply,result);
    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 : background processes that expose 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.

Thanks to Thalium for the image

Android ships with three standard binder devices:

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

bash
$ 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.

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.

bash
$ 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 6 :

- 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 : byte buffers with typed read/write accessors (readInt32(), readString16(), readBlob(), etc.)

The first field written into any Parcel is the interface token, a 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.

cpp
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:

cpp
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 that is completely invisible to the standard service list command, which only talk to /dev/binder.

Registered services

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

cpp
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.

SELinux and sbinder access

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

bash
$ 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:

(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:

cpp
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 so 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 in the same binary. Reversing its onTransact() handler recovers the full set of transaction codes it implements:

cpp
// remote_property_module ; transaction codes recovered from onTransact()
enum {
    TRANSACTION_tos_property_set      = 1,  // write a property
    TRANSACTION_tos_property_get      = 2,  // read a property
    TRANSACTION_tos_property_remove   = 3,  // delete a property
    TRANSACTION_tos_property_reset    = 4,  // reset properties
    // code 5 unidentified
    TRANSACTION_tos_system_authorized = 6,  // add a PID to the whitelist   <-- target
    TRANSACTION_tos_system_logout     = 7,  // remove a PID from the whitelist
    // codes 8+ privileged operations, gated by tos_system_verify
};

The one that matters here is TRANSACTION_tos_system_authorized (code 6), whose sole purpose is to add a PID to the whitelist. The call requires a signed authorization key:

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:

<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

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:

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

That condition lets a small set of low transaction codes through without calling tos_system_verify. Working it out, the codes that skip verification are 2, 3, 6, and 7. Every other code is verified:

This is deliberate. The authorization method (code 6) has to be reachable by a process that isn't trusted yet otherwise nothing could ever be added to the whitelist. So the developers carved this bootstrap range out of the check. The security was meant 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:

cpp
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 itselfwithout 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:

-----BEGIN PUBLIC KEY-----MIGX[...]wIDAQAB-----END PUBLIC KEY-----

The debug strings in the binary confirm the verification steps:

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.

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 approach

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

$ 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:

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

cpp
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.

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

java
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:

cpp
/* 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:

bash
$ 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: the real gatekeeper

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

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:

What would have worked

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

Path 1 : Direct sbinder access from ADB:

  1. open /dev/sbinder  ✅
  2. mmap /dev/sbinder  ❌  SELinux: shell:s0 ≠ shell_30_0

Path 2 : Reach sbinder_system_service as an authorized process:

  1. tos_system_verify passes      ✅  (if PID is whitelisted)
  2. tos_system_execute_cmd → system() with caller-controlled command  ✅
  3. Getting authorized requires RSA private key  ❌

Path 3 : Reach application_manager (no tos_system_verify):

  1. open /dev/sbinder  ✅
  2. mmap /dev/sbinder  ❌  SELinux

Path 4 : PID spoofing via remote_property_module:

  1.  Send method code 6 (bypasses tos_system_verify)  ✅
  2.  Spoof any PID in the Parcel  ✅
  3.  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.

- 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.

Conclusion

This investigation illustrates how vendor customizations can introduce 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.

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 issue 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  

[6] Binder Architecture and Core Components - https://medium.com/swlh/binder-architecture-and-core-components-38089933bba

Further reading

- Fuzzing Samsung system services over Binder - Thalium, 2023

- Android Binder: from basics to exploitation - Duo Security

- Android IPC mechanisms overview - Android documentation

Partager l'article :

Your OWN cyber expert.