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

So, I was bored and I decided to take a look at my Android TV because it was really laggy and I wanted to clean it up in order to gain some RAM or some CPU. I’m not going to mention the brand, but all I can say is that there were many useless apps. But the problem was that I couldn’t uninstall them because I wasn’t root on it and no exploit existed at the time.

So I took some time to dig into it and try to find some quick-win to get root fast. Spoiler: it didn’t work. But it did push me toward something much more interesting: vendor services and their Binder interfaces.

Through this article I will try to explain what are Android Binders and how to deal with them from a pentester/reverser perspective.

Initial recognition

The first thing I always do while pentesting Android IoT is to look at vendor binaries that run as root. This is usually the best starting point because vendor developers make much more mistakes than Android Developers and because it gives me a shortlist of high-privilege attack surface.

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 movemmentclient
root            361    332  247284  11852 0                   0 S customtvservice
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

⚠️ I had to change the name of the vendor and the name of the application.

As we can see there is plenty of non-android-developed binary that run as root on the device. I won’t go into detail here about all the steps I took before finding something interesting but in this article I will talk especially about customtvservice.

First, let’s locate the binary :

1
2
$ cat /proc/361/cmdline
/vendor/tvos/bin/customtvservice all

Also, I will not mention here how I managed to get the firmware.

my little secret

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

As I mentioned earlier, I wanted easy quick wins like a little rat.

sniffing some interesting functions

So I opened Ghidra and immediately started to search for some juicy imports. There were plenty but I decided to focus on system.

system imported

system is used at many places in the binary but something caught my attention.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
sbinder_system_service::onTransact(uint param_1,Parcel *param_2,Parcel *param_3,uint param_4)
{
  iVar4 = log_is_outfile(0x2c,2);
  if (iVar4 != 0) {
    uVar6 = log_get_cmd(0x2c);
    fpi_log_printf(uVar6,2,"sbinder_system_service - onTransact code=%d",param_2);
  }
  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;
  }
  local_59 = 0xff;
  pIVar5 = (IPCThreadState *)sita_android::IPCThreadState::self();
  uVar6 = sita_android::IPCThreadState::getCallingPid(pIVar5);
  tos_system_push_callstack(uVar6,"sbinder_system",param_2,&local_59);
  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);
    sita_android::String16::~String16((String16 *)&local_3c);
    sita_android::Parcel::writeNoException(param_4);
    uVar7 = sita_android::Parcel::readInt32(param_3);
    sita_android::Parcel::Blob::Blob((Blob *)&local_3c);
    if ((int)uVar7 < 1) {
      iVar4 = -1;
    }
    else {
      sita_android::Parcel::readBlob(param_3,uVar7,(ReadableBlob *)&local_3c);
      if (local_30 != (Parcel *)0x0) {
        iVar4 = sita_android::Parcel::data(local_30);
        local_38 = (void *)(iVar4 + local_2c);
      }
      iVar4 = tos_system_execute_cmd(local_38,uVar7);
    }
    sita_android::Parcel::writeInt32(param_4,iVar4);
    break;
  }
}

Don’t leave so fast I will explain what you are seeing. So what is this onTransact and these Parcel ? It is time to introduce Android Binders !

What are Android Binders ?

Before discussing binders, we need to talk about system services (which are different from the services exposed by Android applications). System services are invisible services for the user that are used by the Android OS, hardware drivers, and privileged daemons so that everything works together.

On Android, processes (like application) run in isolated sandboxes. Two applications cannot directly read each other’s memory. So how does, say, a music app request audio focus from the system? Through IPC (Inter-Process Communication). Android uses a kernel mechanism called the Binder to make this efficient.

The kernel driver

The binder is a character device: /dev/binder. Every Android process that wants to use the IPC framework opens and mmap()s this file. The kernel driver acts as a middleman: when process A sends a transaction to process B, the kernel copies the data from A’s address space into B’s mmap’d region directly, without going through userspace.

Binder internal working

There are four standard binder devices on Android:

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

And in our case :

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

Wait, there is a fourth one: /dev/sbinder. That is not standard. We will come back to that.

Not you

ServiceManager

Since multiple services can exist, there needs to be a directory. The ServiceManager is a well-known process that acts as the DNS of the binder world. It listens at handle 0 (the reserved context manager handle). Other services register themselves by name, and clients look them up by name to get a handle.

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

The object model

The binder framework uses three key abstractions:

  • IBinder: The interface. Every binder object implements it.
  • BBinder: The server-side implementation. You subclass this and override onTransact().
  • BpBinder (Binder Proxy): The client-side stub. It holds a handle integer and forwards calls over the kernel driver.

When you call a method on a remote service, you are calling transact() on a BpBinder. The kernel delivers a BR_TRANSACTION to the server, which calls onTransact() on its BBinder implementation.

