Deep-Dive: Intercepting Binder at the ioctl Layer
About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.
This research is published for educational and defensive purposes. The target sample is anonymized throughout.
Deep-Dive: Intercepting Binder at the ioctl Layer
Dynamic analysis and interception of an Android process’s Binder communication.
Intro
Binder is the core mechanism of Android’s cross-process communication, and there are plenty of deep articles online about it. Combined with the system source, they help a lot in understanding Binder’s design and implementation — and in preparing for interception. What can Binder interception get us?
- Virtualization — install-free app-running products from years ago, like
VirtualApp/DroidPluginand various app-cloning / dual-space products. - Testing and verification — usually for
Framework-layer feature development. - Detecting third-party
SDKor module calls to system services (especially sensitiveAPIcalls). - Reverse engineering an app’s lower-layer service-interface calls.
- Extending
Frameworkservices in third-partyROMs.
Existing approach
Up to now, real-time analysis and interception of a process’s Binder communication has been done by proxying AIDL interfaces at the Java layer. Thanks to Android’s Binder service-interface design, upper-layer interfaces all inherit from IBinder.
For example, getting all the interface API methods of a proxy target:
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Proxy;
private static void getInterface(Class<?> cls, final HashSet<Class<?>> ss) {
Class<?>[] ii;
do {
ii = cls.getInterfaces();
for (final Class<?> i : ii) {
if (ss.add(i)) {
getInterface(i, ss);
}
}
cls = cls.getSuperclass();
} while (cls != null);
}
private static Class<?>[] getInterfaces(Class<?> cls) {
final HashSet<Class<?>> ss = new LinkedHashSet<Class<?>>();
getInterface(cls, ss);
if (0 < ss.size()) {
return ss.toArray(new Class<?>[ss.size()]);
}
return null;
}
public static Object createProxy(Object org, InvocationHandler cb) {
try {
Class<?> cls = org.getClass();
Class<?>[] cc = getInterfaces(cls);
return Proxy.newProxyInstance(cls.getClassLoader(), cc, cb);
} catch (Throwable e) {
Logger.e(e);
} finally {
// TODO release fix proxy name
}
return null;
}
- For already-created
Binderservice objects, they’re cached before the app process can participate in the implementation logic, so we need to find and replace them (AMS,PMS,WMS, etc.). For example,AMSon Android 8.0+ is cached like this:
// source code: http://aospxref.com/android-9.0.0_r61/xref/frameworks/base/core/java/android/app/ActivityManager.java
package android.app;
public class ActivityManager {
public static IActivityManager getService() {
return IActivityManagerSingleton.get();
}
private static final Singleton<IActivityManager> IActivityManagerSingleton =
new Singleton<IActivityManager>() {
@Override
protected IActivityManager create() {
final IBinder b = ServiceManager.getService(Context.ACTIVITY_SERVICE);
final IActivityManager am = IActivityManager.Stub.asInterface(b);
return am;
}
};
}
So we need to find and replace it, e.g.:
Object obj;
if (Build.VERSION.SDK_INT < 26) {// <= 7.0
obj = ReflectUtils.getStaticFieldValue("android.app.ActivityManagerNative", "gDefault");
} else {// 8.0 <=
obj = ReflectUtils.getStaticFieldValue("android.app.ActivityManager", "IActivityManagerSingleton");
}
Object inst = ReflectUtils.getFieldValue(obj, "mInstance");
ReflectUtils.setFieldValue(obj, "mInstance", createProxy(inst));
- For
Binderservices that are only obtained later during runtime, we need to proxyServiceManager; the source is:
// source code: http://aospxref.com/android-9.0.0_r61/xref/frameworks/base/core/java/android/os/ServiceManager.java
package android.os;
public final class ServiceManager {
private static final String TAG = "ServiceManager";
private static IServiceManager sServiceManager;
}
So our proxy is:
Class<?> cls = ReflectUtils.findClass("android.os.ServiceManager");
Object org = ReflectUtils.getStaticFieldValue(cls, "sServiceManager");
Object pxy = new createProxy(org);
if (null != pxy) {
ReflectUtils.setStaticFieldValue(getGlobalClass(), "sServiceManager", pxy);
}
Then, on first access to a service, IServiceManager’s getService is called — and it’s already proxied and intercepted. We can identify which service is being requested from the arguments, then proxy the returned service object and return it.
But this approach can’t intercept all of a process’s Binder services. There are several problems:
-
The Android source is getting huge; knowing all services is a lot of work, so figuring out which services are already cached is hard.
-
Vendors increasingly like to extend custom services that aren’t open-source, making identification and adaptation time-consuming.
-
Some services have only
nativeimplementations and can’t be intercepted throughJava-layer interface proxying (e.g.,Sensor/Audio/Video/Cameraservices).// source code: http://aospxref.com/android-13.0.0_r3/xref/frameworks/av/camera/ICamera.cpp class BpCamera: public BpInterface<ICamera> { public: explicit BpCamera(const sp<IBinder>& impl) : BpInterface<ICamera>(impl) { } // start recording mode, must call setPreviewTarget first status_t startRecording() { ALOGV("startRecording"); Parcel data, reply; data.writeInterfaceToken(ICamera::getInterfaceDescriptor()); remote()->transact(START_RECORDING, data, &reply); return reply.readInt32(); } }
New approach: interception at the lowest layer
The principle
Everyone knows the principle of Binder running inside an app process, shown below:

