Binder is just bytes in a Parcel. This lab is a one-method AIDL service I own. I dump the parcel, match it to writeInterfaceToken / writeInt / writeString, then read the native onTransact that consumes the same layout. No system_server attack surface, no token stealing, no permission rewiring.

Lab service

1com.lab.binder
2  IEcho.aidl
3  EchoService.java          // Java Stub path
4  native/libecho.so         // BnEcho::onTransact path (NDK)
1// IEcho.aidl
2package com.lab.binder;
3interface IEcho {
4    String ping(int seq, String msg);
5}
 1package com.lab.binder;
 2
 3public class EchoService extends Service {
 4    static { System.loadLibrary("echo"); }
 5
 6    @Override
 7    public IBinder onBind(Intent i) {
 8        return nativeBinder();   // BBinder from libecho.so
 9    }
10    private static native IBinder nativeBinder();
11}

The Java AIDL Stub is in the APK too (IEcho$Stub), used by a second lab flavor. This note follows the native BnEcho so there is ARM64 to quote. Both flavors speak the same parcel.

Client (lab Activity):

1IEcho echo = IEcho.Stub.asInterface(binder);
2String out = echo.ping(7, "lab_hello_xx");  // seq=7, msg length 12

aidl generates TRANSACTION_ping = IBinder.FIRST_CALL_TRANSACTION + 0 which is code = 1.

dumpsys / logcat

1adb shell dumpsys activity services com.lab.binder
1  * ServiceRecord{a1b2c3d u0 com.lab.binder/.EchoService}
2    intent={cmp=com.lab.binder/.EchoService}
3    app=ProcessRecord{...:com.lab.binder/u0a142}
4    createTime=-3s10ms
5    Connections:
6      ConnectionRecord{...}
7        BIND_AUTO_CREATE

uid u0a142 is an ordinary app uid. I am not looking at AID_SYSTEM.

1adb logcat -s Binder:V Echo:V lab.binder:V
1D Echo    : onBind nativeBinder=0x7fd0c8a010
2I Binder  : incoming BR_TRANSACTION code=1 from pid=4210 uid=10142
3D Echo    : ping seq=7 msg.len=12 prefix=lab_
4D Echo    : reply noException len=12 prefix=lab_

code=1 matches TRANSACTION_ping. uid=10142 is the client app. Prefix only on msg.

Parcel layout (what the client wrote)

AIDL Java proxy for ping:

 1@Override
 2public String ping(int seq, String msg) throws RemoteException {
 3    Parcel data  = Parcel.obtain();
 4    Parcel reply = Parcel.obtain();
 5    try {
 6        data.writeInterfaceToken("com.lab.binder.IEcho");
 7        data.writeInt(seq);
 8        data.writeString(msg);
 9        mRemote.transact(1, data, reply, 0);
10        reply.readException();
11        return reply.readString();
12    } finally {
13        reply.recycle();
14        data.recycle();
15    }
16}

writeInterfaceToken on this API 33 emulator:

  1. writeInt(strictModePolicy) — lab process, 0.
  2. writeString16("com.lab.binder.IEcho") — int32 length, UTF-16LE chars, UTF-16 NUL, pad to 4.

writeString on Java Parcel is UTF-16 with the same length prefix (not modified UTF-8). writeInt is 4-byte little-endian.

Hex from a Frida hook on android.os.Parcel.nativeWriteInt / I instead dump data in the proxy with a lab build that logs data.marshall():

 1/* parcel_dump.js — com.lab.binder only */
 2Java.perform(function () {
 3  const P = Java.use('android.os.Parcel');
 4  const Stub = Java.use('com.lab.binder.IEcho$Stub$Proxy');
 5  Stub.ping.implementation = function (seq, msg) {
 6    const n = msg ? msg.length() : 0;
 7    const pre = n >= 4 ? msg.substring(0, 4) : '';
 8    console.log('[proxy] seq=' + seq + ' len=' + n + ' prefix=' + pre);
 9    const rc = this.ping(seq, msg);
10    return rc;
11  };
12});

Lab Activity also wrote the marshall dump to logcat as hex (debug build):

1D Echo    : data.marshall len=88
2D Echo    : 00 00 00 00 14 00 00 00  63 00 6f 00 6d 00 2e 00
3D Echo    : 6c 00 61 00 62 00 2e 00  62 00 69 00 6e 00 64 00
4D Echo    : 65 00 72 00 2e 00 49 00  45 00 63 00 68 00 6f 00
5D Echo    : 00 00 00 00 07 00 00 00  0c 00 00 00 6c 00 61 00
6D Echo    : 62 00 5f 00 68 00 65 00  6c 00 6c 00 6f 00 5f 00
7D Echo    : 78 00 78 00 00 00 00 00

"com.lab.binder.IEcho" is 20 chars (0x14), not 19. Field by field:

 1offset  0  00 00 00 00     strictModePolicy = 0
 2offset  4  14 00 00 00     string16 len = 20
 3offset  8  63 00 6f 00 ... UTF-16LE 20 chars of the descriptor
 4offset 48  00 00           UTF-16 NUL
 5offset 50  00 00           pad (writeInplace aligns to 4)
 6offset 52  07 00 00 00     writeInt seq = 7
 7offset 56  0c 00 00 00     string16 len = 12
 8offset 60  6c 00 61 00 62 00 5f 00   'l','a','b','_'  ← prefix only in notes
 9offset 68  ...             remaining 8 UTF-16 chars of the lab dummy
10offset 84  00 00 00 00     NUL + pad

