Skip to content

portal: read the procfs files kernel_read() refuses, and stop hiding why - #102

Open
lacraig2 wants to merge 5 commits into
mainfrom
vfs-bad-handle-ebadf
Open

portal: read the procfs files kernel_read() refuses, and stop hiding why#102
lacraig2 wants to merge 5 commits into
mainfrom
vfs-bad-handle-ebadf

Conversation

@lacraig2

Copy link
Copy Markdown
Contributor

Live CI on penguin #938 read procfs through the new vfs_* ops on every arch/kernel combo. All 7 4.10 combos pass; all 10 6.13 combos fail on exactly two files:

/proc/net/tcp      seq=ERROR(vfs_read failed after 0 bytes: errno 22 (EINVAL))  stateless=none
/proc/version      seq=OK(157B)   stateless=OK(157B)
/proc/mounts       seq=OK(453B)   stateless=OK(453B)
/proc/self/status  seq=ERROR(vfs_read failed after 0 bytes: errno 22 (EINVAL))  stateless=none
/proc/uptime OK  /proc/cmdline OK  /sys/kernel/uevent_seqnum OK(4B)

In the same boot the guest reads the file fine (COMPAT_PROC_SPECIAL:self_status=readable), so this is not a kernel-config gap and not pseudofile shadowing.

Cause, verified against the 6.13 source

One line in __kernel_read():

if (unlikely(!file->f_op->read_iter || file->f_op->read))
        return warn_unsupported(file, "read");   /* -EINVAL */

A file that has ->read wired up, or lacks ->read_iter, cannot be read that way at all. That is a property of the file's f_op, not of statefulness — and it partitions procfs exactly as measured:

file ops result
/proc/version, /proc/uptime, /proc/cmdline proc_create_single*proc_iter_file_ops (.read_iter only) works
/proc/mounts mounts_operations (.read_iter = seq_read_iter) works
/sys/... kernfs_file_fops (.read_iter) works
/proc/<pid>/status, /stat proc_single_file_operations (.read = seq_read) -EINVAL
/proc/net/* proc_net_seq_ops.proc_read = seq_readproc_reg_file_ops (.read, no .read_iter) -EINVAL

4.10 has no such guard (kernel_read__vfs_read->read), which is why the same paths work there — and why the older stateless handle_op_read_file fails on exactly the same files: same kernel_read.

What this fixes

The ->read == seq_read case, which is safe to fix: seq_read's contract is that file->private_data is a struct seq_file, so seq_read_iter can be called directly with a kvec iterator, skipping the guard. That makes /proc/<pid>/* readable. Gated on 5.10+ (where seq_read_iter exists and where the refusal appeared); verified to compile out on 4.10 (the 4.10 module references neither symbol) and in on 6.13 (both resolve to exported symbols). Built clean for igloo-4.10-armel and igloo-6.13-armel.

Not extended to proc_reg_file_ops (/proc/net/*), deliberately: its ->read is proc_reg_read, which forwards to a proc_ops a module cannot inspect, so there is no way to prove private_data is a seq_file. In /proc/<pid>/mem it is an mm_struct — guessing would corrupt memory rather than fail. Reading those needs a user-address bounce buffer in the calling task, which touches guest state and is a separate decision. The header comment records the partition so nobody has to re-derive it.

Also: an errno collision that was mine

A read/close against an unknown or already-closed handle now reports -EBADF rather than -EINVAL. The collision cost real diagnosis time — EINVAL is also what kernel_read returns for a refused file, so a live failure could not be attributed to the handle table or the kernel's read path without a debug build, which CI does not run. -EBADF is what read(2) uses for a bad descriptor, so it is also simply correct.

Note for the peer graph

/proc/net/tcp stays unreadable on 6.13. The socket peer graph should not depend on it — the already-approved OSI_FD_ALL / osi_fd_entry2 work reads family/state/addresses/ports from the kernel struct sock directly and removes the procfs dependency entirely. That is the better fix for that consumer regardless of this.

Live CI on all 10 6.13 arch combos found /proc/net/tcp and /proc/self/status
coming back EINVAL after zero bytes, while /proc/version, /proc/mounts,
/proc/uptime, /proc/cmdline and a real sysfs file read fine in the same boot --
and the guest itself reads /proc/self/status fine in that same boot. All 7 4.10
combos pass. Verified against the 6.13 source, the cause is one line in
__kernel_read():

    if (unlikely(!file->f_op->read_iter || file->f_op->read))
            return warn_unsupported(file, "read");   /* -EINVAL */

