Skip to content

fix(libkernel): reject sceKernelRaiseException targets whose thread was released on Linux - #16380

Open
hugodearaujo wants to merge 2 commits into
boykopovar:mainfrom
hugodearaujo:fix/kernel-raise-released-thread
Open

hugodearaujo wants to merge 2 commits into
boykopovar:mainfrom
hugodearaujo:fix/kernel-raise-released-thread

Conversation

@hugodearaujo

@hugodearaujo hugodearaujo commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

What

sceKernelRaiseException uses its thread handle without first checking that the handle still names a guest thread:

  • it reads thread->_finished;
  • on Linux, RaiseOn then locks thread->_join_mtx and calls pthread_kill(thread->hostThread, SIGUSR1).

A PthreadPrivate is deleted in two cases:

  • a detached thread, when it exits (ThreadGuard → ReleaseThread);
  • a joined thread, when the next scePthreadCreate reuses its handle storage (RecycleJoinedThread).

A raise at either handle is a use after free. It reads and locks freed memory, then signals a host thread that may no longer exist.

Why this is a defect even without a title that reproduces it

Every other libkernel call that takes a guest thread handle looks it up in the thread registry before touching it:

Call Check
guest pthread_kill, scePthreadCondSignalto, pthread_setaffinity_np GuestThreadIsLive, ESRCH otherwise
scePthreadAttrGet, pthread_attr_get_np, pthread_getprio, the scheduling calls, ResumeGuestThread_nid_no_patch LockLiveGuestThread
scePthreadJoin, scePthreadDetach LockRegisteredGuestThread, and _join_mtx is taken only under it

sceKernelRaiseException is the one call that skips the lookup. It is also the closest sibling of guest pthread_kill, which does the check.

Titles also count on ESRCH here for a thread that is gone. The Boehm collector in IL2CPP titles raises at every thread it has registered and skips a thread when the raise fails. For example, GC_suspend_all in God of War Sons of Sparta's Il2cppUserAssemblies.prx:

  • calls sceKernelRaiseException(thread, 30) through PS5Util.prx;
  • skips the thread on 0x80020003 (ESRCH) or 0x80020016.

A title reaches the bug only when it raises at a handle whose thread was already released, which depends on thread lifetimes and timing. That is why it is hard to observe in a title. The defect is in the code either way, and it is the same check the neighbouring calls already make.

Change

  • sceKernelRaiseException returns SCE_KERNEL_ERROR_ESRCH unless GuestThreadIsLive(thread) (registered and not finished), so a released handle is never dereferenced.
  • On Linux, RaiseOn holds LockLiveGuestThread(thread) while it takes _join_mtx and sends the signal, so the thread cannot be released between the check and pthread_kill:
    • the registry lock comes before _join_mtx, the order scePthreadJoin and scePthreadDetach use;
    • Send only calls pthread_kill and does not wait for the delivery;
    • a raise at the calling thread still sends directly, without the lock.
  • On Windows, the early check applies too; the Windows RaiseOn is unchanged.