Whether it’s a Java-layer or native-layer interface call, everything ends up going through the ioctl function to access shared memory and exchange data across processes. So if we just hook ioctl, we can intercept all Binder communication data. Low-layer interception has these advantages:
- It can intercept all
Bindercommunication. - It’s stable and highly compatible. From
Android 4.xall the way toAndroid 14— nearly 10 years of system-version evolution — only two adaptations to theBinderlow-layer communication were ever needed. One was 64-bit process support (at the time we had to support both 32-bit and 64-bit processes accessingBinderservices). The other was the arrival of a third-party OEM ROM that added a new flag field to theBindercommunication protocol.
Problems to solve
How to hook
Function interception at the C/C++ layer doesn’t get the fairly stable proxying tools the Java layer provides from the system. That’s not the focus here, so I’ll use open-source Hook frameworks directly:
- https://github.com/bytedance/android-inline-hook
- https://github.com/bytedance/bhook
- https://github.com/asLody/whale
How to filter
ioctl is a low-level system device-access function and is called extremely frequently, while Binder calls are only one of its callers. So we need to quickly identify non-Binder calls without hurting performance.
Function definition:
#include <sys/ioctl.h>
int ioctl(int fildes, unsigned long request, ...);
The request parameter definitions:
// source code: http://aospxref.com/android-14.0.0_r2/xref/bionic/libc/kernel/uapi/linux/android/binder.h
#define BINDER_WRITE_READ _IOWR('b', 1, struct binder_write_read)
#define BINDER_SET_IDLE_TIMEOUT _IOW('b', 3, __s64)
#define BINDER_SET_MAX_THREADS _IOW('b', 5, __u32)
#define BINDER_SET_IDLE_PRIORITY _IOW('b', 6, __s32)
#define BINDER_SET_CONTEXT_MGR _IOW('b', 7, __s32)
#define BINDER_THREAD_EXIT _IOW('b', 8, __s32)
#define BINDER_VERSION _IOWR('b', 9, struct binder_version)
#define BINDER_GET_NODE_DEBUG_INFO _IOWR('b', 11, struct binder_node_debug_info)
#define BINDER_GET_NODE_INFO_FOR_REF _IOWR('b', 12, struct binder_node_info_for_ref)
#define BINDER_SET_CONTEXT_MGR_EXT _IOW('b', 13, struct flat_binder_object)
#define BINDER_FREEZE _IOW('b', 14, struct binder_freeze_info)
#define BINDER_GET_FROZEN_INFO _IOWR('b', 15, struct binder_frozen_status_info)
#define BINDER_ENABLE_ONEWAY_SPAM_DETECTION _IOW('b', 16, __u32)
#define BINDER_GET_EXTENDED_ERROR _IOWR('b', 17, struct binder_extended_error)
The corresponding source:
// source code: http://aospxref.com/android-14.0.0_r2/xref/frameworks/native/libs/binder/IPCThreadState.cpp
void IPCThreadState::threadDestructor(void *st) {
ioctl(self->mProcess->mDriverFD, BINDER_THREAD_EXIT, 0);
}
status_t IPCThreadState::getProcessFreezeInfo(pid_t pid, uint32_t *sync_received, uint32_t *async_received) {
return ioctl(self()->mProcess->mDriverFD, BINDER_GET_FROZEN_INFO, &info);
}
status_t IPCThreadState::freeze(pid_t pid, bool enable, uint32_t timeout_ms) {
return ioctl(self()->mProcess->mDriverFD, BINDER_FREEZE, &info) < 0);
}
void IPCThreadState::logExtendedError() {
ioctl(self()->mProcess->mDriverFD, BINDER_GET_EXTENDED_ERROR, &ee) < 0);
}
status_t IPCThreadState::talkWithDriver(bool doReceive) {
// actual Binder call communication
return ioctl(mProcess->mDriverFD, BINDER_WRITE_READ, &bwr);
}
Quick filtering:
static int ioctl_hook(int fd, int cmd, void* arg) {
if (cmd != BINDER_WRITE_READ || !arg || g_ioctl_disabled) {
return g_ioctl_func(fd, cmd, arg);
}
}
How to parse
Target source: http://aospxref.com/android-14.0.0_r2/xref/frameworks/native/libs/binder
Focus on parsing the send types (i.e. BC_TRANSACTION and BC_REPLY) and the receive types (i.e. BR_TRANSACTION and BR_REPLY).
How to modify data
Modifying data splits into these cases:
- Fixing the call-time argument data.
- Fixing the return/result data after the call.
If the fix doesn’t change the data length — only the content — we can modify it in place by address. Otherwise we need to allocate new memory, modify it, and set the new data address into the buffer member of the BINDER_WRITE_READ structure — while handling the memory release properly.
- Directly intercepting the call.
To keep things stable and avoid breaking the Binder call flow (usually the most important principle for stable interception and reversing), we can change the target function code to a generic parent-class handling method, then intercept by fixing the return value of the call.
Implementation
Parsing the data
The Binder call data structure looks like this:

Parsing bwr
bwr is binder_write_read. From the source, the arg data structure of ioctl’s BINDER_WRITE_READ type is:
struct binder_write_read {
// call data
binder_size_t write_size;// call data
binder_size_t write_consumed;// call data
binder_uintptr_t write_buffer;// call data
// recv data
binder_size_t read_size;// recv data
binder_size_t read_consumed;// recv data
binder_uintptr_t read_buffer;// recv data
};
Whether incoming or returned, the data is a group of BC commands or BR commands — one upper-layer call packs several commands together. So we need to loop to find our command.
void binder_find_for_bc(struct binder_write_read& bwr) {
binder_uintptr_t cmds = bwr.write_buffer;
binder_uintptr_t end = cmds + (binder_uintptr_t)bwr.write_size;
binder_txn_st* txn = NULL;
while (0 < cmds && cmds < end && !txn) {
// Because of the per-call Binder data-size limit, Binder guarantees each call contains one and only one
// effective argument command, so once found we stop; other types are skipped and ignored.
cmds = binder_parse_cmds_bc(cmds, txn);
}
}
The dump data:
write_buffer:0xb400007107d1d400, write_consumed:68, write_size:68
00000000: 00 63 40 40 14 00 00 00 00 00 00 00 00 00 00 00 .c@@............
00000010: 00 00 00 00 01 00 00 00 12 00 00 00 00 00 00 00 ................
00000020: 00 00 00 00 54 00 00 00 00 00 00 00 00 00 00 00 ....T...........
00000030: 00 00 00 00 00 4d 3a ac 70 00 00 b4 00 00 00 00 .....M:.p.......
00000040: 00 00 00 00 ....
BR_NOOP: 0x720c
BR_TRANSACTION_COMPLETE: 0x7206
BR_REPLY: 0
Parsing txn
txn is binder_transaction_data, and the method-argument info of a Binder method call is defined as:
struct binder_transaction_data {
union {
__u32 handle;
binder_uintptr_t ptr;
} target;// target service handle, used by the server side
binder_uintptr_t cookie;// cached Binder access
__u32 code;// method code
__u32 flags;// flags, e.g. whether it is oneway
__s32 sender_pid;
__u32 sender_euid;
binder_size_t data_size;// data length
binder_size_t offsets_size;// if it contains objects, the object data size
union {
struct {
binder_uintptr_t buffer;// Binder method argument value address
binder_uintptr_t offsets;// Binder method argument object data address
} ptr;
__u8 buf[8];
} data;
};
The dump data:
Trace : target: 1 cookie: 0 code: 23 flags: 0x12(READ REPLY)
Trace : pid: 0 uid: 0 size: 196 offs:8
Trace : 00000000: 00 00 00 80 ff ff ff ff 54 53 59 53 1c 00 00 00 ........TSYS....
Trace : 00000010: 61 00 6e 00 64 00 72 00 6f 00 69 00 64 00 2e 00 a.n.d.r.o.i.d...
Trace : 00000020: 61 00 70 00 70 00 2e 00 49 00 41 00 63 00 74 00 a.p.p...I.A.c.t.
Trace : 00000030: 69 00 76 00 69 00 74 00 79 00 4d 00 61 00 6e 00 i.v.i.t.y.M.a.n.
Trace : 00000040: 61 00 67 00 65 00 72 00 00 00 00 00 85 2a 62 73 a.g.e.r......*bs
Trace : 00000050: 13 01 00 00 00 38 dd 2a 71 00 00 b4 00 05 e9 31 .....8.*q......1
Trace : 00000060: 71 00 00 b4 01 00 00 0c 1a 00 00 00 63 00 6f 00 q...........c.o.
Trace : 00000070: 6d 00 2e 00 69 00 66 00 6d 00 61 00 2e 00 74 00 m...i.f.m.a...t.
Trace : 00000080: 72 00 61 00 6e 00 73 00 65 00 63 00 2e 00 63 00 r.a.n.s.e.c...c.
Trace : 00000090: 6f 00 6e 00 74 00 61 00 69 00 6e 00 65 00 72 00 o.n.t.a.i.n.e.r.
Trace : 000000a0: 00 00 00 00 08 00 00 00 73 00 65 00 74 00 74 00 ........s.e.t.t.
Trace : 000000b0: 69 00 6e 00 67 00 73 00 00 00 00 00 00 00 00 00 i.n.g.s.........
Trace : 000000c0: 01 00 00 00 ....
Trace : binder object offs:0x4c type:0x73622a85 flags:0x113 ptr:0x2add3800 cookie:0x31e90500
Parsing the service name
The Binder communication data header is as follows — we can parse out the target service name from it:
void find_server_name(const binder_txn_st* txn) {
const int32_t* ptr = reinterpret_cast<const int32_t*>(txn->data.ptr.buffer);
++ ptr;// skip strict model
if (29 <= sdkVersion()) ++ ptr;// 10.0 <=, skip flags(ff ff ff ff)
int32_t nameLen = *ptr;
const uint16_t* name16 = (const uint16_t*)(ptr+1);
}
Parsing the method name
In the Binder communication data, the parameter identifying the service method is txn->code. After compilation, the AIDL definition class auto-generates a static method for every method.
For example, if the Binder interface methods are defined as:
interface IDemo {
void test();
void test2();
}
Then the compiled class is:
class IDemo$Stub {
void test();
void test2();
static final int TRANSACTION_test = 1;
static final int TRANSACTION_test2 = 2;
}
So via reflection we can find all the static member variables of the class matching the service name, then find the member whose value equals code — that’s the method.
Here we may need to lift the private-API access restriction.
// use the project utility class directly
TstClassPrinter.printStubByCodes("android.app.IActivityManager", 13, 16, 67);
The log output is as follows:
Parsing the data
First we need the data-wrapping class Parcel.
// souce code:
// http://aospxref.com/android-14.0.0_r2/xref/frameworks/native/include/binder/Parcel.h
// http://aospxref.com/android-14.0.0_r2/xref/frameworks/native/libs/binder/Parcel.cpp
With this class we can parse some simpler data and quickly find the target content. But for more complex data — e.g. an argument whose value is Intent, a type that nests multiple layers of Parcelable members — parsing via Parcel at the native layer has poor compatibility. So instead we call back into the Java layer to parse, then format the modified result back into native buffer data. Here we need to handle the data exchange between the Java and native layers, as well as recycling.
native layer:
// create
jobject obtain(JNIEnv* env) {
jclass jcls = env->FindClass("android/os/Parcel");
jmethodID method = env->GetStaticMethodID(jcls, "obtain", "()Landroid/os/Parcel;");
if (!method) return NULL;
mParcelObj = env->CallStaticObjectMethod(jcls, method);
if (!mParcelObj) return NULL;
if (0 < mUparcel->dataSize()) {
method = env->GetMethodID(sParcelClass, "setDataPosition", "(I)V");
if (method) {
unmarshall(env, mUparcel->data(), mUparcel->dataSize());
env->CallVoidMethod(mParcelObj, method, mUparcel->dataPosition());
}
}
return mParcelObj;
}
// recycle
void recycle(JNIEnv* env) {
jclass jcls = env->FindClass("android/os/Parcel");
jmethodID method = env->GetMethodID(jcls, "recycle", "()V");
if (method) {
env->CallVoidMethod(mParcelObj, method);
}
if (mParcelObj) {
env->DeleteLocalRef(mParcelObj);
}
mParcelObj = NULL;
}
Java layer:
public static void clearHttpLink(Parcel p/*IN*/, Parcel q/*OUT*/) {
try {
Intent ii = Intent.CREATOR.createFromParcel(pp);
// TODO something ...
// write new data
q.appendFrom(p, p.dataPosition(), p.dataAvail());
} catch (Throwable e) {
e.printStackTrace();
}
}
Intercepting the data
Parsing and printing Binder data doesn’t change the original content, so it’s relatively simple. Modifying the data is more involved: the fixed data must replace the original, which requires these operations.
- Replacing the data.
Point the method-argument data pointer in txn at the newly created data region.
int binder_replace_txn_for_br(binder_txn_st *txn, ParcelEx* reply, binder_size_t _pos) {
size_t size = reply->ipcDataSize();
uint8_t* repData = (uint8_t*)malloc(size + txn->offsets_size);
memcpy(repData, reply->data(), size);
if (0 < txn->offsets_size) {
binder_replace_objects(txn, repData, _pos, ((int)size) - ((int)(txn->data_size)));
}
txn->data.ptr.buffer = reinterpret_cast<uintptr_t>(repData);
txn->data_size = size;
return 0;
}
- Fixing object pointers.
If the passed arguments contain Binder objects — e.g. the Observe of a register method — the fixed data may shift offset addresses forward or backward, so we need to recalculate the offsets:
void replaceObjects(binder_txn_st *txn, uint8_t* objData, binder_size_t _pos, int _off) {
binder_size_t* offs = reinterpret_cast<binder_size_t*>(txn->data.ptr.offsets);
unsigned count = txn->offsets_size / sizeof(binder_size_t);
while (0 < count--) {
if (0 != memcmp(objData + (int)(*offs), (uint8_t*)txn->data.ptr.buffer + (int)(*offs), sizeof(binder_size_t))) {
*offs += _off;
}
++ offs;
}
}
- Releasing the memory.
We need to save the mapping between the original address A and the new address AA in a custom memory pool.
When the Binder communication command emits BC_FREE_BUFFER or BR_FREE_BUFFER, we take the AA address the command wants to free, look up its corresponding A address in the pool, and set it back so the upper layer continues freeing — closing the memory-use loop.
case BC_FREE_BUFFER:
{
uintptr_t* buffPtr = (uintptr_t *)cmd;
uintptr_t ptr = MemPool::detach(*buffPtr);
if (__UNLIKELY(0 != ptr)) {
*buffPtr = ptr;// set origin buffer
}
cmd += sizeof(uintptr_t);// move to next command
} break;
Appendix
If you need it, you can directly use our already-packaged SDK to implement the above — the project is open-sourced.
Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).
Get in touch