Parcels: the serialization format

Data is passed through Parcel objects. A Parcel is essentially a flat byte buffer with some extra information for embedded binder objects. It supports typed reads and writes: readInt32(), readString16(), readBlob(), etc.

The first field of every Parcel is the interface token, a UTF-16 string that identifies which interface the call is for. The server verifies this with enforceInterface() to prevent confused-deputy attacks.

So now, going back to the Ghidra output from earlier, the onTransact() function is the server-side handler. param_1 is the transaction code (what operation is being requested), param_2 is the input Parcel (data from the caller), and param_3 is the output Parcel (where the response goes).

1
sbinder_system_service::onTransact(uint param_1,Parcel *param_2,Parcel *param_3,uint param_4)

/dev/sbinder: ntv’s custom binder

Going back to Ghidra, the system() call I found earlier is not exposed on the standard /dev/binder. It lives in sbinder_system_service, registered on a completely different device: /dev/sbinder.

The proof is in the binary’s binder_open() function:

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

And this is used to start a ServiceManager loop:

 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 transactions
  [...]
}

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

customtvservice embeds its own Service Manager. It opens /dev/sbinder, registers itself as the context manager (handle 0), and starts a loop exactly like the standard servicemanager process does for /dev/binder. This is a fully parallel IPC universe, invisible to the standard service list command.

What services are registered?

The function proxy_add_service() in customtvservice decides which services to register at startup, based on a bitmask argument:

 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 is set in 0x126). And sbinder_system_service, the one with system(), is registered separately.

On top of customtvservice, the application_manager binary exposes remote_application_manager_sbinder. And ntvi_server exposes over 40 different sbinder clients covering audio, video, CEC, Bluetooth, network settings, EPG, PVR…

SELinux and sbinder access

The /dev/sbinder device has world-readable/writable permissions (crw-rw-rw-), but it 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

The SELinux policy defines which domains can access it. Looking at the policy rules in the firmware:

1
2
3
4
5
(allow ntv_customtvservice 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)))

Interestingly, both untrusted_app (any third-party app) and shell (ADB) are allowed to open, read, write, and even map the device. On paper, this means we can talk to sbinder from ADB.

The security layer: tos_system_verify

If unprivileged processes can open /dev/sbinder, what actually protects the sensitive transactions? The answer is an application-level security mechanism baked into customtvservice: tos_system_verify.

Every onTransact() handler in customtvservice starts with a block like this:

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

The function tos_system_verify maintains a whitelist of authorized PIDs. When a process sends a transaction, the server retrieves the caller’s PID from the kernel (via IPCThreadState::getCallingPid() : this cannot be spoofed) and checks it against the list. If the PID is not on the list, the call is rejected.

Any process whose PID is not in the whitelist will get its call rejected.

How does a process get authorized?

There is a bootstrap service: remote_property_module, registered on sbinder. It exposes a transaction (code 6) called TRANSACTION_tos_system_authorized that adds a PID to the whitelist. The authorization requires a signed key:

1
2
3
4
5
6
Parcel data 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 string has this format:

1
<process_cmdline>|ntv#<base64_RSA_signature>

The function fpi_system_security_authorized verifies it with the following steps:

  1. Base64-decode the signature
  2. RSA-decrypt it with the embedded public key
  3. Verify the SHA1 hash matches SHA1(cmdline)
  4. Verify /proc/<pid>/cmdline matches the declared cmdline
  5. If all checks pass, add the PID to the “licensed app list”

This means that to authorize any process, you need a valid RSA signature from ntv’s private key.

The bypass: transaction codes 1-7 skip verification

But wait. There is something peculiar at the top of the remote_property_module’s onTransact():

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 is ~0x4 so it clears bit 2. Let’s trace through what this condition evaluates to for each transaction code:

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

Transaction codes 1 through 7 all bypass tos_system_verify. Crucially, transaction code 6, the one that adds a PID to the authorization whitelist, is in that unprotected range. The intent is obviously to allow any process to call it in order to authenticate itself. The security comes from the RSA signature requirement, not from access control.

The PID spoofing vulnerability

And here is where it gets interesting. Inside the handler for transaction code 6:

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

The PID being authorized is not obtained from IPCThreadState::getCallingPid() : it is read directly from the Parcel data sent by the client. The caller fully controls which PID gets whitelisted.

This means:

  1. We can open /dev/sbinder from ADB
  2. Send transaction code 6 to remote_property_module
  3. Put any PID we want in the Parcel like the PID of customtvservice itself
  4. If the signature check passes, that PID gets whitelisted for arbitrary operations

The attack is conceptually simple. The problem is step 4.

The RSA wall

Let’s look at what fpi_system_security_authorized actually verifies. Embedded inside customtvservice is a 1024-bit RSA public key:

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

