[PATCH] nptl: Retry SIGSETXID delivery on EAGAIN in setxid (bug 21108)
Adhemerval Zanella Netto
adhemerval.zanella@linaro.org
Fri Aug 14 12:59:53 GMT 2026
Ping (x2) with Andreas typos fixes.
On 01/07/26 14:33, Adhemerval Zanella Netto wrote:
> Ping.
>
> On 09/06/26 17:14, Adhemerval Zanella wrote:
>> setxid_signal_thread assumed tgkill could only fail if the target
>> thread had not started yet or had already exited, and treated any error
>> as "thread is gone". But SIGSETXID is a realtime signal, and for those
>> the tgkill may fail with with EAGAIN once the per-real-user
>> RLIMIT_SIGPENDING limit is reached (process-directed kill uses the
>> guaranteed delivery override, but a thread-directed tgkill cannot.)
>>
>> When that happened, the thread never ran the syscall, yet __nptl_setxid
>> returned success. A multithreaded process dropping privileges with
>> 'setuid' could therefore silently leave one or more threads with the old
>> credentials (a potential security issue).
>>
>> Distinguish EAGAIN from a vanished thread and retry delivery, with a
>> bounded exponential backoff, until tgkill succeeds. If the queue never
>> drains the call blocks indefinitely, which is the intended behaviour:
>> hanging is safer than continuing with threads that wrongly retain
>> privileges.
>>
>> The retry uses a sleeping backoff rather than a busy tgkill loop. Each
>> successfully-sent SIGSETXID holds an RLIMIT_SIGPENDING slot until its
>> target thread runs the handler, so forward progress requires letting
>> those threads run: sleeping yields the CPU, whereas a busy loop merely
>> relies on the scheduler preempting the spinner.
>>
>> Under thread priorities (SCHED_FIFO) or CPU pressure that might becomes a
>> deadlock -- if the setxid caller outranks the thread whose handler must
>> free a slot, a busy loop spins forever. A sleep also avoids pinning a
>> core at 100% in the intentional block-forever case when the queue is
>> exhausted by a source that never drains.
>>
>> The new test reproduces the bug without any privileges by checking
>> signal delivery rather than the credential outcome: with the queue full,
>> a no-op setresuid still broadcasts SIGSETXID and, once fixed, must block
>> until the queue drains instead of returning immediately.
>>
>> Tested on aarch64-linux-gnu, x86_64-linux-gnu, and i686-linux-gnu.
>> I also manually tests the xfail tests on nptl.
>> ---
>> nptl/Makefile | 1 +
>> nptl/nptl_setxid.c | 100 +++++++++++++++++++++++----
>> nptl/tst-setuid-eagain.c | 144 +++++++++++++++++++++++++++++++++++++++
>> 3 files changed, 233 insertions(+), 12 deletions(-)
>> create mode 100644 nptl/tst-setuid-eagain.c
>>
>> diff --git a/nptl/Makefile b/nptl/Makefile
>> index 02862d1c04b..29db8dae505 100644
>> --- a/nptl/Makefile
>> +++ b/nptl/Makefile
>> @@ -350,6 +350,7 @@ tests = \
>> tst-rwlock22 \
>> tst-sched1 \
>> tst-sem17 \
>> + tst-setuid-eagain \
>> tst-signal3 \
>> tst-stack2 \
>> tst-stack3 \
>> diff --git a/nptl/nptl_setxid.c b/nptl/nptl_setxid.c
>> index 2214b325ddf..2ee83e80631 100644
>> --- a/nptl/nptl_setxid.c
>> +++ b/nptl/nptl_setxid.c
>> @@ -150,31 +150,86 @@ setxid_unmark_thread (struct xid_command *cmdp, struct pthread *t)
>> }
>>
>>
>> -static int
>> +enum setxid_signal_state
>> +{
>> + setxid_signal_done, /* Thread has not finished starting or has already
>> + exited; ignore it. */
>> + setxid_signal_sent, /* The signal was delivered and the thread signal
>> + handler will run (the xid_command::crnt was already
>> + incremented. */
>> + setxid_signal_retry, /* tgkill returned EAGAIN (the signal queue
>> + RLIMIT_SIGPENDING is full), the thread is still
>> + running and must be signal. The caller has to
>> + retry. */
>> +};
>> +
>> +static enum setxid_signal_state
>> setxid_signal_thread (struct xid_command *cmdp, struct pthread *t)
>> {
>> if ((t->cancelhandling & SETXID_BITMASK) == 0)
>> - return 0;
>> + return setxid_signal_done;
>>
>> int val;
>> pid_t pid = __getpid ();
>> val = INTERNAL_SYSCALL_CALL (tgkill, pid, t->tid, SIGSETXID);
>>
>> - /* If this failed, it must have had not started yet or else exited. */
>> if (!INTERNAL_SYSCALL_ERROR_P (val))
>> {
>> atomic_fetch_add_relaxed (&cmdp->cntr, 1);
>> - return 1;
>> + return setxid_signal_sent;
>> }
>> - else
>> - return 0;
>> +
>> + /* The kernel might return EAGAIN for realtime signals if the signal queue
>> + reaches its limits (RLIMIT_SIGPENDING) because tgkill does not use the
>> + guaranteed-delivery override path as the kill syscall. The threads is
>> + still running and *must* process the credential change, so instruc the
>> + caller to retry. Any other error means the thread has not started yet
>> + or has already exited (and can be ignored). */
>> + if (INTERNAL_SYSCALL_ERRNO (val) == EAGAIN)
>> + return setxid_signal_retry;
>> +
>> + return setxid_signal_done;
>> +}
>> +
>> +enum
>> +{
>> + setxid_backoff_min_ns = 1000L, /* 1 us first retry. */
>> + setxid_backoff_max_ns = 65536000L /* ~65 ms ceiling. */
>> +};
>> +/* Make sure the maximum backoff sleep is within a resonable maximum value
>> + of 100 ms. It should always fit a 32-bit timespec. */
>> +verify (setxid_backoff_min_ns > 0
>> + && setxid_backoff_min_ns < setxid_backoff_max_ns
>> + && setxid_backoff_max_ns < 100000000L);
>> +
>> +/* Sleep for *NS nanoseconds, then grow the delay towards the ceiling. */
>> +static void
>> +setxid_signal_backoff (long int *ns)
>> +{
>> + /* We can not use __clock_nanosleep because it is a cancellable entrypoint,
>> + and we want to not touch errno. CLOCK_MONOTONIC with flags == 0
>> + requests a *relative* sleep against a clock that never steps backwards,
>> + which is what a retry delay wants. */
>> +#ifdef __ASSUME_TIME64_SYSCALLS
>> +# ifndef __NR_clock_nanosleep_time64
>> +# define __NR_clock_nanosleep_time64 __NR_clock_nanosleep
>> +# endif
>> + struct __timespec64 ts = { .tv_sec = 0, .tv_nsec = *ns };
>> + INTERNAL_SYSCALL_CALL (clock_nanosleep_time64, CLOCK_MONOTONIC, 0, &ts,
>> + NULL);
>> +#else
>> + /* The backoff timer should always fit in 32 bit timespec. */
>> + struct timespec ts = { .tv_sec = 0, .tv_nsec = *ns };
>> + INTERNAL_SYSCALL_CALL (clock_nanosleep, CLOCK_MONOTONIC, 0, &ts, NULL);
>> +#endif
>> +
>> + *ns = *ns >= setxid_backoff_max_ns / 2 ? setxid_backoff_max_ns : *ns * 2;
>> }
>>
>> int
>> -attribute_hidden
>> __nptl_setxid (struct xid_command *cmdp)
>> {
>> - int signalled;
>> + bool signalled;
>> int result;
>> lll_lock (GL (dl_stack_cache_lock), LLL_PRIVATE);
>>
>> @@ -208,9 +263,11 @@ __nptl_setxid (struct xid_command *cmdp)
>> /* Iterate until we don't succeed in signalling anyone. That means
>> we have gotten all running threads, and their children will be
>> automatically correct once started. */
>> + long int backoff_ns = setxid_backoff_min_ns;
>> do
>> {
>> - signalled = 0;
>> + signalled = false;
>> + bool retry = false;
>>
>> list_for_each (runp, &GL (dl_stack_used))
>> {
>> @@ -218,7 +275,12 @@ __nptl_setxid (struct xid_command *cmdp)
>> if (t == self)
>> continue;
>>
>> - signalled += setxid_signal_thread (cmdp, t);
>> + switch (setxid_signal_thread (cmdp, t))
>> + {
>> + case setxid_signal_sent: signalled = true; break;
>> + case setxid_signal_retry: retry = true; break;
>> + case setxid_signal_done: break;
>> + }
>> }
>>
>> list_for_each (runp, &GL (dl_stack_user))
>> @@ -227,7 +289,12 @@ __nptl_setxid (struct xid_command *cmdp)
>> if (t == self)
>> continue;
>>
>> - signalled += setxid_signal_thread (cmdp, t);
>> + switch (setxid_signal_thread (cmdp, t))
>> + {
>> + case setxid_signal_sent: signalled = true; break;
>> + case setxid_signal_retry: retry = true; break;
>> + case setxid_signal_done: break;
>> + }
>> }
>>
>> int cur = cmdp->cntr;
>> @@ -237,8 +304,17 @@ __nptl_setxid (struct xid_command *cmdp)
>> FUTEX_PRIVATE);
>> cur = cmdp->cntr;
>> }
>> +
>> + if (retry)
>> + {
>> + /* Blocking here until delivery eventually succeeds is intentional
>> + and safer than returning while a thread still holds the old
>> + credentials. */
>> + setxid_signal_backoff (&backoff_ns);
>> + signalled = true;
>> + }
>> }
>> - while (signalled != 0);
>> + while (signalled);
>>
>> /* Clean up flags, so that no thread blocks during exit waiting
>> for a signal which will never come. */
>> diff --git a/nptl/tst-setuid-eagain.c b/nptl/tst-setuid-eagain.c
>> new file mode 100644
>> index 00000000000..942091bbbb8
>> --- /dev/null
>> +++ b/nptl/tst-setuid-eagain.c
>> @@ -0,0 +1,144 @@
>> +/* Test that a multithreaded setxid does not silently give up when the
>> + realtime signal queue is exhausted (bug 21108).
>> + Copyright (C) 2026 Free Software Foundation, Inc.
>> + This file is part of the GNU C Library.
>> +
>> + The GNU C Library is free software; you can redistribute it and/or
>> + modify it under the terms of the GNU Lesser General Public
>> + License as published by the Free Software Foundation; either
>> + version 2.1 of the License, or (at your option) any later version.
>> +
>> + The GNU C Library is distributed in the hope that it will be useful,
>> + but WITHOUT ANY WARRANTY; without even the implied warranty of
>> + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
>> + Lesser General Public License for more details.
>> +
>> + You should have received a copy of the GNU Lesser General Public
>> + License along with the GNU C Library; if not, see
>> + <https://www.gnu.org/licenses/>. */
>> +
>> +/* The sexid is implemented on Linux with a SIGSETXID broadcast, and tgkill
>> + might fail with EAGAIN if the signal queue is full (RLIMIT_SIGPENDING)
>> + because SIGSETXID is a realtime signal.
>> +
>> + The test first fills the queue, run the setresuid in a helper thread, and
>> + verifiers that it does *not* complete while the queue is still full.
>> +
>> + The test checks whether the signal delivery works as intended, so there's
>> + no need to change privileges. A no-op setresuid still broadcasts SIGSETXID
>> + to the other threads, and with a full queue, the call may only return once
>> + the queue drains. */
>> +
>> +#include <errno.h>
>> +#include <pthread.h>
>> +#include <semaphore.h>
>> +#include <signal.h>
>> +#include <stdlib.h>
>> +#include <sys/resource.h>
>> +#include <time.h>
>> +#include <unistd.h>
>> +
>> +#include <support/check.h>
>> +#include <support/xthread.h>
>> +
>> +/* How long to wait for the (no-op) setxid to complete before concluding it
>> + is correctly blocking on the full queue. It should not return until the
>> + queue is drained. */
>> +#define BLOCK_WAIT_SECONDS 2
>> +
>> +static pthread_barrier_t start_barrier;
>> +static sem_t setxid_done;
>> +static sem_t worker_exit;
>> +
>> +/* A plain thread that must be signalled by the setxid broadcast. It simply
>> + parks, remaining in the thread list so it is a delivery target. */
>> +static void *
>> +worker_thread (void *closure)
>> +{
>> + xpthread_barrier_wait (&start_barrier);
>> + while (sem_wait (&worker_exit) != 0)
>> + /* Restart if interrupted by the SIGSETXID handler. */;
>> + return NULL;
>> +}
>> +
>> +/* setresuid to the current ids is a no-op but still triggers the full
>> + SIGSETXID broadcast to the other threads. */
>> +static void *
>> +setxid_thread (void *closure)
>> +{
>> + xpthread_barrier_wait (&start_barrier);
>> + uid_t uid = getuid ();
>> + TEST_VERIFY_EXIT (setresuid (uid, uid, uid) == 0);
>> + TEST_VERIFY_EXIT (sem_post (&setxid_done) == 0);
>> + return NULL;
>> +}
>> +
>> +static int
>> +do_test (void)
>> +{
>> + TEST_COMPARE (sem_init (&setxid_done, 0, 0), 0);
>> + TEST_COMPARE (sem_init (&worker_exit, 0, 0), 0);
>> + xpthread_barrier_init (&start_barrier, NULL, 3);
>> +
>> + sigset_t set;
>> + sigemptyset (&set);
>> + sigaddset (&set, SIGRTMIN);
>> + TEST_COMPARE (pthread_sigmask (SIG_BLOCK, &set, NULL), 0);
>> +
>> + pthread_t worker = xpthread_create (NULL, worker_thread, NULL);
>> + pthread_t setxid = xpthread_create (NULL, setxid_thread, NULL);
>> +
>> + /* Lower the pending-signal limit, and fill the realtime signal queue until
>> + it reaches RLIMIT_SIGPENDING. */
>> + {
>> + struct rlimit rl;
>> + TEST_COMPARE (getrlimit (RLIMIT_SIGPENDING, &rl), 0);
>> + rl.rlim_cur = 64;
>> + TEST_COMPARE (setrlimit (RLIMIT_SIGPENDING, &rl), 0);
>> + }
>> +
>> + {
>> + const union sigval val = { .sival_int = 0 };
>> + while (sigqueue (getpid (), SIGRTMIN, val) == 0)
>> + continue;
>> + TEST_COMPARE (errno, EAGAIN);
>> + }
>> +
>> + xpthread_barrier_wait (&start_barrier);
>> +
>> + struct timespec ts;
>> + TEST_COMPARE (clock_gettime (CLOCK_REALTIME, &ts), 0);
>> + ts.tv_sec += BLOCK_WAIT_SECONDS;
>> + int r = sem_timedwait (&setxid_done, &ts);
>> +
>> + if (r == 0)
>> + /* The setxid completed while the queue was still full: the broadcast
>> + silently gave up instead of delivering SIGSETXID (bug 21108). */
>> + FAIL_EXIT1 ("setxid returned while the signal queue was full");
>> + TEST_COMPARE (errno, ETIMEDOUT);
>> +
>> + /* Now drain the queue so the (correctly blocking) setxid can finish. */
>> + while (1)
>> + {
>> + struct timespec zero = { 0, 0 };
>> + if (sigtimedwait (&set, NULL, &zero) < 0)
>> + {
>> + if (errno == EINTR)
>> + continue;
>> + break;
>> + }
>> + }
>> +
>> + /* With the queue drained, the setxid must complete. */
>> + while (sem_wait (&setxid_done) != 0)
>> + /* Restart if interrupted. */;
>> +
>> + /* Release the worker and clean up. */
>> + TEST_COMPARE (sem_post (&worker_exit), 0);
>> + xpthread_join (worker);
>> + xpthread_join (setxid);
>> + return 0;
>> +}
>> +
>> +#define TIMEOUT 10
>> +#include <support/test-driver.c>
>
More information about the Libc-alpha
mailing list