Skip to content

fix(libkernel): fail recvmsg with EFAULT on guest memory it cannot read or write - #7675

Merged
oneandonlydean merged 4 commits into
boykopovar:mainfrom
valenciana-nc:fix/recvmsg-guest-validation
Oct 19, 2026
Merged

oneandonlydean merged 4 commits into
boykopovar:mainfrom
valenciana-nc:fix/recvmsg-guest-validation

Conversation

@valenciana-nc

@valenciana-nc valenciana-nc commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

What

recvmsg_nid_postfix read the msghdr and the iovec array, and wrote the received data, the sender address and the msghdr results, with only null checks. This PR makes those copies fail with EFAULT instead of faulting. It checks only the bytes that are actually read or written.

  • The msghdr and the iovec array are copied in before use. On Linux this uses process_vm_readv on the process itself. On Windows it uses VirtualQuery, requiring committed, readable pages. On macOS it uses mach_vm_read_overwrite on the current task. If the header or iovec array is unmapped, or on a PROT_NONE/PAGE_NOACCESS page, the call fails with EFAULT before anything is received.
  • The received bytes, the sender address, and nameLength, controlLength and flags are written under GuestArena::HostWrite, covering exactly the bytes written. On Linux this uses process_vm_writev; on macOS it uses mach_vm_write on the current task. On Windows, VirtualQuery must report a writable page after the guard opens, so an armed write-watched view is still accepted. A read-only or unmapped destination fails with EFAULT.
  • iovec and name lengths larger than the received data are accepted again. This fixes the guest_udp regression in the previous head (nameLength = UINT32_MAX on a 32-byte buffer, or a 1 GiB iovec over a 16-byte buffer).
  • The output checks run after the host receive, so a fault drops the received data. FreeBSD 11.4 also drops a datagram on a copy fault (soreceive_dgram, soreceive_generic) but keeps unread stream data. The libkernel technical debt file records this and that a Linux process_vm_* error other than EFAULT throws. The unchecked macOS-copy gap has been removed.

This replaces the previous CommittedRange check (mincore / MEM_COMMIT), which caused the guest_udp regression and did not catch inaccessible mapped pages.

This overlaps #7090 (SCM_RIGHTS), which also changes recvmsg_nid_postfix. Whichever PR merges second will need a rebase.

Tested