Any process that wants to use the sbinder services must present a valid token of the form <cmdline>|ntv#<base64_signature>, where the signature is an RSA-SHA1 signature of the process cmdline, signed with ntv’s private key.

The error strings in the binary make the verification steps explicit:

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

I searched the entire firmware for keys matching this public key’s modulus, and for any pre-signed authorization tokens. Nothing. The private key lives on ntv’s build servers. No application in the firmware carries a hardcoded signed token either.

I hope to have a leak soon.

This effectively blocks any direct exploitation of sbinder_system_service. Even with the PID spoofing bug and the bypass on transaction code 6, we cannot generate a valid RSA signature without the private key.

application_manager: a different beast

While customtvservice is well-protected by tos_system_verify, I noticed that application_manager (another root binary) tells a very different story.

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

Looking at application_manager’s onTransact() in Ghidra, there is no call to tos_system_verify. At all. The service processes transactions from any caller without checking whether they are authorized.

The service descriptor is remote_application_manager_sbinder. It exposes operations mapped from libtaf_client.so (ntv’s Application Framework client library):

CodeFunctionNotes
22PushKeyeventKey injection
34InstallAppInstall a .tpk application
35UninstallAppUninstall an application
38GetInstalledAppsList installed apps

Transaction 34 - InstallApp is particularly interesting. Looking at AppRunner::StartApp in Ghidra:

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 provided path is executed via fork + execve. Since application_manager runs as root, the spawned process also runs as root. Install an application, get a root process. No RSA signatures required.

Maybe??

The Parcel format for InstallApp (transaction 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 (ChatGPT) wrote a native exploit 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);

And the 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

The file opened fine. But mmap() failed. SELinux struck again.

SELinux keeps me awake at night

SELinux: the real gatekeeper

Despite the SELinux policy allowing shell_30_0 to perform read write open map on sbinder_device, the kernel’s AVC log tells a different story:

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
     ntvass=chr_file

The denial happens because the ADB shell context is u:r:shell:s0, not u:r:shell_30_0:s0. The policies for shell_30_0 and shell are separate SELinux domains, and shell (the context used for processes launched via ADB) is not granted map on sbinder_device.

Without mmap, the binder protocol cannot function, the kernel driver requires a memory-mapped region to deliver reply data back to userspace. The exploit is blocked before it can even look up the service.

The situation across all binder devices:

DeviceADB shell accessReason
/dev/binderFullStandard Android, shell context allowed
/dev/hwbinderFullHardware HAL, shell context allowed
/dev/vndbinderFullVendor, shell context allowed
/dev/sbinderNo mmapSELinux blocks shell:s0 from mapping

What would have worked

Let me draw the complete picture of the security layers encountered:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
Attack path 1 (direct sbinder from ADB):
  ADB shell → open /dev/sbinder ✅
           → mmap /dev/sbinder ❌ SELinux (shell:s0 ≠ shell_30_0)

Attack path 2 (sbinder_system_service via authorized process):
  Authorized process → tos_system_verify ✅
                    → tos_system_execute_cmd → system() ✅
  BUT: getting a process authorized requires RSA private key ❌

Attack path 3 (application_manager, no tos_system_verify):
  ADB shell → open /dev/sbinder ✅
           → mmap /dev/sbinder ❌ SELinux

Attack path 4 (remote_property_module PID spoofing):
  ADB shell → send transaction 6 with spoofed PID ✅ (bypass codes 1-7)
           → BUT requires valid RSA key in the authorized_key field ❌

The real vulnerabilities identified are:

  • PID spoofing in transaction code 6 of remote_property_module: the PID to whitelist is read from client-controlled data
  • application_manager has no tos_system_verify: any process that can speak sbinder can install apps that execute as root
  • sbinder_system_service exposes system() behind what should be an authorization layer

But the defense-in-depth approach : SELinux blocking the mmap, and RSA signatures for the authorization path, prevented full exploitation from the ADB shell context.

RSA and SELinux teaming up to ruin my article

To go further, one would need either:

  • A way to run code in a process context that SELinux allows to mmap /dev/sbinder (e.g., exploit an already-authorized process like com.ntv.systemserver)
  • The RSA private key, or a way to extract an existing signed key from memory of a running authorized process
  • A race condition on /proc/<pid>/cmdline during the PID spoofing window

Conclusion

Android binders are a fascinating IPC mechanism and a rich attack surface on vendor-customized devices. ntv went further than most by implementing a completely parallel binder universe on /dev/sbinder with their own service manager, their own authorization mechanism, and their own signed-key whitelist.

A PID that is controlled by an attacker should never be trusted for security decisions, and a service with no access control (application_manager) that executes binaries as root is one SELinux bypass away from full compromise.

References