From 1484b2c8ee6f80c1b0c306ef54073501111a9fda Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Fri, 7 Aug 2026 18:44:58 +0200 Subject: [PATCH] Randomize the CLI socket to remove a /proc/net/unix fingerprint The daemon bound its CLI socket to a fixed filesystem path, /data/adb/lspd/.cli_sock. A filesystem socket's bind address is copied verbatim into /proc/net/unix, which is world-readable across Android's single global network namespace, so any unprivileged app reads the full path regardless of the 0700 directory guarding the node on disk. That constant string was a stable, version-independent signature: a scanner only had to grep for it, even with every other surface clean (#891). Permissions were never the leak, so the fix changes the address itself. The socket now binds in the abstract namespace under a name minted from 128 random bits at each daemon start, appearing as @ - no path, nothing framework-specific, different every boot. Since the daemon and CLI are separate processes, they can no longer share the name through a compile-time constant. The daemon writes the current name to a root-only file (/data/adb/lspd/.sock) and the CLI reads it at connect time; that file is not a socket, so it never appears in /proc/net/unix. Both auth layers are unchanged - the compiled-in CLI_TOKEN still gates every connection and the CLI still requires root - so the abstract namespace's lack of a filesystem-permission gate costs nothing exploitable. The bind carries no file, so the startup delete and shutdown unlink are gone. --- daemon/README.md | 2 +- .../kotlin/org/matrix/vector/daemon/Cli.kt | 16 +++--- .../matrix/vector/daemon/data/FileSystem.kt | 57 ++++++++++++++++--- .../vector/daemon/env/CliSocketServer.kt | 18 +++--- 4 files changed, 66 insertions(+), 27 deletions(-) diff --git a/daemon/README.md b/daemon/README.md index ab1946f44..9a15cb1ad 100644 --- a/daemon/README.md +++ b/daemon/README.md @@ -62,7 +62,7 @@ Unlike target applications which request access, the daemon actively pushes its ### 4. Native Socket IPC For native components that operate outside the Java Binder context, the daemon provisions two distinct types of UNIX domain sockets. -* Command-Line Interface: The `CliSocketServer` exposes a filesystem-based socket at `/data/adb/lspd/.cli_sock`. The CLI client authenticates using a compiled-in UUID token and communicates using structured JSON. For live log streaming, the daemon attaches the log file's raw `FileDescriptor` to the socket reply payload, allowing the client to read directly from the OS-level stream buffer. +* Command-Line Interface: The `CliSocketServer` binds an abstract UNIX domain socket under a per-boot random name, which the daemon publishes to a root-only file (`/data/adb/lspd/.sock`) for the CLI client to read. This keeps `/proc/net/unix` free of any constant, framework-specific string that an integrity scanner could match. The client authenticates using a compiled-in UUID token and communicates using structured JSON. For live log streaming, the daemon attaches the log file's raw `FileDescriptor` to the socket reply payload, allowing the client to read directly from the OS-level stream buffer. * Dex2Oat Wrapper: The `Dex2OatServer` listens on an abstract UNIX domain socket. To prevent conflicts and detection, the exact name of this abstract socket is randomized during module installation. The C++ `dex2oat` wrapper connects to this socket to receive necessary file descriptors via `SCM_RIGHTS`. ## Native Environment Subsystems diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/Cli.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/Cli.kt index 8786ab82e..7762bb4ec 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/Cli.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/Cli.kt @@ -43,14 +43,14 @@ object VectorIPC { fun transmit(request: CliRequest): CliResponse { val socket = LocalSocket() return try { - val cliSocket = FileSystem.socketPath.toString() - val socketFile = java.io.File(cliSocket) - - if (!socketFile.exists()) { - System.err.println("Error: Socket file not found at $cliSocket") - System.err.println("Current UID: ${android.os.Process.myUid()}") - } - socket.connect(LocalSocketAddress(cliSocket, LocalSocketAddress.Namespace.FILESYSTEM)) + // The daemon binds an abstract socket under a per-boot random name and publishes that name + // through FileSystem; absent it, no daemon is running to talk to. + val socketName = + FileSystem.readSocketName() + ?: return CliResponse( + success = false, + error = "Vector daemon is not running (no CLI socket published).") + socket.connect(LocalSocketAddress(socketName, LocalSocketAddress.Namespace.ABSTRACT)) val output = DataOutputStream(socket.outputStream) val input = DataInputStream(socket.inputStream) diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt index a657223d2..93a92b40e 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt @@ -25,6 +25,7 @@ import java.nio.file.Path import java.nio.file.Paths import java.nio.file.StandardOpenOption import java.nio.file.attribute.PosixFilePermissions +import java.security.SecureRandom import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -69,7 +70,6 @@ object FileSystem { val logDirPath: Path = basePath.resolve("log") val oldLogDirPath: Path = basePath.resolve("log.old") val modulePath: Path = basePath.resolve("modules") - val socketPath: Path = basePath.resolve(".cli_sock") val daemonApkPath: Path = Paths.get(System.getProperty("java.class.path", "")) val managerApkPath: Path = daemonApkPath.parent.resolve("manager.apk") val configDirPath: Path = basePath.resolve("config") @@ -79,6 +79,26 @@ object FileSystem { private val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME.withZone(ZoneId.systemDefault()) private val lockPath: Path = basePath.resolve("lock") + + /** + * The root-only file that publishes the name of the abstract socket the CLI reaches the daemon + * on. + * + * The CLI endpoint used to be a fixed filesystem socket at /data/adb/lspd/.cli_sock. The kernel + * copies a socket's bind address verbatim into /proc/net/unix, and that table is readable by any + * process sharing the (on Android, global) network namespace, regardless of the 0700 directory + * guarding the file on disk. So the constant path string was a stable, version-independent + * signature for the framework (#891): a scanner only had to grep /proc/net/unix for it, no + * privilege required, because the leak is the kernel's own bookkeeping rather than any surface + * this daemon controls. + * + * The socket is now bound in the abstract namespace under a per-boot random name, which shows up + * as @ with nothing constant to match. The daemon and the CLI are separate processes, so + * the name cannot live only in this object's memory the way the compile-time constant did; the + * daemon writes it to this file — inside the same 0700 root-owned directory, and never itself a + * socket, so it stays out of /proc/net/unix — for the CLI to read back. + */ + private val socketNamePath: Path = basePath.resolve(".sock") private var fileLock: FileLock? = null private var lockChannel: FileChannel? = null @@ -92,6 +112,11 @@ object FileSystem { .onFailure { Log.e(TAG, "Failed to initialize directories", it) } } + /** + * Deploys the CLI helper and mints the name for the abstract socket the daemon is about to bind, + * publishing it to [socketNamePath] so the separate CLI process can find it. Returns the fresh + * name. Called once per daemon start, so each boot advertises a new name. + */ fun setupCli(): String { val cliSource = daemonApkPath.parent.resolve("cli").toFile() val cliDest = basePath.resolve("cli").toFile() @@ -103,14 +128,30 @@ object FileSystem { .onFailure { Log.e(TAG, "Failed to deploy CLI script", it) } } - val cliSocket: String = socketPath.toString() - val socketFile = File(cliSocket) - if (socketFile.exists()) { - Log.d(TAG, "Existing $cliSocket deleted") - socketFile.delete() - } + val socketName = generateSocketName() + runCatching { + Files.write(socketNamePath, socketName.toByteArray()) + Os.chmod(socketNamePath.toString(), "600".toInt(8)) + } + .onFailure { Log.e(TAG, "Failed to publish the CLI socket name", it) } + return socketName + } + + /** + * The abstract socket name the running daemon published, or null when no daemon has written one + * (so the CLI can say "not running" rather than fail to connect to a stale name). Read fresh at + * connect time so a daemon restart, which rewrites the file, is picked up without coordination. + */ + fun readSocketName(): String? = + runCatching { Files.readAllBytes(socketNamePath).toString(Charsets.UTF_8).trim() } + .getOrNull() + ?.takeIf { it.isNotEmpty() } - return cliSocket + /** 128 random bits as hex: unguessable, and no constant a scanner can key on. */ + private fun generateSocketName(): String { + val bytes = ByteArray(16) + SecureRandom().nextBytes(bytes) + return bytes.joinToString("") { "%02x".format(it.toInt() and 0xff) } } /** Tries to lock the daemon lockfile. Returns false if another daemon is running. */ diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/env/CliSocketServer.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/env/CliSocketServer.kt index 20359d109..530c1440c 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/env/CliSocketServer.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/env/CliSocketServer.kt @@ -7,7 +7,6 @@ import android.system.Os import android.util.Log import java.io.DataInputStream import java.io.DataOutputStream -import java.io.File import java.io.FileInputStream import java.io.IOException import kotlinx.coroutines.launch @@ -29,16 +28,17 @@ object CliSocketServer { // Keep these references outside the loop to prevent GC from closing them var rootSocket: LocalSocket? = null var server: LocalServerSocket? = null - var socketFile: File? = null try { - val cliSocketPath: String = FileSystem.setupCli() - socketFile = File(cliSocketPath) + val socketName: String = FileSystem.setupCli() // Create a standard LocalSocket rootSocket = LocalSocket() - // Bind it to the filesystem path - val address = LocalSocketAddress(cliSocketPath, LocalSocketAddress.Namespace.FILESYSTEM) + // Bind it in the abstract namespace: the name shows up in /proc/net/unix as @ with + // no filesystem path, and the daemon mints a fresh random name every boot, so there is no + // constant string left for an integrity scanner to fingerprint (#891). The bind carries no + // file, so nothing has to be unlinked when the socket closes either. + val address = LocalSocketAddress(socketName, LocalSocketAddress.Namespace.ABSTRACT) rootSocket.bind(address) // LocalServerSocket(FileDescriptor) requires the FD to already be listening. @@ -46,7 +46,7 @@ object CliSocketServer { // Wrap the underlying FileDescriptor into a ServerSocket server = LocalServerSocket(rootSocket.fileDescriptor) - Log.d(TAG, "CLI server started at $cliSocketPath") + Log.d(TAG, "CLI server started") while (!Thread.currentThread().isInterrupted) { try { @@ -61,13 +61,11 @@ object CliSocketServer { Log.e(TAG, "Fatal CLI Server error", e) } finally { try { + // Closing the bound fd drops the abstract socket; there is no file to unlink. server?.close() rootSocket?.close() } catch (ignored: Exception) {} - if (socketFile?.exists() == true) { - socketFile.delete() - } isRunning = false Log.d(TAG, "CLI server stopped") }