Full marshall is 88 bytes. I am not copying the dummy body past the four-char prefix in the narrative.

writeInterfaceToken must match checkInterface on the other side. Wrong descriptor → SecurityException: Binder invocation to an incorrect interface in Java, or PERMISSION_DENIED (-1) from native checkInterface. That is an interface-token check, not an access-control bypass target in this note.

Native BnEcho::onTransact

 1/* echo.cpp — lab NDK, libbinder */
 2#include <binder/IInterface.h>
 3#include <binder/Parcel.h>
 4#include <binder/IBinder.h>
 5
 6enum { PING = android::IBinder::FIRST_CALL_TRANSACTION };
 7
 8class BnEcho : public android::BBinder {
 9public:
10    android::status_t onTransact(uint32_t code, const android::Parcel& data,
11                                 android::Parcel* reply, uint32_t flags) override {
12        if (code != PING)
13            return android::BBinder::onTransact(code, data, reply, flags);
14        if (!data.checkInterface(this))
15            return android::PERMISSION_DENIED;
16        int32_t seq = data.readInt32();
17        android::String16 msg = data.readString16();
18        /* log length only; copy into 16-byte buf is the lab bug */
19        char buf[16];
20        size_t n = android::String8(msg).bytes();
21        memcpy(buf, android::String8(msg).string(), n + 1);  /* planted */
22        (void)seq;
23        reply->writeNoException();
24        reply->writeString16(msg);
25        return android::OK;
26    }
27};

checkInterface reads the same strictMode int and the UTF-16 descriptor, compares to BnEcho’s interface string. Then readInt32 / readString16 walk the rest. Layout on the wire is the layout in the dump.

ARM64 at onTransact

C++ member, AAPCS64: x0=this, x1=code, x2=&data, x3=reply, w4=flags.

 1; llvm-objdump -d libecho.so
 2; BnEcho::onTransact  VA 0x14c0
 314c0:  a9bc7bfd   stp     x29, x30, [sp, #-0x40]!
 414c4:  910003fd   mov     x29, sp
 514c8:  a90153f3   stp     x19, x20, [sp, #0x10]
 614cc:  a9025bf5   stp     x21, x22, [sp, #0x20]
 714d0:  aa0003f3   mov     x19, x0          ; this
 814d4:  2a0103f4   mov     w20, w1          ; code
 914d8:  aa0203f5   mov     x21, x2          ; Parcel* data
1014dc:  aa0303f6   mov     x22, x3          ; Parcel* reply
1114e0:  7100069f   cmp     w20, #1          ; PING
1214e4:  540001a1   b.ne    1518             ; BBinder::onTransact
1314e8:  aa1503e0   mov     x0, x21
1414ec:  aa1303e1   mov     x1, x19
1514f0:  97ffff80   bl      12f0 <_ZNK7android6Parcel14checkInterfaceEPNS_7IBinderE>
1614f4:  34000200   cbz     w0, 1534         ; PERMISSION_DENIED
1714f8:  aa1503e0   mov     x0, x21
1814fc:  97ffffa4   bl      138c <_ZNK7android6Parcel9readInt32Ev>
191500:  2a0003f4   mov     w20, w0          ; seq
201504:  aa1503e0   mov     x0, x21
211508:  9100c3e1   add     x1, sp, #0x30    ; String16 out-slot
22150c:  97ffffb0   bl      13cc <_ZNK7android6Parcel12readString16Ev>

checkInterface returning 0 is a descriptor mismatch, not a “bypass this”. I do not patch it in this lab.

memcpy into buf[16] is the planted bug. msg of length 12 fits; a 40-char lab string does not.

Sanitized crash

UI set msg to 40 As, seq=7.

1F DEBUG  : pid: 4302, tid: 4320, name: Binder:4302_1
2F DEBUG  : signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x[REDACTED]
3F DEBUG  : backtrace:
4F DEBUG  :   #00 pc 0000000000001510  libecho.so (_ZN6BnEcho10onTransactEjRKN7android6ParcelEPS1_j+0x50)

Thread name Binder:4302_1 is the thread-pool worker, not the UI thread. That is expected: onTransact runs on a binder thread.

ASAN NDK rebuild:

1==4302==ERROR: AddressSanitizer: stack-buffer-overflow
2WRITE of size 41
3    #1 BnEcho::onTransact echo.cpp:24

Frida on the native, length only:

1const p = Module.findExportByName('libecho.so',
2    '_ZN6BnEcho10onTransactEjRKN7android6ParcelEPS1_j');
3Interceptor.attach(p, {
4  onEnter(args) {
5    console.log('[onTransact] code=' + args[1].toInt32());
6  }
7});
1[onTransact] code=1

I do not dump Parcel pointers from production. The hex above is from a debug marshall() of dummy lab_ strings.

What this is not

  • Not dumpsys of activity, package, or appops as an escalation path.
  • Not BIND_* flag abuse.
  • Not forging writeInterfaceToken to call someone else’s service. Token check failure is a failed call.

I file: descriptor com.lab.binder.IEcho, code=1, layout policy + string16 + int32 + string16, native IMP libecho.so+0x14c0, lab bug = 16-byte copy of the message.

Commands appendix

1adb shell dumpsys activity services com.lab.binder
2adb logcat -s Binder:V Echo:V
3unzip -p binder.apk lib/arm64-v8a/libecho.so > libecho.so
4llvm-objdump -d libecho.so | less +/onTransact
5frida -U -f com.lab.binder -l parcel_dump.js --no-pause
6adb logcat -b crash -d | tail -40