Signal forwarded to the command is lost when it arrives between fork() and execve() of the command
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
- Domain
- cli, operating-systems
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-104restore_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_handlerfrominit_signals(), orsudo_ev_handler
once the event handlers are set up) untilexecve()replaces it.src/exec.c:264exec_cmnd()callsrestore_signals()and then does the
rest of the setup (exec_setup(): uid/gid, rlimits, closefrom, chdir, ...)
beforesudo_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 beforefork(), 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 inheritedsudo_handlerinstead and the signal was lost the same way
(1.9.17p2 in the table above).use_ptypath:src/exec_pty.c:841signal_cb_pty()->:619
schedule_signal()sendsCMD_SIGNOover the backchannel;
src/exec_monitor.c:308mon_backchannel_cb()->:68deliver_signal()
->:118killpg(mc->cmnd_pid, signo). The monitor installs its handlers at
:622and 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:
- 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, restore_signals()restores the saved state of every signal, not only
the ones flaggedrestore; the saved state is whatexecve()would
leave (SIG_IGNorSIG_DFL, sudo itself was exec'd),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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from sudo-project/sudo
-
Difficulty 4/5 3-5 days Newbie friendliness 42/100
sudo-project/sudo#583 ·
Maintainers usually reply within 1 day
-
Difficulty 4/5 3-5 days Newbie friendliness 45/100
sudo-project/sudo#581 · 4 comments ·
Maintainers usually reply within 1 day
-
Difficulty 3/5 1-2 days Newbie friendliness 52/100
sudo-project/sudo#553 ·
Maintainers usually reply within 1 day
-
Difficulty 4/5 3-5 days Newbie friendliness 48/100
sudo-project/sudo#552 ·
Maintainers usually reply within 1 day
-
Documentation text overflows container instead of wrappingMay be free again @BittuBarnwal7479 claimed this 129 days ago, and no pull request is open. Open
Difficulty 2/5 1-3 hours Newbie friendliness 58/100
sudo-project/sudo#547 · 1 comment ·
Maintainers usually reply within 1 day
All issues in sudo-project/sudo
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 66/100
Maintainers usually reply within 2 days
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
zephyrproject-rtos/zephyr#123881 ·
Maintainers usually reply within 2 days
-
Difficulty 2/5 1-3 hours Newbie friendliness 77/100
eclipse-zenoh/zenoh-pico#1354 · 1 comment ·
Maintainers usually reply within 1 day
-
Difficulty 1/5 Under an hour Newbie friendliness 65/100
ly05010419/MacNativeGames#1252 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
Maintainers usually reply within 2 days