Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/test-bootstrap.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: Host validation

on:
push:
branches: [master, main]
pull_request:
branches: [master, main]

permissions:
contents: read

jobs:
host-validation:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false

- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 21

- uses: actions/setup-python@v6
with:
python-version: '3.x'

- name: Bootstrap filesystem tests
run: sh tests/run.sh

- name: Java logging tests
run: sh tests/run-java.sh

- name: Process runner tests
run: sh tests/run-process.sh

- name: Native safety tests
run: sh tests/run-native-safety.sh

- name: Log collector tests
run: python3 -m unittest discover -s tests -p 'test_collect_logs.py'
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,7 @@ lkm/Module.symvers
out/
app/src/main/jni/splicehelper
app/src/main/jni/ko/*.ko

# Python test and collector bytecode
__pycache__/
*.py[cod]
56 changes: 44 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,55 @@ DFRoot is an Android rooting tool that exploits CVE-2026-43284 (DirtyFrag) — a

## FAQ

**Q: Log says patching failed and that my device isn't vulnerable**
**Q: Log says patching failed**

A: Sorry, there is no fix. Either:
- Your device's kernel is too new
- Your device manufacturer backported the official mitigation
A: A patch failure alone does not establish the cause. Save the complete log and check the preceding errors. Kernel mitigations can prevent the exploit, including:

- A newer kernel containing the official mitigation
- A manufacturer backport of the official mitigation
- [Accidental mitigation](https://github.com/V4bel/dirtyfrag/issues/23#issuecomment-4405314290) (typically seen on kernel 6.1)

**Q: Log says "SUCCESS" or "ksud exited with error", but I don't have root**
**Q: The launcher returned successfully, but I don't have root**

A: A successful launcher exit means the launch command returned zero; it does not verify usable root. Older versions called this "SUCCESS". Check the selected SU manager's status. Turn off **Auto Soft Reboot** while investigating and save the logs before another attempt. See also [the troubleshooting discussion](https://github.com/diabl0w/DFRoot/discussions/69).

**Q: The log says the previous attempt's outcome is unknown**

A: First, try disabling "Auto Soft Reboot" in DFRoot settings if you have it enabled, otherwise https://github.com/diabl0w/DFRoot/discussions/69
A: DFRoot found an unfinished run record after the app process stopped. A reboot, process termination, or another interruption may leave such a record. It does not identify a kernel panic or prove root is available. Save the logs and collect additional diagnostics below.

**Q: I installed a module and now I can't launch root without crashing**

A: Toggle "Disable KSU Modules" in DFRoot settings
A: Enable **Disable KSU Modules** before the next run. DFRoot will mark installed modules disabled before launching the SU daemon, and stop startup if a module cannot be disabled. This may help with a broken module; it does not prevent failures earlier in startup or establish the cause of a crash.

**Q: I installed a module and have "Start at Boot" enabled and now I am bootlooping**

A: Typically Android will detect bootloops and boot you into Safe Mode, otherwise you can usually hold the Volume Down button during boot to enter safe mode. From there, disable "Start on Boot" and enable "Disable KSU Modules" in DFRoot settings or just uninstall DFRoot until you stabilize.
A: Follow your device manufacturer's instructions for entering Safe Mode. If you can open DFRoot in normal mode, turn off **Start at Boot** before further testing. Downloaded apps may be unavailable in Safe Mode; use Android Settings to uninstall DFRoot if needed to stop automatic startup. Uninstalling also removes its saved logs. Restore stable startup before trying again. **Start at Boot** is disabled by default.

## Saving and collecting logs

DFRoot keeps the current and previous run in private, device-protected storage, including manual and boot attempts. Open the app and choose **Save logs** to export a text snapshot through Android's file picker. The export includes the saved app output and available native bootstrap logs. It needs neither root nor storage permission.

A foreground service owns active runs independently of the app screen. If notifications are disabled, Android may show the running service only in Task Manager instead of the notification drawer. Interrupted work is not automatically restarted. **Start at Boot** makes at most one automatic attempt per boot, with a wake lock capped at two minutes; unlocking does not retry a failed attempt.

App output is limited to 128 KiB per run and bootstrap output to 64 KiB per run. Save the failed run before further attempts rotate it out. Startup stops before native work if it cannot prepare separate logs for the new attempt, preserving the previous record. Later logging failures are reported without aborting native execution or cleanup. A force-stop or abrupt reset can leave the final messages unwritten; an incomplete record is reported as an unknown outcome, not a diagnosis of the interruption.

For additional diagnostics, connect a computer with Python 3 and ADB, enable USB debugging, and authorize the computer. Capture the available DFRoot logcat messages and device metadata:

```sh
python3 tools/collect_logs.py --serial DEVICE --output dfroot-logs
```

To capture messages while reproducing a problem, start a timed capture first, then run DFRoot yourself:

```sh
python3 tools/collect_logs.py --serial DEVICE --watch --duration 120 --output dfroot-watch
```

Replace `DEVICE` with the serial shown by `adb devices`. Use `--adb PATH` if ADB is not on your PATH. Each capture creates a local session directory under `--output` (default: `out/diagnostics`). Stop a watch early with Ctrl+C to keep the partial capture.

The default capture filters logcat to DFRoot's tag and includes selected version and boot metadata. On a device with working root, add `--root` to request current `dmesg`, available pstore/last-kmsg records, and saved private app logs. Unavailable or denied sources are recorded as collection failures; their absence does not rule out a kernel panic.

The collector reads diagnostics without clearing logs, changing settings, rebooting, or starting DFRoot. It does not upload anything. Review the files for personal information before sharing them, and include the selected SU manager/version, DFRoot settings, and steps that led to the failure.

## Usage

Expand All @@ -48,11 +79,12 @@ A: Typically Android will detect bootloops and boot you into Safe Mode, otherwis

## Features

- Start on Boot
- Automatic soft reboot (if `late-load --soft-reboot` supported by your SU Manager)
- Optional Start at Boot, disabled by default
- Optional soft reboot when supported by your SU Manager
- RO Partition Protection
- Disable all KernelSU modules, to recover from a broken one
- Shizuku not needed — regain root without WiFi!
- Disable KernelSU modules before daemon launch to help recover from a broken module
- Saved run logs and export through Android's file picker
- No Shizuku or Wi-Fi connection required

## How it works

Expand Down
10 changes: 6 additions & 4 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ android {

signingConfigs {
create("release") {
storeFile = file(signingProps.getProperty("KEYSTORE_FILE"))
storePassword = signingProps.getProperty("KEYSTORE_PASSWORD")
keyAlias = signingProps.getProperty("KEY_ALIAS")
keyPassword = signingProps.getProperty("KEY_PASSWORD")
signingProps.getProperty("KEYSTORE_FILE")?.let { keystoreFile ->
storeFile = file(keystoreFile)
storePassword = signingProps.getProperty("KEYSTORE_PASSWORD")
keyAlias = signingProps.getProperty("KEY_ALIAS")
keyPassword = signingProps.getProperty("KEY_PASSWORD")
}
}
}

Expand Down
20 changes: 18 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="df.root">
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<!-- Compatible SU managers are discovered by their native libksud payload;
there is no shared query intent or fixed package-name allowlist. -->
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<application
android:label="DFRoot"
android:icon="@mipmap/ic_launcher"
Expand All @@ -27,6 +33,16 @@
android:exported="false"
android:parentActivityName=".MainActivity" />

<service
android:name=".RunService"
android:directBootAware="true"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Brief DFRoot native startup requires continuous ownership of live IPsec resources until the child exits; initiated by the user or an enabled boot action." />
</service>

<receiver
android:name=".BootReceiver"
android:directBootAware="true"
Expand Down
57 changes: 57 additions & 0 deletions app/src/main/java/df/root/AppRuns.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package df.root;

import android.content.Context;
import android.util.Log;

import java.io.File;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;

/** Process-wide ownership; never retains an Activity or its views. */
final class AppRuns {
static final ExecutorService IO = Executors.newFixedThreadPool(2);
static final ExecutorService EXPORT = Executors.newSingleThreadExecutor();
static final AtomicBoolean EXPORT_BUSY = new AtomicBoolean();
static final Set<String> ACTIVE_EXPORTS = ConcurrentHashMap.newKeySet();
private static RunCoordinator coordinator;

private AppRuns() {}

static synchronized RunCoordinator get(Context context) {
if (coordinator == null) {
Context app = context.getApplicationContext().createDeviceProtectedStorageContext();
coordinator = new RunCoordinator(Executors.newSingleThreadExecutor(),
new RunLogStore(app.getFilesDir()), message -> Log.i("dfroot", message.trim()));
}
return coordinator;
}

static boolean start(Context context, String origin, Runnable completion) {
Context app = context.getApplicationContext().createDeviceProtectedStorageContext();
return get(app).start(origin, reporter -> {
try (RunService.Lease ignored = RunService.acquire(app)) {
return ExploitRunner.run(app, reporter);
}
}, completion);
}

/** Only our own abandoned snapshots; active picker snapshots are retained. */
static void pruneExports(File cache) {
File[] files = cache.listFiles(file -> file.isFile()
&& file.getName().matches("dfroot-export-[a-zA-Z0-9-]+\\.txt"));
if (files == null) return;
Arrays.sort(files, Comparator.comparingLong(File::lastModified).reversed());
long oldest = System.currentTimeMillis() - 24 * 60 * 60 * 1000L;
int retained = 0;
for (File file : files) {
if (ACTIVE_EXPORTS.contains(file.getName())) continue;
if (file.lastModified() < oldest || retained >= 4) file.delete();
else retained++;
}
}
}
66 changes: 49 additions & 17 deletions app/src/main/java/df/root/BootReceiver.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,70 @@
import android.content.Context;
import android.content.Intent;
import android.os.PowerManager;
import android.provider.Settings;
import android.util.Log;

