# 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 shell-uid `app_process` daemon) hand its privileged exec
service to a foreground 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. 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 (uid 2000) 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, env-var package whitelist, sender
driven by the existing fg resolution via mailbox), 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 `removeContentProviderExternal`.
3. **No observer machinery.** No `registerProcessObserver`/`registerUidObserver`,
no `getPackagesForUid`, no per-call runtime permission check. The sender is
driven by the existing foreground-resolution mailbox, not subscriptions. A
stateless `AIBinder_getCallingUid()` read inside `on_transact` is **not** the
same cost and is kept (see §5).
4. **Authorization is the fg resolution + an env-var whitelist + a pinned-uid
per-transaction check.** The sender only hands the binder to a package whose
name is in the `CORESHIFT_EXEC_*` env whitelist; at handoff it 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.** It runs today as
uid 2000 (shell) via `app_process`, hosting 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.
- **Reuse the existing fg resolution.** The foreground package name is already
produced by the fg channel and delivered through the crate's `Mailbox`
pattern. The sender must consume that, not invent a second source of truth.
- **No client-supplied identity or privilege.** The whitelist is daemon-side
policy from the environment, never from the wire.
- **Full lifecycle duty:** handoff happens on fg change, 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` via `app_process`, uid 2000, SELinux `untrusted_app`-adjacent shell domain | hosts the exec `BinderService` (an `AIBinder_Class_define` object + thread pool), runs `BinderSender` |
| **app / client** | a foreground sandboxed app | hosts an **exported ContentProvider** (authority `<pkg>.shizuku`-style), 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 (uid 2000) ───────────┐ ┌───── app (sandbox) ─────┐
│ BinderService (exec) ←←←←←←←←←←←←←←←←←←←←←←←←←←←←← Binder.transact() ─────┐│
│ ▲ │ │ ▲ ││
│ │ on fg pkg change (mailbox) │ │ │ stores binder ││
│ BinderSender: │ │ IContentProvider.call( ││
│ 1. env whitelist check (pkg) │ │ "sendBinder", ││
│ 2. getContentProviderExternal(auth) ──►│──────┐ │ arg, extras{binder}) ││
│ 3. parse ContentProviderHolder │ │ │ ←───────────────────┘│
│ ─► extract provider binder │ │ │ (app's exported provider)│
│ 4. call("sendBinder", bundle{binder}) ─►│─────┼──►│ │
│ 5. removeContentProviderExternal(auth) │ │ │ │
└──────────────────────────────────────────┘ ▼ └──────────────────────────┘
system_server
(AM.getContentProviderExternal)
```
1. The daemon learns the foreground package via the **existing fg resolution**
(mailbox), not a UID observer.
2. If the package is on the **env-var whitelist**, the sender resolves the app's
exported provider authority `<pkg>.shizuku` 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
`removeContentProviderExternal`; 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**,
`removeContentProviderExternal` = **130**, `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) // byte length of the following fields, backpatched
writeInt(pid) // e.g. Process.INVALID_PID = -1
writeInt(uid) // 2000 (the daemon's own uid — NOT read by server)
writeString(packageName) // daemon's package ("android" / ours) — optional
writeString(attributionTag) // null
writeStrongBinder(token) // sDefaultToken (an internal binder) or null
writeStringArray(renouncedPermissions) // null → writeInt(-1)
writeTypedArray(next, flags) // AttributionSourceState[] — empty → writeInt(0)
```
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 = 22` → `writeInt(22)` + `writeStrongBinder(binder)`. 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>.shizuku`,
`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 requests. This is the same contract Shizuku's
client SDK provides. 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)` → `removeContentProviderExternal`
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 fg-resolution (mailbox), not by a process/uid observer.
That has consequences:
- **App restart.** Shizuku re-sends the binder on `registerUidObserver`
`UID_OBSERVER_*` events. Without an observer, a *whitelisted* app that is
killed and restarted while still foreground gets a stale/absent provider
handoff until the next fg *change* event. Options: (a) accept the gap and
re-handoff on every fg notification even for an unchanged package; (b) accept a
*timer* re-send while the fg package is whitelisted; (c) reintroduce a minimal
`registerProcessObserver`-style wakeup — which the user constraint excludes.
- **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). `removeContentProviderExternal`
must be paired with each successful acquire.
- **Non-whitelisted fg.** The sender must be a no-op (do not resolve providers
for packages not on the whitelist) — the whitelist check happens *before*
any Binder work.
- **Ordering vs. fg resolution.** If the fg resolution reports the package after
a slight delay, a just-started app may be missed; the same re-send policy as
(a) covers it.
- **Redundant notifications.** The ProviderInfo hand-walk (§3.6) is a real cost —
dozens of fields including a full `ApplicationInfo` — so "re-send on every fg
notification" is only acceptable if the mailbox delivers *genuine* package
transitions, not a wake on every reactor event. This project has a documented
history of "we assumed the event only fires on a genuine change, strace proved
it fires on every wake" (the fps registration-churn investigation). Before
trusting idempotency to absorb the cost, the mailbox must be verified to emit
only genuine transitions; independently of that, an **explicit dedup gate** is
cheap insurance.
**Working decision:** (a) re-run the handoff for the whitelisted fg package on
fg notification — **but gated**: skip the re-handoff when the incoming package
matches the last *successfully handed-off* package and its pinned uid is still
current, mirroring the `want != registered` pattern already used in the fps
demand-sync logic. This dedup is a plain equality check on the sender state, not
a new subscription, and it is what makes the §3.6 walk cost bounded regardless of
mailbox semantics. Document the restart gap as accepted for this cut; a
timer-based re-send is the fallback if the gap proves real in testing.
## 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/130/30/121/165/20) | `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")` →
`removeContentProviderExternal`).
2. **Sender driver (§3.2):** the existing fg-resolution mailbox, not UID/process
observers. Re-send on fg notification for a whitelisted package, **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
even if the mailbox fires redundantly.
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 env-var
whitelist 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,
removeContentProviderExternal=130, getContentProvider=30,
registerProcessObserver=121, getFocusedRootTaskInfo=165,
getPackagesForUid=20, CALL_TRANSACTION=21 stable).
6. **App contract (§3.7):** exported provider authority `<pkg>.shizuku`
implementing `call("sendBinder")`; idempotent store of the received binder.
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, or does it need a timer? and:
the mailbox-fires-on-every-wake assumption is to be strace-verified, not
assumed).
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).*