* m4/jm-macros.m4 (coreutils_MACROS): Check for ptrace.
* src/timeout.c [HAVE_PTRACE && (__linux__ || __ANDROID__)]: Include
sys/ptrace.h.
(resume_command): New function.
(main): Call it if the monitored command has been stopped.
* tests/timeout/ptrace-traceme.sh: New file.
* tests/local.mk (all_tests): Add the new test case.
* NEWS: Mention the bug fix.

Discovered by Anthropic's OSS Scanner, as bug ANT-2026-AKDIHN0Y.
---
 NEWS                            |  5 +++
 m4/jm-macros.m4                 |  1 +
 src/timeout.c                   | 40 ++++++++++++++++-
 tests/local.mk                  |  1 +
 tests/timeout/ptrace-traceme.sh | 78 +++++++++++++++++++++++++++++++++
 5 files changed, 123 insertions(+), 2 deletions(-)
 create mode 100755 tests/timeout/ptrace-traceme.sh

diff --git a/NEWS b/NEWS
index 3361336ef..d8e2868dc 100644
--- a/NEWS
+++ b/NEWS
@@ -259,6 +259,11 @@ GNU coreutils NEWS                                    -*- 
outline -*-
   but will now be resilient to a blocked standard error, and no longer hangs.
   [hang bug introduced in coreutils-8.29, and loop bug in coreutils 9.10]
 
+  'timeout' no longer leaves commands running, with or without --kill-after,
+  when they turn 'timeout' into their tracer using ptrace with the
+  PTRACE_TRACEME option.
+  [bug introduced with timeout in coreutils-7.0]
+
   'unexpand' no longer overflows a heap buffer on non-GLIBC platforms,
   given whitespace characters whose display width is negative.  E.g.,
   this edge case can occur with \u0085 (next line) characters on musl.
diff --git a/m4/jm-macros.m4 b/m4/jm-macros.m4
index fb00f9faf..5a4567c65 100644
--- a/m4/jm-macros.m4
+++ b/m4/jm-macros.m4
@@ -67,6 +67,7 @@ AC_DEFUN([coreutils_MACROS]
     fallocate
     fchown
     fchmod
+    ptrace
     setgroups
     sethostname
     siginterrupt
diff --git a/src/timeout.c b/src/timeout.c
index bc6e97b18..40b238c22 100644
--- a/src/timeout.c
+++ b/src/timeout.c
@@ -52,6 +52,9 @@
 #if HAVE_PRCTL
 # include <sys/prctl.h>
 #endif
+#if HAVE_PTRACE && (defined __linux__ || defined __ANDROID__)
+# include <sys/ptrace.h>
+#endif
 #include <sys/wait.h>
 
 #include "system.h"
@@ -106,6 +109,34 @@ static struct option const long_options[] =
   {NULL, 0, NULL, 0}
 };
 
+
+/* Resumed the monitored command that was stopped with signal SIG.
+   Return true if it was resumed.
+
+   This applies to child processes on Linux who use PTRACE_TRACEME
+   to turn us into their tracer.  */
+static bool
+resume_command (MAYBE_UNUSED int sig)
+{
+#if HAVE_PTRACE && (defined __linux__ || defined __ANDROID__)
+  siginfo_t si;
+
+  /* If stopped by SIGTRAP, it may be because the monitored command made a
+     successful call to execve(2).  If that is not the case, or if SIG is
+     not SIGTRAP, we want to send the signal to the monitored command.  */
+  if (sig == SIGTRAP
+      && ptrace (PTRACE_GETSIGINFO, monitored_pid, NULL, &si) == 0
+      && si.si_code == SI_USER && si.si_pid == monitored_pid)
+    sig = 0;
+
+  /* Restart the monitored command, send signal SIG, and detach from it.  */
+  ptrace (PTRACE_DETACH, monitored_pid, NULL, sig);
+  return true;
+#else
+  return false;
+#endif
+}
+
 /* Start the timeout after which we'll receive a SIGALRM.
    Round DURATION up to the next representable value.
    Treat out-of-range values as if they were maximal,
@@ -688,8 +719,13 @@ main (int argc, char **argv)
          we don't cleanup() after waitpid() reaps the child,
          to avoid sending signals to a possibly different process.  */
 