import java.io.File;

public class BootReceiver extends BroadcastReceiver implements IReporter {
public class BootReceiver extends BroadcastReceiver {
private static final String TAG = "dfroot";

@Override
public void report(String msg) {
Log.i(TAG, msg.trim());
}

@Override
public void onReceive(Context context, Intent intent) {
String action = intent == null ? null : intent.getAction();
if (!Intent.ACTION_LOCKED_BOOT_COMPLETED.equals(action)
&& !Intent.ACTION_BOOT_COMPLETED.equals(action)) return;
if (new File("/dev/df").exists()) {
Log.i(TAG, "boot: already hooked, skipping");
Log.i(TAG, "boot: an attempt already exists; skipping without inferring root status");
return;
}
Log.i(TAG, "boot: " + intent.getAction());
final Context deCtx = context.createDeviceProtectedStorageContext();
// Both broadcasts can arrive during the same boot. A failed attempt is
// not permission to silently retry at unlock or after process restart.
synchronized (BootReceiver.class) {
int bootCount;
try {
bootCount = Settings.Global.getInt(context.getContentResolver(),
Settings.Global.BOOT_COUNT, -1);
} catch (RuntimeException e) {
Log.e(TAG, "boot: cannot identify this boot; automatic launch skipped", e);
return;
}
if (bootCount < 0) {
Log.e(TAG, "boot: boot count unavailable; automatic launch skipped");
return;
}
var attempts = deCtx.getSharedPreferences("boot_attempt", Context.MODE_PRIVATE);
if (attempts.getInt("boot_count", -1) == bootCount) {
Log.i(TAG, "boot: request already handled this boot");
return;
}
if (!attempts.edit().putInt("boot_count", bootCount).commit()) {
Log.e(TAG, "boot: cannot record attempt; automatic launch skipped");
return;
}
}
Log.i(TAG, "boot: " + action);
PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
if (pm == null) {
Log.e(TAG, "boot: PowerManager unavailable");
return;
}
PowerManager.WakeLock wl = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "dfroot:boot");
wl.acquire();
new Thread(() -> {
wl.acquire(120_000L);
Runnable release = () -> {
try {
int rc = ExploitRunner.run(deCtx, this);
Log.i(TAG, "boot: exploit rc=" + rc);
} catch (Exception e) {
Log.e(TAG, "boot: exploit exception", e);
} finally {
wl.release();
if (wl.isHeld()) wl.release();
} catch (RuntimeException e) {
Log.w(TAG, "boot: wake lock already released", e);
}
}, "dfroot-boot").start();
};
try {
AppRuns.start(deCtx, Intent.ACTION_LOCKED_BOOT_COMPLETED.equals(action)
? "locked boot" : "boot", release);
} catch (RuntimeException e) {
release.run();
Log.e(TAG, "boot: could not schedule launch", e);
}
}
}
Loading