Skip to content

bug(timeout): failure to exit on strace when due to SIGTTIN interactions with child process #12979

Open

Description

@HackingRepo

Hi, uutil mainteners

as we confirmed when clock_nanosleep syscall returns -4 it just hangs and wait forever regardless of the time, whiches a deadlock

relunsec@relunsec:~/software/coreutils/target/debug$ strace -e inject=clock_nanosleep:retval=-4 -o /dev/null ./timeout 1s gnucat
strace: Inadvertent injection of error 4 is possible for retval=18446744073709551612

while the gnu one

relunsec@relunsec:~/software/coreutils/target/debug$ strace -e inject=clock_nanosleep:retval=-4 -o /dev/null gnutimeout 1s gnucat
strace: Inadvertent injection of error 4 is possible for retval=18446744073709551612

when the syscalls return -4, it does not hang

Activity

  1. changed the title [-]bug(timeout): when clock_nanosleep syscall returns -4, it hangs[/-] [+]bug(timeout): when clock_nanosleep syscall returns -4, it hangs forever[/+] on Jun 19, 2026
  2. changed the title [-]bug(timeout): when clock_nanosleep syscall returns -4, it hangs forever[/-] [+]bug(timeout): when clock_nanosleep syscall returns -4, it hangs/deadlocks forever[/+] on Jun 19, 2026
  3. HackingRepo commented on Jun 19, 2026

    @HackingRepo
    ContributorAuthor

    we confirmed further

    relunsec@relunsec:~/software/coreutils/target/debug$ time strace -e inject=clock_nanosleep:retval=-4 -o /dev/null gnutimeout 10s gnucat
    strace: Inadvertent injection of error 4 is possible for retval=18446744073709551612
    
    real	0m10.025s
    user	0m0.007s
    sys	0m0.020s

    the gnu one behavior it retries the syscall after it fails and will sleep for 10s exactly

    relunsec@relunsec:~/software/coreutils/target/debug$ time strace -e inject=clock_nanosleep:retval=-4 -o /dev/null ./timeout 10s gnucat
    strace: Inadvertent injection of error 4 is possible for retval=18446744073709551612
    ^Z
    [5]+  Stopped                    strace -e inject=clock_nanosleep:retval=-4 -o /dev/null ./timeout 10s gnucat
    
    real	0m46.159s
    user	0m0.001s
    sys	0m0.001s
    relunsec@relunsec:~/software/coreutils/target/debug$ 

    as you can see it deadlocks forever after i CTRL+z it sleeped for 46s whiches far than the expected 10s

  4. oech3 commented on Jun 19, 2026

    @oech3
    Contributor

    When does -4 happen in the actual world? Legacy kernel? or container?

  5. HackingRepo commented on Jun 19, 2026

    @HackingRepo
    ContributorAuthor

    -4 it means the syscall interrupted

  6. oech3 commented on Jun 19, 2026

    @oech3
    Contributor

    Please use EINTR instead of magic number.

  7. HackingRepo commented on Jun 19, 2026

    @HackingRepo
    ContributorAuthor

    read https://man7.org/linux/man-pages/man2/clock_nanosleep.2.html, @oech3

           If the call is interrupted by a signal handler, clock_nanosleep()
           fails with the error EINTR.  In addition, if remain is not NULL,
           and flags was not TIMER_ABSTIME, it returns the remaining unslept
           time in remain.  This value can then be used to call
           clock_nanosleep() again and complete a (relative) sleep.

    as you can see it is not in containers or legacy kernel, it is in any kernel

  8. changed the title [-]bug(timeout): when clock_nanosleep syscall returns -4, it hangs/deadlocks forever[/-] [+]bug(timeout): when clock_nanosleep syscall returns EINTR, it hangs/deadlocks forever[/+] on Jun 19, 2026
  9. oech3 commented on Jun 19, 2026

    @oech3
    Contributor

    I think this Rust std::thread itself's issue. It assumes always succeeds.

    thread::sleep(Duration::from_millis(100));

  10. HackingRepo commented on Jun 19, 2026

    @HackingRepo
    ContributorAuthor

    maybe, keep that issue for a lead maintener like @sylvestre, he will understand in deep and what will do with it

  11. Alonely0 commented on Jul 25, 2026

    @Alonely0
  12. HackingRepo commented on Jul 27, 2026

    @HackingRepo
    ContributorAuthor

    no not fixed @Alonely0 i recloned the repo just minutes

    strace -e inject=clock_nanosleep:retval=-4 -o /dev/null ./timeout 1s gnucat
    strace: Inadvertent injection of error 4 is possible for retval=18446744073709551612
    ^C^C^C^Z
    [2]+  Stopped                    strace -e inject=clock_nanosleep:retval=-4 -o /dev/null ./timeout 1s gnucat

    and samething

  13. Alonely0 commented on Jul 27, 2026

    @Alonely0
    Contributor

    hmmm. It seems the error was misattributed to clock_nanosleep. We fail to exit out of strace here; notice removing the injection also leads to a stall because we get SIGTTIN'd by the kernel. This appears to happen with cat and other commands that do some funky stuff with stdio.

  14. Alonely0 commented on Jul 27, 2026

    @Alonely0
    Contributor

    Mind renaming the issue to reflect the actual error? I'll try to fix it in a PR soon-ish. I think it should be enough to also catch it in our signal mask.

  15. HackingRepo commented on Jul 27, 2026

    @HackingRepo
    ContributorAuthor

    so what title you suggest to me?

  16. Alonely0 commented on Jul 27, 2026

    @Alonely0
    Contributor

    so what title you suggest to me?

    bug(timeout): failure to exit on strace when due to SIGTTIN interactions with child process

  17. changed the title [-]bug(timeout): when clock_nanosleep syscall returns EINTR, it hangs/deadlocks forever[/-] [+]bug(timeout): failure to exit on `strace` when due to `SIGTTIN` interactions with child process[/+] on Jul 27, 2026
  18. Alonely0 commented on Jul 27, 2026

    @Alonely0
    Contributor

    Note that I won't push the fix until the Windows backend by Niko is merged, because I don't want him to deal with more of my bs hahah.

  19. Alonely0 commented on Jul 27, 2026

    @Alonely0
    Contributor

    Reproduced to sh -c 'trap "echo lol" SIGTTIN; ./target/release/timeout 1 cat'. We should probably get away with ignoring it, but I'll do some experimentation to see what's best.

  20. sylvestre commented on Oct 25, 2026

    @sylvestre
    SponsorContributor

    Also reported by Anthropic's OSS Scanner as ANT-2026-URMP44MS (scan uutils-coreutils-20261015-006).

    OSS Scanner report
    ============================================================================
    ANT-2026-URMP44MS  [NOT SECURITY: ordinary bug]
    timeout: hangs when the command reads the tty (SIGTTIN, SIGTTOU not ignored)
    Location: src/uu/timeout/src/platform/unix.rs:53  in prepare()
    Commit:   7bc1eb6d1d6fa2e1dc2c9d2277cf576c05f61e47
    ============================================================================
    

    Not a security issue. This is a real, reproducible bug that is NOT a security issue: by your project's own policy nobody gains anything from it that they did not already have. It is not part of the vulnerability report, is in none of its counts, needs no embargo and no advisory, and can be filed and fixed in the open like any bug.

    When timeout is run from a script that has a controlling terminal, it hangs indefinitely if the command it runs reads from the terminal or changes the terminal's settings. For example, sh -c 'timeout 1 openssl pkey -in key.pem' with a passphrase-protected key never returns. openssl gives up on the prompt within milliseconds, but timeout itself is stopped, so the 1-second limit never fires and the script never continues. GNU timeout returns at once in the same situation, and timeout 1 cat shows the same difference (GNU exits with 124 after one second).

    The cause is in prepare() in src/uu/timeout/src/platform/unix.rs. It calls setpgid(0, 0), which puts timeout and the command into a new process group that is a background group of the terminal, but it leaves SIGTTIN and SIGTTOU at their default action in timeout itself. When the command touches the terminal, the tty driver sends the signal to the whole group. timeout then stops (state T) inside sigtimedwait(): it never acts on its timer, never reaps the child, and stays that way until something sends SIGCONT or SIGKILL, the calling shell is killed, or the terminal is closed. The pre_exec hook already resets both signals to SIG_DFL in the child, which is only half of GNU's approach. --foreground, a job-control shell invoking timeout directly, and jobs without a controlling terminal (cron, systemd, most CI) are not affected. Scripts run by hand, over ssh -t, under docker run -t or inside tmux are. The missing disposition has been there since the first implementation (a38ee8a, 2014) and is in every release since 0.0.1. The attached reproducer runs the openssl example on a fresh pty. With GNU timeout the script finishes with status 1. With uutils, timeout is left in state T and openssl as an unreaped zombie. The attached patch ignores both signals in timeout right after setpgid() and adds a regression test that does not need a terminal.

    This is an ordinary bug, not a security issue: it needs a controlling terminal and a caller without job control, and
    its only effect is one hung job, the same as a script that does not use timeout at all; nothing is read, written or
    gained across users.

    Details

    timeout moves itself and the command into a new process group, which on a terminal is a background group, but keeps
    the default action for SIGTTIN and SIGTTOU. When the command reads from the terminal or changes its settings, the kernel
    sends the signal to every member of the group, so timeout itself is stopped (state T). A stopped timeout does not
    act on its timer and does not reap the command; the calling script never continues.

    On a terminal, from a shell without job control (any script; sh -c here), with a passphrase-protected key:

    $ openssl genpkey -algorithm ed25519 -aes256 -pass pass:whatever -out upload.pem
    $ sh -c 'coreutils timeout 1 openssl pkey -in upload.pem -noout; echo rc=$?'
    Enter pass phrase for upload.pem:
    Could not find private key of key from upload.pem
    

    rc= is never printed. ps shows coreutils timeout … in state T and openssl as a zombie, unchanged 20 s later.
    With GNU timeout 9.7 the same line prints rc=1 at once. timeout 1 cat behaves the same way: uutils leaves timeout
    and cat both stopped, GNU prints rc=124 after one second.

    The disposition is the whole cause, and it shows without a terminal too: coreutils timeout .5 sh -c 'kill -s TTIN 0; sleep 10'
    never returns (GNU: 124 after 0.5 s).

    prepare() creates the group and sets up the child, src/uu/timeout/src/platform/unix.rs:52-68:

        if !foreground {
            let _ = setpgid(Pid::from_raw(0), Pid::from_raw(0));
        }
        …
                cmd_builder.pre_exec(move || {
                    // Reset terminal signals to default
                    let _ = libc::signal(Signal::as_raw(Signal::TTIN), libc::SIG_DFL);
                    let _ = libc::signal(Signal::as_raw(Signal::TTOU), libc::SIG_DFL);

    A script's shell runs its commands in its own process group, which is the terminal's foreground group. After
    setpgid(0, 0) timeout leads a new group that is not the foreground group, and the command is spawned into it. The
    pre_exec hook gives the child the default action for SIGTTIN/SIGTTOU, which only has a purpose if the parent ignores
    them; nothing in timeout does. Neither signal is in timeout_signal_set()
    (src/uucore/src/lib/features/process/unix.rs:99-111), so they are not blocked either, and prepare() goes straight on
    to install_sigchld() and thread_block() (src/uu/timeout/src/platform/unix.rs:85-86).

    For a background group the tty driver answers read() with SIGTTIN and tcsetattr() (and write() under
    stty tostop) with SIGTTOU, sent to the whole group. timeout is then waiting in timer.timed_sigwait()
    (src/uucore/src/lib/features/process/unix.rs:81). Trace of the example; 184 is timeout, 185 is openssl:

    184   setpgid(0, 0)                     = 0
    184   rt_sigtimedwait([HUP INT QUIT USR1 USR2 PIPE ALRM TERM CHLD],  <unfinished ...>
    185   ioctl(4, TCSETS, {… echo off …})  = ? ERESTARTSYS
    185   --- SIGTTOU {si_signo=SIGTTOU, si_code=SI_KERNEL} ---
    184   --- SIGTTOU {si_signo=SIGTTOU, si_code=SI_KERNEL} ---
    184   --- stopped by SIGTTOU ---
    185   +++ exited with 1 +++
    

    openssl catches the signal, gives up and exits within milliseconds. SIGCHLD and, one second later, SIGALRM become
    pending for timeout, but a stopped process does not return from sigtimedwait(). The shell (dash) waits with
    wait4(-1, …, 0, …), without WUNTRACED, and does not see the stop. It ends only when something outside sends
    SIGCONT or SIGKILL, kills the calling shell, or closes the terminal (the kernel then hangs up and continues the stopped
    group). Ctrl-C does not help: the shell waits for the stopped timeout before acting on it.

    Not affected: --foreground (no setpgid(); timeout --foreground 1 cat gives 124), a job-control shell that starts
    timeout directly (timeout is then already the leader of the foreground group; bash -c 'set -m; timeout 1 cat'
    gives 124), and jobs without a controlling terminal.

    The missing disposition dates back to the utility's first version, a38ee8a00766 ("Implement timeout (resolves #364)",
    2014-07-21), and is in every release since 0.0.1.

    Impact

    A script that bounds a step with timeout hangs without limit, where GNU timeout returns. It
    happens whenever the command touches the terminal: a tool that prompts for a passphrase or password (an encrypted PEM
    or PKCS#12 file, a password-protected archive, a client asking for credentials), a plain read such as timeout 1 cat,
    or a tcsetattr() to turn echo off. In the example the tool itself fails in milliseconds; it is timeout that never
    comes back. The script keeps its terminal, locks and open files until someone sends SIGCONT, kills it or its shell, or
    closes the terminal. The end state is the same as a script with no timeout at all: one hung job.

    Users meet it only with a controlling terminal and a caller without job control: a script started by hand from a login
    shell, over ssh -t, under docker run -t, in script/tmux, or by a runner that allocates a pty. Typing timeout
    directly at an interactive job-control shell, --foreground, cron jobs, systemd units and most CI runners are not
    affected.

    The same hang occurs when the command runs under another uid: root's
    timeout 1 setpriv --reuid=65534 --regid=65534 --clear-groups sh -c 'read x </dev/tty' leaves root's timeout in state
    T (GNU: 124 after 1 s). That command is still one the caller chose to run, so the outcome is the same hang.

    Reproducing

    repro needs no real terminal. It gives a small shell script a fresh pty as its controlling terminal, with the script as leader of a new session, the way a login shell, ssh -t or docker run -t would. It runs the script twice with the same passphrase-protected key: first with GNU /usr/bin/timeout as the control, then with uutils' timeout. It uses the prebuilt /src/target/debug/coreutils, so nothing is built, and it takes about 5 s. It expects its companion files at /tmp/repro.d/. Only openssl and /usr/bin/timeout (GNU) are needed from the image. ps is not needed.

    The pieces:

    • The input: the key. repro creates it with a single openssl genpkey -algorithm ed25519 -aes256 -pass pass:whatever call. Because the key is encrypted, openssl asks for the passphrase on the terminal. Nothing else about the key matters.
    • The script: repro.d/check_upload.sh, which holds exactly timeout 1 openssl pkey -in "$1" -noout and then echo "check finished with status $?". The script calls plain timeout from PATH. For the uutils run, repro puts a directory first in PATH that holds a timeout symlink to target/debug/coreutils, and the multicall binary picks the utility from argv[0]. Before the runs, repro prints timeout --version for both PATHs, which shows that the first run uses GNU 9.7 and the second uses uutils 0.13.0.
    • Setup: repro.d/terminal_session.py runs a command on a new pty as a session leader. It collects what the terminal shows until the session ends or 5 s pass. It then reads name and state of the session's other processes from /proc/<pid>/stat and kills them.

    For each run, repro prints the terminal output and the processes still left in the session. Expected output:

    CONTROL  GNU timeout: processes left (waited up to 5 s): []
    Enter pass phrase for …/upload.pem:
    Could not find private key of key from …/upload.pem
    …
    check finished with status 1
    
    OBSERVED uutils timeout: processes left (waited up to 5 s): [('openssl', 'Z'), ('timeout', 'T')]
    Enter pass phrase for …/upload.pem:
    Could not find private key of key from …/upload.pem
    …
    
    BUG
    

    In both runs openssl gives up on the passphrase prompt at once. With GNU the script finishes immediately. With uutils, openssl has already exited and is still waiting to be reaped (Z), timeout itself is stopped (T), and the 1 s limit never fires, so "check finished" is never printed. repro prints BUG only when all of these hold: the GNU run finished, the uutils run did not finish within 5 s, and the uutils timeout process is in state T. With the attached patch applied (uu_timeout rebuilt), the same script prints "check finished with status 1" for both runs and OK.

    Suggested fix

    The attached patch ignores SIGTTIN and SIGTTOU in timeout itself, in prepare() right after the setpgid(0, 0) call
    that creates the exposure (src/uu/timeout/src/platform/unix.rs:53), using the same libc::signal(.., SIG_IGN) idiom
    the file already uses. GNU timeout ignores both signals the same way. The pre_exec hook already restores the default
    action in the child, so the command keeps normal job-control behaviour (its SigIgn mask in /proc/self/status stays
    empty), and send_signal() already follows the timeout signal with SIGCONT, which wakes a command stopped at the
    terminal. The calls also run under --foreground, where they are harmless.

    --- a/src/uu/timeout/src/platform/unix.rs
    +++ b/src/uu/timeout/src/platform/unix.rs
    @@ -53,6 +53,16 @@ pub(crate) fn prepare(
             let _ = setpgid(Pid::from_raw(0), Pid::from_raw(0));
         }
     
    +    // Our new process group is a background group of the controlling terminal,
    +    // and the tty driver sends SIGTTIN/SIGTTOU to every member of it. Ignore
    +    // them, or a child touching the terminal would stop us along with itself
    +    // and the timer would never fire. The child restores the default actions
    +    // in its pre-exec hook below.
    +    unsafe {
    +        let _ = libc::signal(Signal::as_raw(Signal::TTIN), libc::SIG_IGN);
    +        let _ = libc::signal(Signal::as_raw(Signal::TTOU), libc::SIG_IGN);
    +    }
    +

    The patch also adds a regression test that needs no terminal, test_not_stopped_by_terminal_signal in
    tests/by-util/test_timeout.rs: timeout .5 sh -c 'kill -s TTIN 0; sleep 10' (and the same with TTOU) must exit
    with 124. At this commit it hangs; with the patch it returns 124 after 0.5 s, and the reproducer prints OK on a
    rebuilt clean checkout.

    Running the reproducer

    bugs-not-security/ANT-2026-URMP44MS/repro, next to this file, with its companion files in bugs-not-security/ANT-2026-URMP44MS/repro.d/ (check_upload.sh, terminal_session.py) — the script reads them from /tmp/repro.d/. It was written by an automated agent and our check ran it as root in a privileged container: run it INSIDE A THROWAWAY VM, with the image oss-scan-7bc1eb6d1d6f built by bash build/build-image.sh, from the archive's top directory:

    docker run --rm --network=none --privileged -u 0 -w /src -v "$PWD/bugs-not-security/ANT-2026-URMP44MS/repro:/tmp/repro:ro" -v "$PWD/bugs-not-security/ANT-2026-URMP44MS/repro.d:/tmp/repro.d:ro" oss-scan-7bc1eb6d1d6f /tmp/repro
    

    It prints its evidence; the last line is BUG if the issue triggered, OK otherwise.

  21. Alonely0 commented on Oct 25, 2026

    @Alonely0
    Contributor

    Also reported by Anthropic's OSS Scanner as ANT-2026-URMP44MS (scan uutils-coreutils-20261015-006).
    OSS Scanner report

    @sylvestre It seems the report prefers doing what I had suggested first, but we can also integrate them in our sigwait loop, which I'd say is definitely less hacky.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions