Skip to content

[FEATURE] Allocate container console PTY from container's private devpts instance to prevent host /dev/pts leakage` #369

Closed

Description

@ha7v

Problem Description

Currently, when Droidspaces initializes a container console (e.g., in src/daemon.c / src/terminal.c), the PTY pair is allocated from the host's mount namespace by opening /dev/ptmx on the host side before or during container startup.

On Android, this causes two noticeable issues:

  1. Host /dev/pts Pollution & Detection Leakage:
    A slave PTY node (e.g., /dev/pts/2) is created under the host's /dev/pts directory with uid=0 (root) and SELinux context u:object_r:devpts:s0. On Android, normal apps (untrusted_app, UID >= 10000) own their respective PTYs, and ADB shell sessions belong to AID_SHELL (2000). A persistent uid=0 PTY slave on the host's /dev/pts never occurs naturally on stock Android and immediately exposes the presence of a running root daemon/container to runtime environment detectors (flagged as Abnormal Permission: Found ROOT PTY).
  2. Namespace Isolation & Security:
    Sharing the host's devpts instance with the container violates standard Linux container isolation practices and historically exposes containers to terminal injection boundary issues (e.g., TIOCSTI concerns if FDs are ever inherited across contexts).

Proposed Solution (Container-Private devpts)

Following industry-standard container runtimes (runc, LXC, systemd-nspawn), the console PTY should be allocated strictly from the container's own isolated devpts instance:

1. Mount a private devpts instance inside the container's Mount Namespace

Inside the container child process (after CLONE_NEWNS), mount a dedicated devpts with newinstance and bind-mount/symlink /dev/pts/ptmx to /dev/ptmx:

// 1. Mount isolated devpts instance (gid=5 is standard 'tty' group)
mount("devpts", "/dev/pts", "devpts", MS_NOSUID | MS_NOEXEC, "newinstance,ptmxmode=0666,mode=0620,gid=5");

// 2. Ensure /dev/ptmx points to the isolated instance for in-container apps (sshd, tmux, etc.)
mount("/dev/pts/ptmx", "/dev/ptmx", NULL, MS_BIND, NULL); 
// Or symlink("/dev/pts/ptmx", "/dev/ptmx") after unlinking the old node

2. Allocate the PTY from the private instance & obtain master_fd on the host

Note: To guarantee the kernel allocates from the new instance, we must open the ptmx node belonging to that specific devpts mount (/dev/pts/ptmx).

There are three clean ways for the host daemon (droidspacesd) to obtain the master_fd while keeping the slave node (/dev/pts/0) strictly inside the container:

  • Approach A (Simplest code change - Direct /proc path traversal):
    Once the child process finishes mounting its private /dev/pts (synchronized via a pipe/eventfd), the host daemon can directly open the child's private ptmx from the host side:

    snprintf(ptmx_path, sizeof(ptmx_path), "/proc/%d/root/dev/pts/ptmx", child_pid);
    int host_master_fd = open(ptmx_path, O_RDWR | O_NOCTTY | O_CLOEXEC);

    Why this works: Opening /dev/pts/ptmx through /proc/<pid>/root resolves the dentry inside the container's mount namespace. The slave node /dev/pts/0 is created exclusively inside the container, while the host daemon immediately gets the master_fd without needing IPC FD-passing!

  • Approach B (Industry Standard - socketpair + SCM_RIGHTS):
    The container child opens /dev/pts/ptmx locally, sets up /dev/pts/0 as its controlling terminal (setsid() + TIOCSCTTY), and sends the master_fd back to the host daemon over a pre-established AF_UNIX socketpair() using SCM_RIGHTS. (100% kernel version compatibility).

  • Approach C (Modern Syscall - pidfd_getfd on Kernel 5.6+):
    The child opens /dev/pts/ptmx, and the host daemon duplicates the descriptor using pidfd_open() + pidfd_getfd().


Benefits

  • Zero Host Pollution: The host's /dev/pts remains 100% clean and untainted—no root-owned slave nodes are ever created on the host.
  • Native Stealth: Completely eliminates Found ROOT PTY detections at the architectural root cause, without needing kernel-level LKM hooks or VFS getattr spoofing.
  • Standards Compliance: Aligns Droidspaces with the official Linux kernel devpts multi-instance container specification.

Activity

  1. changed the title [-][FEATURE/Security] Allocate container console PTY from container's private devpts instance to prevent host /dev/pts leakage`[/-] [+][FEATURE] Allocate container console PTY from container's private devpts instance to prevent host /dev/pts leakage`[/+] on Oct 22, 2026
  2. ravindu644 commented on Oct 23, 2026

    @ravindu644
    Owner

    thanks for the detailed writeup. you're right that there was a root-owned pty on the host, but the two halves you proposed were already in the tree:

    • the private devpts (newinstance, ptmxmode=0666, mode=0620, gid=5, pts/ptmx bound over /dev/ptmx) is what setup_devpts() in src/mount.c has done for a long time
    • enter and run already allocate their pty after setns and pass the master back over a socketpair with SCM_RIGHTS, which is your approach B

    the actual leak was the boot console. start opened the console pty on the host before forking the monitor and bind-mounted that host /dev/pts/N over the container's /dev/console before pivot_root, so every running container pinned a root-owned slave in the host devpts for its whole life. that's the old pre-4.0 LXC model.

    fixed in 3b6759c on dev. init now allocates the console itself from the devpts it just mounted, binds it over /dev/console by fd and hands the master+slave pair up to the monitor over a socketpair, the way LXC 4+ does it. foreground start, internal reboots and hw_access mode all still work, tested on a 5.15 phone. an idle container now leaves nothing in the host's /dev/pts.

    two things worth knowing:

    • the daemon's session pty (the one behind the app terminal) still lives on the host for as long as a terminal tab is open. moving it into the guest devpts wouldn't change anything observable, because on a rooted phone every interactive root shell also comes with the su daemon's own root pty (magisk and ksu both allocate one), and that's outside droidspaces. so with a terminal open a detector will still see a root pty, with it closed it won't.
    • the TIOCSTI angle wasn't an escape in practice. the only readers of that slave were guest init and the monitor's master side, so injecting into it only fed the guest's own input queue. the fix is isolation hygiene more than a privilege boundary.

    will be in the next release.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions