Re: [PATCH] nptl: Retry SIGSETXID delivery on EAGAIN in setxid (bug 21108)

Adhemerval Zanella Netto <[email protected]>
Newsgroups gmane.comp.lib.glibc.alpha
Organization Linaro
Message-ID <[email protected]>
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>
>
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.