-      while ((wait_result = waitpid (monitored_pid, &status, WNOHANG)) == 0)
-        sigsuspend (&orig_set);  /* Wait with cleanup signals unblocked.  */
+      while ((wait_result = waitpid (monitored_pid, &status, WNOHANG)) == 0
+             || (0 < wait_result && WIFSTOPPED (status)
+                 && resume_command (WSTOPSIG (status))))
+        {
+          if (wait_result == 0)
+            sigsuspend (&orig_set);  /* Wait with cleanup signals unblocked.  
*/
+        }
 
       if (wait_result < 0)
         {
diff --git a/tests/local.mk b/tests/local.mk
index 0a49022a9..fc006e4e6 100644
--- a/tests/local.mk
+++ b/tests/local.mk
@@ -536,6 +536,7 @@ all_tests =                                 \
   tests/test/test-file.sh                      \
   tests/misc/time-style.sh                     \
   tests/timeout/init-parent.sh                 \
+  tests/timeout/ptrace-traceme.sh              \
   tests/timeout/timeout.sh                     \
   tests/timeout/timeout-blocked.pl             \
   tests/timeout/timeout-group.sh               \
diff --git a/tests/timeout/ptrace-traceme.sh b/tests/timeout/ptrace-traceme.sh
new file mode 100755
index 000000000..fdee101cf
--- /dev/null
+++ b/tests/timeout/ptrace-traceme.sh
@@ -0,0 +1,78 @@
+#!/bin/sh
+# Test the behavior of 'timeout' with a child that uses PTRACE_TRACEME.
+
+# Copyright (C) 2026 Free Software Foundation, Inc.
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program 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 General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+. "${srcdir=.}/tests/init.sh"; path_prepend_ ./src
+print_ver_ timeout
+require_gcc_shared_
+
+grep '^#define HAVE_PTRACE 1' $CONFIG_HEADER > /dev/null \
+  || skip_ 'ptrace is not supported on this system'
+
+# Set up a shared library to use PTRACE_TRACE upon loading.
+cat > k.c <<'EOF' || framework_failure_
+#include <sys/ptrace.h>
+#include <stddef.h>
+
+static void __attribute__ ((__constructor__))
+traceme (void)
+{
+  ptrace (PTRACE_TRACEME, 0, NULL, NULL);
+}
+EOF
+gcc_shared_ k.c k.so || framework_failure_ 'failed to build shared library'
+
+# Make sure we can use it.
+env LD_PRELOAD="$LD_PRELOAD":./k.so grep '^TracerPid:' /proc/self/status \
+  | cut -f2 > tracer-pid
+test "$?" -eq 0 && test "$$" = "$(cat tracer-pid)" \
+  || skip_ 'Unable to use PTRACE_TRACEME'
+
+# Test that the signal terminates the program.
+# coreutils-9.12 and earlier would print "unknown status" here.
+returns_ 124 timeout .1 env LD_PRELOAD="$LD_PRELOAD":./k.so \
+  sleep 10 >out 2>err || fail=1
+compare /dev/null out || fail=1
+compare /dev/null err || fail=1
+
+# The KILL signal should be sent if the timeout signal is ignored.
+# coreutils-9.12 and earlier would exit with a status of 124, but with
+# the command left running.
+returns_ 137 timeout --foreground --kill-after .1 .1 \
+  env --ignore-signal=TERM LD_PRELOAD="$LD_PRELOAD":./k.so \
+  sleep 10 >out 2>err || fail=1
+compare /dev/null out || fail=1
+compare /dev/null err || fail=1
+
+# Signals that were not sent by 'timeout' should be delivered to the
+# child process.
+# coreutils-9.12 and earlier would exit with a status of 1, but with the
+# command left running.
+returns_ 3 timeout 10 env LD_PRELOAD="$LD_PRELOAD":./k.so \
+  "$SHELL" -c 'trap "exit 3" USR1; kill -USR1 "$$"; exit 2' \
+  >out 2>err || fail=1
+compare /dev/null out || fail=1
+compare /dev/null err || fail=1
+
+# Make sure a TRAP, when caused by a traced exec, does not
+# kill the child process.
+returns_ 3 timeout 10 env LD_PRELOAD="$LD_PRELOAD":./k.so \
+  "$SHELL" -c 'exec "$SHELL" -c "exit 3"' >out 2>err || fail=1
+compare /dev/null out || fail=1
+compare /dev/null err || fail=1
+
+Exit $fail
-- 
2.56.1


Reply via email to