So a file that has ->read wired up, or lacks ->read_iter, cannot be read this
way at all. That is a property of the file's f_op, not of statefulness, and it
partitions procfs exactly as observed: proc_create_single* entries get
proc_iter_file_ops (.read_iter only) and work, while /proc/<pid>/status uses
proc_single_file_operations (.read = seq_read) and /proc/net/* register
proc_read = seq_read, landing on proc_reg_file_ops (.read, no .read_iter) --
both refused. 4.10 has no such guard (kernel_read -> __vfs_read -> ->read),
which is why the same paths work there, and why the older stateless
handle_op_read_file fails on exactly the same files: it uses the same
kernel_read.

Fixed for the ->read == seq_read case, which is safe to fix: seq_read's contract
is that file->private_data is a struct seq_file, so seq_read_iter can be called
directly with a kvec iterator. That makes /proc/<pid>/* readable. Gated on 5.10+,
where seq_read_iter exists and where the refusal it works around appeared;
verified to compile out on 4.10 (the 4.10 module references neither symbol) and
in on 6.13 (both resolve to exported symbols).

Deliberately NOT extended to proc_reg_file_ops (/proc/net/*): its ->read is
proc_reg_read, which forwards to a proc_ops a module cannot inspect, so there is
no way to prove private_data is a seq_file. In /proc/<pid>/mem it is an
mm_struct -- guessing would corrupt memory rather than fail. Reading those needs
a user-address bounce buffer in the calling task, which touches guest state and
is a separate decision. The header comment records the partition so the next
reader does not have to re-derive it.

Also: a read or close against an unknown or already-closed handle now reports
-EBADF instead of -EINVAL. That collision was mine and it cost real time: EINVAL
is also what kernel_read() returns for a file it will not serve, so a live
failure could not be attributed to the handle table or to the kernel's read path
without a debug build, which CI does not run. -EBADF is what read(2) uses for a
bad descriptor, so it is also just the right answer.
…hazards

Four defects in the vfs bridge, all of the kind that surfaces as a hang or a
mysterious dead slot rather than a test failure.

1. vfs_lock was held across the read. That is a liveness bug twice over. A read
   that blocks -- a FIFO with no writer, /proc/kmsg with an empty buffer -- held
   the mutex for as long as it blocked, so every other handle stalled behind one
   unlucky path with no timeout anywhere. And a read of a modelled pseudofile
   issues a hypercall from inside the read, re-entering the portal; that nested
   path demonstrably executes, so it was one table access away from deadlocking
   against itself. The read now runs with the lock dropped, and the slot carries
   a busy flag so a concurrent read or close on the same handle gets -EBUSY
   instead of interleaving chunks of one seq_file. Busy slots are never
   reclaimed, which is what keeps the struct file alive across the unlocked
   window; the read also holds its own reference, so the invariant is local
   rather than spread across three functions.

2. filp_open() used O_RDONLY alone, so opening a FIFO with no writer blocked in
   the OPEN -- before any locking question arises -- and wedged the hypercalling
   guest task forever. Opens are now O_NONBLOCK: such a file opens and reads
   -EAGAIN, which the host can report. Synthetic filesystems ignore the flag.

3. The generation counter was compared unmasked against a handle that only has
   24 bits to carry it. After 2^24 closes of one slot no comparison could ever
   match again and the slot was permanently -EBADF: a slow leak that would
   surface on a long run and on no test. Masked, it wraps and reuses.

4. vfs_claim_slot() reclaimed the oldest slot unconditionally, including one
   with a read in flight -- freeing the file being read from. It now skips busy
   slots, and refuses the open with -ENFILE if every slot is mid-read rather
   than picking a victim.

Builds clean for 4.10 and 6.13 (armel).
…the unlocked read safe

igloo_portal() takes a fresh page per call, so the nested portal call a modelled
pseudofile read makes cannot clobber the payload buffer being filled. The
previous commit's fix depends on that and did not say so.
…wards

It said a model should implement ONLY read_iter, on the reasoning that
__kernel_read refuses any file with .read set. Both halves are wrong.

The guard applies to the f_op PROCFS chose, not to what the model supplied. On
5.6+ procfs sets i_fop to proc_iter_file_ops (.read_iter, no .read) iff
proc_ops->proc_read_iter is non-NULL, else proc_reg_file_ops -- and it never
looks at proc_read, so keeping ->read costs nothing.

And read_iter alone breaks 4.10 outright: every regular procfs file gets
proc_reg_file_ops there whatever the module supplied, and proc_reg_read forwards
only ->read, so the file returns -EIO to the guest as well as to the host. A
model needs BOTH. Verified against fs/proc/inode.c and fs/read_write.c in both
kernels; the rule is now a truth table with citations in penguin's
tests/unit/test_pseudofile_host_readability.py.
handle_op_exec parsed the host's blob with `buf += strlen(buf) + 1` and no end
pointer, so a blob that was not properly double-NUL terminated walked strlen()
off the end of the page. The host is the only caller and now always terminates
it correctly, which makes this defence in depth rather than a live bug -- a
length-free walk over a page boundary is still a bug.

Three things it truncated now fail instead:

* An exe_path longer than 255 was shortened by strncpy and then RUN, as a
  different binary. And because `offset += strlen(exe_path) + 1` used the
  truncated length, argv parsing started in the middle of the real path, so
  every argument was garbage too. -ENAMETOOLONG.
* More than 15 arguments: the extras were dropped AND env_buf was derived from
  where the argv walk stopped -- the middle of the argument list -- so the
  environment came out garbage. -E2BIG.
* More than 15 environment variables: dropped. -E2BIG.

Errors ride the existing contract: handle_op_exec already returns the
call_usermodehelper retval in header.size as HYPER_RESP_READ_NUM, where negative
already means failure.

The host refuses all three before building the blob, so this is the second line
rather than the first. Builds clean for 4.10 and 6.13 (armel).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant