Next-Gen Root Framework
Inverted logic root manager: su is invisible by default,
access is granted only to authorized applications.
- What is AOSK
- Core Principles
- Architecture
- Components
- File-based IPC
- Mount Namespace Visibility
- Minfilter - Detector Evasion
- SELinux and System Properties
- Daemon Autostart
- Boot/init_boot Patching
- Ramdisk Format
- Request Protocol
- Binary Response Format
- Authorization
- Grant Storage
- Process Discovery
- Resolved Issues
- Building from Source
- Installation
- Usage
- Project Structure
- Security
- Compatibility
- Comparison with Magisk / KernelSU
- Known Limitations
- Roadmap
- License
AOSK (Android Open Source Kernel Patch) is a root access management system for Android with a fundamentally different security model: by default, su is completely invisible to all applications. No application can detect the presence of root until the user explicitly grants it permission.
Unlike Magisk (which hides root from selected applications) and KernelSU (which operates at the kernel level), AOSK applies inverted logic: root exists, but nobody can see it until the user allows it. This is a fundamentally safer approach.
Magisk and similar tools operate on a "root is visible to everyone except the deny-list" model. This means:
- New applications automatically see root
- If you forget to add an app to the deny-list - it immediately detects root
- Banking apps detect Magisk through dozens of signals (files, properties, mount points)
- The "deny-list" model requires manual management of every application
AOSK flips the model:
- Root is invisible to all applications by default
- The user explicitly grants root to selected applications
- No files, sockets, or ports that detectors can find
- IPC via ordinary files in
/sdcard/and/data/local/tmp/- indistinguishable from normal activity - Mount namespace isolation: granted applications see
/system/xbin/su, everyone else sees nothing
| Principle | Description |
|---|---|
| Default-deny | su is invisible to everyone. Access only through explicit grant. |
| File-based IPC | No sockets, no ports, no binders. Communication via queue files. |
| Mount namespace isolation | Each app in its own mount namespace. su is mounted only in granted apps' namespaces. |
| Minimal footprint | No files in /system/, no suspicious properties, no processes with known names. |
| SELinux tolerant | Daemon runs with setenforce 0 and spoofs AOSP properties. |
| Universality | Works on AOSP, GSI, stock firmware, Samsung, and more. |
| Ramdisk autostart | Patcher injects init.rc + startup script into the boot image ramdisk. |
┌─────────────────────────────────────────────────────────┐
│ Android System │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Bank App │ │ Terminal │ │ Game App │ │
│ │ (denied) │ │(granted) │ │ (denied) │ │
│ └────┬─────┘ └────┬─────┘ └──────────┘ │
│ │ │ │
│ │ no su │ /system/xbin/su mounted │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ Mount Namespace │ │
│ │ Bank: /system/xbin/ = empty │ │
│ │ Terminal: /system/xbin/su → aoskd │ │
│ └─────────────────────────────────────────┘ │
│ │ │ │
│ │ ┌───────────┘ │
│ │ │ File IPC: /sdcard/AOSKP/aosk_queue/ │
│ ▼ ▼ │
│ ┌─────────────────┐ │
│ │ aoskd │ ← root daemon │
│ │ (pid watcher) │ │
│ │ (ns watcher) │ │
│ │ (grant mgr) │ │
│ │ (minfilter) │ │
│ └─────────────────┘ │
│ │ │
│ ▼ │
│ sh -c <command> (root shell execution) │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Patch Pipeline (PC / Device) │
│ │
│ boot/init_boot.img │
│ │ │
│ ▼ │
│ aosk_patcher │
│ ├─ Format detection: LZ4 legacy / gzip / raw CPIO │
│ ├─ Ramdisk decompression │
│ ├─ CPIO parsing │
│ ├─ Injection: aoskd.rc + aoskd_start.sh + init.rc │
│ ├─ Recompression (LZ4 HC level 12 / gzip best) │
│ └─ Assembly: header + kernel + ramdisk + rest │
│ │ │
│ ▼ │
│ Flash: dd if=patched.img of=/dev/block/by-name/... │
└─────────────────────────────────────────────────────────┘
The root access daemon, running with UID 0. The only process with root privileges that accepts requests from clients.
Source code: aoskd/src/main.rs (~806 lines)
Functionality:
- Request handling - monitoring two queue directories every 100 ms
- Authorization - UID verification via
/proc/PID/statusand filestat() - Command execution -
sh -c <cmd>capturing stdout/stderr/exit code - Grant management - grant/revoke per-package with persistence
- Mount namespace watcher - monitoring
/procevery 2 sec, mounting/unmounting su in granted apps' namespaces - Minfilter - bind-mounting an empty file over suspicious folders in the target app's namespace
- Property spoofing - setting AOSP-compatible values like
ro.build.type=user,ro.secure=1, etc. - SELinux -
setenforce 0on startup - Binary installation - copying itself to
/data/local/tmp/aoskdand creating a hardlink/data/local/tmp/su
Daemon lifecycle:
daemon_main()
├─ setenforce 0
├─ spoof_aosp_props() → ro.build.type=user, ro.secure=1, etc.
├─ setup_local() → cp self → /data/local/tmp/aoskd, ln → su
├─ mkdir QUEUE_DIR + APP_QUEUE_DIR
├─ Clean up stale queue files
├─ load_grants() → read /data/local/tmp/aosk_grants
├─ thread: watcher() → poll queues every 100ms
├─ thread: namespace_watcher() → poll /proc every 2sec
└─ loop { sleep(1s) } → main thread waits
The same binary as aoskd. It determines the operating mode by argv[0]: if the name is su or starts with su - client mode, otherwise - daemon.
Source code: aoskd/src/main.rs (function su_main(), lines 562-614)
Behavior:
su -c 'command'- send command to daemon, print result, exit with return codesu 'command'- same without-csuwithout arguments - interactive mode: read commands from stdin, send one at a timeexit- exit interactive modeensure_daemon()- if daemon is not running, automatically starts it from/data/local/tmp/aoskdor/sdcard/AOSKP/aoskd
Request format:
pid:<client_PID>
su_cmd:<command>
The pid: line allows the daemon to determine the real UID via /proc/PID/status.
The boot/init_boot image patcher for Android. Modifies the ramdisk to autostart aoskd on device boot.
Source code: patcher/src/main.rs (~640 lines)
Supported ramdisk formats:
| Format | Magic | Detection | Decompression | Compression |
|---|---|---|---|---|
| LZ4 legacy | 02 21 4C 18 |
First 4 bytes | lz4::block::decompress |
LZ4 HC level 12 |
| gzip | 1F 8B |
First 2 bytes | flate2::GzDecoder |
gzip best |
| raw CPIO | 070701/070702 |
Scan first 4096 bytes | - | - |
| unknown | - | - | - | - |
Patching stages:
[1/6] Detect ramdisk format
[2/6] Parse CPIO archive
[3/6] Inject files:
+ aoskd.rc (init service definition)
+ aoskd_start.sh (startup script)
[4/6] Patch init.rc:
- If init.rc exists → add "import /aoskd.rc"
- If init.rc doesn't exist → create minimal init.rc with early-init exec
- GKI ramdisk: first-stage init doesn't parse .rc from root,
but aoskd.rc is picked up via import
[5/6] Recompress + assemble CPIO
[6/6] Final size (truncate to original via set_len)
Injected files:
aoskd.rc - init service definition:
service aoskd /aoskd_start.sh
class late_start
user root
group root system
oneshot
seclabel u:r:su:s0
aoskd_start.sh - startup script:
#!/system/bin/sh
sleep 10
cp /sdcard/AOSKP/aoskd /data/local/tmp/aoskd
chmod 755 /data/local/tmp/aoskd
mkdir -p /data/local/tmp/aosk_queue
chmod 777 /data/local/tmp/aosk_queue
mkdir -p /sdcard/AOSKP/aosk_queue
/data/local/tmp/aoskdThe sleep 10 delay gives the system time to fully boot and mount /sdcard/, where the aoskd binary is stored.
Output file truncation:
The patcher truncates the output file to the original size via out_file.set_len(input_size). This is necessary because:
- The boot partition has a fixed size
- Injection adds data, but recompression (especially LZ4 HC level 12) can reduce the size
- Typical delta after injection is ~208 bytes (LZ4 HC compresses well)
- The bootloader requires exact match to partition size
Cross-platform desktop application built with Rust + iced 0.13 for managing AOSK via ADB.
Source code: aosk-gui/src/main.rs (~637 lines)
Functionality:
- Device IP input - for ADB over Wi-Fi connection (empty field = USB)
- Daemon status check -
pidof aoskdvia ADB - Daemon launch - copy binary +
nohupstart - Full patch pipeline:
adb root+disable-verity+remount- Boot/init_boot partition search
- Boot image backup on device
adb pull→ patch on PC →adb push→ddflash- Fallback: patch on device if PC patcher is unavailable
- Patcher log - scrollable area with patcher output
- Backup location selection -
/sdcard/AOSKP,/data/local/tmp,/system_ext,/vendor
Dark theme:
Background: #12121A
Primary: #64F58A (green)
Danger: #FF6464 (red)
Cards: #1A1A2E with border #333344
Patch pipeline (detailed):
1. adb root
2. disable-verity (if needed → reboot → wait)
3. remount
4. mkdir on device
5. Copy aoskd + aosk_patcher binaries to /data/local/tmp/
6. Find boot partition:
/dev/block/by-name/init_boot_a
/dev/block/by-name/init_boot
/dev/block/by-name/boot_a
/dev/block/by-name/boot
7. dd if=boot_part of=backup.img
8. adb pull backup.img → PC
9. Run aosk_patcher on PC
(fallback: patch on device)
10. adb push patched.img → device
11. dd if=patched.img of=boot_part
12. Start aoskd
Native Android application for managing AOSK directly on the device.
Source code: app/src/main/java/com/aoskpatch/
Interface tabs:
| Tab | Description |
|---|---|
| Minfilter | Evasion configuration: target apps, hidden folders, hidden packages |
| Install | Daemon status, daemon launch, backup location, patch button |
| Apps | List of all installed apps with grant/revoke switches |
Key classes:
MainActivity.kt - main activity:
- BottomNavigationView with 3 tabs
- Minfilter configuration with SharedPreferences
- Patch pipeline via RootBridge
- RecyclerView with app icons
- Auto-extraction of binaries from assets →
/sdcard/AOSKP/ - App search
RootBridge.kt - root communication bridge:
- File-based IPC via
/sdcard/AOSKP/aosk_queue/and/data/local/tmp/aosk_queue/ - Request format:
pid:<PID>\n<message> - Binary response format:
[stdout+stderr][4 bytes LE exit code] - Fallback: direct
suprocess if daemon is not running - ADB execution via AdbBridge
- Grant reading from file or daemon
AdbBridge.kt - ADB connection (wireless):
- dadb library (pure Java ADB client)
- Auto-generated ADB key pair
- 30-second connection timeout
BootReceiver.kt - autostart after reboot:
BOOT_COMPLETED→ executesu -c 'echo boot'- This triggers
ensure_daemon()in the su client - If aoskd is not running, it will be automatically started
AOSK uses file-based IPC instead of traditional Android mechanisms (binder, unix sockets, etc.). This is a key architectural decision for undetectability.
| Mechanism | Problem |
|---|---|
Abstract unix socket (\0aosk_root_socket) |
SELinux blocks untrusted_app → su |
| TCP/UDP ports | Detectors scan ports |
| Binder | Requires system registration, visible in service list |
| Named unix socket | File in filesystem, discoverable |
Queue directories:
/data/local/tmp/aosk_queue/- primary queue (for root/shell clients)/sdcard/AOSKP/aosk_queue/- app queue (for untrusted_app via sdcard)
Process:
Client Daemon
│ │
├─ Create file req_<pid>_<ts> │
│ Contents: │
│ pid:12345 │
│ su_cmd:id │
│ │
│◄──────── poll every 100ms ────┤
│ ├─ Read req file
│ ├─ Determine UID (pid→/proc→Uid)
│ ├─ Check authorization
│ ├─ Execute sh -c <cmd>
│ ├─ Write req_<pid>_<ts>.resp
│ │ [stdout+stderr][exit_code LE i32]
│ ├─ Delete req file
│ │
├─ Wait for .resp file │
│ (poll every 50ms, │
│ timeout 10 sec) │
│ │
├─ Read .resp │
├─ Delete .resp │
│ │
▼ ▼
Result Next request
Why this is undetectable:
- Files in
/sdcard/AOSKP/aosk_queue/look like normal app activity - No network connections, no sockets, no open ports
- Files are created and deleted quickly (hundreds of milliseconds)
- Even if a detector scans
/sdcard/, files are already deleted by check time /data/local/tmp/aosk_queue/is inaccessible to untrusted_app (shell/root only)
Critical problem: sdcardfs maps UIDs. A file created by an app with UID 10XXX on /sdcard/ shows a fake UID (e.g. 10539) instead of the real one. stat() returns a fake UID.
Solution: two-level verification
fn resolve_uid(req_path: &str) -> Option<u32> {
// Priority 1: pid: line in request → /proc/PID/status
if let Ok(content) = fs::read_to_string(req_path) {
for line in content.lines() {
if line.starts_with("pid:") {
if let Ok(pid) = line[4..].parse::<i32>() {
if let Some(uid) = get_uid_of_pid(pid) {
return Some(uid); // Real UID from /proc
}
}
}
}
}
// Priority 2: stat() UID (for root and shell)
if let Ok(meta) = fs::metadata(req_path) {
let stat_uid = meta.uid();
if stat_uid == 0 || stat_uid == 1000 {
return Some(stat_uid); // root and system - trusted
}
// AOSK_PKG is also trusted (our own app)
if let Some(auid) = get_app_uid_fast(AOSK_PKG) {
if stat_uid == auid {
return Some(stat_uid);
}
}
}
None // Failed to determine UID
}pid: lines are filtered from the request before command processing:
let req = raw
.lines()
.filter(|l| !l.starts_with("pid:"))
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_string();If pid: lines are not filtered, the daemon would attempt to execute pid:12345 as a command and return an error.
This is the heart of the AOSK security model. Every Android application runs in its own mount namespace. AOSK mounts su only in granted apps' namespaces.
namespace_watcher() - background thread running every 2 seconds:
1. Read current grant list
2. For each granted package:
- Find all PIDs via /proc (UID match + cmdline match)
- Get mount namespace inode via stat(/proc/PID/ns/mnt)
3. For each new namespace:
- mount_su_for_pid(pid):
a. nsenter -t PID -m -- sh -c "
mkdir -p /system/xbin
mount -t tmpfs tmpfs /system/xbin
cp /data/local/tmp/su /system/xbin/su
chmod 755 /system/xbin/su
touch /system/bin/su 2>/dev/null
mount --bind /data/local/tmp/su /system/bin/su 2>/dev/null
# Also mount in /data/data/pkg/files/usr/bin/su if it exists
if [ -d /data/data/PKG/files/usr/bin ]; then
touch /data/data/PKG/files/usr/bin/su 2>/dev/null
mount --bind /data/local/tmp/su /data/data/PKG/files/usr/bin/su 2>/dev/null
fi
"
4. For each namespace that is no longer granted:
- unmount_su_for_pid(pid):
nsenter -t PID -m -- sh -c "umount /system/xbin 2>/dev/null"
5. Clean up dead namespaces (process no longer exists)
| Application | /system/xbin/su |
/system/bin/su |
which su |
|---|---|---|---|
| Granted (Terminal) | ✅ exists, executable | ✅ bind mount | ✅ found |
| Not granted (Bank) | ❌ does not exist | ❌ does not exist | ❌ not found |
| Not granted (Game) | ❌ does not exist | ❌ does not exist | ❌ not found |
Before mounting, the daemon checks whether su has already been mounted in the process namespace:
let check_out = Command::new("nsenter")
.args(["-t", &pid.to_string(), "-m", "--"])
.arg("/system/bin/sh")
.arg("-c")
.arg("test -x /system/bin/su -o -x /system/xbin/su")
.status();
if let Ok(st) = check_out {
if st.success() {
return true; // su already visible, no need to mount
}
}For apps that search for su in non-standard paths, the daemon also mounts su at /data/data/<pkg>/files/usr/bin/su:
if uid >= 10000 {
if let Some(pkg) = get_pkg_for_uid_fast(uid) {
let app_bin = format!("/data/data/{}/files/usr/bin", pkg);
let app_su = format!("{}/su", app_bin);
script.push_str(&format!(
" && if [ -d {d} ]; then touch {t} 2>/dev/null; mount --bind {s} {t} 2>/dev/null; fi",
d = app_bin, s = su_src, t = app_su
));
}
}Minfilter is a proactive evasion mechanism that hides suspicious folders and files from targeted applications.
- User specifies target applications (e.g. banking apps)
- Specifies folders to hide (e.g.
/sdcard/TWRP,/data/adb) - Specifies packages to hide (e.g.
com.topjohnwu.magisk) - Daemon performs
mount --bindof an empty file onto each target folder in the target app's mount namespace
fn minfilter_for_pkg(pkg: &str, folders: &str, grants: &Arc<Mutex<HashSet<String>>>) -> String {
// Remove from grants (minfiltered apps should not have root)
grants.lock().unwrap().remove(pkg);
save_grants(&grants.lock().unwrap());
// For each PID of the target app
for pid in get_pids_for_package(pkg) {
hide_folders_for_pid(pid, folders);
}
}
fn hide_folders_for_pid(pid: i32, folders: &str) -> Result<(), String> {
let empty = "/data/local/tmp/aosk_empty";
let _ = fs::File::create(empty); // Empty file
enter_ns_and(pid, || {
for f in folders.split(',') {
let f = f.trim();
if !f.is_empty() {
// Bind-mount empty file onto target folder
let _ = Command::new("mount")
.args(["-o", "bind", empty, f])
.status();
}
}
})
}Target applications:
ru.sberbankmobile (SberBank)
ru.tinkoff.cr (Tinkoff)
com.alfabank.qapp (Alfa-Bank)
ru.vtb.msa (VTB)
Hidden folders:
/sdcard/TWRP (TWRP recovery)
/sdcard/Fox (Fox recovery)
/data/adb (Magisk/KernelSU data)
/sbin (Magisk tmpfs)
Hidden packages:
com.topjohnwu.magisk (Magisk)
org.lsposed.manager (LSPosed)
me.weishu.kernelsu (KernelSU)
io.github.apatcher (APatcher)
fn enter_ns_and<F, R>(pid: i32, f: F) -> Result<R, String>
where F: FnOnce() -> R,
{
// Save own namespace
let self_ns = fs::File::open("/proc/self/ns/mnt")?;
// Open target process namespace
let target_ns = fs::File::open(&format!("/proc/{}/ns/mnt", pid))?;
// Switch
unsafe { libc::setns(target_ns.as_raw_fd(), CLONE_NEWNS)?; }
// Perform operation
let result = f();
// Switch back
unsafe { libc::setns(self_ns.as_raw_fd(), CLONE_NEWNS); }
Ok(result)
}The daemon sets setenforce 0 on startup. This is Permissive mode, which:
- Allows all operations but logs violations
- Permits aoskd to execute
mount,nsenter,setns - Does not block file-based IPC
Ideally, AOSK should work in Enforcing mode with proper SELinux policies, but this requires separate work to create policy files.
The daemon sets AOSP-compatible system property values:
| Property | Value | Purpose |
|---|---|---|
ro.boot.verifiedbootstate |
green |
Indicates verified boot is enabled |
ro.boot.flash.locked |
1 |
Indicates bootloader is locked |
ro.secure |
1 |
Indicates secure boot is enabled |
ro.debuggable |
0 |
Indicates non-debug build |
ro.build.type |
user |
Indicates user (not userdebug) build |
ro.build.tags |
release-keys |
Indicates release keys |
These values are typical for factory AOSP images and contain no signs of root/modification. Banking detectors check these properties.
AOSK ensures daemon autostart through three mechanisms:
The patcher injects into the ramdisk:
aoskd.rc- init service definition (class late_start)aoskd_start.sh- startup script (sleep 10 → copy → chmod → run)- Patched
init.rc- addsimport /aoskd.rc
On Android boot:
- Init parses
init.rc, findsimport /aoskd.rc - Imports
aoskd.rc, registersaoskdservice - After late_start phase (system loaded) init launches
aoskd_start.sh - The script copies aoskd from
/sdcard/AOSKP/to/data/local/tmp/and runs it
When the user or an app calls su, the client automatically checks:
fn ensure_daemon() {
// Check: is aoskd running?
for entry in fs::read_dir("/proc") {
if cmdline == "aoskd" { return; } // Already running
}
// Not running - start it
// Try /data/local/tmp/aoskd
// If not there - try /sdcard/AOSKP/aoskd
// nohup /data/local/tmp/aoskd >/dev/null 2>&1 &
}After device boot, the BroadcastReceiver executes su -c 'echo boot', which triggers ensure_daemon().
Boot partition search priority:
1. /dev/block/by-name/init_boot_a ← Android 12+ with A/B
2. /dev/block/by-name/init_boot ← Android 12+ without A/B
3. /dev/block/by-name/boot_a ← Older Android with A/B
4. /dev/block/by-name/boot ← Older Android without A/B
init_boot (Android 12+) contains only the ramdisk (GKI), boot contains kernel + ramdisk. The patcher handles both formats.
Android boot image header (versions 0-3):
Offset Size Field
0 8 Magic "ANDROID!"
8 4 kernel_size
12 4 kernel_addr (v0-2) / ramdisk_size (v3+)
16 4 ramdisk_size (v0-2)
...
36 4 page_size (v0-2) / unused (v3+)
40 4 header_version
...
The patcher reads:
- Header (page_size bytes) - contains sizes and metadata
- Kernel data (kernel_size + padding)
- Ramdisk data (ramdisk_size) - this is what gets patched
- Rest (dtb, dtbo, etc.)
After modifying the ramdisk, the patcher:
- Updates
ramdisk_sizein the header - Writes header + kernel + new_ramdisk + padding + rest
- Truncates file to original size:
out_file.set_len(input_size)
| Field | v0-2 | v3+ |
|---|---|---|
| page_size | From header (usually 2048 or 4096) | Always 4096 |
| ramdisk_size_offset | 16 | 12 |
| extra fields | yes (tags, second, etc.) | minimal |
Magic: 02 21 4C 18
LZ4 legacy block archive format:
[4 bytes magic: 02214C18]
[4 bytes block_size_1]
[block_size_1 bytes compressed data]
[4 bytes block_size_2]
[block_size_2 bytes compressed data]
...
[4 bytes 0] ← end marker
Bit 31 of block_size = uncompressed flag:
(block_size & 0x7FFFFFFF)= data size(block_size >> 31) & 1= 1 → data is uncompressed (raw copy)
Decompression:
fn lz4_block_decompress(input: &[u8]) -> Option<Vec<u8>> {
// Verify magic
// For each block:
// Read block_size
// If block_size == 0 → end
// If uncompressed → raw copy
// Else → lz4::block::decompress(data, Some(BLOCK_SIZE))
// Collect all blocks into output
}Compression:
fn lz4_block_compress(input: &[u8]) -> Vec<u8> {
// Magic: 0x184C2102 (LZ4 block magic)
// Split into 8MB blocks
// For each block:
// lz4::block::compress(chunk, HC level 12)
// If compressed is smaller → write block_size + compressed
// Else → write (size | 0x80000000) + raw
// End marker: 0
}LZ4 HC level 12 - maximum compression level. Typical delta after injection: ~208 bytes (even with added files, high compression compensates).
Magic: 1F 8B
Standard gzip. Decompression via flate2::GzDecoder, compression via flate2::GzEncoder with Compression::best().
If the string 070701 or 070702 is found in the first 4096 bytes - the ramdisk is uncompressed. Data is read/written directly.
A request is a text file named req_<PID>_<timestamp> in a queue directory.
pid:<client_PID>
<command>
Command types:
| Command | Description | Example |
|---|---|---|
su_cmd:<shell command> |
Execute command as root | su_cmd:id |
grant:<package> |
Grant root to package | grant:com.termux |
revoke:<package> |
Revoke root from package | revoke:com.termux |
minfilter:<pkg>:<folders> |
Hide folders from app | minfilter:ru.sberbankmobile:/sdcard/TWRP,/data/adb |
status |
Query daemon status | status |
Special cases:
- The
statuscommand is available without authorization - The AOSK app (identified via
/proc/PID/cmdline) has grant/revoke/minfilter privileges without authorization pid:lines are NOT passed to the shell, they are filtered out
A response is a binary file named req_<PID>_<timestamp>.resp.
[stdout bytes][stderr bytes][4 bytes: exit_code LE i32]
Response parsing (Kotlin):
private fun parseResponse(bytes: ByteArray): String {
if (bytes.size >= 4) {
val de = bytes.size - 4
val ec = bytes.sliceArray(de..bytes.size - 1)
val exitCode = (ec[0].toInt() and 0xFF) or
((ec[1].toInt() and 0xFF) shl 8) or
((ec[2].toInt() and 0xFF) shl 16) or
((ec[3].toInt() and 0xFF) shl 24)
val output = String(bytes, 0, de, Charsets.UTF_8)
if (exitCode != 0 && output.isEmpty()) {
return "Error:exit=$exitCode"
}
return output.trim()
}
if (bytes.isNotEmpty()) {
return String(bytes, Charsets.UTF_8).trim()
}
return ""
}Text responses (for grant/revoke/status/minfilter):
The daemon uses write_resp() to write a text response + exit code:
fn write_resp(resp_path: &str, data: &str, exit_code: i32) {
let mut r = Vec::new();
r.extend_from_slice(data.as_bytes());
r.extend_from_slice(&exit_code.to_le_bytes());
let _ = fs::write(resp_path, &r);
}Text response formats:
| Command | Success response | Error response |
|---|---|---|
su_cmd:cmd |
[stdout+stderr][exit_code LE] |
[error text][126 LE] |
grant:pkg |
granted:pkg:mounted:N |
Error:empty_pkg |
revoke:pkg |
revoked:pkg:unmounted:N |
Error:empty_pkg |
minfilter:pkg:folders |
filtered:pkg:N |
Error:bad_minfilter |
status |
granted:pkg1,pkg2:N |
- |
The daemon verifies authorization by UID and request content.
1. UID 0 (root) → Always authorized
2. UID 1000 (system) → Always authorized
3. AOSK_PKG → Always authorized (own app)
4. Granted packages → Authorized if UID is in grant list
5. Everyone else → Denied
fn is_aosk_app_pid(pid: i32) -> bool {
let cp = format!("/proc/{}/cmdline", pid);
if let Ok(cmdline) = fs::read_to_string(&cp) {
return cmdline.trim_matches('\0').contains(AOSK_PKG);
}
false
}If a request comes from a PID whose cmdline contains com.aoskpatch, the daemon processes it as a privileged request without grant checking.
fn is_authorized(uid: u32, raw_content: &str, grants: &Arc<Mutex<HashSet<String>>>) -> bool {
if uid == 0 { return true; } // root
if uid == 1000 { return true; } // system
// Check: from AOSK app?
for line in raw_content.lines() {
if line.starts_with("pid:") {
if let Ok(pid) = line[4..].parse::<i32>() {
if is_aosk_app_pid(pid) { return true; }
}
}
}
// Check: UID in grants?
let g = grants.lock().unwrap();
for pkg in g.iter() {
if let Some(app_uid) = get_app_uid_fast(pkg) {
if uid == app_uid { return true; }
}
}
false
}Grants persist across reboots.
Path: /data/local/tmp/aosk_grants
Format: one package per line
com.termux
com.aidlux
org.kde.necessitas
Reading:
fn load_grants() -> HashSet<String> {
let mut set = HashSet::new();
if let Ok(content) = fs::read_to_string(GRANTS_FILE) {
for line in content.lines() {
let pkg = line.trim().to_string();
if !pkg.is_empty() {
set.insert(pkg);
}
}
}
set
}Writing:
fn save_grants(set: &HashSet<String>) {
let content: String = set.iter().cloned().collect::<Vec<_>>().join("\n");
let _ = fs::write(GRANTS_FILE, content);
let _ = Command::new("chmod").args(["666", GRANTS_FILE]).status();
}chmod 666 - so the AOSK app (untrusted_app) can read the grants file directly as a fallback.
For fast UID determination of a package, the daemon uses /data/system/packages.list instead of the slower pm list packages -U:
fn get_app_uid_fast(pkg: &str) -> Option<u32> {
// /data/system/packages.list format:
// com.example 10123 0 /data/data/com.example ...
if let Ok(content) = fs::read_to_string("/data/system/packages.list") {
for line in content.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 && parts[0] == pkg {
if let Ok(uid) = parts[1].parse::<u32>() {
return Some(uid);
}
}
}
}
// Fallback: pm list packages -U
get_app_uid(pkg)
}packages.list is read in O(n) but much faster than launching pm via Command::new().
fn get_pkg_for_uid_fast(target_uid: u32) -> Option<String> {
if let Ok(content) = fs::read_to_string("/data/system/packages.list") {
for line in content.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
if let Ok(uid) = parts[1].parse::<u32>() {
// UID may differ by 100000 (multi-user)
if (uid % 100000) == (target_uid % 100000) {
return Some(parts[0].to_string());
}
}
}
}
}
None
}Comparison modulo 100000 - for compatibility with multi-user Android, where a secondary user's UID = primaryUID + 100000 * userId.
The daemon uses two methods to find processes belonging to a package:
- UID match - if the package UID is known, find processes with matching UID in
/proc/PID/status - cmdline match - find
/proc/PID/cmdlinecontaining the package name
fn get_pids_for_package(pkg: &str) -> Vec<i32> {
let mut pids = Vec::new();
let target_uid = get_app_uid_fast(pkg);
for entry in fs::read_dir("/proc") {
let pid = parse entry name as i32;
if pid <= 1 { continue; }
let mut matched = false;
// Method 1: UID match
if let Some(t_uid) = target_uid {
if let Some(uid) = get_uid_of_pid(pid) {
if uid == t_uid || (uid >= 99000 && (uid % 100000) == (t_uid % 100000)) {
matched = true;
}
}
}
// Method 2: cmdline match (fallback)
if !matched {
if let Ok(cmdline) = fs::read_to_string(format!("/proc/{}/cmdline", pid)) {
if cmdline.trim_matches('\0').contains(pkg) {
matched = true;
}
}
}
if matched { pids.push(pid); }
}
pids
}fn get_uid_of_pid(pid: i32) -> Option<u32> {
let status = format!("/proc/{}/status", pid);
let content = fs::read_to_string(&status).ok()?;
for line in content.lines() {
if line.starts_with("Uid:") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() > 1 {
return parts[1].parse().ok(); // Real UID
}
}
}
None
}/proc/PID/status contains the Uid: field with four values: Real, Effective, Saved, FS UID. We take the first one (Real UID).
During development, a number of critical issues were resolved:
Problem: Building Kotlin with paths containing Cyrillic characters (e.g. /home/gen8elite/Документы/) breaks Gradle/Kotlin compiler.
Solution: Build from an ASCII path via ~/aosk_build_tmp/ with source copy and cleanup after.
Problem: Binaries cannot be executed directly from /sdcard/ - the filesystem is mounted with noexec.
Solution: Binaries are stored in /sdcard/AOSKP/, but executed from /data/local/tmp/ after copying + chmod 755.
Problem: abstract:\0aosk_root_socket - SELinux blocks connections from untrusted_app to su process.
Solution: Switch to file-based IPC. Files in /sdcard/AOSKP/aosk_queue/ are accessible to untrusted_app via sdcardfs.
Problem: stat() on /sdcard/ returns a fake UID. A file created by root (UID 0) shows UID 10539.
Solution: pid: line in request + /proc/PID/status for real UID. stat() UID is used only as fallback for root/system.
Problem: adb shell "/data/local/tmp/aoskd &" - adb keeps the session open, the command does not return.
Solution: nohup /data/local/tmp/aoskd >/dev/null 2>&1 & + timeout on ADB command.
Problem: adb pull/push run as direct adb subcommands, not through adb shell.
Solution: In the desktop GUI, tokio::process::Command::new("adb").args(["pull", ...]) is used directly.
Problem: On GKI devices (Samsung GSI Project Infinity) first-stage init doesn't parse .rc from ramdisk root. init.rc in CPIO root is ignored.
Solution: The patcher creates aoskd.rc (which is picked up via import) and a minimal init.rc with import /aoskd.rc + on early-init exec. Working autostart: su ensure_daemon().
Problem: The daemon tried to execute pid:12345 as a shell command and got an error.
Solution: Filter pid: lines before command processing.
Problem: If the daemon cannot determine UID, it doesn't write a .resp file, and the client waits for a 10 second timeout.
Solution: Always write .resp with error Error:uid_resolve_failed if resolve_uid returns None.
Problem: Ramdisk on Samsung GSI is compressed with LZ4 legacy (magic 0x02214C18), not gzip. Using gzip decompression causes bootloop.
Solution: Auto-detection of format by magic bytes. LZ4 legacy → lz4::block::decompress, gzip → flate2::GzDecoder.
Problem: mount --bind /source/su /system/xbin/su doesn't work if the target file doesn't exist. /system/ = read-only (dm-verity).
Solution: tmpfs overlay: mount -t tmpfs tmpfs /system/xbin → cp su /system/xbin/su. Also touch /system/bin/su 2>/dev/null && mount --bind for /system/bin/su.
Problem: File injection + 16 byte marker + padding increase the output file, which may cause flashing issues.
Solution: out_file.set_len(input_size) - truncate to original size. LZ4 HC level 12 ensures minimal overhead.
Problem: lz4-sys crate (native C binding) requires CC_aarch64_linux_android environment variable for cross-compilation.
Solution: Switch to pure Rust lz4 crate (no C dependencies).
Problem: Without <queries> in the manifest, the app only sees itself and system packages.
Solution: Added <queries> block in AndroidManifest.xml with intent-filter for MAIN and VIEW.
For Rust components (aoskd, patcher, aosk-gui):
- Rust toolchain:
rustup default stable - NDK r27c:
~/Android/Sdk/ndk-bundle/android-ndk-r27c/ - aarch64-linux-android target:
rustup target add aarch64-linux-android
For Android application:
- Android SDK
- Kotlin 2.0.0
- Gradle 8.x
- Android Gradle Plugin 8.5.0
aoskd and su are the same binary. The operating mode is determined by argv[0].
Cross-compilation config (.cargo/config.toml):
[target.aarch64-linux-android]
linker = "/path/to/ndk/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android35-clang"
rustflags = ["-C", "link-arg=-fuse-ld=lld"]Build:
cd aoskd
cargo build --release --target aarch64-linux-androidResult:
aoskd/target/aarch64-linux-android/release/aoskd- ARM64 binary
Install on device:
adb push aoskd/target/aarch64-linux-android/release/aoskd /sdcard/AOSKP/aoskd
adb shell "cp /sdcard/AOSKP/aoskd /data/local/tmp/aoskd && chmod 755 /data/local/tmp/aoskd"Launch:
adb shell "nohup /data/local/tmp/aoskd >/dev/null 2>&1 &"The patcher can be built for two platforms:
- aarch64-linux-android - for running on device
- x86_64-unknown-linux-gnu - for running on PC
Build for device:
cd patcher
cargo build --release --target aarch64-linux-androidBuild for PC:
cd patcher
cargo build --releaseDependencies:
lz4 = "1.28"- pure Rust LZ4 block compression/decompressionflate2 = "1.1"- gzip compression/decompression
Dependencies:
iced = "0.13"(with featuretokio) - GUI frameworktokio = "1"(with featuresprocess,io-util) - async runtimerfd = "0.15"- file dialog (not yet used in UI)
Build:
cd aosk-gui
cargo build --releaseRun:
./target/release/aosk-guibuild.gradle.kts:
android {
namespace = "com.aoskpatch"
compileSdk = 34
defaultConfig {
applicationId = "com.aoskpatch"
minSdk = 26
targetSdk = 34
}
}
dependencies {
implementation("dev.mobile:dadb:1.2.10") // ADB over Wi-Fi
implementation("com.google.android.material:material:1.11.0")
}Binaries in assets:
Before building the APK, copy the compiled binaries:
cp aoskd/target/aarch64-linux-android/release/aoskd app/src/main/assets/aoskd
cp patcher/target/aarch64-linux-android/release/patcher app/src/main/assets/aosk_patcherThe app extracts them on first launch to /sdcard/AOSKP/.
Build:
# If the path contains Cyrillic - use an ASCII symlink:
mkdir -p ~/aosk_build_tmp
cp -r . ~/aosk_build_tmp/
cd ~/aosk_build_tmp
./gradlew assembleDebug
cp app/build/outputs/apk/debug/app-debug.apk ~/aosk_build_tmp/Important: Cyrillic paths break Kotlin compilation. Always build from an ASCII path.
- Connect the device via USB or Wi-Fi ADB
- Run
aosk-gui - Enter the device IP (or leave empty for USB)
- Click "Check" to verify daemon status
- Click "Patch Boot Image"
- Wait for completion (log shows progress)
- Reboot the device
- Install the APK on the device
- Grant file access permission (MANAGE_EXTERNAL_STORAGE)
- Go to the "Install" tab
- Click "Check" to verify daemon
- Click "Patch" to patch the boot partition
- Reboot the device
Step 1: Preparation
adb root
adb disable-verity
adb reboot
# After reboot:
adb root
adb remountStep 2: Copy binaries
adb push aoskd /sdcard/AOSKP/aoskd
adb push aosk_patcher /sdcard/AOSKP/aosk_patcher
adb shell "cp /sdcard/AOSKP/aoskd /data/local/tmp/aoskd && chmod 755 /data/local/tmp/aoskd"
adb shell "cp /sdcard/AOSKP/aosk_patcher /data/local/tmp/aosk_patcher && chmod 755 /data/local/tmp/aosk_patcher"Step 3: Find boot partition
adb shell "ls -l /dev/block/by-name/init_boot_a 2>/dev/null || \
ls -l /dev/block/by-name/init_boot 2>/dev/null || \
ls -l /dev/block/by-name/boot_a 2>/dev/null || \
ls -l /dev/block/by-name/boot 2>/dev/null"Step 4: Backup
adb shell "dd if=/dev/block/by-name/init_boot of=/sdcard/AOSKP/aosk_boot_backup.img"
adb pull /sdcard/AOSKP/aosk_boot_backup.img /tmp/boot_backup.imgStep 5: Patch (on PC)
./aosk_patcher /tmp/boot_backup.img /tmp/boot_patched.imgStep 6: Flash
adb push /tmp/boot_patched.img /sdcard/AOSKP/boot_patched.img
adb shell "dd if=/sdcard/AOSKP/boot_patched.img of=/dev/block/by-name/init_boot"Step 7: Start daemon
adb shell "nohup /data/local/tmp/aoskd >/dev/null 2>&1 &"Step 8: Reboot
adb rebootSingle command:
su -c id
# uid=0(root) gid=0(root)
su -c "cat /proc/version"
# Linux version 5.15. ...
su -c "pm list packages -3"
# package:com.termux
# package:com.aidluxInteractive mode:
su
id
# uid=0(root) gid=0(root)
whoami
# root
ls /data/adb/
# ...
exitWithout arguments (no -c):
su
# su: empty command - use su -c 'command'
# (exit code 1)Via Android application:
- Open the "Apps" tab
- Find the app in the list (or use search)
- Toggle the switch for grant/revoke
- Changes are applied instantly
Via command line (file-based IPC):
# Grant
echo "pid:$$
grant:com.termux" > /sdcard/AOSKP/aosk_queue/req_$$_$(date +%s)
# Revoke
echo "pid:$$
revoke:com.termux" > /sdcard/AOSKP/aosk_queue/req_$$_$(date +%s)
# Check status
echo "status" > /sdcard/AOSKP/aosk_queue/req_$$_$(date +%s)
# Response: granted:com.termux:1Via su (as root):
su -c "echo 'grant:com.termux' > /data/local/tmp/aosk_queue/req_$$_$(date +%s)"Via Android application:
- Open the "Minfilter" tab
- Press "Default" for preset values (Sber, Tinkoff, Alfa, VTB)
- Configure target apps, folders, and packages
- Toggle "Minfilter enabled" switch
Via command line:
# Hide /sdcard/TWRP and /data/adb from SberBank
echo "pid:$$
minfilter:ru.sberbankmobile:/sdcard/TWRP,/data/adb" > /sdcard/AOSKP/aosk_queue/req_$$_$(date +%s)# Via su
su -c "echo 'status' > /data/local/tmp/aosk_queue/req_$$_$(date +%s)"
# Via ADB
adb shell "pidof aoskd"aoskpatch/
├── .gitignore
├── build.gradle.kts # Root Gradle config (AGP 8.5.0, Kotlin 2.0.0)
├── settings.gradle.kts # Gradle settings
├── gradle.properties
├── gradle/
│ └── wrapper/
├── gradlew
├── gradlew.bat
│
├── aoskd/ # Daemon + su client
│ ├── Cargo.toml # edition 2024, dep: libc 0.2.185
│ ├── .cargo/
│ │ └── config.toml # Cross-compile: aarch64-linux-android
│ └── src/
│ └── main.rs # ~806 lines
│ # daemon_main(), su_main()
│ # watcher(), namespace_watcher()
│ # resolve_uid(), is_authorized()
│ # mount_su_for_pid(), minfilter
│ # spoof_aosp_props(), ensure_daemon()
│
├── patcher/ # Boot/init_boot image patcher
│ ├── Cargo.toml # edition 2024, deps: lz4 1.28, flate2 1.1
│ ├── .cargo/
│ │ └── config.toml # Cross-compile config
│ └── src/
│ └── main.rs # ~640 lines
│ # CPIO parser/writer (pure Rust)
│ # LZ4 legacy + gzip decompress/compress
│ # inject_into_ramdisk()
│ # Boot image header parsing (v0-3)
│
├── aosk-gui/ # Desktop GUI (Rust + iced 0.13)
│ ├── Cargo.toml # deps: iced 0.13 (tokio), tokio, rfd
│ └── src/
│ └── main.rs # ~637 lines
│ # AoskApp state, update(), view()
│ # do_patch(), do_check_daemon()
│ # adb_exec(), adb_exec_timeout()
│ # Dark theme, card UI
│
└── app/ # Android application
├── build.gradle.kts # compileSdk 34, minSdk 26, dadb 1.2.10
└── src/
└── main/
├── AndroidManifest.xml # MANAGE_EXTERNAL_STORAGE, BOOT_COMPLETED, queries
├── assets/
│ ├── aoskd # ARM64 binary (daemon + su client)
│ └── aosk_patcher # ARM64 binary (boot patcher)
├── res/
│ ├── layout/
│ │ ├── activity_main.xml # BottomNavigationView + FrameLayout
│ │ ├── fragment_minfilter.xml # Minfilter tab
│ │ ├── fragment_install.xml # Install tab
│ │ ├── fragment_apps.xml # Apps tab (RecyclerView)
│ │ └── item_app.xml # App item
│ ├── menu/
│ │ └── bottom_nav_menu.xml # Bottom nav menu
│ ├── color/
│ │ └── bottom_nav_color.xml # Nav colors
│ └── drawable/
│ ├── ic_filter.xml # Minfilter icon
│ ├── ic_install.xml # Install icon
│ └── ic_apps.xml # Apps icon
└── java/
└── com/
└── aoskpatch/
├── MainActivity.kt # Main activity (~461 lines)
├── RootBridge.kt # File IPC bridge (~207 lines)
├── AdbBridge.kt # ADB over Wi-Fi (~60 lines)
└── BootReceiver.kt # Autostart (~20 lines)
| Threat | Defense |
|---|---|
| App tries to find su | su does not exist in its mount namespace |
| App scans sockets/ports | No sockets, no ports |
| App checks system properties | AOSP value spoofing |
| App scans /sdcard/ | IPC files are created and deleted in milliseconds |
| App checks mount points | tmpfs mount in /system/xbin/ only visible in granted namespace |
| Root app tries to execute su | Must be in grant list or be the AOSK app |
| Attacker replaces request file | UID verified via /proc (not stat() on sdcard) |
| Race condition in IPC | Unique file names (pid + timestamp) |
-
SELinux Permissive -
setenforce 0disables SELinux enforcement. This is needed for mount/nsenter operations. In Enforcing mode, custom SELinux policy will be required. -
Files in /sdcard/ - although they are deleted quickly, an app could theoretically detect them during their brief existence. Mitigation: minimal file lifetime.
-
/data/local/tmp/aosk_grants - file readable by all (chmod 666). A detector could check for its existence. Mitigation: can be hidden via minfilter.
-
aoskd process -
pidof aoskdor/proc/PID/cmdlinecould reveal the daemon. Mitigation: process name can be changed.
| Version | Boot partition | Status |
|---|---|---|
| Android 12+ (GKI) | init_boot | ✅ Tested (Samsung GSI) |
| Android 11 | boot | ✅ Should work |
| Android 10 | boot | ✅ Should work |
| Android 9 | boot | ✅ Should work |
| Android 8 | boot | ✅ Should work |
| Firmware | Status | Notes |
|---|---|---|
| AOSP | ✅ | Full support |
| GSI (Project Infinity) | ✅ | Tested on Samsung |
| Samsung One UI | May require Knox disable | |
| Xiaomi MIUI | Not tested | |
| OnePlus OxygenOS | Not tested | |
| Stock AOSP-based | ✅ | Full support |
- ARM64 (aarch64) processor
- Unlocked bootloader
- ADB access (for initial installation)
- Root via ADB (
adb root) for patching
| Feature | AOSK | Magisk | KernelSU |
|---|---|---|---|
| Security model | Default-deny | Default-allow | Default-allow |
| Root hiding | su invisible by default | Zygisk DenyList | Kernel level |
| IPC mechanism | Files | Socket + binder | Kernel level |
| Requirements | Boot patch | Boot patch | Custom kernel / GKI module |
| SELinux | Permissive (setenforce 0) | Magisk patch | Kernel-level |
| Detectability | Low (no sockets) | Medium (known patterns) | Low |
| Interface | Android app + Desktop GUI | Android app | Android app |
| Compatibility | Universal | Universal | GKI / custom kernel |
| Mount namespace | Per-app su visibility | Zygisk namespace | Kernel namespace |
| Open source | ✅ | ✅ | ✅ |
| Language | Rust + Kotlin | C++ + Kotlin | C + Kotlin |
AOSK vs Magisk:
- Magisk hides root from selected apps. AOSK shows root only to selected apps.
- Magisk uses Zygisk for injection. AOSK uses mount namespaces.
- Magisk is well-known to detectors. AOSK uses atypical mechanisms (file-based IPC).
- Magisk leaves traces (files, properties). AOSK minimizes its footprint.
AOSK vs KernelSU:
- KernelSU operates at the kernel level - more powerful, but requires a custom kernel or GKI module.
- AOSK operates in userspace - easier to install, but depends on SELinux permissive.
- KernelSU has finer control. AOSK has inverted model by default.
-
SELinux Permissive - AOSK requires
setenforce 0. In Enforcing mode, many operations (mount, nsenter, setns) will be blocked. Custom SELinux policy is required for full functionality. -
adb root - initial installation and patching require ADB root. This may not be available on production devices.
-
dm-verity -
disable-verityis required. This may not work on devices with strict verified boot. -
LZ4 HC compression overhead - while LZ4 HC level 12 usually compresses well, if the ramdisk is already heavily compressed, adding files may not fit within the original size. The patcher truncates the file, which may cause data loss at the end.
-
No Zygisk equivalent - AOSK does not inject code into app processes. It only manages su visibility and mount namespaces.
-
Monitoring every 2 sec - namespace_watcher checks /proc every 2 seconds. Between checks, a newly granted app may not see su.
-
Filesystem-based queue - file-based IPC is slower than sockets. Typical request-response: 200-500ms (including 100ms daemon poll interval).
-
ARM64 only - build only for
aarch64-linux-android. ARM32, x86, x86_64 are not supported. -
/sdcard/ noexec - binaries cannot run directly from /sdcard/, require copying to /data/local/tmp/.
-
GKI ramdisk - on GKI devices, first-stage init doesn't parse .rc from ramdisk root. Ramdisk autostart may not work, fallback: ensure_daemon() in su client.
- SELinux Enforcing mode support (custom policy)
- Module system (analog of Magisk modules)
- Zygisk-compatible API for modules
- WebUI for browser-based management
- ARM32, x86, x86_64 support
- System-less approach (no /system/ modification)
- Daemon auto-update
- Grant backup/restore
- Built-in SafetyNet/Play Integrity check
- TWRP recovery integration
- Uninstall protection (device admin)
- Dynamic su binary name (random process name)
- IPC file encryption
- Android 15+ support (new restrictions)
- CI/CD for automated builds
The AOSK project is distributed under an open license. All components are written from scratch without borrowing code from Magisk or KernelSU.
AOSK - root that doesn't exist, until you decide otherwise.