Implementing KernelGP to Extend the Race Window
The talk KernelGP: Racing Against the Android Kernel at OffensiveCon 2025 demonstrates four techniques to leverage Android’s internal design to extend the race window during kernel exploitation. In this post, I will walk through my exploration of the first method — the proxy file descriptor — and explain how I implemented it. I’ll also share some side notes on writing an Android app.
1. JNI
1.1. Introduction
JNI (Java Native Interface) is an interface between Java/Kotlin applications and C/C++ libraries. It allows developers to write C libraries and load them into applications. This is very useful in exploitation development, because applications are written in high-level languages, where we lack fine-grained control over operations.
To write a library, you first need to download the NDK. It includes toolchains for building libraries, most of which are pre-built, so no additional compilation is required. I used android-ndk-r27d-linux.zip
in my virtual machine.
Once uncompressed, you can use JNI APIs to write a library. For example, here is a hello.c
that implements a simple JNI function returning a "Hello World"
string:
#include <jni.h>
JNIEXPORT jstring JNICALL
Java_com_example_myapplication_MainActivity_stringFromJNI(JNIEnv* env, jobject thiz) {
return (*env)->NewStringUTF(env, "Hello from C!");
}
The function name cannot be arbitrary — it must follow JNI naming conventions. The format is: Java_<application_name>_<class_name>_<method_name>
with dots (.
) in names replaced by underscores (_
), and underscores (_
) further escaped as _1
.
The first two parameters are predefined: the JNI environment object (env
) and the caller object (thiz
). The return type must be a Java-compatible type, such as void
, jboolean
, jint
, jstring
, etc.
To compile it, use the toolchain compiler with flags for building a shared object:
~/android-ndk-r27d/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android34-clang -shared -fPIC -o libhello.so hello.c
Then, copy libhello.so
to ~/path_to_your_application/app/src/main/jniLibs/arm64-v8a/
.
In the com.example.myapplication
project, you need to add the attribute android:extractNativeLibs="true"
in AndroidManifest.xml
to ensure the application extracts native shared objects:
<application
[...]
android:extractNativeLibs="true"
>
The MainActivity
class then uses System.loadLibrary()
to load the shared library. The file name must follow the format "libXXXXX.so"
, otherwise it will not be extracted and cannot be loaded. You also need to declare an external function for later use:
init { System.loadLibrary("hello") }
external fun helloworld(): String
Now, you can call helloworld()
anywhere in your application!
1.2. Real JNI
The actual library libfuse_mmap.so
used to trigger FUSE is shown below:
#include <jni.h>
#include <errno.h>
#include <string.h>
#include <unistd.h>
#include <stdio.h>
#include <sys/mman.h>
char buf[0x100];
JNIEXPORT jstring JNICALL
Java_com_example_fuse_1test_MainActivity_mmapfuse(JNIEnv* env, jobject thiz, jint fd /* [1] */) {
void* ptr = mmap(NULL, 0x1000, PROT_READ, MAP_SHARED, fd, 0); // [2]
if (ptr == MAP_FAILED) {
char buf[128];
snprintf(buf, sizeof(buf), "mmap failed: errno=%d (%s)", errno, strerror(errno));
return (*env)->NewStringUTF(env, buf);
}
memset(buf, 'A', sizeof(buf));
memcpy(buf, ptr, 0x10); // [3]
return (*env)->NewStringUTF(env, buf);
}
This function is called mmapfuse()
and belongs to the MainActivity
class in the com.example.fuse_test
project. It takes the FUSE file descriptor as a parameter [1] and maps it into the process address space with mmap()
[2]. When memcpy()
reads from the mapped memory [3], page fault will be handled by the FUSE handler.
2. App
The following is the source code of the application com.example.fuse_test
. I will explain it line by line.
class MainActivity : ComponentActivity() {
// =============== [1] ===============
init { System.loadLibrary("fuse_mmap") }
external fun mmapfuse(fuseFd: Int): String
// ===================================
override fun onCreate(savedInstanceState: Bundle?) { // [2]
super.onCreate(savedInstanceState)
setContent {
// =============== [3] ===============
var output by remember { mutableStateOf("running...") }
var fusePfd by remember { mutableStateOf<ParcelFileDescriptor?>(null) }
var callbackThread by remember { mutableStateOf<HandlerThread?>(null) }
var callbackHandler by remember { mutableStateOf<Handler?>(null) }
// ===================================
LaunchedEffect(Unit) { // [4]
callbackThread = HandlerThread("ProxyFDCallbacks").apply { start() } // [6]
callbackHandler = Handler(callbackThread!!.looper)
val data = "from FUSE callback :)".toByteArray(Charsets.UTF_8)
val sm = getSystemService(STORAGE_SERVICE) as StorageManager // [7]
fusePfd = sm.openProxyFileDescriptor( // [8]
ParcelFileDescriptor.MODE_READ_ONLY,
FuseCallback(data),
callbackHandler
)
output = mmapfuse(fusePfd!!.fd) // [11]
}
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Text(output, modifier = Modifier.padding(innerPadding)) // [12]
}
DisposableEffect(Unit) { // [5]
onDispose {
try { fusePfd!!.close() } catch (_: Exception) {}
callbackThread!!.quitSafely()
}
}
}
}
}
class FuseCallback(private val data: ByteArray) : ProxyFileDescriptorCallback() { // [9]
override fun onGetSize(): Long = data.size.toLong()
override fun onRead(offset: Long, size: Int, dst: ByteArray): Int {
if (offset < 0 || size < 0) throw ErrnoException("onRead", OsConstants.EINVAL)
if (offset >= data.size) return 0
val n = minOf(size, data.size - offset.toInt())
System.arraycopy(data, offset.toInt(), dst, 0, n)
Thread.sleep(2000) // [10]
return n
}
override fun onWrite(offset: Long, size: Int, data: ByteArray): Int {
throw ErrnoException("onWrite", OsConstants.EBADF)
}
override fun onFsync() {}
override fun onRelease() {}
}
First, we load the shared library libfuse_mmap.so
and function mmapfuse()
[1] which was implemented in section 1.2.
Once the application is loaded, the onCreate()
method is invoked [2], so it can be considered the entry point of the application. Next, we define several mutable variables to maintain state within a Composable by keywork remember
[3].
After that, we use LaunchedEffect(Unit)
[4] and DisposableEffect(Unit)
[5] to define the prologue and epilogue handlers when entering and leaving the Composition.
The prologue handler creates a thread "ProxyFDCallbacks"
as a proxy fd handler [6], since the proxy fd must be managed on a separate thread. Then, the getSystemService()
function [7] is called to obtain the StorageManager
system service. Using this handle, we can communicate with the storage manager service and request it to create a proxy fd for us by calling the openProxyFileDescriptor()
function [8].
This function takes three parameters: opened file mode, callback object and handling thread. Since the file mode is set to ParcelFileDescriptor.MODE_READ_ONLY
, we can only read the file but cannot write to it. The FuseCallback()
[9] class extends the callback handler to provide custom behavior.
In the onRead()
handler, we insert a sleep call [10] before returning the read size. As a result, when mmapfuse()
[11] is invoked, the memory copy operation — specifically memcpy(buf, ptr, 0x10)
— on the mapped FUSE fd will block the read access for two seconds.
Later, the output of the function call is displayed on the screen by invoking Text()
[12], which is expected to look like:
Finally, the epilogue closes the FUSE fd and releases the "ProxyFDCallbacks"
thread.
3. Internal
According to the API documentation, we can call getSystemService()
to obtain a handle to a system-level service by name. This allows us to retrieve a StorageManager
instance and access its predefined handlers.
public abstract Object getSystemService (String name)
The StorageManager
class provides the method openProxyFileDescriptor()
, which is implemented in android.os.storage.StorageManager.
@SystemService(Context.STORAGE_SERVICE)
public class StorageManager {
// [...]
public @NonNull ParcelFileDescriptor openProxyFileDescriptor(
int mode, ProxyFileDescriptorCallback callback, Handler handler)
throws IOException {
Preconditions.checkNotNull(handler);
return openProxyFileDescriptor(mode, callback, handler, null);
}
// [...]
}
Internally, the method mountProxyFileDescriptorBridge()
[1] is invoked to obtain an AppFuseMount
object and enter the FUSE app loop to handle file operations.
public @NonNull ParcelFileDescriptor openProxyFileDescriptor(
int mode, ProxyFileDescriptorCallback callback, Handler handler, ThreadFactory factory)
throws IOException {
// [...]
while (true) {
try {
synchronized (mFuseAppLoopLock) {
boolean newlyCreated = false;
if (mFuseAppLoop == null) {
final AppFuseMount mount = mStorageManager.mountProxyFileDescriptorBridge(); // [1]
}
// [...]
mFuseAppLoop = new FuseAppLoop(mount.mountPointId, mount.fd, factory);
// [...]
}
// [...]
}
}
// [...]
}
This call corresponds to a Binder transaction in IStorageManager
, as defined in IStorageManager.aidl. The IStorageManager
service runs inside system_server
, which hosts almost all of the core system services.
interface IStorageManager {
// [...]
AppFuseMount mountProxyFileDescriptorBridge() = 73;
// [...]
}
We can verify this information using the service
and ps
shell commands.
akita:/ # service list | grep mount
242 mount: [android.os.storage.IStorageManager]
akita:/ # ps -A | grep -i system_server
system 1436 903 23594364 775776 do_epoll_wait 0 S system_server
The method mountProxyFileDescriptorBridge()
is implemented in StorageManagerService.java, where it creates an AppFuseMountScope
object [2].
class StorageManagerService extends IStorageManager.Stub
implements Watchdog.Monitor, ScreenObserver {
// [...]
@Override
public @Nullable AppFuseMount mountProxyFileDescriptorBridge() {
// [...]
while (true) {
// [...]
try {
return new AppFuseMount(
name, mAppFuseBridge.addBridge(new AppFuseMountScope(uid, name))); // [2]
} catch (FuseUnavailableMountException e) {
// [...]
}
}
}
// [...]
}
The AppFuseMountScope
class is also defined in StorageManagerService.java. Its open()
method eventually calls mVold.mountAppFuse()
[3] to obtain the FUSE fd.
class AppFuseMountScope extends AppFuseBridge.MountScope {
private boolean mMounted = false;
public AppFuseMountScope(int uid, int mountId) {
super(uid, mountId);
}
@Override
public ParcelFileDescriptor open() throws AppFuseMountException {
extendWatchdogTimeout("#open might be slow");
try {
final FileDescriptor fd = mVold.mountAppFuse(uid, mountId); // [3]
mMounted = true;
return new ParcelFileDescriptor(fd);
} catch (Exception e) {
throw new AppFuseMountException("Failed to mount", e);
}
}
// [...]
}
The method mountAppFuse()
is a Binder call exposed by the Vold (Volume Daemon) process, defined in IVold.aidl.
@SensitiveData
interface IVold {
FileDescriptor mountAppFuse(int uid, int mountId);
}
Vold runs as a dedicated process, which is responsible for handling the FUSE open and mount operations internally:
akita:/ # service list | grep -i vold
# [...]
368 vold: [android.os.IVold]
akita:/ # ps -A | grep -i vold
root 559 1 11025252 9996 binder_thread_read 0 S vold
Finally, within the C++ function MountAppFuse()
, the mount path is defined [4], the "/dev/fuse"
file is opened [5], and the mount operation [6] is performed.
int MountAppFuse(uid_t uid, int mountId, android::base::unique_fd* device_fd) {
std::string name = std::to_string(mountId);
// [...]
std::string path;
// [...]
if (GetMountPath(uid, name, &path) != android::OK) { // [4]
LOG(ERROR) << "Invalid mount point name";
return -1;
}
// [...]
device_fd->reset(open("/dev/fuse", O_RDWR)); // [5]
// [...]
return RunCommand("mount", uid, path, device_fd->get()); // [6]
}