Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

Signal forwarded to the command is lost when it arrives between fork() and execve() of the command

Open
#566 0 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
4/5
Estimated time
3-5 days
Newbie friendliness
35/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
c, linux

Research direction

The race sits between fork() and execve() in src/exec.c (exec_cmnd), src/signal.c (restore_signals), src/exec_nopty.c (signal_cb_nopty) and, for the pty path, src/exec_monitor.c (deliver_signal). Start by reading how signal handlers and the mask are set before the fork. Reproduce with the sudo_lost_signal.py script in the linked gist. Done means a SIGTERM forwarded during that window terminates the command, and make check still passes.

Written by the indexing model from the issue text.

Description

Summary

When sudo receives a signal (e.g. SIGTERM from a supervisor that decides to stop
the command shortly after starting it), it forwards the signal to the command.
If the forwarded signal reaches the command process before it has called
execve(), the signal is silently dropped: the process still runs sudo's own
signal handler, inherited across fork(), which only records the signal in
memory nobody reads any more. The command then starts normally, runs to
completion and sudo exits with its status (0), so the sender has no way to tell
that the signal was lost.

On a multi-core machine this is not a rare race: a SIGTERM sent to sudo right
after it has forked is lost almost every time (see numbers below). It affects
both the exec_nopty path and the use_pty path (where the monitor forwards
the signal), and both the current main and the latest release.

Versions

Reproduced with sudo built from unmodified upstream sources on x86_64 Linux
(kernel 7.0, Debian bookworm container, 16 CPUs):

sudo source forwarded SIGTERMs lost
1.9.18, main @ 43defa1309 (2026-10-14) tarball, ./configure && make 196 of 200 without a tty, 200 of 200 with a tty (use_pty)
1.9.17p2, tag v1.9.17p2 tarball, ./configure && make 190 of 200 without a tty
1.9.15p5, Ubuntu package distro build, run from a terminal (use_pty) 200 of 200

Reproducer

sudo_lost_signal.py (in https://gist.github.com/pepa-cz/b231de7faecb03affd8977ac559e05fe) runs sudo -n /bin/sleep 0.3, waits until sudo
has a child process whose name is still sudo (i.e. forked, not yet exec'd) and
sends SIGTERM to sudo. It then classifies sudo's exit status:

  • sudo killed by SIGTERM, or exit 143: the command was terminated as expected;
  • exit 0: the command ran to completion, the forwarded signal was lost.
$ sudo -v && ./sudo_lost_signal.py -n 200
Sudo version 1.9.18; kernel 7.0.0-38-generic; CPUs 16; command: /opt/sudo-main/bin/sudo -n /bin/sleep 0.3
terminated: 4, lost: 196, other: 0 of 200 runs; child name when the lost signal was sent: {'sudo': 196}

build_and_test.sh REV (also in the gist, together with pty_test.sh, patch_test.sh, the patch, the full strace outputs and the logs) builds the given upstream revision in a
throw-away Debian container and runs the reproducer against it, with and
without strace.

The race needs the parent to forward the signal while the child is still
between fork() and execve(). On a multi-core machine the two run in
parallel and the forwarding (~100 us) wins against the child's pre-exec work
(~0.5 ms) almost every time. With everything pinned to one CPU the child usually
reaches execve() first and the race is hidden.

strace of a lost signal (main, no tty)

25385 execve("/opt/sudo-main/bin/sudo", ["sudo", "-n", "/bin/sleep", "0.3"], ...) = 0
25385 --- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=25378, si_uid=0} ---
25385 rt_sigreturn({mask=[]}) = 0
25385 kill(25386, SIGTERM) = 0                     # sudo forwards the signal to the command
25386 --- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=25385, si_uid=0} ---
25386 rt_sigreturn({mask=[]}) = 0                  # the child handles it with the inherited handler
25386 execve("/bin/sleep", ["/bin/sleep", "0.3"], ...) = 0   # and only now resets the handlers
25386 exit_group(0)
25385 exit_group(0)

strace of a lost signal (main, use_pty)

26044 clone(...) = 26045                           # sudo forks the monitor
26044 --- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=26036, si_uid=0} ---
26045 clone(...) = 26046                           # the monitor forks the command
26045 kill(-26046, SIGTERM) = 0                    # the monitor forwards the signal (from the backchannel)
26046 --- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=26045, si_uid=0} ---
26046 rt_sigreturn({mask=[]}) = 1                  # handled by the handler inherited from the monitor
26046 execve("/bin/sleep", ["/bin/sleep", "0.3"], ...) = 0
26046 exit_group(0)

Cause (line numbers refer to main @ 43defa1309)

  • src/signal.c:84-104 restore_signals(): only the signals flagged
    ss->restore (those that were ignored on entry, SIGPIPE, SIGCHLD if ignored)
    get their original disposition back. Every other signal keeps the handler
    sudo installed (sudo_handler from init_signals(), or sudo_ev_handler
    once the event handlers are set up) until execve() replaces it.
  • src/exec.c:264 exec_cmnd() calls restore_signals() and then does the
    rest of the setup (exec_setup(): uid/gid, rlimits, closefrom, chdir, ...)
    before sudo_execve(). During all of this the child still catches signals
    with sudo's handler, which just sets a flag / writes to the parent's signal
    pipe. The flag is never read in the child, so the signal is gone.
  • src/exec_nopty.c:616-621: since commit 8781032140 (PR #519) the handlers
    are installed and the signal mask is restored before fork(), so the child
    is created with signals unblocked and sudo_ev_handler installed. In the
    parent, signal_cb_nopty() (:192) forwards a received signal with
    kill(ec->cmnd_pid, signo) as soon as the event loop sees it. Before #519 the
    child inherited sudo_handler instead and the signal was lost the same way
    (1.9.17p2 in the table above).
  • use_pty path: src/exec_pty.c:841 signal_cb_pty() -> :619
    schedule_signal() sends CMD_SIGNO over the backchannel;
    src/exec_monitor.c:308 mon_backchannel_cb() -> :68 deliver_signal()
    -> :118 killpg(mc->cmnd_pid, signo). The monitor installs its handlers at
    :622 and forks the command at :624; the command inherits them and, when
    foreground, additionally waits for the tty hand-over before exec
    (exec_cmnd_pty(), :377), which makes the window even larger.

What execve() does to caught signals (reset to SIG_DFL) is simply done too
late: a signal that arrives between fork() and execve() should either
terminate the child (default action) or stay pending until the exec'd command
can handle it. Neither happens.

Fix (patch in the gist)

0001-Reset-signal-handlers-in-the-child-before-signals-ca.patch against
main @ 43defa1309:

  1. the parent keeps signals blocked across the fork() of the command
    (exec_nopty(); exec_monitor() now blocks them too), the handlers are
    still installed before the fork as #519 intended,
  2. restore_signals() restores the saved state of every signal, not only
    the ones flagged restore; the saved state is what execve() would
    leave (SIG_IGN or SIG_DFL, sudo itself was exec'd),
  3. exec_cmnd() takes the saved signal mask and restores it right after
    restore_signals(), so a signal that arrived in the window is delivered
    with the default action and terminates the child.

Point 2 also fixes a side effect of #519 visible in current main: because
the event handlers are now installed before the fork over a SIG_IGN
disposition, a signal ignored by sudo's caller was no longer ignored by the
command (sh -c "trap '' TERM; exec sudo sh -c 'kill -TERM \$\$; echo survived'"
prints nothing and exits 143 on main, prints survived on 1.9.17p2 and on
patched main).

Results with patch_test.sh (in the gist; builds the given revision in a Debian
container, runs make check, the reproducer and a few behaviour checks):

main unpatched main patched 1.9.17p2
make check all pass all pass all pass
forwarded SIGTERM lost, no tty 194 of 200 0 of 200 199 of 200
forwarded SIGTERM lost, use_pty 198 of 200 0 of 200 200 of 200
SIGTERM/SIGINT to sudo while the command runs 143 / 130 143 / 130 143 / 130
SIGTERM forwarded to a command that traps it trapped trapped trapped
signal ignored by the caller stays ignored for the command no yes yes
stdin pipe, exit status 0/1, command under a pty ok ok ok

I can open a pull request with the patch if that is preferred.

Dominant language
C
Stars
1.7k
Forks
306
Avg merge
1d 2h
Merged PRs (30d)
22

Getting set up

This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from sudo-project/sudo

All issues in sudo-project/sudo

Similar issues

More C issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.