Where it came up: God of War Sons of Sparta (PPSA28997)

  • On main f2d874c, one of the first nine boots died with SIGSEGV (SEGV_MAPERR) on the main thread, 37 s in.
    • systemd-coredump stored no core for it (Storage: none), so there is no stack.
    • The last lines of that boot's log are signal-30 deliveries of a collector stop-the-world. Those deliveries make up most of every boot's log, though (655 of that log's 940 lines), so the log does not single out the raise.
  • I found this defect by reading the raise path while looking for the cause.
  • I have not reproduced that crash since:
    • 0 crashes in 32 boots on main 04a9eb4, with a warm shader cache, with it disabled, and pinned to 3 CPUs;
    • 0 crashes in 20 boots on f2d874c, with the emulator and Mesa shader caches disabled.
    • Four of the 32 boots ran a build that logs every raise at a released or finished handle. It logged none, out of about 1000 raises per boot.

So I don't know yet how to reproduce it in a title, and I can't say this defect caused that crash. This PR stands on the defect itself.

Tested

Arch Linux 7.2.11, GCC, Release with BUILD_TESTING=ON, on main 1ff0fe9.

  • New guest_raise_released_thread test (Linux).
    • It replaces operator delete so that the watched PthreadPrivate is zero-filled and kept instead of freed. That makes a read through the stale handle deterministic instead of depending on what the heap did with the memory.
    • It then raises at:
      1. a joined thread whose handle storage the next scePthreadCreate reused;
      2. eight detached threads, after they exited.
    • It expects ESRCH and no handler call.
  • Negative control: the test with main's Exception.cpp fails with ***Exception: SegFault in 5 of 5 runs. The zeroed _finished reads false, and pthread_kill gets host thread 0. With the change it passes in 50 of 50 runs.
  • ctest -R "raise|guest_exception_handler|released_handle|handle_after_exit|final_release|sigaltstack|sigtimedwait": all 15 tests pass.
  • tools/check_conventions.py --base origin/main: passes.
  • AddressSanitizer + UBSan (-DANYPS5_ENABLE_ASAN=ON, -g1 -fsanitize=undefined -fno-sanitize=vptr, ASAN_OPTIONS=detect_leaks=0): guest_raise_released_thread passes 3 of 3 with no report. The first version of the test did not define the nothrow operator new overloads and failed there with alloc-dealloc-mismatch (found by StepFPV below, fixed in 99af0d9).
  • God of War Sons of Sparta: 4 boots of 60 s with the change, on 04a9eb4. All reach the time limit, each past the first save and about 1000 collector suspends, the same as main.
  • Windows: not built or run.

Help wanted

If a title is needed as evidence before this can merge, that's where I'd appreciate help:

  • Intermittent crash during IL2CPP garbage collection. If you have a title that sometimes dies with SIGSEGV during IL2CPP garbage collection (a run of [exception] guest context signal=0x1e lines right before the crash), please run it with and without this change and post the crash counts.
  • Logging patch. I can post a small patch that logs any raise at a released or finished handle, so a run shows whether a title reaches this path at all.
  • Windows. A check of guest_raise_exception and guest_raise_released_thread would help. The second is Linux-only for now, because it relies on glibc's malloc_usable_size.

Checklist

  • Based on current main; no other open PR implements the same functions (feat(libkernel_sys): implement pthread_suspend_np #12130 also edits sceKernelRaiseException, in its Windows suspended branch; the two changes touch different lines)
  • 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 known; see "Where it came up"
  • GPUs tested: n/a (libkernel only)
  • AI-assisted: yes

…as released on Linux

sceKernelRaiseException used its thread handle without checking that it
still named a guest thread. It read thread->_finished, and on Linux
RaiseOn then locked thread->_join_mtx and called
pthread_kill(thread->hostThread). A PthreadPrivate is deleted when a
detached thread exits and when a joined thread's handle storage is
reused by the next scePthreadCreate, so a raise at such a handle reads
and locks freed memory and signals a host thread that may be gone.

Every other libkernel call that takes a guest thread handle looks it up
in the thread registry before touching it: guest pthread_kill,
scePthreadCondSignalto and pthread_setaffinity_np through
GuestThreadIsLive, scePthreadAttrGet and the priority and scheduling
calls through LockLiveGuestThread, scePthreadJoin and scePthreadDetach
through LockRegisteredGuestThread. sceKernelRaiseException was the one
call that skipped the lookup.

Return ESRCH unless the thread is registered and not finished. On Linux,
keep the registry lock while RaiseOn takes _join_mtx and sends the
signal, in the order scePthreadJoin and scePthreadDetach use, so the
thread cannot be released between the check and pthread_kill. A raise
at the calling thread still sends directly.

The new guest_raise_released_thread test keeps the released
PthreadPrivate zero-filled instead of freeing it and raises at a joined
thread whose storage was reused and at detached threads that exited.
Without this change it dies with SIGSEGV; with it every raise returns
ESRCH.

Found while looking into a SIGSEGV 37 s into a boot of God of War Sons
of Sparta (PPSA28997), whose IL2CPP collector raises at every thread it
has registered and skips a thread when the raise returns ESRCH. That
crash has not reproduced since and is not tied to this defect.
@JJenkx

JJenkx commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

Windows check of head ab2611008a, merged onto main 3bd88ca119 (df4c46dc9c, clean merge).

Setup: Windows 11 Enterprise 22621, WinLibs GCC 15.2 UCRT, Release. The hosted Windows check does not run these tests on pull requests.

  • The new guest_raise_released_thread is registered only for Linux (CMAKE_SYSTEM_NAME STREQUAL "Linux"), so it doesn't exist in the Windows build.
  • Exception.cpp is built here too, so I ran the existing guest_raise_exception as a regression check: it passes in 3 of 3 runs, none skipped.

AI-assisted: yes.

@StepFPV

StepFPV commented Nov 6, 2026

Copy link
Copy Markdown

ASan + UBSan check of head ab2611008a, merged onto main 5779a4ea51 (3c9774305b, clean merge). guest_raise_released_thread fails in that configuration. The cause is in the test, not in the change.

Setup: Ubuntu 26.04 container, GCC 15.2.0, the Sanitizers workflow's flags (-DANYPS5_ENABLE_ASAN=ON, -g1 -fsanitize=undefined -fno-sanitize=vptr), ASAN_OPTIONS=detect_leaks=0, lavapipe, 4 CPUs.

Result: the test fails 3 of 3 runs with an alloc-dealloc-mismatch:

ERROR: AddressSanitizer: alloc-dealloc-mismatch (operator new vs free)
    #1 scePthreadAttrDestroy  core/libs/prx/libkernel/Pthread/src/Attr.cpp:50
    #2 main                   core/libs/tests/GuestRaiseReleasedThread.cpp:97
allocated by thread T0 here:
    #0 operator new(unsigned long, std::nothrow_t const&)
    #1 scePthreadAttrInit     core/libs/prx/libkernel/Pthread/src/Attr.cpp:37
    #2 main                   core/libs/tests/GuestRaiseReleasedThread.cpp:86

Cause: the test replaces operator new(std::size_t) and the operator delete overloads, and its delete frees with std::free. It does not replace the std::nothrow_t overloads. scePthreadAttrInit allocates with new (std::nothrow), which under ASan is ASan's own allocator, and scePthreadAttrDestroy then releases it through the test's operator delete, which calls free. This is the same pattern as #15463 (guest_cnd_at_thread_exit).

Change that fixes it: the two nothrow overloads next to the existing ones in the test:

void* operator new(std::size_t bytes, const std::nothrow_t&) noexcept { return std::malloc(bytes != 0 ? bytes : 1); }
void* operator new[](std::size_t bytes, const std::nothrow_t&) noexcept { return std::malloc(bytes != 0 ? bytes : 1); }

With them the test passes 3 of 3 runs and the sanitizers report nothing.

Other control: with the test as it is in this PR and main's Exception.cpp, the test dies with a segmentation fault, so it still covers the fix.

Once this is merged the test would show up as a new failure in the nightly Sanitizers job.

AI-assisted: yes

…hread

The test replaces operator new and the operator delete overloads, and
its delete frees with std::free, but it left the std::nothrow_t forms of
operator new to the runtime. scePthreadAttrInit allocates with
new (std::nothrow), so under AddressSanitizer the attribute came from
ASan's allocator and went back to free through the test's delete, which
ASan reports as alloc-dealloc-mismatch. Allocate the nothrow forms with
std::malloc too, as boykopovar#15463 does for guest_cnd_at_thread_exit.
@hugodearaujo

Copy link
Copy Markdown
Contributor Author

@StepFPV thanks, confirmed and fixed in 99af0d9 with the two nothrow overloads you suggested.

Local check with an ANYPS5_ENABLE_ASAN=ON build (plus -g1 -fsanitize=undefined -fno-sanitize=vptr), ASAN_OPTIONS=detect_leaks=0:

  • the previous test fails 3 of 3 with the same alloc-dealloc-mismatch (scePthreadAttrInit → scePthreadAttrDestroy);
  • the new one passes 3 of 3, with no sanitizer report.

Release: the 15 raise|guest_exception_handler|released_handle|handle_after_exit|final_release|sigaltstack|sigtimedwait tests pass, the new test passes 50 of 50, and with main's Exception.cpp it still dies with SIGSEGV (3 of 3).

This branch has not been deployed

No deployments
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.

3 participants