Latest follow-up: rebased onto main b106f6f8c (including merged #7672), Release, BUILD_TESTING=ON.

  • Windows 11 (26300), WinLibs GCC 15.2.0 r7: 19/19 focused socket tests pass: guest_recvmsg_memory, guest_socket_pair, guest_udp, guest_tcp, guest_socket_timeouts and the 14 guest_socket_host_write_* tests.
  • Linux, Ubuntu 24.04 container on Docker Desktop (WSL2 kernel 6.6), GCC 14.2: 5/5 focused socket tests pass: guest_recvmsg_memory, guest_socket_pair, guest_udp, guest_tcp, guest_socket_timeouts.
  • The new guest_recvmsg_memory CTest entry runs the memory-fault cases independently (guest_socket_pair_tests recvmsg-memory). It can run even when an earlier, unrelated socketpair check fails on macOS.
  • The original socket tests pass on the same main baseline on Windows and Linux. The earlier fail-on-main check with this PR's tests and unmodified Udp.cpp segfaults on both platforms.
  • Earlier broad testing against a373e85e5: the Windows guest suite passed (one Vulkan-absence test skipped); Linux had only two failures also present on its baseline. Those broad suites were not rerun for this follow-up.
  • Linux write-watched receive was not exercised: userfaultfd is unavailable in this container.

CheckReceiveMessageFaults in core/libs/tests/GuestSocketPair.cpp runs on both platforms. It uses flexible memory with a writable, a read-only and a no-access page:

  • EFAULT, datagram still queued: header at 0x1000, header on the no-access page, iovec array at 0x1000, iovec array on the no-access page.
  • EFAULT after the receive: iovec base at 0x1000, iovec base on the no-access page, iovec base on the read-only page, name at 0x1000, name on the read-only page, header on the read-only page. Nothing is left queued afterwards.
  • Success cases:
    • A 1 MiB iovec spanning all three pages, receiving 4 bytes.
    • nameLength = UINT32_MAX: 16 bytes are written and the rest of the buffer is untouched.
    • An unused second iovec on the no-access page.
    • The iovec array on the read-only page.
    • A zero-length name on the read-only page.

Not run locally: macOS. The Mach implementation follows @jotitadev's independently run macOS probe/review; a rerun of this exact head is requested. No local macOS build or write-watch result is claimed.

Checklist

  • Based on current main; no other open PR implements the same functions
  • One topic per PR; follow-ups go in a new PR
  • No comments in code except technical debt
  • Unimplemented paths throw (NotImplemented_nid_no_patch); silent stubs are listed under Silent stubs in their library's technical debt file
  • No notes, investigation .md files or images added to the repository (attach them to this PR)
  • New third-party code is a submodule built from source
  • Generic behaviour, not specific to one title
  • Depends on: none
  • Fixes: none
  • Unblocks: none
  • GPUs tested: n/a
  • AI-assisted: yes

@MarcZX10

Copy link
Copy Markdown
Contributor

One remaining pointer-validation gap: MessageLength(message) runs before the new per-base HostWrite checks, and it directly reads message->iov[i]. A non-null forged message->iov can therefore fault or read outside guest memory before any iovec-base validation occurs. Could the iovec descriptor array itself be safely copied/validated as guest-readable before MessageLength and the receive loop use it? A regression with an out-of-arena or inaccessible iovec-array pointer would cover this path.

@MarcZX10

Copy link
Copy Markdown
Contributor

More important, HostWrite::Open() is not itself a guest-range validator. On Linux, GuestArenaBeginHostWrite_nid_postfix calls the write-watch hook and then unconditionally returns true; on Windows, WindowsMappings::BeginHostWrite also returns true when the pointer is not in any tracked view. Thus an out-of-arena iov[i].base can pass this guard and still be copied to as a host pointer. Please pair the write-watch guard with an actual guest-range/committed-range validation (including the name and msghdr destinations), and add an out-of-arena pointer test. The descriptor-array read I noted above is a separate instance of the same trust-boundary issue.

@github-actions

github-actions Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Other open pull requests touch the same code. Merging one will break the other, or both implement the same thing. If this one builds on them, list them in Depends on.

Pull request Conflicting files (lines on main) Same exports Same new files
PR 7090 core/libs/prx/libkernel/Socket/src/Udp.cpp L1376

Some comparisons could not be completed because a pull request conflicts with the base branch. Review these shared files manually before concluding that there is no overlap.

Pull request Shared files not compared
PR 4972 core/libs/prx/libkernel/CMakeLists.txt
PR 5021 core/libs/prx/libkernel/CMakeLists.txt
core/libs/prx/libkernel/Socket/src/Udp.cpp
core/libs/tests/GuestSocketPair.cpp
docs/dev/debt/libkernel.md
PR 6044 core/libs/prx/libkernel/CMakeLists.txt
core/libs/tests/GuestSocketPair.cpp
PR 6149 docs/dev/debt/libkernel.md
PR 6226 core/libs/prx/libkernel/CMakeLists.txt
PR 6446 docs/dev/debt/libkernel.md
PR 6469 core/libs/prx/libkernel/CMakeLists.txt
docs/dev/debt/libkernel.md
PR 6519 docs/dev/debt/libkernel.md
PR 6740 docs/dev/debt/libkernel.md
PR 6942 core/libs/prx/libkernel/CMakeLists.txt
PR 6977 core/libs/prx/libkernel/CMakeLists.txt
core/libs/prx/libkernel/Socket/src/Udp.cpp
docs/dev/debt/libkernel.md
PR 7049 docs/dev/debt/libkernel.md
PR 7075 core/libs/prx/libkernel/Socket/src/Udp.cpp
docs/dev/debt/libkernel.md
PR 7088 core/libs/prx/libkernel/Socket/src/Udp.cpp
docs/dev/debt/libkernel.md
PR 7105 docs/dev/debt/libkernel.md
PR 7121 docs/dev/debt/libkernel.md
PR 7149 docs/dev/debt/libkernel.md
PR 7201 core/libs/prx/libkernel/CMakeLists.txt
core/libs/prx/libkernel/Socket/src/Udp.cpp
PR 7332 core/libs/prx/libkernel/CMakeLists.txt
core/libs/prx/libkernel/Socket/src/Udp.cpp
core/libs/tests/GuestSocketPair.cpp
docs/dev/debt/libkernel.md
PR 7509 docs/dev/debt/libkernel.md
PR 7541 docs/dev/debt/libkernel.md
PR 7554 docs/dev/debt/libkernel.md
PR 7556 docs/dev/debt/libkernel.md
PR 7605 core/libs/prx/libkernel/Socket/src/Udp.cpp
docs/dev/debt/libkernel.md
PR 7653 docs/dev/debt/libkernel.md
PR 7937 docs/dev/debt/libkernel.md

@LeandroLP23

LeandroLP23 commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Windows build and run, since the description has a syntax check only. Radeon 860M (RDNA 3.5, driver 32.0.13058.8001), Windows 11 26300, WinLibs GCC 15.2.0 r7, Release, BUILD_TESTING=ON. Build: main 8a456334f with #7677, #7673, #7671, #7666, #7676, #7675, #7672, #7668, #7664 and #7658 merged together (each merge clean, built once).

The build is clean. The full guest_* run on that build: 474 of 476 pass, with guest_video_out_wqhd_detection_no_vulkan skipped. The 2 failures, guest_sysarch and guest_offline_imports, come from #7658 in the same build: they fail with #7658 alone and pass without it. I didn't check which of them reach the changed function, and no test checks the new buffer validation itself.

AI-assisted

@MarcZX10

Copy link
Copy Markdown
Contributor

Thanks for posting the Windows run. The 474/476 result is useful for the composed tree, with the two failures attributed to #7658. As you noted, it does not exercise the new recvmsg validation path, so it does not resolve the concrete guest-pointer issues from my earlier comments: the iovec array is read before validation, and HostWrite::Open() does not reject pointers outside tracked guest ranges. A focused test for an invalid iovec-array pointer and an out-of-arena output pointer would directly cover those cases.

@valenciana-nc
valenciana-nc force-pushed the fix/recvmsg-guest-validation branch from a482f91 to f5ef1c1 Compare October 18, 2026 23:34
@valenciana-nc

Copy link
Copy Markdown
Contributor Author

All three points addressed in the latest push:

1. Iovec array validated before MessageLength. The descriptor array range (iovLength * sizeof(GuestIovec), with the < 0 || > 1024 bound checked first so the size computation is safe) is now verified before MessageLength reads message->iov[i]. A forged message->iov fails with EFAULT instead of faulting.

2. HostWrite now paired with committed-range validation. New file-local CommittedRange(pointer, bytes): mincore over the page-aligned range on POSIX, VirtualQuery requiring MEM_COMMIT on every page on Windows. Every output destination — the msghdr struct itself, each iov[i].base, and name — gets the committed check plus the HostWrite guard, all validated up front before any packet is waited on or received (guards stay alive in a std::deque through the copies, same pattern as libSceNet's sceNetRecvmsg).

One deliberate design call: I used committed-range rather than strict guest-arena containment. A GuestArenaContains requirement would reject legitimate stack buffers — the existing GuestSocketPair tests pass stack-allocated msghdr/iov/data to this exact entry point (as do recv/recvfrom/read, and real titles routinely do recvmsg(fd, &stack_msg, 0)). The committed check rejects exactly the forged/unmapped pointers while preserving that calling convention.

3. Focused tests added in core/libs/tests/GuestSocketPair.cpp: forged iovec-array pointer, forged iovec base, and forged name pointer — each must return -1/EFAULT. On the pre-fix code the first segfaults in MessageLength and the others fault on the copy, so they cover the reported paths. The Windows VirtualQuery path is implemented but not executed here (no Windows machine); the new guest tests are Linux-side.'

@MarcZX10

Copy link
Copy Markdown
Contributor

I think the new CommittedRange still lets inaccessible-but-mapped pointers through. On POSIX, mincore reports mapping/residency and does not establish that a range is readable or writable; on Windows, MEM_COMMIT likewise includes PAGE_NOACCESS and read-only pages. recvmsg then dereferences the iovec array or writes to the output range, so a PROT_NONE/PAGE_NOACCESS iovec array can still fault before returning EFAULT, and a read-only output page can fault during memcpy. Could the check verify the required page protections (read for the descriptor array, write for outputs), with focused inaccessible-mapped-range tests on each platform?

@jotitadev jotitadev left a comment

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.

Linux run of f5ef1c1cb. It breaks guest_udp.

Machine: Linux x86_64, i7-8700, Ubuntu 26.04, GCC 15.2, Release, BUILD_TESTING=ON, full build.
Head f5ef1c1cb merged onto main 84c1a4d9a (clean merge).

  • guest_udp aborts with this head, 3 of 3 runs. main passes, and so did the previous head a482f911b. With the Require calls numbered, it stops at GuestUdp.cpp:94: recvmsg(receiver, &message, 0) returns -1 with errno 14. That check runs in the loop over name capacities {0, 1, 2, addressSize - 1, addressSize, 32, UINT32_MAX} on a 32-byte std::array. CommittedRange(message->name, message->nameLength) checks the capacity the caller declares, not the bytes recvmsg writes, and an oversized capacity is valid input that the test covers. The iovec lengths are checked the same way. A probe on a Unix datagram pair, sending 4 bytes each time:

    call main f5ef1c1cb
    32-byte name, nameLength = 32 4 4
    32-byte name, nameLength = UINT32_MAX 4 -1, errno 14
    16-byte iov[0].base, length = 1 GiB 4 -1, errno 14
    message, iov[0].base or message->iov = 0x1000 (unmapped) SIGSEGV -1, errno 14
    message->iov or iov[0].base on a PROT_NONE page SIGSEGV SIGSEGV
    iov[0].base on a PROT_READ page SIGSEGV SIGSEGV

    The last two rows match @MarcZX10's point about mincore.

  • The new guest_socket_pair cases pass. With only GuestSocketPair.cpp from this PR, main segfaults, so the new cases do cover the crash.

  • Non-GPU suite (ctest -E "^agc|gpu|vulkan|video_out"): 738/739. The one failure is guest_udp; main is 739/739.

Written with AI assistance (Claude Code). I ran the builds and tests above on my machine.

@valenciana-nc valenciana-nc changed the title fix(libkernel): validate recvmsg guest output buffers in the guest arena fix(libkernel): fail recvmsg with EFAULT on guest memory it cannot read or write Oct 19, 2026
@valenciana-nc

Copy link
Copy Markdown
Contributor Author

Thanks, both. Reworked in 3cb4ae8.

@jotitadev: the guest_udp regression is fixed. Only the bytes that are actually copied are checked now:

  • the name gets min(nameLength, address size) bytes;
  • each iovec gets the received bytes that land in it.

I reproduced your abort with f5ef1c1cb on Linux, and guest_udp passes with the new head. From your probe table, the oversized nameLength and the 1 GiB iovec now return the received count. An unmapped header, iovec array or base returns EFAULT.

@MarcZX10: CommittedRange (mincore / MEM_COMMIT) is gone. Reading the header and iovec array, and every write, now go through checked copies:

  • On Linux, process_vm_readv / process_vm_writev on the process itself. The kernel applies the page protections, so PROT_NONE and PROT_READ pages return EFAULT instead of faulting.
  • On Windows, VirtualQuery with read or write protection masks. The write check runs after HostWrite opens, so armed write-watched views still pass.

CheckReceiveMessageFaults in GuestSocketPair.cpp covers read-only and no-access pages on both platforms.

Trade-off: an output fault is found after the host receive, so the data is dropped. FreeBSD also drops a datagram on a copy fault, but keeps unread stream data. The libkernel technical debt file records this, along with the unchecked copies on macOS. Results are in the description. A receive into write-watched memory was not exercised on Linux, because userfaultfd write protection was not available in my environment.

@github-actions github-actions Bot added the docs label Oct 19, 2026

@jotitadev jotitadev left a comment

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.

macOS run of 3cb4ae859. The new CheckReceiveMessageFaults crashes on a macOS host, and XNU has a checked-copy equivalent that makes it pass.

Machine: Apple M2 Max, macOS 27.2, Xcode 27 clang, Release, BUILD_TESTING=ON, x86_64 under Rosetta. Not main: main does not build on macOS yet, so the base is main a373e85e5 + mugurc's ci/macos-series-2 (91e4eccaf) + the macOS bits of #7332. The PR diff applied cleanly on top (git apply -3). I rebuilt and ran the socket tests (ctest -R 'guest_(socket|udp|tcp|sendfile)', 14 tests).

  • guest_udp passes with this head. The regression from f5ef1c1cb is gone on macOS too.

  • guest_socket_pair aborts on macOS both with and without this PR, at an existing check: shutdown(SHUT_RD) on the peer after shutdown(SHUT_WR) returns ENOTCONN on XNU. That abort comes before CheckReceiveMessageFaults, so the full test never reaches the new cases. I ran them on their own with a local test-only patch that calls CheckReceiveMessageFaults() first:

    • base + this PR's test only: SIGSEGV (expected);
    • this PR: SIGSEGV on the first case, the header at 0x1000. On macOS ReadGuest and WriteGuest fall through to plain memcpy. The debt entry says the copies are unchecked on macOS, but the test is not gated, so it crashes on a macOS host.
  • XNU primitives, standalone probe (arm64 native and x86_64 Rosetta give the same results). mach_vm_read_overwrite and mach_vm_write on mach_task_self() behave like process_vm_readv/process_vm_writev:

    target mach_vm_read_overwrite mach_vm_write
    RW page OK OK
    PROT_READ page OK KERN_INVALID_ADDRESS, page unchanged
    PROT_NONE page KERN_INVALID_ADDRESS KERN_INVALID_ADDRESS
    0x1000 KERN_INVALID_ADDRESS KERN_INVALID_ADDRESS
    range spanning RW into PROT_NONE KERN_INVALID_ADDRESS KERN_INVALID_ADDRESS
  • With an __APPLE__ branch that uses those two calls (below), every case in CheckReceiveMessageFaults passes on macOS. The other socket tests show the same results as base: 13/14, with only the pre-existing guest_socket_pair abort.

#elif defined(__APPLE__)            // ReadGuest (with <mach/mach.h>, <mach/mach_vm.h>)
    mach_vm_size_t copied = 0;
    return ::mach_vm_read_overwrite(::mach_task_self(), reinterpret_cast<mach_vm_address_t>(source), bytes,
               reinterpret_cast<mach_vm_address_t>(destination), &copied) == KERN_SUCCESS && copied == bytes;
#elif defined(__APPLE__)            // WriteGuest
    return ::mach_vm_write(::mach_task_self(), reinterpret_cast<mach_vm_address_t>(destination),
               reinterpret_cast<vm_offset_t>(source), static_cast<mach_msg_type_number_t>(bytes)) == KERN_SUCCESS;

So there are two options: use this branch and drop "unchecked on macOS" from the debt entry, or gate CheckReceiveMessageFaults off on __APPLE__. I did not test a receive into write-watched memory on macOS, and I did not test an arm64 build of the tree.

Written with AI assistance (Claude Code). I ran the builds and tests above on my machine.

@oneandonlydean oneandonlydean self-assigned this Oct 19, 2026

@jotitadev jotitadev left a comment

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.

Linux rerun of 3cb4ae859 with the same probe as my report on f5ef1c1cb.

Machine: Linux x86_64, i7-8700, Ubuntu 26.04, GCC 15.2, Release, BUILD_TESTING=ON, full build. Head 3cb4ae859 merged onto main 8816fd772 (clean merge).

  • guest_udp passes again, 3 of 3 runs directly and under ctest. The socket tests pass (12/12) and the non-GPU suite is 775/775, same as main.

  • The PR's guest_socket_pair on main (library unchanged) segfaults, so the new cases cover the change.

  • Probe (recvmsg on a socketpair with a 4-byte message):

    case f5ef1c1cb 3cb4ae859
    message, message->iov or iov[0].base at 0x1000 EFAULT EFAULT
    message->iov on a PROT_NONE page SIGSEGV EFAULT
    iov[0].base on a PROT_NONE page SIGSEGV EFAULT
    iov[0].base on a PROT_READ page SIGSEGV EFAULT
    32-byte name, nameLength = UINT32_MAX EFAULT 4, as on main
    16-byte iov, length 1 GiB EFAULT 4, as on main

So on Linux both the regression and the mapped-but-inaccessible cases are fixed. The macOS point from my other comment (plain memcpy there) is unchanged by this head.

Written with AI assistance (Claude Code). I ran the builds and tests above on my machine.

@valenciana-nc
valenciana-nc force-pushed the fix/recvmsg-guest-validation branch from 3cb4ae8 to cec28a2 Compare October 19, 2026 12:30
@valenciana-nc

valenciana-nc commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor Author

@jotitadev thanks for the macOS probe and Linux rerun. Addressed in f7fd9db, rebased onto main b106f6f8c (including the concurrent merge of #7672):

  • ReadGuest now uses mach_vm_read_overwrite on macOS; WriteGuest uses mach_vm_write, under the existing host-write guard. The unchecked macOS fallback is gone, and that gap is removed from the debt entry.
  • Added guest_recvmsg_memory (guest_socket_pair_tests recvmsg-memory), so the memory-fault checks can run independently of the earlier shutdown assertion you identified. Added an explicit no-access iovec-base case too.

Locally: 19/19 focused socket tests pass on Windows (GCC 15.2 r7), and 5/5 on Linux (GCC 14.2). I cannot run macOS here, so I am not claiming a macOS build or execution result. Could you rerun ctest -R '^guest_recvmsg_memory$' --output-on-failure on your macOS build? No test-only reorder should be needed now.

AI-assisted: yes.

recvmsg_nid_postfix wrote the msghdr, every iovec base and the sender
address with only null checks, unlike recv_nid_postfix and the
sceNetRecvfrom sender outputs which all gate on GuestArena::HostWrite.
Validate the message, each iovec base and the name buffer the same way
so a forged pointer fails with EFAULT instead of writing received bytes
to an arbitrary host address.
Address review: check the msghdr, iovec array, iovec bases and name
are mapped before MessageLength or the receive loop touches them,
pairing the HostWrite guard with committed-range validation. Add
regression tests for forged iovec-array and output pointers.
@valenciana-nc
valenciana-nc force-pushed the fix/recvmsg-guest-validation branch from cec28a2 to f7fd9db Compare October 19, 2026 12:39
@github-actions github-actions Bot added the build label Oct 19, 2026
@oneandonlydean
oneandonlydean merged commit 6b981a2 into boykopovar:main Oct 19, 2026
12 checks passed
@valenciana-nc
valenciana-nc deleted the fix/recvmsg-guest-validation branch October 19, 2026 21:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants