* 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