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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,11 @@ object ConfigCache {

Log.d(TAG, "Cache Update Complete. Map Swap successful.")

// Published right after the swap, because it describes the same set of scopes the swap just
// made current: a HyperOS process forked a moment from now has to see the configuration the
// daemon is already answering with, not the one it replaced.
publishHyosRuntimeIndex(newScopes)

// Targets are removed only after the module set has been published.
(oldState.modules.keys - newModules.keys).forEach {
FrameworkService.forgetHotReloadTargets(it)
Expand All @@ -456,6 +461,63 @@ object ConfigCache {
}
}

/**
* Writes the index a process spawned by the HyperOS Rust Runtime's spawner reads to find the
* modules in its scope, and stages the libraries it names.
*
* This is the only one of the two ways a process learns what to load that does not go through the
* daemon's binder: those processes have no JVM, so no daemon service can exist in them, and no
* module list can be fetched over IPC. The scope map is the same one every other reader gets, so
* what a HyperOS process sees is exactly what an ART process of the same name would.
*
* Nothing happens on a device without that runtime, and nothing happens for a module that
* declares no native libraries — there is no `native_init` to call and so nothing to load.
*/
private fun publishHyosRuntimeIndex(scopes: Map<ProcessScope, List<LoadedModule>>) {
if (!FileSystem.hasHyosRuntime()) return

setupMiscPath()
val misc = state.miscPath ?: return

// Each module is extracted once rather than once per process it is scoped to: extraction is the
// expensive half of this, and every reader gets the same file names from the same copy. A null
// is a module that ships nothing this ABI can load — an ordinary answer, and one worth
// remembering so the next scope does not ask again.
val staged =
scopes.values
.asSequence()
.flatten()
.filter { !it.code?.moduleLibraryNames.isNullOrEmpty() }
.distinctBy { it.packageName }
.associate { module ->
module.packageName to
FileSystem.stageHyosNativeLibraries(misc, module.packageName, module.apkPath)
}

val index = mutableMapOf<String, List<HyosModuleLibrary>>()
scopes.forEach { (scope, modules) ->
val libraries =
modules.flatMap { module ->
val dir = staged[module.packageName] ?: return@flatMap emptyList()
module.code?.moduleLibraryNames.orEmpty().mapNotNull { name ->
val path = Paths.get(dir, name)
if (Files.isReadable(path)) {
HyosModuleLibrary(module.packageName, name, path.toString())
} else {
// The module named a library its APK does not carry for this ABI. It still loads
// everywhere else; only this half of it has nothing to load.
Log.w(TAG, "Module ${module.packageName} declares $name, which is not in $dir")
null
}
}
}
if (libraries.isNotEmpty()) index[scope.processName] = libraries
}

FileSystem.pruneHyosNativeLibraries(misc, staged.filterValues { it != null }.keys)
FileSystem.publishHyosIndex(misc, index)
}

fun getModulesForProcess(processName: String, uid: Int): List<LoadedModule> {
ensureCacheReady()
if (processName == "system_server") {
Expand Down
179 changes: 175 additions & 4 deletions daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import android.os.Process
import android.os.RemoteException
import android.os.SELinux
import android.os.SharedMemory
import android.os.SystemProperties
import android.system.ErrnoException
import android.system.Os
import android.system.OsConstants
Expand Down Expand Up @@ -50,6 +51,48 @@ private const val TAG = "VectorFileSystem"
*/
private const val SYSTEM_FILE_CONTEXT = "u:object_r:system_file:s0"

/**
* The properties the HyperOS Rust Runtime advertises itself with.
*
* Read rather than looking for `/system_ext/bin/hyos_spawner`: the runtime can be switched off on a
* device that still ships that binary, and an index nothing will ever read is work done for
* nothing. These are the same two properties LSPosed 2.2.0 reads for the same decision, in its
* daemon's `ILSPManagerService` transaction 67; they were recovered from its released daemon.
*/
private const val HYOS_ACTIVE_PROPERTY = "rust.runtime_active"
private const val HYOS_VERSION_PROPERTY = "rust.runtime_version"

/**
* The index a process spawned by the HyperOS Rust Runtime's spawner reads to find the modules in
* its scope.
*
* Everything here has a counterpart in `zygisk/src/main/cpp/hyos_runtime.cpp`, which is the only
* reader; the two sets of constants have to agree or the feature quietly does nothing.
*
* The pointer file is the part that makes the random directory findable without listing /data/misc,
* which an app-domain process may not do. It sits directly in /data/misc because that directory
* belongs to the system uid with mode 0771: nothing running as an application can create an entry
* there, and the file itself is mode 0600, so only root — which is what the companion runs as — can
* read it. An application therefore cannot learn where the index is, and so cannot ask whether it
* is being hooked.
*/
private const val HYOS_POINTER_PATH = "/data/misc/vector.hyos"
private const val HYOS_INDEX_DIR = "hyos"
private const val HYOS_INDEX_MARKER = ".version"
private const val HYOS_INDEX_MAGIC = "vector-hyos 1"

/**
* The subdirectory of the misc root holding copies of module libraries that a process cannot map
* out of the module's APK.
*
* Two of them, because the two readers have different lifetimes and each prunes what it no longer
* needs. system_server's copies are pruned down to the modules still bound for system_server; the
* HyperOS Runtime's, to the modules still in some scope. Sharing one directory would have each
* pass delete the other's copies, leaving whichever reader ran first with nothing to load.
*/
private const val STAGED_LIBRARY_DIR = "lib"
private const val HYOS_LIBRARY_DIR = "libhyos"

/**
* What came of trying to load a module APK.
*
Expand All @@ -74,6 +117,16 @@ sealed interface ModuleLoad {
val ModuleLoad.apkOrNull: ModuleCode?
get() = (this as? ModuleLoad.Loaded)?.apk

/**
* One native library the index names for one process: which module asked for it, the name that
* module declared, and the absolute path the process should load it from.
*/
data class HyosModuleLibrary(
val modulePackage: String,
val libraryName: String,
val libraryPath: String,
)

object FileSystem {
val basePath: Path = Paths.get("/data/adb/lspd")
val logDirPath: Path = basePath.resolve("log")
Expand Down Expand Up @@ -488,11 +541,19 @@ object FileSystem {
*
* Returns null when the module ships nothing for this ABI or the copy failed, in which case the
* module still loads and only its native part fails, exactly as it does today.
*
* [dirName] selects which of the readers the copy is for; see [STAGED_LIBRARY_DIR]. The two sets
* are kept apart because each is pruned against a different notion of "still needed".
*/
fun stageNativeLibraries(root: Path, packageName: String, apkPath: String): String? =
fun stageNativeLibraries(
root: Path,
packageName: String,
apkPath: String,
dirName: String = STAGED_LIBRARY_DIR,
): String? =
runCatching {
val apk = File(apkPath)
val dir = root.resolve("lib").resolve(packageName)
val dir = root.resolve(dirName).resolve(packageName)

// Re-extract only when the APK behind the copy changed. Getting this wrong in the
// lenient direction would leave system_server running a module's superseded native
Expand Down Expand Up @@ -546,10 +607,14 @@ object FileSystem {
* Drops staged libraries belonging to modules that are no longer bound for system_server, so an
* uninstalled or rescoped module does not leave a copy of its native code behind for good.
*/
fun pruneStagedNativeLibraries(root: Path?, keep: Set<String>) {
fun pruneStagedNativeLibraries(
root: Path?,
keep: Set<String>,
dirName: String = STAGED_LIBRARY_DIR,
) {
if (root == null) return
runCatching {
val libRoot = root.resolve("lib")
val libRoot = root.resolve(dirName)
if (!libRoot.isDirectory()) return
Files.list(libRoot).use { stream ->
stream
Expand All @@ -560,6 +625,112 @@ object FileSystem {
.onFailure { Log.e(TAG, "Failed to prune staged native libraries", it) }
}

/** Whether this device runs applications on the HyperOS Rust Runtime at all. */
fun hasHyosRuntime(): Boolean =
SystemProperties.getBoolean(HYOS_ACTIVE_PROPERTY, false) &&
SystemProperties.get(HYOS_VERSION_PROPERTY).orEmpty().isNotEmpty()

/**
* Stages a module's native libraries for the HyperOS Runtime's reader rather than system_server's.
*
* A process there cannot be told a search path the way an injected ART process can, so it is
* handed absolute paths in the index below and needs the libraries to exist at those paths. The
* APK itself would do — /data/app is readable and mappable by any app domain — but the entry has
* to be STORED inside the zip for the loader to open it, and a module that compressed its
* libraries would silently lose its native part. A copy has neither problem.
*/
fun stageHyosNativeLibraries(root: Path, packageName: String, apkPath: String): String? =
stageNativeLibraries(root, packageName, apkPath, HYOS_LIBRARY_DIR)

/** Drops HyperOS-staged libraries of modules that are in no scope any more. */
fun pruneHyosNativeLibraries(root: Path?, keep: Set<String>) {
pruneStagedNativeLibraries(root, keep, HYOS_LIBRARY_DIR)
}

/**
* Publishes the index a process spawned by the HyperOS Rust Runtime reads to find the modules in its
* scope.
*
* The whole file lives inside the daemon's random directory and is readable by path to anything
* that knows the path, which is deliberate: the reader is a process with no binder and no JVM, so
* a file is the only channel there is. What keeps the list private is that the path is not
* discoverable — see [HYOS_POINTER_PATH] for the half of that which the companion reads.
*
* A process with no entry is in nobody's scope, and files for processes that stopped being in
* scope are removed, so the index always describes exactly the current configuration.
*/
fun publishHyosIndex(miscPath: Path, index: Map<String, List<HyosModuleLibrary>>) {
runCatching {
val dir = miscPath.resolve(HYOS_INDEX_DIR)
Files.createDirectories(dir)

val written = mutableSetOf<String>()
index.forEach { (processName, libraries) ->
// A process name is a manifest string that becomes a file name here. Android's own
// validation is not something to lean on for a write performed as root, so anything
// that is not a plain name is refused rather than resolved.
if (processName.isEmpty() || processName.contains('/') || processName == "." ||
processName == "..") {
Log.w(TAG, "Refusing to publish an index under the name '$processName'")
return@forEach
}
val target = dir.resolve(processName)
val text =
buildString {
appendLine(HYOS_INDEX_MAGIC)
libraries.forEach {
append(it.modulePackage)
append('\t')
append(it.libraryName)
append('\t')
append(it.libraryPath)
append('\n')
}
}
Files.writeString(
target,
text,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
)
Os.chmod(target.toString(), "644".toInt(8))
written.add(processName)
}

val marker = dir.resolve(HYOS_INDEX_MARKER)
Files.writeString(
marker,
"$HYOS_INDEX_MAGIC\n",
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
)
Os.chmod(marker.toString(), "644".toInt(8))

Files.list(dir).use { stream ->
stream
.filter {
val name = it.fileName.toString()
name != HYOS_INDEX_MARKER && name !in written
}
.forEach { it.toFile().delete() }
}

// The daemon runs with a zero umask, so every mode here is set rather than inherited. The
// directory is searchable but not listable, the way the rest of the staged tree is, and
// the label is what lets a process running as an application read it at all.
Os.chmod(dir.toString(), "711".toInt(8))
setSelinuxContextRecursive(dir, "u:object_r:xposed_data:s0")

val pointer = File(HYOS_POINTER_PATH)
pointer.writeText("misc=$miscPath\n")
// 0600 and owned by the daemon: the companion, which is root, is the only reader meant to
// have this. An application that could read it would learn where the index is and could
// then ask whether it is being hooked.
Os.chmod(pointer.absolutePath, "600".toInt(8))
}
.onFailure { Log.e(TAG, "Failed to publish the HyperOS Runtime index", it) }
}

fun toGlobalNamespace(path: String): File {
return if (path.startsWith("/")) File("/proc/1/root", path) else File("/proc/1/root/$path")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ data class AppInfo(
val appName: String,
val isSystemApp: Boolean,
val isGame: Boolean,
/**
* Whether the app announced itself as one that runs on the HyperOS Rust Runtime.
*
* HyperOS applications carry the name of the runtime library they want loaded in the
* `hyperos_app_lib_name` metadata entry of their manifest, and the entry's presence is what
* marks them — its value is for the runtime, not for us. That is the same test LSPosed 2.2.0's
* manager applies for the same label, recovered from its released build.
*
* It says nothing about whether the runtime is actually being injected into them; that is a
* property of the device, not of the app, and is answered separately.
*/
val isHyperOsRuntime: Boolean = false,
val isSelectedInScope: Boolean,
/**
* In the scope without anyone having put it there, and not removable.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ import org.matrix.vector.manager.data.model.versionCodeCompat
import org.matrix.vector.manager.ipc.DaemonClient
import org.matrix.vector.manager.logW

/**
* The manifest metadata entry a HyperOS application uses to name its Rust runtime library.
*
* Recovered from LSPosed 2.2.0's manager, which reads this same key for the same label. The entry
* is written by the application, so what it says is a claim rather than a fact; the framework only
* ever shows it next to the app, which is why that is an acceptable way to learn it.
*/
private const val HYPEROS_APP_LIB_NAME = "hyperos_app_lib_name"

/** Fetches and caches the list of installed applications from the daemon. */
class AppRepository(
private val daemonClient: DaemonClient,
Expand Down Expand Up @@ -122,12 +131,18 @@ class AppRepository(

val userId = appInfo.uid / PER_USER_RANGE

// GET_META_DATA is part of the flags the daemon is asked with, so the bundle is
// already here and reading it costs nothing. An absent entry and an empty one mean
// the same thing, and both are the ordinary case.
val hyperOsLibrary = appInfo.metaData?.getString(HYPEROS_APP_LIB_NAME)

AppInfo(
packageName = pkg.packageName,
userId = userId,
appName = appInfo.loadLabel(packageManager).toString(),
isSystemApp = isSystem,
isGame = isGame,
isHyperOsRuntime = !hyperOsLibrary.isNullOrEmpty(),
isSelectedInScope = false, // To be merged later in the ViewModel
isRecommended = false,
lastUpdateTime = pkg.lastUpdateTime,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -991,6 +991,20 @@ private fun AppRow(
color = ring,
)
}
// A property of the app rather than of the scope, so it is stated in its own colour
// and on its own line: the origin label above says why the row is in the list, and
// this says what the row cannot be expected to behave like. A HyperOS application
// runs on the Rust runtime, where none of the Java side of the framework exists, so
// what a module hooks there is its native half and nothing else — which is a
// difference worth stating before the user wonders why nothing happened.
if (app.isHyperOsRuntime) {
Text(
text = stringResource(R.string.scope_hyperos_runtime),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
}
// Why this row does not behave like the rest: the framework is one process shared
// by every user, and a legacy module's own app is in the scope without anyone
// having put it there. Both are things a checkbox cannot say.
Expand Down
2 changes: 2 additions & 0 deletions manager/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,8 @@
<string name="scope_no_match">No app matches that search or filter.</string>
<string name="scope_system_apps">System apps</string>
<string name="scope_games">Games</string>
<!-- Shown on an app whose manifest declares the HyperOS Rust Runtime's library entry. -->
<string name="scope_hyperos_runtime">HyperOS Runtime</string>
<string name="scope_recommended">Requested by this module</string>
<string name="scope_static">This module declares a fixed scope and cannot be pointed at other apps.</string>
<!-- e.g. "3 to add, 1 to remove" -->
Expand Down
2 changes: 1 addition & 1 deletion native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ This module defines the central abstractions and manages the runtime state. It's

- **`Context`**: An abstract base class that defines the injection lifecycle. It contains pure virtual methods like `LoadDex` and `SetupEntryClass`. The consumer of this library (e.g., the Zygisk module) must inherit from `Context` and provide the concrete implementations for these steps.
- **`ConfigBridge`**: A simple, native-side singleton that acts as a cache for configuration data (specifically, the obfuscation map) that is fetched and provided by the consumer.
- **`native_api`**: Implements the native module support system. It works by hooking the system's `do_dlopen` function. When it detects a registered module library being loaded, it calls that library's `native_init` entry point, providing it with a set of [API](include/core/native_api.h)s for creating its own native hooks.
- **`native_api`**: Implements the native module support system. It works by hooking the system's `do_dlopen` function. When it detects a registered module library being loaded, it calls that library's `native_init` entry point, providing it with a set of [API](include/core/native_api.h)s for creating its own native hooks. The library names normally arrive over JNI from the injected framework, and the hook engine behind the API is Dobby, but neither is required: `SetHookBackend` lets a runtime supply its own primitives, which is how the HyperOS Rust Runtime path (see `zygisk/src/main/cpp/hyos_runtime.cpp`) drives this same code from a process that has no JVM at all.

### `elf` - Symbol Resolution

Expand Down
Loading