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:
writeInt(strictModePolicy)— lab process, 0.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
dumpsysofactivity,package, orappopsas an escalation path. - Not
BIND_*flag abuse. - Not forging
writeInterfaceTokento 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