# Exec-over-Binder — how the daemon talks to the app: dilemma & design space
**Status:** **IN PROGRESS** — design space exploration for the Binder backend that
lets `observerd` (a pure-Rust native daemon, launched by the manager app via
libsu) hand its privileged exec service to that same app over Android's Binder
IPC instead of an abstract socket. The mechanism is grounded in the Shizuku
reference implementation and verified against the live ROM's `framework.jar`
(Android 14 / SDK 34) and the AOSP android-14.0.0_r1 sources. Revision 4 adds
§3.8, a comparison against the shipped AxManager analogue (a Shizuku fork)
confirming the §3.5/§3.6 wire shape in production and giving an honest
better/worse efficiency assessment against it. Revision 3 resolved §5 to a
pinned-uid per-transaction gate (2B), added a dedup gate to §6, and completed
§3.6.1 with the fully field-by-field `ApplicationInfo` walk (including the two
Parcelling builtins) so the §4 fragility verdict is backed by the exact layout.
Revision 5 deletes the foreground trigger (Shizuku debt): the app launches the
daemon, so the launcher app is the binder holder by construction — the sender is
a bootstrap handoff plus a control-plane re-handoff, never an fg-watching loop.
The dilemmas below are the remaining open design decisions, and the last section
records what has been decided so far.
**Scope:** how the daemon process communicates with the app so the app can request
privileged exec. This document is the "complete picture": the exact Binder
mechanism, every wire-level layout we must reproduce byte-for-byte, the
user-set design constraints (no UID machinery, single-package env whitelist +
control-plane additions, the bootstrap-handoff sender), and the open problems
where a solution is still needed. Implementation of the decided path is out of
scope of this document.
**Decisions at a glance:**
1. Transport is **Binder, not the abstract socket** `@coreshift_exec`. The app
cannot reach the daemon's socket (AuthPolicy denies non-root/shell uids), and
the direction is server→app: the daemon must *push* a service binder into the
app so the app can call back.
2. The handoff uses the only sanctioned app↔shell IPC channel: an **exported
ContentProvider** in the app. The daemon resolves it with
`IActivityManager.getContentProviderExternal`, then calls
`IContentProvider.call("sendBinder", …)` passing its service binder, and
finally releases it with `removeContentProviderExternalAsUser`.
3. **No observer machinery.** No `registerProcessObserver`/`registerUidObserver`,
no `getPackagesForUid`, no per-call runtime permission check. The sender is
driven by **bootstrap + control-plane events** (the launcher app passes its
identity at daemon spawn; `whitelist add` / `re-handoff` commands re-fire it)
— not subscriptions, not the fg mailbox. A stateless
`AIBinder_getCallingUid()` read inside `on_transact` is **not** the same cost
and is kept (see §5).
4. **Authorization is the launcher identity + a package whitelist + a pinned-uid
per-transaction check.** The sender only hands the binder to a package on
the whitelist. The whitelist is two-sourced: a **single baseline package**
seeded at daemon start from the `CORESHIFT_MANAGER` env var (exactly
one package — no comma lists, no glob), plus **ephemeral live additions and
removals** via the daemon's control plane (`whitelist add|remove|list`
socket commands, same-uid gated like `status`/`ping`). The env value is the
durable baseline that survives a clean boot; live adds are session-only and
die with the daemon, so there is exactly one way to grow the set at runtime
and no dual-source-of-truth between "what env said" and "what the live set
is." At handoff the sender pins that package's uid once (from the provider's
`ApplicationInfo` during the §3.6 walk — no extra IPC), and `on_transact`
rejects any caller whose uid is not the pinned one. This closes the
binder-proxy-leak vector without the observer machinery Dilemma 3 rules out.
No app-permission parsing.
5. The target wire is **Android 14 (SDK 34)**; tx codes and parcel layouts below
were resolved against `/mnt/shared/exp/framework.jar` and the AOSP
android-14.0.0_r1 sources. Non-Android builds keep the existing stubs.
---
## 1. The hard constraints (inherited, not negotiable)
- **The daemon is `observerd`, one binary, feature threads.** A pure-Rust native
binary launched by the manager app via libsu (`su -c …observerd daemon`), no
`app_process`, no JVM; it hosts the abstract sockets
`@coreshift_fg` / `@coreshift_fps` / `@coreshift_exec`. The Binder backend must
be a thread (or threads) inside this same process; no helper binary.
- **Core owns Binder.** All Binder work lives in `coreshift_core`
(`src/binder/mod.rs`), android-gated behind `#[cfg(target_os = "android")]`,
with non-Android stubs. The daemon consumes the crate's API.
- **The launcher is the binder holder.** The manager app spawns the daemon and
passes its package at spawn time (launch arg or env); the sender does **not**
consume the fg mailbox or any second source of foreground truth.
- **No client-supplied identity or privilege.** The whitelist is daemon-side
policy — the env baseline and the control-plane adds both arrive from the
operator/daemon environment, never from the wire.
- **Full lifecycle duty:** handoff happens on bootstrap + control-plane triggers,
must be repeatable, must not leak providers, must be testable without an
Android device for the parts that can be (parcel builds, tx resolution);
on-device verification is a
documented requirement.
- **Do not modify unrelated FPS/task work.**
## 2. Why this is a dilemma at all: the app and the daemon are on opposite sides
`@coreshift_exec` (FocusSource, `EXEC-PRIVILEGED-EXECUTION-DILEMMA.md`) is a
root/shell-privileged command runner. Its boundary is `SO_PEERCRED` + a UID
allowlist. That works for *shell tools* that connect out. The new requirement is
different in kind: **a normal sandboxed app** must be able to ask for privileged
execution. The app is uid 10000+ in its own SELinux domain:
- It **cannot connect** to `@coreshift_exec` — even if the kernel allows the
abstract-socket connect, the daemon's AuthPolicy denies any uid other than
0/2000, and (on device) SELinux blocks the connect before that.
- Even if it could, the app has no way to *receive* anything from the daemon —
no listening socket of its own that a shell process is allowed to reach, no
callback channel.
- So the communication is not "app connects to daemon" but **"daemon pushes a
Binder proxy into the app"**, and afterwards the app uses normal
`Binder.transact` against that proxy.
That "push" is the crux. Android has exactly one sanctioned, uid-agnostic way for
an arbitrary process to obtain an arbitrary app's service object: an **exported
ContentProvider**. This is precisely the trick Shizuku uses, and it is what the
rest of this document walks through.
## 3. The complete picture (the mechanism, grounded in sources)
### 3.1 Actors
| Actor | Who | Role |
|---|---|---|
| **daemon / server** | `observerd`, a pure-Rust native binary launched by the manager app via libsu (`su -c …observerd daemon`), shell-uid, no `app_process`, no JVM | hosts the exec `BinderService` (an `AIBinder_Class_define` object + thread pool), runs `BinderSender` |
| **app / client** | the manager app (the daemon's launcher) | hosts an **exported ContentProvider** (authority `<pkg>.coreshift`), receives the server binder, then `Binder.transact`s it |
| **system_server** | uid 1000 (system) | mediates provider resolution via `ActivityManager`; the only process that can start an app's provider and hand out its binder |
### 3.2 The sequence (daemon → app)
```
┌─────────── daemon (shell uid) ──────────┐ ┌───── app (sandbox) ─────┐
│ BinderService (exec) ←←←←←←←←←←←←←←←←←←←←←←←←←←←←← Binder.transact() ─────┐│
│ ▲ │ │ ▲ ││
│ │ on bootstrap (launcher identity) │ │ │ stores binder ││
│ BinderSender: │ │ IContentProvider.call( ││
│ 1. whitelist check (launcher pkg │ │ "sendBinder", ││
│ ⊂ env baseline + control-plane set) │ │ arg, extras{binder}) ││
│ 2. getContentProviderExternal(auth) ──►│──────┐ │ ←───────────────────┘│
│ 3. parse ContentProviderHolder │ │ │ ←───────────────────┘│
│ ─► extract provider binder │ │ │ (app's exported provider)│
│ 4. call("sendBinder", bundle{binder}) ─►│─────┼──►│ │
│ 5. removeContentProviderExternalAsUser(auth, userId) │ │ │ │
└──────────────────────────────────────────┘ ▼ └──────────────────────────┘
system_server
(AM.getContentProviderExternal)
```
1. The daemon starts **because the manager app launched it** (libsu). The launcher's
package is passed at spawn time (launch arg or env) — the sender knows the
intended binder holder without any foreground observation or UID machinery.
2. If the package is on the **whitelist** (the `CORESHIFT_MANAGER` env baseline, or
a live control-plane addition), the sender resolves the app's exported provider
authority `<pkg>.coreshift` through `ActivityManager.getContentProviderExternal`.
3. system_server starts the app if needed, finds the exported provider, and
returns a `ContentProviderHolder` parcel whose fields the daemon parses to
pull out the `IContentProvider` binder.
4. The daemon calls `IContentProvider.call("sendBinder", …)` on that binder,
passing its own **exec service binder** inside a `Bundle`.
5. The app's `call()` implementation reads the binder from the bundle, stores it,
and from then on transacts it directly. The daemon releases the provider with
`removeContentProviderExternalAsUser`; the app keeps the service proxy.
### 3.3 The BinderService the daemon hosts
Reuses the existing NDK Binder machinery in `src/binder/mod.rs`
(`AIBinder_Class_define` + `transact_write` + thread pool
`set_thread_pool_max(0)` / `join_thread_pool`). The service object is a normal
exec-dispatch object; its `on_transact` runs whatever command the app requests.
The app's calls arrive on binder thread-pool threads; exec dispatch itself stays
on the existing exec-channel reactor via the mailbox.
### 3.4 Wire level 1 — `getContentProviderExternal` and the holder reply
Resolved tx code against the **live** `/mnt/shared/exp/framework.jar`:
`IActivityManager.getContentProviderExternal` = **129**,
`removeContentProviderExternalAsUser` (the live Android 14 release method;
the plain `removeContentProviderExternal` is deprecated there and takes an
`IBinder` token, not a user id) = resolved fresh via
`TRANSACTION_removeContentProviderExternalAsUser`,
`getContentProvider` = **30**,
`registerProcessObserver` = **121**, `getFocusedRootTaskInfo` = **165**.
(`IContentProvider$Stub` constants do not exist in framework.jar — the
`call` tx is a stable compile-time constant = 21, see §3.5.)
Request (Java `Parcel.writeInterfaceToken` + args, order per AIDL):
`IActivityManager` token, `writeString(name /* authority */)`,
`writeInt(userId)`, `writeStrongBinder(token /* null */)`,
`writeString(callingTag /* name again */)`.
Reply (server → daemon): `writeNoException()` then the holder as
`writeTypedObject(holder)`, i.e. `writeInt(1)` marker + `holder.writeToParcel`.
On Android 14 the `ContentProviderHolder` is in **`android.app`** and its parcel
is (`ContentProviderHolder.java:58-68`), in this exact order:
```
info.writeToParcel(out) // ProviderInfo — see §3.5, the painful part
out.writeStrongBinder(provider) // ◄── the IContentProvider binder we want
out.writeStrongBinder(connection)
out.writeInt(noReleaseNeeded ? 1 : 0)
out.writeInt(mLocal ? 1 : 0)
```
So to reach `provider`, the daemon must **skip exactly the `info` (ProviderInfo)
parcel**, whose length is not prefixed (plain `writeToParcel`, not
`writeTypedObject`). §4 is the dilemma this creates.
### 3.5 Wire level 2 — `IContentProvider.call` and the `sendBinder` bundle
`CALL_TRANSACTION` = `IBinder.FIRST_CALL_TRANSACTION (1) + 20` = **21** (stable
constant, absent from DEX; from AOSP `IContentProvider.java`). Interface method:
`Bundle call(AttributionSource callingAttributionSource, String authority,
String method, String arg, Bundle extras)`.
Request (Android 14, from AOSP `ContentProviderNative.java:296-313`):
```
writeInterfaceToken("android.content.IContentProvider")
AttributionSource.writeToParcel // S+; see below
writeString(authority)
writeString(method) // "sendBinder"
writeString(arg) // authority again
writeBundle(extras) // holds our service binder
```
`AttributionSource.writeToParcel` (Android 14) delegates to
`AttributionSourceState.writeToParcel` — the AIDL-generated layout
(`AttributionSourceState`, `android.content`, SDK 34 mirror):
```
writeInt(_aidl_start_size) // AIDL: (end_pos − start_pos), INCLUDING the size
// int itself. All-null optionals + pid/uid:
// size 4 + pid 4 + uid 4 + pkg-null 4 + tag-null 4
// + token-null 24 + renounced-null 4 + next-null 4
// = 52. Reader: setDataPosition(start + 52).
writeInt(pid) // e.g. Process.INVALID_PID = -1
writeInt(uid) // 2000 (the daemon's own uid — NOT read by server)
writeString(packageName) // null
writeString(attributionTag) // null
writeStrongBinder(token) // null → flat_binder_object (24 bytes)
writeStringArray(renouncedPermissions) // null → writeInt(-1)
writeTypedArray(next, flags) // null → writeInt(-1)
```
The `extras` `Bundle` must be built so the app can read the binder back. AOSP
`BaseBundle.writeToParcel`: empty bundle = `writeInt(0)`; otherwise
`writeInt(byteLength)` + `writeInt(BUNDLE_MAGIC=0x4C444E42)` + `writeInt(N)` +
per key `writeString(key)` + `writeValue(value)`, where the binder value type is
`VAL_IBINDER = 15` → `writeInt(15)` + `writeStrongBinder(binder)`. For the
single-entry `{"binder": IBinder}` bundle the `writeToParcelInner` length
field is exact: `N=1` 4 + String16 `"binder"` 20 + `VAL_IBINDER` 4 +
flat_binder_object 24 = **52** (the total bundle on the wire is 60 with the
length int and magic). Shizuku wraps
the binder in a `BinderContainer` parcelable instead; either is valid as long as
the app's `call()` reads it back (`.getBinder(key)` / the container).
Reply (app → daemon): `writeNoException()` + `writeBundle(responseBundle)` —
readable with the crate's existing `ParcelReader` primitives
(`read_int32`, `read_string`, `read_strong_binder`).
### 3.6 Wire level 3 — the ProviderInfo chain (the cost of the handoff)
`ContentProviderHolder.info` is a `ProviderInfo` whose parcel is a variable-length
chain (`ProviderInfo.writeToParcel` → `super` = `ComponentInfo` → `super` =
`PackageItemInfo`, plus `ApplicationInfo` inside `ComponentInfo`). Extracted
field orders (Android 14):
- **ProviderInfo** (`ProviderInfo.java:146-159`): `super.writeToParcel`,
`writeString8(authority)`, `writeString8(readPermission)`,
`writeString8(writePermission)`, `writeInt(grantUriPermissions)`,
`writeInt(forceUriPermissions)`, `writeTypedArray(uriPermissionPatterns)`
(PatternMatcher[]), `writeTypedArray(pathPermissions)` (PathPermission[]),
`writeInt(multiprocess)`, `writeInt(initOrder)`, `writeInt(flags)`,
`writeInt(isSyncable)`.
- **ComponentInfo** (`ComponentInfo.java:233-243`): `super.writeToParcel`,
`applicationInfo.writeToParcel` (the big one — full field walk below),
`writeString8(processName)`, `writeString8(splitName)`,
`writeString8Array(attributionTags)`, `writeInt(descriptionRes)`,
`writeInt(enabled)`, `writeInt(exported)`, `writeInt(directBootAware)`.
- **PackageItemInfo** (`PackageItemInfo.java:435-445`): `writeString8(name)`,
`writeString8(packageName)`, `writeInt(labelRes)`,
`TextUtils.writeToParcel(nonLocalizedLabel)` (a CharSequence — **variable**),
`writeInt(icon)`, `writeInt(logo)`, `writeBundle(metaData)` (**variable**),
`writeInt(banner)`, `writeInt(showUserIcon)`.
#### 3.6.1 The full `ApplicationInfo.writeToParcel` walk (the version-fragile core)
Verified field-by-field from AOSP android-14.0.0_r1
(`ApplicationInfo.java:1987-2077`, `Parcel.java`, `Parcelling.java`). This is
what the daemon must skip — and where it reads `uid` (2B in §5) — to reach the
provider binder. Every line is a wire primitive:
1. `dest.maybeWriteSquashed(this)` — **squash marker**: `writeInt(0)` (first
write; the reply parcel is never squash-rewound, so the marker is always 0,
but a non-zero marker would mean "skip to earlier position" and must be
rejected by the walk).
2. `super.writeToParcel` — the PackageItemInfo block (§3.6): `name`,
`packageName`, `labelRes`, `nonLocalizedLabel` CharSequence, `icon`, `logo`,
`metaData` Bundle, `banner`, `showUserIcon`.
3. `writeString8(taskAffinity)`, `writeString8(permission)`,
`writeString8(processName)`, `writeString8(className)` — 4 strings.
4. `writeInt(theme)`, `writeInt(flags)`, `writeInt(privateFlags)`,
`writeInt(privateFlagsExt)`, `writeInt(requiresSmallestWidthDp)`,
`writeInt(compatibleWidthLimitDp)`, `writeInt(largestWidthLimitDp)` — 7 ints.
5. `storageUuid`: `writeInt(0)` (null) **or** `writeInt(1)` + `writeLong` +
`writeLong` (2 longs).
6. `writeString8(scanSourceDir)`, `writeString8(scanPublicSourceDir)`,
`writeString8(sourceDir)`, `writeString8(publicSourceDir)` — 4 strings.
7. `writeString8Array(splitNames)`, `writeString8Array(splitSourceDirs)`,
`writeString8Array(splitPublicSourceDirs)` — 3 arrays; null → `writeInt(-1)`,
else `writeInt(N)` + N×String8.
8. `writeSparseArray(splitDependencies)` — SparseArray<int[]>: null →
`writeInt(-1)`; else `writeInt(N)` + per entry `writeInt(key)` +
`writeValue(int[])` = `writeInt(18)` (VAL_INTARRAY, not length-prefixed) +
`writeIntArray` (`writeInt(len)` + len×`writeInt`).
9. `writeString8(nativeLibraryDir)`, `writeString8(secondaryNativeLibraryDir)`,
`writeString8(nativeLibraryRootDir)`,
`writeInt(nativeLibraryRootRequiresIsa ? 1 : 0)`,
`writeString8(primaryCpuAbi)`, `writeString8(secondaryCpuAbi)`.
10. `writeString8Array(resourceDirs)`, `writeString8Array(overlayPaths)`,
`writeString8(seInfo)`, `writeString8(seInfoUser)`,
`writeString8Array(sharedLibraryFiles)`.
11. `writeTypedList(sharedLibraryInfos)` — List<SharedLibraryInfo>: null →
`writeInt(-1)`; else `writeInt(N)` + per entry `writeTypedObject`
(`writeInt(1)` + `SharedLibraryInfo.writeToParcel` body, or `writeInt(0)`
for a null element).
12. `writeString8(dataDir)`, `writeString8(deviceProtectedDataDir)`,
`writeString8(credentialProtectedDataDir)`.
13. **`writeInt(uid)`** — the pinned-uid source for §5 (2B). Everything from
step 14 on is still walked (the provider binder comes after), so capturing
`uid` costs nothing extra.
14. `writeInt(minSdkVersion)`, `writeInt(targetSdkVersion)`,
`writeLong(longVersionCode)`, `writeInt(enabled ? 1 : 0)`,
`writeInt(enabledSetting)`, `writeInt(installLocation)`,
`writeString8(manageSpaceActivityName)`, `writeString8(backupAgentName)`,
`writeInt(descriptionRes)`, `writeInt(uiOptions)`,
`writeInt(fullBackupContent)`, `writeInt(dataExtractionRulesRes)`.
15. `writeBoolean(crossProfile)` — `writeInt(1|0)`, `writeInt(networkSecurityConfigRes)`,
`writeInt(category)`, `writeInt(targetSandboxVersion)`,
`writeString8(classLoaderName)`, `writeString8Array(splitClassLoaderNames)`,
`writeInt(compileSdkVersion)`, `writeString8(compileSdkVersionCodename)`,
`writeString8(appComponentFactory)`, `writeInt(iconRes)`,
`writeInt(roundIconRes)`, `writeInt(mHiddenApiPolicy)`,
`writeInt(hiddenUntilInstalled ? 1 : 0)`, `writeString8(zygotePreloadName)`,
`writeInt(gwpAsanMode)`, `writeInt(memtagMode)`,
`writeInt(nativeHeapZeroInitialized)`.
16. **`sForBoolean.parcel(requestRawExternalStorageAccess, …)`** — a Parcelling
builtin, **not** a plain boolean: null → `writeInt(1)`, false → `writeInt(0)`,
true → `writeInt(-1)`. The reversed null/true encodings are a trap for any
walk that treats it as `writeInt(0|1)`.
17. `writeLong(createTimestamp)`.
18. `mAppClassNamesByProcess` (SparseArray<String>, written manually): null →
`writeInt(0)` (**not** -1); else `writeInt(N)` + per entry
`writeString(key)` + `writeString(value)` — note **String16** (`writeString`),
unlike the String8s everywhere else.
19. `writeInt(localeConfigRes)`.
20. **`sForStringSet.parcel(mKnownActivityEmbeddingCerts, …)`** — a Parcelling
builtin: null → `writeInt(-1)`; else `writeInt(N)` + N×`writeString`
(String16).
The two Parcelling builtins (steps 16, 20) are internal framework machinery with
no API contract; the `ApplicationInfo` layout as a whole changes across releases
(`privateFlagsExt`, `mAppClassNamesByProcess`, `sForBoolean` all landed in
specific SDKs). This is why §4 calls the walk the version-fragile spot and why it
must be validated against the live jar per device.
The variable-length fields (CharSequence, Bundle, typed arrays, nested
ApplicationInfo) make the provider binder land at a **non-constant offset** — the
daemon must replicate this walk to skip it. §4 Dilemma 1 is about this.
### 3.7 What the app must implement (the other half of the contract)
The app ships an **exported** `ContentProvider` (authority `<pkg>.coreshift`,
`android:exported="true"`) whose `call()` handles `"sendBinder"`:
read the service binder out of the extras bundle, hold it, and expose a
`Binder.transact` wrapper for exec/watch requests. **No AIDL, no client SDK
dependency** — the daemon is hand-rolled NDK parcels (raw transaction codes, no
interface token), so the app mirrors the same wire contract with hand-built
`android.os.Parcel`s: build the request with `writeInt(request_id)` /
`writeInt(argc)` / `writeString(arg)` per token, `transact` with the daemon's
tx-code constants, and read the reply with `readInt(status)` / `readInt(exit)` /
`readInt(pid)` / `readString(stdout)` / `readString(stderr)`. (AIDL would write
an interface token and its own codes, silently shifting the hand-rolled layout
— so the app side must stay a raw `Parcel` mirror.) The daemon only needs the
*provider side* to exist; the whitelist decides *which* apps are handed the
binder.
### 3.8 Reference implementation: AxManager (a working, shipped analogue)
AxManager (github.com/fahrez182/AxManager) is the closest production analogue to
this design: a shell-uid privileged daemon that hands a service binder to
apps. It is a fork of Shizuku's server — it reuses `moe.shizuku.server.*`,
`rikka.hidden.compat.*`, and the Shizuku-API client wholesale — so it is direct
evidence that the mechanism in §3.5/§3.6 is the real, shipped pattern, not a
paper invention. What it does (verified against source):
- **Daemon bootstrap**: `starter.cpp` (a native `app_process` argv builder)
rejects any uid other than 0 or 2000, then fork→setsid→execvp
`/system/bin/app_process` with `-Djava.class.path=<apk>` and the main class
`AxeronService`. That main is just
`Looper.prepareMainLooper(); new AxeronService(); Looper.loop()` — a `Service`
instantiated directly, no ActivityThread machinery. **It runs on the full ART
framework**, which is the entire point: the framework's hidden APIs
(`getContentProviderExternal`, observers, `addPowerSaveTempWhitelistApp`) *are*
the mechanism.
- **Handoff (push model)**: `BinderSender.register()` subscribes a
`ProcessObserver` (foreground changes) + `UidObserver` (GONE/IDLE/ACTIVE/
CACHED). On a uid going active / process coming foreground, it calls
`sendBinder(uid, pid)`, which: (1) dedups on `uid:pid` via a `SENT_BINDERS`
set — the same idea as our §6 dedup gate; (2) resolves the caller's packages
via `getPackagesForUid`; (3) checks `PackageInfo.requestedPermissions` for a
manager or API_V23 permission and confirms it is *granted* via
`checkPermission`; (4) `getContentProviderExternal("<pkg>.shizuku", …)` →
`pingBinder()` → `call(provider, …,"sendBinder", extra)` where `extra` is a
Bundle carrying `BinderContainer(serviceBinder)` →
`removeContentProviderExternalAsUser(name, token, userId)`
in a `finally`. This is exactly our §3.5/§3.6 wire shape, proven in the wild.
If `pingBinder()` fails it `forceStopPackage`s the target and retries once.
- **Temp doze whitelist**: before handoff it calls
`DeviceIdleControllerApis.addPowerSaveTempWhitelistApp(pkg, 30s, …,
REASON_SHELL)` so the target app is not dozed/backgrounded mid-handoff. This
directly addresses the §6 "target killed or dozed before it can use the
binder" race and is a shell-uid-callable API.
- **Per-transaction authz, not pinned uid**: `ShizukuServiceIntercept`
implements `IShizukuService.Stub` and delegates `checkPermission`,
`getFlagsForUid/updateFlagsForUid` to Shizuku's `ConfigManager` (a persisted
per-uid allow/deny JSON). Every call goes through this. `exit()` reads
`getCallingUid()` on the transaction — confirming per-transaction caller
identity is available and used.
**Why it validates this design**: the handoff contract, the dedup, the
provider-per-app naming (`<pkg>.shizuku`), the Bundle-carried binder, the
`pingBinder` dead-provider check, and the use of per-transaction calling uid are
all real, shipped mechanisms. Our §3.6.1 walk exists precisely because AxManager
*doesn't* hand-walk — the framework does it for them.
**Where this design is deliberately better/efficient (and where it isn't)**:
| Dimension | AxManager | This design | Verdict |
|---|---|---|---|
| Daemon footprint | full ART + framework process | native Rust daemon | **better** (ms startup, tiny RSS) |
| Hot path per-call gate | ConfigManager flags/JSON, delegated per call | one stateless `AIBinder_getCallingUid()` read vs a pinned uid, zero IPC | **better** (faster, no state) |
| Handoff blast radius | pushes the binder to *every* installed API_V23 app on every uid/foreground transition | exactly one whitelisted fg app + dedup gate | **better** (less binder surface) |
| Dead-provider handling | `forceStopPackage` (kills the target app) + retry | no kill path; §6 dedup + pinned-uid re-handoff | **better** |
| Walk correctness | inherited from the framework | hand-rolled §3.6.1 walk, ROM-fragile | **worse** — this is the whole Dilemma 1 |
| Authz capability | full per-uid flags + request-permission UI | deliberately minimal 2B gate + same-uid residual | **worse** — narrower on purpose |
The honest summary: this design is more efficient on the hot path and strictly
lighter, but that efficiency is bought with the hand-rolled walk — the exact
fragility Dilemma 1 warns about. AxManager's framework-backed correctness is the
baseline we are trading against, and the walk (§3.6.1) + on-device validation
(§7) is the price.
## 4. Dilemma 1 — Skipping `ProviderInfo` in the holder reply (the engineering crux)
The provider binder sits at `holder.data[after info]`, and `info` has **no length
prefix**. Options:
| Option | Shape | Cost | Risk | Verdict |
|---|---|---|---|---|
| **1A. Full hand-walk** | replicate `ProviderInfo`→`ComponentInfo`→`PackageItemInfo`(+`ApplicationInfo`) field-by-field with the crate's `ParcelReader` (`read_int32`, `read_string8`, skip bundle/typed-array/CharSequence) | one focused module; every field listed in §3.6 is a known primitive or a known skip | **version fragility**: any ROM that reorders/extends these classes silently shifts the binder offset; needs a live-jar cross-check per device | feasible; must be validated against this ROM's jar (§7) |
| **1B. `readStrongBinder` scan** | read the whole reply, treat it as bytes, and `readStrongBinder` at the first plausible boundary | no schema | fragile/hacky; binder handles have no magic number to find by | rejected |
| **1C. Use `getContentProvider` instead of `getContentProviderExternal`** | tx 30 (also resolved) returns the same holder shape | same walk needed | no shorter path; different API surface for shell | rejected (same parse cost) |
| **1D. Ask the app to expose the binder another way** | e.g. the app drops its binder in a file / the daemon calls into a binder the app *already* owns | changes the contract; a sandboxed app cannot host a reachable binder for a shell process except through a provider | rejects the whole premise | rejected |
**Working decision:** 1A, with the walk validated against the live jar before
relying on it. The walk is fully specified in §3.6.1 (every field is a known
primitive or a known skip, including the two Parcelling builtins) and reuses
existing `ParcelReader` primitives; the risk is bounded by an on-device smoke
test (§7) and by keeping the walk in one module so a ROM bump is a localized
fix. The estimate from §3.6.1 is ~120-150 lines plus per-device validation —
dominated by the `ApplicationInfo` tail (steps 14-20), which is the most
release-volatile part.
## 5. Dilemma 2 — What the server binder is authorized to do (the per-call gate)
Shizuku checks the caller on *every* transaction
(`ShizukuService.checkCallerPermission`), and it does so precisely because a
Binder proxy is a **transferable bearer credential, not a bound one**: an
`IBinder` reference is not pinned to the process it was handed to — any process
holding one can forward it through ordinary IPC (an Intent extra, a Bundle sent
to another component, an exported activity/receiver), and then *that* process
transacts the daemon's exec service. The brief's "no uid machinery" was aimed at
the **heavyweight** pieces — `registerProcessObserver`/`registerUidObserver`
subscriptions and `getPackagesForUid` scans, the always-on state Dilemma 3
rightly rejected for driving the *handoff lifecycle*. A stateless
`AIBinder_getCallingUid()` read inside `on_transact` is a different cost class:
a synchronous read of the kernel-supplied caller identity on the transaction
already in flight — conceptually identical to `SO_PEERCRED` on the sibling
`@coreshift_exec` socket, which the design already relies on as unspoofable. The
two are not the same thing, and the constraint is scoped to the former.
The consequence of dropping all per-call verification (2A): a leaked or
re-forwarded proxy grants root-equivalent exec permanently, with zero daemon
visibility — no gate and no log of who is actually calling. That is strictly
weaker than the sibling socket channel, and it is the one decision in this
document that is unsafe to accept by default.
| Option | Mechanism | Protects against | Weakness | Verdict |
|---|---|---|---|---|
| **2A. No gate** | any process holding the proxy can transact exec | nothing at transact time | leaked/re-forwarded proxy = permanent root-exec, invisible to the daemon | **rejected** |
| **2B. Pinned-uid per-transaction check** | at handoff, resolve and pin the whitelisted package's uid **once** (a single lookup — from the `ApplicationInfo.uid` field already walked in §3.6, so no extra IPC and no `getPackagesForUid` scan); store it with that handoff generation; in `on_transact`, reject unless `AIBinder_getCallingUid() == pinned_uid` | a leaked proxy handed to a third process fails immediately — the caller identity reflects who is *actually* transacting now | needs the uid pinned per handoff generation; a stale pin after app restart must be refreshed by the same re-handoff path | **chosen** |
| **2C. Per-transaction shared-secret check** | app must include a token issued at handoff | leakage without the token | more wire machinery; contradicts the "just send the binder" simplicity | rejected |
**Decision:** 2B. The check is a few lines inside the existing dispatch path —
one `AIBinder_getCallingUid()` (the NDK symbol is already dlsym-able through the
same `Vtable` that resolves the rest of libbinder_ndk) compared against the
handoff-generation's pinned uid. It adds no threads, no subscriptions, no
persistent watching — it is not the machinery Dilemma 3 correctly ruled out.
Pinning the uid at handoff time (rather than per call) keeps the "no
`getPackagesForUid` scanning" constraint intact: one lookup per handoff, not per
transaction.
## 6. Dilemma 3 — Sender lifecycle: restarts, multiple users, ordering
The sender is driven by **bootstrap + control-plane events**, not by a
process/uid observer and not by fg resolution. The manager app launches the
daemon (libsu), so the launcher is the binder holder by construction. The sender
is a handoff-on-trigger loop: it fires on daemon start (launcher identity passed
at spawn) and on `whitelist add`/re-handoff control-plane commands. It does not
consume the fg mailbox — that keeps the fg channel's demand-gated publish
untouched (zero demand ⇒ the focus source stays truly lazy; the sender never
forces demand). Consequences:
- **App restart.** When the whitelisted app is killed and restarted, the binder
must reach the new process. Without an observer the daemon cannot see the
restart; the app is responsible for re-triggering through the control plane
(`re-handoff` command) after it comes back up — the app owns the daemon's
lifecycle, so it owns the re-handoff. No timer, no observer.
- **Provider already open.** Calling `getContentProviderExternal` twice on the
same authority may return the same holder; the app's `call()` should be
idempotent (storing the same binder twice is harmless).
`removeContentProviderExternalAsUser` must be paired with each successful
acquire (unless the holder set `noReleaseNeeded`).
- **Non-whitelisted trigger.** The sender must be a no-op for any package not on
the whitelist — the whitelist check happens *before* any Binder work. "On the
whitelist" means the package equals the env baseline or is present in the live
control-plane set. A launcher not on the whitelist is refused at bootstrap.
- **Dedup gate.** Each trigger (bootstrap, `whitelist add`, `re-handoff`) skips
the handoff when the target package matches the last *successfully
handed-off* package and its pinned uid is still current, mirroring the
`want != registered` pattern used in the fps demand-sync logic. This is a
plain equality check on the sender state, not a subscription, and it bounds
the §3.6 walk cost to genuine changes.
**Working decision:** hand off on bootstrap, on `whitelist add`, and on an
explicit `re-handoff` control-plane command — **gated** by the dedup equality
check above. The app owns re-handoff after its own restarts; the restart gap is
closed by the app, not by a daemon-side observer or timer. The sender never
touches the fg mailbox or the demand refcount.
## 7. Dilemma 4 — Validation without a device
`cargo check --target aarch64-linux-android` compiles the android-gated code
(there is no NDK link step needed for check), but no binder transaction can be
executed here. What can be verified in this environment, and what cannot:
| Can verify here | How |
|---|---|
| Tx-code resolution (129/30/121/165/20 + DEX-resolved remove-AsUser) | `src/android/dex.rs` `find_transaction_code` against `/mnt/shared/exp/framework.jar` (already done) |
| The full §3.6.1 walk (ProviderInfo + ApplicationInfo incl. both Parcelling builtins) against the **spec** | unit tests constructing `Parcel` buffers byte-by-byte per §3.6.1 and asserting the walk lands on `uid` and then on the provider binder offset |
| Parcel-build byte layout of the `call` request, the `sendBinder` bundle, and the holder-walk skips | unit tests constructing `Parcel` buffers and asserting offsets against the §3.5/§3.6 orders |
| Compilation of the whole android-gated module | `cargo check --target aarch64-linux-android` |
| Stub correctness on non-Android | `cargo test` on host (stubs return `Unsupported`) |
| Needs a device | Why |
|---|---|
| Actual `getContentProviderExternal` round-trip (permissions, SELinux for the shell uid resolving an app's provider) | system_server policy, not reproducible locally |
| The ProviderInfo/ApplicationInfo walk against the *real* device holder — **including `uid`** | ROM differences beyond the one live jar; the §3.6.1 layout (esp. steps 16-20) is release-volatile |
| End-to-end app handoff + transact | needs the app side installed |
**Decision:** build a host-runnable `tests/` module that byte-checks every parcel
the daemon writes against the documented layouts, plus a device smoke-test
procedure as a documented release requirement. The `tests/tx_resolve_tmp.rs`
harness is the seed for the tx-code half of that.
## 8. Decision record
1. **Transport (§3):** Binder; the daemon pushes its exec `BinderService` proxy
into the app through the app's exported ContentProvider
(`getContentProviderExternal` → `IContentProvider.call("sendBinder")` →
`removeContentProviderExternalAsUser`).
2. **Sender driver (§3.2):** bootstrap handoff plus control-plane re-handoff —
the launcher app passes its identity at daemon spawn (libsu `su -c`); the
sender fires on daemon start, on `whitelist add`, and on an explicit
`re-handoff` command, each **gated by a dedup equality check** against the
last successfully handed-off package+pinned uid (the fps `want !=
registered` pattern), so the §3.6 walk cost is bounded to genuine changes.
No fg mailbox, no UID/process observers.
3. **Authorization (§5):** 2B — per-transaction pinned-uid gate. At handoff,
resolve the whitelisted package's uid once (from `ApplicationInfo.uid` in the
§3.6 walk; no extra IPC, no `getPackagesForUid`), pin it to that handoff
generation, and reject any `on_transact` caller whose
`AIBinder_getCallingUid()` does not match. No observers, no subscriptions, no
shared secrets. This closes the binder-proxy-leak vector; the package
whitelist (env baseline + control-plane set) remains the only *acquisition*
gate, applied before any Binder work.
4. **ProviderInfo skip (§4):** 1A hand-walk in one module, fully specified in
§3.6.1 (every field incl. both Parcelling builtins), validated against the
live jar (§7); documented as the fragile, version-sensitive spot. The
shipped AxManager analogue (§3.8) validates the surrounding mechanism but
deliberately does *not* hand-walk — the framework does it for them — so this
walk is the unique fragility this design trades for its footprint gains.
5. **Wire target (§3.4-3.6):** Android 14 / SDK 34 exclusively; tx codes
resolved from the live framework.jar (getContentProviderExternal=129,
removeContentProviderExternalAsUser=DEX-resolved, getContentProvider=30,
registerProcessObserver=121, getFocusedRootTaskInfo=165,
getPackagesForUid=20, CALL_TRANSACTION=21 stable).
6. **App contract (§3.7):** exported provider authority `<pkg>.coreshift`
implementing `call("sendBinder")`; idempotent store of the received binder;
a hand-built `Parcel` transact mirror of the daemon's wire contract — no
AIDL, no client SDK dependency.
7. **Validation (§7):** host-runnable parcel byte-tests + `cargo check --target
aarch64-linux-android` + documented device smoke test.
## 9. How to respond
This document's purpose is to give the complete picture so a better solution can
be found if one exists. In particular, challenge:
- §4 (is there a *cheaper* way to reach the provider binder than replicating the
whole ProviderInfo chain?);
- §5 (is the pinned-uid gate enough? the residual risk is a leaked proxy being
transacted from *the same uid* — e.g. an exported component of the pinned app
forwarding it to another process *of that same app*, or a malicious process
holding the app's uid — which 2B does not cover; the socket channel's
`SO_PEERCRED` has the same blind spot, so this is at parity, but it is worth
stating);
- §6 (is the no-observer restart gap acceptable? the app owns re-handoff after
its own restarts via the control plane — challenge whether that responsibility
split holds on-device, or whether a timer re-send is needed after all).
Any change to transport, authorization, or the sender lifecycle updates this
record with the design docs together.
---
*Constraint references: `src/binder/mod.rs` (Vtable at 260-292, `transact_write`,
thread pool, stubs), `src/android/dex.rs` (`find_transaction_code`),
`tests/tx_resolve_tmp.rs` (live-jar resolution); FocusSource
`src/daemon/{mod,hub,fg,exec}.rs`, `EXEC-PRIVILEGED-EXECUTION-DILEMMA.md`;
Shizuku `starter.cpp`, `BinderSender.java:185`, `ShizukuService.java:331-392`,
`IContentProviderUtils.java`; AxManager (github.com/fahrez182/AxManager)
`server/src/main/cpp/starter.cpp`, `BinderSender.java`,
`AxeronService.kt` (`sendBinderToUserApp`), `ServiceStarter.java`,
`ShizukuServiceIntercept.kt`, `AxeronConfigManager.java`, `Shell.java`,
`manager/.../AxManagerProvider.kt`; AOSP android-14.0.0_r1
`ContentProviderNative.java:296-313`,
`IContentProvider.java:152-153`, `ContentProviderHolder.java:58-68`,
`ProviderInfo.java:146-159`, `ComponentInfo.java:233-243`,
`PackageItemInfo.java:435-445`, `ApplicationInfo.java:1987-2077`,
`AttributionSourceState` (SDK 34 AIDL), `BaseBundle`,
`Parcel.java` (incl. `writeSparseArray` 1445, `maybeWriteSquashed` 2698,
`writeTypedList` 2051, `writeValue` 2348, `isLengthPrefixed` 4762),
`Parcelling.java` (ForBoolean 252, ForStringSet 171); live ROM
`/mnt/shared/exp/framework.jar`, `build.prop` (SDK 34).*