feat(libkernel): guest memory protection and VirtualQuery on macOS hosts

- mprotect: the guest rounds a range to its 16 KiB pages. A Mach-O image
  is loaded at a multiple of the host page size only (4 KiB under
  Rosetta), so the pages are counted from where the ELF address 0 of the
  image holding the range was loaded, and a rounded edge smaller than a
  guest page that lies in the gap between two segments, where no host page
  exists, is left out. The write watch is told the new protection.
- sceKernelVirtualQuery reads the region with mach_vm_region.
- sceKernelMlock fails with ENOMEM when the range holds an inaccessible
  page: macOS wires such pages, which FreeBSD and Linux refuse.
- sceKernelMunlockall unlocks the ranges libkernel locked, one by one:
  macOS has no munlockall, which fails there with ENOSYS and would
  overwrite errno.
- The stack protect test reads page protections with mach_vm_region.
This commit is contained in:
mugurc committed 2026-10-17 17:29:23 +03:00
1 parent 47a266cf97
commit f66a4df741
6 files changed
+122 -6

No files matched your search

@@ -60,6 +60,8 @@ void GuestAllocationsAdd_nid_postfix(void* mutation, void* pointer, std::size_t
void GuestAllocationsRequireUnpinned_nid_postfix(void* mutation, const void* pointer, std::size_t bytes);
void GuestAllocationsRequireAvailable_nid_postfix(void* mutation, const void* pointer, std::size_t bytes);
bool GuestAllocationsCovers_nid_postfix(void* mutation, const void* pointer, std::size_t bytes);
void GuestAllocationsTrimToRegistered_nid_postfix(void* mutation, const void** pointer, std::size_t* bytes);
void GuestAllocationsGuestPages_nid_postfix(std::uintptr_t address, std::size_t bytes, std::size_t page, std::uintptr_t* first, std::uintptr_t* end);
bool GuestAllocationsOverlaps_nid_postfix(void* mutation, const void* pointer, std::size_t bytes);
bool GuestAllocationsForRegistered_nid_postfix(void* mutation, const void* pointer, std::size_t bytes, const std::function<void(const void*, std::size_t)>& visit);
Range GuestAllocationsFind_nid_postfix(void* mutation, const void* pointer);
@@ -99,6 +101,7 @@ public:
void RequireUnpinned(const void* pointer, std::size_t bytes) const { GuestAllocationsRequireUnpinned_nid_postfix(handle, pointer, bytes); }
void RequireAvailable(const void* pointer, std::size_t bytes) const { GuestAllocationsRequireAvailable_nid_postfix(handle, pointer, bytes); }
bool Covers(const void* pointer, std::size_t bytes) const { return GuestAllocationsCovers_nid_postfix(handle, pointer, bytes); }
void TrimToRegistered(const void*& pointer, std::size_t& bytes) const { GuestAllocationsTrimToRegistered_nid_postfix(handle, &pointer, &bytes); }
bool Overlaps(const void* pointer, std::size_t bytes) const { return GuestAllocationsOverlaps_nid_postfix(handle, pointer, bytes); }
bool ForRegistered(const void* pointer, std::size_t bytes, const std::function<void(const void*, std::size_t)>& visit) const { return GuestAllocationsForRegistered_nid_postfix(handle, pointer, bytes, visit); }
Range Find(const void* pointer) const { return GuestAllocationsFind_nid_postfix(handle, pointer); }
@@ -15,6 +15,11 @@
#define NOMINMAX
#endif
#include <windows.h>
#elif defined(__APPLE__)
#include "prx/libc/include/specifics/linux/ElfTypes.hpp"
#include <mach-o/dyld.h>
#include <mach-o/loader.h>
#include <unistd.h>
#else
#include <cerrno>
#include <cstring>
@@ -439,6 +444,54 @@ bool GuestAllocationsForRegistered_nid_postfix(void*, const void* pointer, std::
return any;
}
void GuestAllocationsGuestPages_nid_postfix(std::uintptr_t address, std::size_t bytes, std::size_t page, std::uintptr_t* first, std::uintptr_t* end) {
std::uintptr_t bias = 0;
#ifdef __APPLE__
struct Search {
std::uintptr_t address;
std::uintptr_t bias;
} search{address, 0};
dl_iterate_phdr([](dl_phdr_info* image, std::size_t, void* data) {
auto& found = *static_cast<Search*>(data);
for (unsigned index = 0; index < image->dlpi_phnum; ++index) {
const auto& header = image->dlpi_phdr[index];
const auto start = static_cast<std::uintptr_t>(image->dlpi_addr + header.p_vaddr);
if (header.p_type == PT_LOAD && found.address >= start && found.address - start < header.p_memsz) {
found.bias = static_cast<std::uintptr_t>(image->dlpi_addr);
return 1;
}
}
return 0;
}, &search);
bias = search.bias;
#endif
const auto mask = static_cast<std::uintptr_t>(page - 1);
*first = bias + ((address - bias) & ~mask);
*end = bias + ((address + bytes - bias + mask) & ~mask);
}
void GuestAllocationsTrimToRegistered_nid_postfix(void*, const void** pointer, std::size_t* bytes) {
#ifdef __APPLE__
constexpr std::uint64_t guestPage = 0x4000;
auto start = reinterpret_cast<std::uintptr_t>(*pointer);
auto end = start + *bytes;
const auto& ranges = registry().ranges;
const auto following = ranges.upper_bound(start);
const bool startCovered = following != ranges.begin() && std::prev(following)->first + std::prev(following)->second->bytes > start;
if (!startCovered && following != ranges.end() && following->first < end && following->first - start < guestPage) start = following->first;
const auto beyond = ranges.lower_bound(end);
if (beyond != ranges.begin()) {
const auto finish = std::prev(beyond)->first + std::prev(beyond)->second->bytes;
if (finish < end && finish > start && end - finish < guestPage) end = finish;
}
*pointer = reinterpret_cast<const void*>(start);
*bytes = end - start;
#else
static_cast<void>(pointer);
static_cast<void>(bytes);
#endif
}
bool GuestAllocationsCovers_nid_postfix(void*, const void* pointer, std::size_t bytes) {
const auto address = reinterpret_cast<std::uintptr_t>(pointer);
require(address != 0 && bytes != 0 && bytes <= std::numeric_limits<std::uint64_t>::max() - address, "invalid guest allocation range");
@@ -1079,10 +1079,13 @@ int DoMprotect(const void* addr, size_t len, int prot) {
constexpr auto pageMask = static_cast<std::uintptr_t>(PS5_PAGE_SIZE - 1);
const auto limit = std::numeric_limits<std::uintptr_t>::max();
if (address == 0 || len == 0 || len > limit - address || address + len > limit - pageMask) throw std::invalid_argument("Invalid guest memory protection range");
const auto first = address & ~pageMask;
const auto end = (address + len + pageMask) & ~pageMask;
const auto bytes = static_cast<std::size_t>(end - first);
const auto* pointer = reinterpret_cast<const void*>(first);
auto first = address & ~pageMask;
auto end = (address + len + pageMask) & ~pageMask;
#ifdef __APPLE__
GuestAllocations::GuestAllocationsGuestPages_nid_postfix(address, len, PS5_PAGE_SIZE, &first, &end);
#endif
auto bytes = static_cast<std::size_t>(end - first);
const void* pointer = reinterpret_cast<const void*>(first);
const auto nativeProtection = LinuxProtFromSce(prot);
if (GuestThreadStackHostRangeOverlaps(address, address + len))
throw std::system_error(EACCES, std::generic_category(), "mprotect overlaps host-only guest thread stack pages");
@@ -1120,14 +1123,18 @@ int DoMprotect(const void* addr, size_t len, int prot) {
if (!image.guestModule) throw std::invalid_argument("Memory protection of a foreign image is not supported");
mutation.RegisterImage(reinterpret_cast<const void*>(image.base));
}
mutation.TrimToRegistered(pointer, bytes);
#endif
std::vector<std::pair<const void*, std::size_t>> spans;
mutation.ForRegistered(pointer, bytes, [&](const void* span, std::size_t spanBytes) { spans.emplace_back(span, spanBytes); });
if (spans.empty() || reinterpret_cast<std::uintptr_t>(spans.front().first) != first) return SCE_KERNEL_ERROR_EINVAL;
if (spans.empty() || spans.front().first != pointer) return SCE_KERNEL_ERROR_EINVAL;
for (const auto& [span, spanBytes] : spans) mutation.RequireUnpinned(span, spanBytes);
for (const auto& [span, spanBytes] : spans) {
mutation.Protect(span, spanBytes, (prot & 3) != 0, (prot & 2) != 0, (prot & GuestProtGpuReadWrite) != 0, [&] {
if (mprotect(const_cast<void*>(span), spanBytes, nativeProtection) != 0) throw std::system_error(errno, std::generic_category(), "mprotect failed");
#ifdef __APPLE__
GuestWriteWatch::GuestWriteWatchProtectionChanged_nid_postfix(span, spanBytes, nativeProtection);
#endif
});
RecordProtection(span, spanBytes, prot);
}
@@ -19,6 +19,10 @@
#include <sstream>
#include <sys/mman.h>
#include <sys/resource.h>
#ifdef __APPLE__
#include <mach/mach.h>
#include <mach/mach_vm.h>
#endif
#endif
#include <algorithm>
#include <cassert>
@@ -342,6 +346,17 @@ int APS5_VABI sceKernelVirtualQuery(const void* addr, int flags, VirtualQueryInf
info->end = info->start + host.RegionSize;
const bool writable = (host.Protect & (PAGE_READWRITE | PAGE_EXECUTE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_WRITECOPY)) != 0;
const bool executable = (host.Protect & (PAGE_EXECUTE | PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY)) != 0;
#elif defined(__APPLE__)
mach_vm_address_t region = address;
mach_vm_size_t regionSize = 0;
vm_region_basic_info_data_64_t host{};
mach_msg_type_number_t count = VM_REGION_BASIC_INFO_COUNT_64;
mach_port_t object = MACH_PORT_NULL;
if (mach_vm_region(mach_task_self(), &region, &regionSize, VM_REGION_BASIC_INFO_64, reinterpret_cast<vm_region_info_t>(&host), &count, &object) != KERN_SUCCESS || region > address || (host.protection & VM_PROT_READ) == 0) return SCE_KERNEL_ERROR_EACCES;
info->start = region;
info->end = region + regionSize;
const bool writable = (host.protection & VM_PROT_WRITE) != 0;
const bool executable = (host.protection & VM_PROT_EXECUTE) != 0;
#else
std::ifstream maps("/proc/self/maps");
std::string line;
@@ -629,9 +644,28 @@ int UnlockHostPages(std::uintptr_t start, std::uintptr_t end) {
return 0;
}
#else
#ifdef __APPLE__
bool HasInaccessiblePages(std::uintptr_t start, std::uintptr_t end) {
for (auto address = static_cast<mach_vm_address_t>(start); address < end;) {
auto region = address;
mach_vm_size_t size = 0;
vm_region_basic_info_data_64_t info{};
mach_msg_type_number_t count = VM_REGION_BASIC_INFO_COUNT_64;
mach_port_t object = MACH_PORT_NULL;
if (mach_vm_region(mach_task_self(), &region, &size, VM_REGION_BASIC_INFO_64, reinterpret_cast<vm_region_info_t>(&info), &count, &object) != KERN_SUCCESS) return true;
if (region > address || info.protection == VM_PROT_NONE) return true;
address = region + size;
}
return false;
}
#endif
int LockHostPages(std::uintptr_t start, std::uintptr_t end) {
auto* const address = reinterpret_cast<void*>(start);
const std::size_t bytes = end - start;
#ifdef __APPLE__
if (HasInaccessiblePages(start, end)) return SCE_KERNEL_ERROR_ENOMEM;
#endif
if (::mlock(address, bytes) == 0) {
RecordLockedRange(start, end);
return 0;
@@ -689,7 +723,7 @@ int APS5_VABI sceKernelMunlock_nid_postfix(void* address, std::uint64_t length)
}
int APS5_VABI sceKernelMunlockall_nid_postfix() {
#ifndef _WIN32
#if !defined(_WIN32) && !defined(__APPLE__)
::munlockall();
#endif
std::map<uintptr_t, size_t> ranges;
@@ -715,6 +749,10 @@ int APS5_VABI sceKernelMunlockall_nid_postfix() {
cursor = next;
}
}
#elif defined(__APPLE__)
const int saved = errno;
for (const auto& [start, len] : ranges) ::munlock(reinterpret_cast<void*>(start), len);
errno = saved;
#endif
return 0;
}
@@ -7,6 +7,10 @@
#define NOMINMAX
#endif
#include <windows.h>
#elif defined(__APPLE__)
#include <mach/mach.h>
#include <mach/mach_vm.h>
#include <sys/mman.h>
#else
#include <cstdio>
#include <sys/mman.h>
@@ -38,6 +42,16 @@ static PageState QueryPage(std::uintptr_t address) {
Require(VirtualQuery(reinterpret_cast<const void*>(address), &memory, sizeof(memory)) == sizeof(memory));
const bool mapped = memory.State == MEM_COMMIT;
return {mapped, mapped && (memory.Protect & (PAGE_READWRITE | PAGE_EXECUTE_READWRITE)) != 0, (static_cast<std::uint64_t>(memory.State) << 32) | memory.Protect};
#elif defined(__APPLE__)
mach_vm_address_t region = address;
mach_vm_size_t size = 0;
vm_region_basic_info_data_64_t info{};
mach_msg_type_number_t count = VM_REGION_BASIC_INFO_COUNT_64;
mach_port_t object = MACH_PORT_NULL;
if (mach_vm_region(mach_task_self(), &region, &size, VM_REGION_BASIC_INFO_64, reinterpret_cast<vm_region_info_t>(&info), &count, &object) != KERN_SUCCESS || region > address) return {false, false, 0};
const bool readable = (info.protection & VM_PROT_READ) != 0;
const bool writable = (info.protection & VM_PROT_WRITE) != 0;
return {true, readable && writable, 1ull | (readable ? 2ull : 0) | (writable ? 4ull : 0) | ((info.protection & VM_PROT_EXECUTE) != 0 ? 8ull : 0)};
#else
std::FILE* maps = std::fopen("/proc/self/maps", "r");
Require(maps != nullptr);
+1
View File
@@ -176,6 +176,7 @@
- [getsockopt, setsockopt](../../../core/libs/prx/libkernel/Socket/src/SocketOptions.cpp) (libkernel) on a guest socket handle the SOL_SOCKET level and `TCP_NODELAY`, after FreeBSD 11.4's [`sosetopt` and `sogetopt`](https://github.com/freebsd/freebsd-src/blob/release/11.4.0/sys/kern/uipc_socket.c): `SO_REUSEADDR`, `SO_KEEPALIVE`, `SO_DONTROUTE`, `SO_BROADCAST`, `SO_OOBINLINE` and `SO_LINGER` act on the host socket, and `SO_ERROR`, `SO_TYPE`, `SO_ACCEPTCONN`, `SO_SNDBUF` and `SO_RCVBUF` are read. A boolean option reads back as its own bit, as FreeBSD returns `so_options & option`. Winsock does not clear a socket's pending error when it is read, so on Windows the library reports a pending error once and shows it again only after a new `connect`; a second failure with the same code on the same socket without a `connect` in between would be hidden. `SO_SNDBUF` and `SO_RCVBUF` read the host's value, which is not the size FreeBSD reports (Linux doubles it). `SO_NOSIGPIPE` is kept per socket and reads back as its bit. On Linux a `send`, `sendto`, `sendmsg` or `write` on that socket passes `MSG_NOSIGNAL` to the host, as [`kern_sendit`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/kern/uipc_syscalls.c#L890-L895) and [`soo_write`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/kern/sys_socket.c#L147-L151) leave out the signal. An accepted socket takes it from its listener, as [`sonewconn`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/kern/uipc_socket.c#L603) copies the options. `SO_NBIO` (`0x1200`) is the console's own option, not FreeBSD's. It switches the socket between blocking and non-blocking as `FIONBIO` does and reads back as 0 or 1, as `sceNetSetsockopt` and `sceNetGetsockopt` treat it here; what the console reads back for it is not measured. `TCP_NODELAY` (`IPPROTO_TCP`, 1) is set on the host socket and reads back as `TF_NODELAY` (4), as [`tcp_default_ctloutput`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/netinet/tcp_usrreq.c#L1860-L1861) returns it. The `IPPROTO_TCP` level on a datagram or `AF_UNIX` socket fails with `EINVAL`, as [`ip_ctloutput`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/netinet/ip_output.c#L943-L944) and [`uipc_ctloutput`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/kern/uipc_usrreq.c#L1228-L1229) answer it, and an accepted socket takes `TCP_NODELAY` through the host. `SO_SNDTIMEO` and `SO_RCVTIMEO` follow [`sosetopt`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/kern/uipc_socket.c#L2657-L2689) and [`sogetopt`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/kern/uipc_socket.c#L2846-L2850). A 16-byte `timeval` with a negative field or 1000000 microseconds or more fails with `EDOM`, and more than `INT32_MAX` seconds becomes the longest wait. The value is kept and read back through FreeBSD's 32.32 fixed-point conversion ([`tvtosbt`, `sbttotv`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/sys/time.h#L178-L286)), so half a second reads back as 499999 microseconds and one microsecond as 0. A `recv`, `recvfrom`, `recvmsg`, `read` or `readv`, and a `send`, `sendto`, `sendmsg`, `write` or `writev`, on a blocking socket then waits that long for the host to report the socket readable or writable, and fails with `EWOULDBLOCK`; a call with `MSG_DONTWAIT` does not wait. An accepted socket takes both from its listener ([`sonewconn`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/kern/uipc_socket.c#L629-L630)). The wait covers only the first transfer: once the socket is writable the host send runs without a limit, where FreeBSD stops a send that cannot finish and returns the bytes sent so far, and a receive whose data another thread takes between the wait and the host call blocks again. `sendfile` ignores the send timeout. The host wait has millisecond steps, so a shorter timeout lasts up to a millisecond longer. A null value fails with `EINVAL`, where FreeBSD's [`kern_setsockopt`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/kern/uipc_syscalls.c#L1414-L1415) returns `EFAULT`. Every other SOL_SOCKET option (`SO_DEBUG`, `SO_USELOOPBACK`, `SO_REUSEPORT` and `SO_TIMESTAMP`, which FreeBSD stores and no title has been seen to set, the low-water marks among them), every other `IPPROTO_TCP` option (`TCP_MAXSEG`, `TCP_KEEPIDLE`, `TCP_KEEPINTVL` and `TCP_KEEPCNT` among them) and every other level (`IPPROTO_IP`, `IPPROTO_IPV6`) fail with `ENOPROTOOPT`. None of this was measured on a console.
- [connect to AF_UNSPEC](../../../core/libs/prx/libkernel/Socket/src/Udp.cpp) (libkernel) dissolves a datagram association as FreeBSD 11.4's `udp_disconnect()` does, which resets the local address to the wildcard address and keeps the port. On Linux a socket bound to an explicit port keeps its local address as well (`127.0.0.1:<port>` stays `127.0.0.1:<port>`), because a bound socket cannot be bound again; one bound to port 0 is bound again to the wildcard address with the port it had
- [msync](../../../core/libs/prx/libkernel/DirectMemory/PosixMemory.cpp) (libkernel) follows FreeBSD 11's `kern_msync` and `vm_map_sync`, not measured on the console: the address is rounded down to the page, a range that wraps is `EINVAL`, as is `MS_ASYNC` with `MS_INVALIDATE`, and a range whose start or any page is not mapped (as `sceKernelVirtualQuery` reports it, reservations included) is `ENOMEM`; a page unmapped from inside a larger mapping still counts as mapped there, since `sceKernelVirtualQuery` keeps reporting the whole mapping. The guest has no file mappings, so a successful call writes nothing back. `MS_INVALIDATE` on a mapped range throws: FreeBSD returns `EBUSY` when the range holds `mlock`ed pages, and locked pages are not tracked
- Guest memory protection on macOS ([mprotect](../../../core/libs/prx/libkernel/DirectMemory/DirectMemory.cpp), [sceKernelVirtualQuery, sceKernelMlock](../../../core/libs/prx/libkernel/DirectMemory/Export.cpp)): the guest rounds a range to its 16 KiB pages, counted from where the ELF address 0 of the image holding it was loaded, which is a multiple of the host page size only (4 KiB under Rosetta). A rounded edge smaller than a guest page that lies in the gap between two segments of an image, where no host page exists, is left out of the request. `sceKernelVirtualQuery` reports the region `mach_vm_region` returns, so its extent is the host's. `sceKernelMlock` fails with `ENOMEM` when the range holds an inaccessible page, which macOS wires where FreeBSD and Linux refuse. After a protection change the write watch is told the protection, which the host reports for a watched page as its own
- [madvise](../../../core/libs/prx/libkernel/DirectMemory/PosixMemory.cpp) (libkernel) accepts `MADV_PROTECT` (10) as a no-op hint without modifying process protection. FreeBSD 11's `kern_madvise` (`sys/vm/vm_mmap.c`) handles it before the address check, ignores the address and length, and calls `kern_procctl(..., PROC_SPROTECT, PPROT_SET)`, which requires `PRIV_VM_MADV_PROTECT` and fails with `EPERM` without it. Returning 0 unconditionally assumes the calling process is privileged. Console behaviour is unverified
- [mincore](../../../core/libs/prx/libkernel/DirectMemory/PosixMemory.cpp) (libkernel) follows FreeBSD 11's `sys_mincore` (`sys/vm/vm_mmap.c`), not measured on the console: the address is rounded down to the page, length 0 returns 0, a null vector over a mapped range returns `EFAULT`, an overflowing range or any unmapped page in the range (as reported by `sceKernelVirtualQuery`) returns `ENOMEM`. For valid ranges, a page reports `MINCORE_INCORE` (0x1) when `sceKernelVirtualQuery` reports it committed and 0 when it is only reserved; actual residency, referenced and modified bits are not modelled. Length 0 returns 0 when the start page is mapped and `ENOMEM` otherwise, and the range and mapping checks come before the null vector `EFAULT`, as in `kern_mincore` Java 25 LAN / DNS (PPSA99869) imports it
- [mlockall, munlockall](../../../core/libs/prx/libkernel/DirectMemory/PosixMemory.cpp) (libkernel) - `mlockall` returns `EINVAL` for zero or unknown flags. Valid `MCL_CURRENT` throws because it would lock host stacks, heaps, modules and driver mappings along with guest memory; `MCL_FUTURE` throws because later mappings are not tracked for locking. FreeBSD 11.4's `sys_munlockall` (`sys/vm/vm_mmap.c`:1133-1165) checks `priv_check(td, PRIV_VM_MUNLOCK)` first (returning `EPERM` without it) and unwires the user map (`vm_map_unwire` over `vm_map_min`...`vm_map_max` with `VM_MAP_WIRE_HOLESOK`). Returning 0 unconditionally assumes the calling process is privileged. `munlockall` unlocks memory locked by `mlock` (`VirtualUnlock` on Windows, tolerating `ERROR_NOT_LOCKED` for pages never locked, and `munlockall` on Linux). On main, XPSemu (PPSA97358) fails to load at these imports (mlockall #4386, munlockall); their calls in QEMU `os-posix.c` execute only when `-overcommit mem-lock=on` is enabled. The call has not been observed in a run with this implementation.