Skip to content
Merged
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
80 changes: 62 additions & 18 deletions src/runtime/procemu.c
Original file line number Diff line number Diff line change
Expand Up @@ -1589,6 +1589,45 @@ static void proc_task_collect_cb(thread_entry_t *t, void *arg)
*/
#define PTY_KEEPALIVE_MAX 256
#define PTY_KEEPALIVE_FREE (-1)
/* Group that owns pty slaves. Linux distributions mount devpts with gid=5
* ("tty") and glibc's grantpt(3) looks that group up before deciding whether
* the slave needs chowning.
*/
#define PTY_SLAVE_TTY_GID 5u

/* Parse the N out of "/dev/pts/N". Returns false for the directory itself, a
* missing or non-numeric tail, or trailing garbage.
*
* Deliberately stricter than strtoul, which would take leading whitespace, a
* "+" sign and leading zeros. devpts dentries are decimal and canonical, so
* Linux answers ENOENT for "/dev/pts/016" even while slave 16 is open, and
* accepting the alias here would let one live slave answer under many names --
* for its stat, its statfs identity, and for whether chmod and chown are
* intercepted at all.
*/
static bool pty_slave_num_from_path(const char *path, uint32_t *out)
{
if (!path || strncmp(path, "/dev/pts/", 9) != 0)
return false;
const char *digits = path + 9;
if (!*digits)
return false;
/* "0" is the only name that may start with a zero. */
if (digits[0] == '0' && digits[1] != '\0')
return false;

unsigned long n = 0;
for (const char *d = digits; *d; d++) {
if (*d < '0' || *d > '9')
return false;
if (n > (UINT32_MAX - (unsigned long) (*d - '0')) / 10)
return false;
n = n * 10 + (unsigned long) (*d - '0');
}
if (out)
*out = (uint32_t) n;
return true;
}
/* PTY_SLAVE_PATH_MAX lives in procemu.h so this table and the fork-IPC payload
* (proc_pty_ipc_entry_t) cannot drift apart.
*/
Expand Down Expand Up @@ -1954,6 +1993,16 @@ static int pty_lookup_slave_path(uint32_t linux_pts_num,
return 0;
}

bool proc_pty_slave_stat(const char *path, struct stat *out)
{
if (!path || strncmp(path, "/dev/pts/", 9) != 0 || !path[9])
return false;
struct stat st;
if (proc_intercept_stat(path, out ? out : &st) != 0)
return false;
return true;
}

static int pty_open_slave(uint32_t linux_pts_num, int linux_flags)
{
int oflags = translate_open_flags(linux_flags) &
Expand Down Expand Up @@ -2816,14 +2865,8 @@ int proc_intercept_open(const guest_t *g,
* Linux devpts behavior for an unallocated slave number.
*/
if (!strncmp(path, "/dev/pts/", 9)) {
const char *digits = path + 9;
if (!*digits) {
errno = ENOENT;
return -1;
}
char *endp;
unsigned long n = strtoul(digits, &endp, 10);
if (endp == digits || *endp != '\0' || n > UINT32_MAX) {
uint32_t n;
if (!pty_slave_num_from_path(path, &n)) {
errno = ENOENT;
return -1;
}
Expand Down Expand Up @@ -3539,14 +3582,8 @@ int proc_intercept_stat(const char *path, struct stat *st)
return 0;
}
if (!strncmp(path, "/dev/pts/", 9)) {
const char *digits = path + 9;
if (!*digits) {
errno = ENOENT;
return -1;
}
char *endp;
unsigned long n = strtoul(digits, &endp, 10);
if (endp == digits || *endp != '\0' || n > UINT32_MAX) {
uint32_t n;
if (!pty_slave_num_from_path(path, &n)) {
errno = ENOENT;
return -1;
}
Expand All @@ -3567,8 +3604,15 @@ int proc_intercept_stat(const char *path, struct stat *st)
memset(st, 0, sizeof(*st));
st->st_mode = S_IFCHR | 0620;
st->st_nlink = 1;
st->st_uid = host_st.st_uid;
st->st_gid = host_st.st_gid;
/* devpts gives the slave to whoever opened the master, group tty --
* that is what grantpt(3) expects to find. Reporting the host owner
* instead makes glibc see a foreign uid, try to chown the slave, fail,
* and fall back to exec'ing the pt_chown helper, which does not exist
* on a modern distro: grantpt then fails with ENOENT and no pty can be
* allocated even though the master opened fine.
*/
st->st_uid = (uid_t) proc_get_uid();
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Live slave ownership changes when the guest changes credentials after opening the master because this uses the caller's current real uid instead of the uid captured at PTY allocation. Retain the opener's uid in the PTY keepalive/fork-IPC entry and use that value for synthesized stats and no-op chown checks.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/runtime/procemu.c, line 3614:

<comment>Live slave ownership changes when the guest changes credentials after opening the master because this uses the caller's current real uid instead of the uid captured at PTY allocation. Retain the opener's uid in the PTY keepalive/fork-IPC entry and use that value for synthesized stats and no-op chown checks.</comment>

<file context>
@@ -3567,8 +3604,15 @@ int proc_intercept_stat(const char *path, struct stat *st)
+         * on a modern distro: grantpt then fails with ENOENT and no pty can be
+         * allocated even though the master opened fine.
+         */
+        st->st_uid = (uid_t) proc_get_uid();
+        st->st_gid = (gid_t) PTY_SLAVE_TTY_GID;
         /* macOS dev_t = (major << 24) | minor; the fs-stat translation layer
</file context>

st->st_gid = (gid_t) PTY_SLAVE_TTY_GID;
/* macOS dev_t = (major << 24) | minor; the fs-stat translation layer
* (mac_to_linux_dev) re-encodes that into Linux's split major/minor
* layout, so storing 136 in the macOS-major slot makes glibc's
Expand Down
12 changes: 12 additions & 0 deletions src/runtime/procemu.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

#pragma once

#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <sys/mount.h>
Expand Down Expand Up @@ -45,6 +46,17 @@ int proc_intercept_readlink(const char *path, char *buf, size_t bufsiz);
*/
int proc_intercept_stat(const char *path, struct stat *mac_st);

/* True when PATH names a live Unix98 pty slave (/dev/pts/N whose master is
* open), filling out with the same synthesized stat proc_intercept_stat would
* return. Such a path has no host backing, so metadata operations on it must be
* answered here rather than passed through to the host filesystem.
*
* Answering and stat'ing together keeps callers that need both from walking the
* locked pty table twice, which would leave a window where the slave goes away
* between the test and the stat. out may be NULL for a pure test.
*/
bool proc_pty_slave_stat(const char *path, struct stat *out);

/* Intercept writes to synthetic proc files that need stateful behavior.
* Returns 1 if handled (with *written_out set), 0 if not intercepted, or -1 on
* error with errno set.
Expand Down
83 changes: 83 additions & 0 deletions src/syscall/fs-stat.c
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,67 @@ static bool statfs_path_is_proc(const char *path)
return !strncmp(path, "/proc", 5) && (path[5] == '\0' || path[5] == '/');
}

/* /dev/pts itself and the pty slaves under it. /dev/ptmx is the multiplexer
* that hands out those slaves; Linux reports devpts for a master fd too.
*/
/* How statfs should answer for a path under the virtual devpts mount. */
typedef enum {
DEVPTS_UNRELATED = 0, /* not under /dev/pts; carry on */
DEVPTS_MOUNT, /* the mount point or a live slave: synthesize */
DEVPTS_ABSENT, /* under /dev/pts but no such slave: ENOENT */
} devpts_class_t;

static devpts_class_t statfs_devpts_class(const char *path)
{
if (!path || strncmp(path, "/dev/pts", 8) != 0)
return DEVPTS_UNRELATED;
if (path[8] != '\0' && path[8] != '/')
return DEVPTS_UNRELATED; /* "/dev/ptsfoo" is an ordinary name */

const char *tail = path + 8;
while (*tail == '/')
tail++;
if (!*tail)
return DEVPTS_MOUNT; /* "/dev/pts", "/dev/pts/", "/dev/pts//" */

/* The mount point exists for as long as the pty layer does. A particular
* slave does not: Linux answers ENOENT for an unallocated or malformed
* /dev/pts/N. Report that rather than falling through to the host, so the
* answer cannot depend on whether the sysroot happens to carry a file of
* the same name -- the devpts mount shadows whatever is underneath it, and
* the stat and open intercepts already treat the directory that way.
*
* Only the canonical spelling of a slave resolves, matching those
* intercepts: "/dev/pts/0" is the dentry, "/dev/pts/00" and "/dev/pts/./0"
* are not. Non-canonical spellings land here as ENOENT rather than
* resolving, which diverges from a real kernel for the "." form and is
* consistent across every /dev/pts intercept.
*
* /dev/ptmx is deliberately not claimed. Which filesystem backs it depends
* on whether it resolves to /dev/pts/ptmx or to the devtmpfs node, the same
* ambiguity that keeps sys_fstatfs from answering for a master fd, and
* nothing asks: glibc's getpt statfs's /dev/pts and /dev, never /dev/ptmx.
*/
return proc_pty_slave_stat(path, NULL) ? DEVPTS_MOUNT : DEVPTS_ABSENT;
}

/* devpts is virtualized rather than host-backed, so answer synthetically.
* Matches what Linux reports for a devpts mount: no blocks, no inodes.
*/
static void fill_devpts_statfs(linux_statfs_t *lin)
{
memset(lin, 0, sizeof(*lin));
lin->f_type = 0x1cd1; /* DEVPTS_SUPER_MAGIC */
lin->f_bsize = 4096;
lin->f_blocks = 0;
lin->f_bfree = 0;
lin->f_bavail = 0;
lin->f_files = 0;
lin->f_ffree = 0;
lin->f_namelen = 255;
lin->f_frsize = 4096;
}

static void fill_proc_statfs(linux_statfs_t *lin)
{
memset(lin, 0, sizeof(*lin));
Expand Down Expand Up @@ -431,6 +492,23 @@ static int64_t sys_statfs_impl(guest_t *g,
}
}

/* glibc's posix_openpt() opens /dev/ptmx and then confirms devpts is
* mounted before handing the master back (sysdeps/unix/sysv/linux/getpt.c).
* /dev/pts has no host backing here, so a pass-through statfs fails and
* glibc closes a perfectly good master -- breaking Unix98 pty allocation
* for every glibc program. Answer from the virtual filesystem instead.
*/
devpts_class_t devpts = statfs_devpts_class(tx.intercept_path);
if (devpts == DEVPTS_ABSENT)
return -LINUX_ENOENT;
if (devpts == DEVPTS_MOUNT) {
linux_statfs_t lin_st;
fill_devpts_statfs(&lin_st);
if (guest_write_small(g, buf_gva, &lin_st, sizeof(lin_st)) < 0)
return -LINUX_EFAULT;
return 0;
}

/* Report /dev/shm and its leaves as tmpfs, from the backing dir. statfs()
* on the leaf would follow a symlink onto the host and leak the host fs
* identity, so answer synthetically; lstat is the nofollow existence probe.
Expand Down Expand Up @@ -480,6 +558,11 @@ int64_t sys_statfs(guest_t *g, uint64_t path_gva, uint64_t buf_gva)

int64_t sys_fstatfs(guest_t *g, int fd, uint64_t buf_gva)
{
/* Deliberately no devpts case for a pty master fd: Linux answers from

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue #263 reports two bugs and this fixes one of them. f_type is still passed straight through from the macOS struct statfs, so the guest sees macOS mount-type indices rather than Linux superblock magics. Measured on this branch:

fstatfs(master) f_type=0x13
fstatfs(slave)  f_type=0x13
statfs("/")     f_type=0x1a

None of those are Linux magics. That is the second half of what #263 describes (lin->f_type = mac->f_type). Worth saying in the PR body whether it is deliberately out of scope, because as it stands the issue is only half closed and the untranslated value is now load-bearing for test 7 in the new test file.

* whatever filesystem provides /dev/ptmx, which is devpts only when it is
* the bind-mounted /dev/pts/ptmx and tmpfs or devtmpfs otherwise. There is
* no single correct value to report, and nothing needs one.
*/
fd_entry_t snap;
memset(&snap, 0, sizeof(snap));
if (fd_snapshot(fd, &snap) && statfs_path_is_proc(snap.proc_path)) {
Expand Down
36 changes: 36 additions & 0 deletions src/syscall/fs.c
Original file line number Diff line number Diff line change
Expand Up @@ -2709,6 +2709,20 @@ int64_t sys_fchmodat(guest_t *g,
if (rc != INT64_MIN)
return rc;

/* A pty slave has no host file to chmod -- its mode comes from the pty
* layer. Passing this through would hit the host and fail with ENOENT,
* which is what grantpt(3) does when it decides the slave's mode needs
* adjusting: it would then fall back to the pt_chown helper and fail.
*
* The requested mode is accepted but not retained, so a later stat still
* reports the 0620 the pty layer synthesizes. grantpt only ever asks for
* the owner-access bits it is about to hand out, so nothing observes the
* difference; keeping it would need per-slave state that also has to cross
* the fork-IPC boundary.
*/
if (proc_pty_slave_stat(tx.intercept_path, NULL))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Path-based chmod/chown on live pty slaves now use virtual semantics, but fd-based fchmod/fchown still mutate the host tty node, so the same slave can behave differently depending on call path. Mirroring the pty interception in fd-based handlers would keep metadata behavior consistent and avoid host-side leakage.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/syscall/fs.c, line 2723:

<comment>Path-based chmod/chown on live pty slaves now use virtual semantics, but fd-based `fchmod`/`fchown` still mutate the host tty node, so the same slave can behave differently depending on call path. Mirroring the pty interception in fd-based handlers would keep metadata behavior consistent and avoid host-side leakage.</comment>

<file context>
@@ -2709,6 +2709,20 @@ int64_t sys_fchmodat(guest_t *g,
+     * difference; keeping it would need per-slave state that also has to cross
+     * the fork-IPC boundary.
+     */
+    if (proc_pty_slave_stat(tx.intercept_path, NULL))
+        return 0;
+
</file context>

return 0;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

host_fd_ref_t dir_ref;
if (host_dirfd_ref_open(dirfd, &dir_ref) < 0)
return -LINUX_EBADF;
Expand Down Expand Up @@ -2860,6 +2874,28 @@ int64_t sys_fchownat(guest_t *g,
if (rc != INT64_MIN)
return rc;

/* A pty slave has no host file to chown -- its owner comes from the pty
* layer. Accept only a request that leaves the reported owner alone;
* anything else would have to be remembered to be observable, so refuse
* rather than report a success the next stat() contradicts.
*
* Not for grantpt(3): the synthesized stat already reports proc_get_uid(),
* so glibc's "chown only when st_uid != getuid()" test is false by
* construction and musl's grantpt is a no-op. This exists so that a request
* naming some other owner is refused instead of silently lost. The known
* divergence is a privileged guest -- login(1) or sshd handing a tty to a
* user -- which Linux would allow and which is refused here, because
* reporting success without retaining the owner would be the worse lie.
*/
struct stat pty_st;
if (proc_pty_slave_stat(tx.intercept_path, &pty_st)) {
bool keeps_owner =
owner == (uint32_t) -1 || owner == (uint32_t) pty_st.st_uid;
bool keeps_group =
group == (uint32_t) -1 || group == (uint32_t) pty_st.st_gid;
return (keeps_owner && keeps_group) ? 0 : -LINUX_EPERM;
}

host_fd_ref_t dir_ref;
if (host_dirfd_ref_open(dirfd, &dir_ref) < 0)
return -LINUX_EBADF;
Expand Down
Loading
Loading