URL: <https://savannah.gnu.org/bugs/?67777>
Summary: make mangles .SHELLFLAGS to death (buffer overflow,
and more...)
Group: make
Submitter: edquist
Submitted: Sat 06 Dec 2025 12:15:10 AM UTC
Severity: 3 - Normal
Priority: 5 - Normal
Item Group: Bug
Status: None
Privacy: Public
Assigned to: None
Open/Closed: Open
Discussion Lock: Unlocked
Component Version: 4.4.1
Operating System: POSIX-Based
Fixed Release: None
Triage Status: None
_______________________________________________________
Follow-up Comments:
-------------------------------------------------------
Date: Sat 06 Dec 2025 12:15:10 AM UTC By: Carl <edquist>
Hi there!
Summary:
I seem to have stumbled upon a buffer overrun, leading to undefined
behavior (segfault, or other weird behavior, or nothing), when make
tries to deal with .SHELLFLAGS containing special shell characters.
Besides the buffer overrun, the logic seems broken, and the documentation
is unclear at best.
Background:
So, in looking into bug #67750, I had thought to try rigging up .SHELLFLAGS
for a workaround, such that the main SHELL could call another shell.
The command line I had in mind was something like:
$ /bin/sh1 -c '"$@"' - /bin/sh2 -c "$RECIPE"
I had in mind that SHELL would be set to /bin/sh1, RECIPE would be the
recipe as defined in the makefile, and the stuff in between would have
to go into .SHELLFLAGS.
That raises a question: how is .SHELLFLAGS split into execve arguments to
pass to the SHELL?
The documentation doesn't seem to specify with any detail:
> The argument(s) passed to the shell are
> taken from the variable '.SHELLFLAGS'
That seems to imply that more than one argument is allowed. And based
on the fact that everywhere else, make simply splits words at whitespace
(spaces and tabs), not getting into the business of interpreting quote
characters, it seemed reasonable to infer that .SHELLFLAGS would also
be split on whitepace and the words would be passed as arguments to
execve.
In other words, I imagined that .SHELLFLAGS should be set like so:
SHELL = /bin/sh1
.SHELLFLAGS = -c "$$@" - /bin/sh2 -c
target:
$(RECIPE)
The '$' is doubled to prevent expansion by make, but the thought anyway
was that the double-quotes would be passed in literally, without
interpretation by make.
So, here's what I tried:
$ make --version
GNU Make 4.4.1
Built for x86_64-pc-linux-gnu
With this test makefile:
$ cat makefile
.ONESHELL:
SHELL = ./pargs
.SHELLFLAGS = -c "$$@" - /bin/bash -c
x:
echo hi
echo there
echo bye now
I made the 'pargs' wrapper script to print the command line args passed
to it, to confirm what make is actually passing:
$ cat pargs
#!/bin/bash
[[ $# -eq 0 ]] || printf "[%s]\n" "$@"
Any guesses?
I guess I struck gold!
$ make
Segmentation fault (core dumped)
Pretty exciting right? I mean, I'm up for a wild goose chase.
Entertainingly, the segv happens in a call to malloc(3) of all places,
which --I'll spare you-- turns out a red herring.
$ gdb make
GNU gdb (GDB) 16.3
[... snip copyright blah blah ...]
(gdb) run
Starting program: /usr/bin/make
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/usr/lib/libthread_db.so.1".
Program received signal SIGSEGV, Segmentation fault.
0x00007ffff7d1636f in ?? () from /usr/lib/libc.so.6
(gdb) bt
#0 0x00007ffff7d1636f in ?? () from /usr/lib/libc.so.6
#1 0x00007ffff7d193ea in ?? () from /usr/lib/libc.so.6
#2 0x00007ffff7d1a3c2 in malloc () from /usr/lib/libc.so.6
#3 0x00007ffff7cf3924 in _IO_file_doallocate () from /usr/lib/libc.so.6
#4 0x00007ffff7d03134 in _IO_doallocbuf () from /usr/lib/libc.so.6
#5 0x00007ffff7d01178 in _IO_file_overflow () from /usr/lib/libc.so.6
#6 0x00007ffff7d01c98 in _IO_file_xsputn () from /usr/lib/libc.so.6
#7 0x00007ffff7cf492a in fputs () from /usr/lib/libc.so.6
#8 0x000055555557302a in _outputs (
msg=0x5555555cb360 "echo hi\necho there\necho bye now\n",
is_err=<optimized out>, out=<optimized out>) at src/output.c:70
#9 outputs (is_err=<optimized out>,
msg=0x5555555cb360 "echo hi\necho there\necho bye now\n")
at src/output.c:385
#10 0x00005555555731a7 in outputs (
msg=0x5555555cb360 "echo hi\necho there\necho bye now\n",
is_err=0)
at src/output.c:378
#11 message (prefix=prefix@entry=0, len=<optimized out>,
fmt=fmt@entry=0x55555558c524 "%s") at src/output.c:438
#12 0x00005555555743b0 in start_job_command (child=0x5555555cb030)
at src/job.c:1359
#13 0x00005555555757d8 in start_waiting_job (c=0x5555555cb030)
at src/job.c:1646
#14 0x0000555555578841 in new_job (file=<optimized out>) at src/job.c:1960
#15 0x0000555555586e58 in remake_file (file=0x5555555c09d0)
at src/remake.c:1313
#16 update_file_1 (depth=<optimized out>, file=<optimized out>)
at src/remake.c:905
#17 update_file (file=file@entry=0x5555555c09d0, depth=<optimized out>)
at src/remake.c:367
#18 0x000055555558771b in update_goal_chain (goaldeps=<optimized out>)
at src/remake.c:184
#19 0x000055555555fc01 in main (argc=<optimized out>, argv=<optimized out>,
envp=<optimized out>) at src/main.c:2918
By tweaking the makefile a little here or there, I managed to get other
segfaults with completely different stack traces, though still finally
breaking within a call to malloc(). And on another platform (arm linux),
I got other weird behavior, where strings from the environment appeared to
overlap with the recipe string.
Turned out adding some old fashioned print statements was more
illuminating than running gdb.
Apparently the actual buffer overrun didn't trigger the segfault
directly, but overwrote some other important data structure, only
causing the failure to manifest in a future call to malloc().
That's my enthusiast-grade diagnosis anyway.
Well, it turns out in src/job.c, there's an intimidating function
called construct_command_argv_in[tf]ernal() that promises to
construct the command argv array representing:
"$(SHELL) $(.SHELLFLAGS) LINE"
Wildly, it is a recursive function, calling itself again to handle
parsing .SHELLFLAGS.
As becomes apparent browsing this chimeric 953-line function, make itself
does try to interpret quotes and other special shell chars and builtin
commands, and handles things differently if it finds them. Apparently it
tries to hand things off to the shell (specifically /bin/sh) if it thinks
the shell should handle the quoting.
But how is the shell supposed to handle quotes or special shell characters
within .SHELLFLAGS? And which shell will do that work? The documentation
doesn't mention any of this. There would be a chicken-and-the-egg problem
if $(SHELL) was supposed to handle parsing special characters in
$(.SHELLFLAGS), since $(.SHELLFLAGS) need to be parsed in order to invoke
$(SHELL). I suppose that is why the default shell (/bin/sh) specifically
is used when .SHELLFLAGS contains special shell characters or commands.
How /bin/sh is supposed to solve this problem is another question, and
it's not obvious to me that it can. (Spoiler: the logic behind the
current implementation does not seem to work.)
Wouldn't it be cleaner and more consistent just to split words in
.SHELLFLAGS on whitespace, like make does everywhere else (ie, without
special treatment for quotes and special shell chars and commands), and
pass them in as separate args to the shell invocation?
Anyway, the relevant section in job.c of what currently happens starts
here, around line 3355:
/* Create an argv list for the shell command line. */
{
int n = 1;
char *nextp;
new_argv = xmalloc ((4 + sflags_len/2) * sizeof (char *));
nextp = new_argv[0] = xmalloc (shell_len + sflags_len + line_len +
3);
nextp = mempcpy (nextp, shell, shell_len + 1);
/* Chop up the shellflags (if any) and assign them. */
if (! shellflags)
{
new_argv[n++] = nextp;
*(nextp++) = '\0';
}
else
{
/* Parse shellflags using construct_command_argv_internal to
handle quotes. */
char **argv;
char *f = alloca (sflags_len + 1);
memcpy (f, shellflags, sflags_len + 1);
argv = construct_command_argv_internal (f, 0, 0, 0, 0, flags,
0);
if (argv)
{
char **a;
for (a = argv; *a; ++a)
{
new_argv[n++] = nextp;
nextp = stpcpy (nextp, *a) + 1;
}
free (argv[0]);
free (argv);
}
}
/* Set the command to invoke. */
new_argv[n++] = nextp;
memcpy(nextp, line, line_len + 1);
new_argv[n++] = NULL;
}
return new_argv;
}
Apparently the 'nextp' buffer is allocated to store the entire command
line, with NULs for the arg terminators. The size (shell_len +
sflags_len + line_len + 3) apparently corresponds to the length of
$(SHELL) plus the length of $(.SHELLFLAGS) plus the length of the
recipe (line), plus one NUL byte for each of those three. Internal
whitespace in $(.SHELLFLAGS) would become NULs also but wouldn't
require extra buffer space.
When .SHELLFLAGS contains (in my case) "$@", the 'else' block is taken,
calling construct_command_argv_internal() again, with no shell and no
shellflags, implying the default shell (/bin/sh) for parsing the
original shellflags.
In the inner call, again at the top, shell = "/bin/sh", which becomes
new_argv[0]. And shellflags = NULL, so the "if (! shellflags)" block
is taken, curiously setting new_argv[1] = "". Then $(.SHELLFLAGS)
is treated as the "line" to invoke, making that new_argv[2].
Only, because the default shell (/bin/sh) is considered POSIX-style,
the "line" (ie $(.SHELLFLAGS)) is subject to leading special prefix
char removal, which means that if $(.SHELLFLAGS) starts with a hyphen
(as it typically does!), that hyphen will be removed.
I have no idea what the intention was there, but the result is that
the execve args for the command line become:
argv = {shell, "/bin/sh", "", shellflags, line};
where shell = $(SHELL), and shellflags is $(.SHELLFLAGS) with leading
hyphens (and @, +, and whitespace) stripped.
Probably it goes without saying, but that doesn't seem to do anything
useful to parse shellflags or produce a usable command line arg array.
MOREOVER, this appears to be where the buffer overrun happens.
The 'nextp' buffer made room for shell + shellflags + line (plus NULs),
but does not account for the added "/bin/sh" + "" (plus NULs).
I tried fixing this with:
- nextp = new_argv[0] = xmalloc (shell_len + sflags_len + line_len + 3);
+ nextp = new_argv[0] = xmalloc (shell_len + sflags_len + line_len + 3
+ + strlen(default_shell) + 2);
and I stopped getting segfaults.
So now with the patched make, (again with the same original makefile as
before, with the 'pargs' wrapper as the SHELL) you can see clearly the
behavior I describe:
$ ./gits/make/bld/make
echo hi
echo there
echo bye now
[/bin/sh]
[]
[c "$@" - /bin/bash -c]
[echo hi
echo there
echo bye now]
(Again, this shows that {shell, "/bin/sh", "", shellflags, line} gets
executed, with the leading '-' removed from the '-c' in .SHELLFLAGS)
...
Conclusions:
- The buffer overrun (undefined behavior) should definitely be fixed.
(My simple patch seems to work, though if you're feeling inspired you
might consider revamping the whole construct_command_argv_internal()
function.)
- There is no logic in executing {shell, "/bin/sh", "", shellflags, line}
as a command line. It's completely useless and broken, and should
be replaced with something that at least conceivably could work in
some scenario.
- Doing simple word splitting of .SHELLFLAGS on whitespace (without
trying to interpret quotes or shell chars or builtin commands
specially) seems like a reasonable and consistent approach.
- If _any other approach_ is taken, to interpret or mangle .SHELLFLAGS,
the behavior should be documented.
Thanks!
_______________________________________________________
Reply to this item at:
<https://savannah.gnu.org/bugs/?67777>
_______________________________________________
Message sent via Savannah
https://savannah.gnu.org/
signature.asc
Description: PGP signature
