Follow @Openwall on Twitter for new release announcements and other news
[<prev] [next>] [<thread-prev] [thread-next>] [day] [month] [year] [list]
Message-ID: <20260630195333.GU27423@brightrain.aerifal.cx>
Date: Tue, 30 Jun 2026 15:53:33 -0400
From: Rich Felker <dalias@...c.org>
To: musl@...ts.openwall.com
Subject: Re: [PATCH 0/4] wordexp fixes

On Tue, Jun 30, 2026 at 04:31:33PM +0200, Szabolcs Nagy wrote:
> 2 bugfix patches, WRDE_UNDEF implementation and
> a much tighter WRDE_CMDSUB.

> >From b241b6210294a9b2cb8bd44ba4105d862f582ee6 Mon Sep 17 00:00:00 2001
> From: Szabolcs Nagy <nsz@...t70.net>
> Date: Sun, 21 Jun 2026 11:33:28 +0000
> Subject: [PATCH 1/4] wordexp: fix stderr redirection
> 
> stderr redirection to /dev/null didn't work, can be done in the
> command, but would not redirect errors printed during sh startup.
> 
>   wordexp(")", p, 0)
> 
> clobbered stderr, now silent.
> ---
>  src/misc/wordexp.c | 9 ++++++---
>  1 file changed, 6 insertions(+), 3 deletions(-)
> 
> diff --git a/src/misc/wordexp.c b/src/misc/wordexp.c
> index db83a69f..73349aac 100644
> --- a/src/misc/wordexp.c
> +++ b/src/misc/wordexp.c
> @@ -29,7 +29,6 @@ static int do_wordexp(const char *s, wordexp_t *we, int flags)
>  	int sq=0, dq=0;
>  	size_t np=0;
>  	char *w, **tmp;
> -	char *redir = (flags & WRDE_SHOWERR) ? "" : "2>/dev/null";
>  	int err = 0;
>  	FILE *f;
>  	size_t wc = 0;
> @@ -108,9 +107,13 @@ static int do_wordexp(const char *s, wordexp_t *we, int flags)
>  	if (!pid) {
>  		if (p[1] == 1) fcntl(1, F_SETFD, 0);
>  		else dup2(p[1], 1);
> +		if (!(flags & WRDE_SHOWERR)) {
> +			int fd = open("/dev/null", O_WRONLY|O_CLOEXEC);
> +			if (fd < 0 || dup2(fd, 2) < 0) close(2);
> +		}
>  		execl("/bin/sh", "sh", "-c",
> -			"eval \"printf %s\\\\\\\\0 x $1 $2\"",
> -			"sh", s, redir, (char *)0);
> +			"eval \"printf %s\\\\\\\\0 x $1\"",
> +			"sh", s, (char *)0);
>  		_exit(1);
>  	}
>  	close(p[1]);
> -- 
> 2.52.0

I don't think silently proceeding with close(2) is reasonable here. It
will potentially cause any descendant to corrupt other output by
mixing in things intended for stderr.

If we need to open /dev/null ourselves, it should probably be done in
the parent with only the dup2 in the child. This way the error can be
handled before there's even a child.

> From 700db8148126afef1331b4109377c6c8be07b43b Mon Sep 17 00:00:00 2001
> From: Szabolcs Nagy <nsz@...t70.net>
> Date: Sun, 21 Jun 2026 12:18:51 +0000
> Subject: [PATCH 2/4] wordexp: clear positional parameters
> 
> added 'set --' so $@, $*, $#, $1, $2 are cleared
> (does not seem to be in the spec, but cleaner)
> 
>   wordexp("$*", p, 0)
> 
> was "$*","2>/dev/stderr", now empty list.
> ---
>  src/misc/wordexp.c | 2 +-
>  1 file changed, 1 insertion(+), 1 deletion(-)
> 
> diff --git a/src/misc/wordexp.c b/src/misc/wordexp.c
> index 73349aac..c4154fad 100644
> --- a/src/misc/wordexp.c
> +++ b/src/misc/wordexp.c
> @@ -112,7 +112,7 @@ static int do_wordexp(const char *s, wordexp_t *we, int flags)
>  			if (fd < 0 || dup2(fd, 2) < 0) close(2);
>  		}
>  		execl("/bin/sh", "sh", "-c",
> -			"eval \"printf %s\\\\\\\\0 x $1\"",
> +			"eval \"set --;printf %s\\\\\\\\0 x $1\"",
>  			"sh", s, (char *)0);
>  		_exit(1);
>  	}
> -- 
> 2.52.0

I think this is right. It's in the eval so as not to affect the $1
used in the eval expression, right?

> From 6083d3ed8ca93a69cb4be9d3ea73e4a2d968e344 Mon Sep 17 00:00:00 2001
> From: Szabolcs Nagy <nsz@...t70.net>
> Date: Sun, 21 Jun 2026 13:33:38 +0000
> Subject: [PATCH 3/4] wordexp: implement WRDE_UNDEF
> 
> use sh -u to detect undef vars
> 
>   wordexp("x $U y", p, WRDE_UNDEF)
> 
> was "x","y", now fails with WRDE_BADVAL, note that
> 
>   wordexp("$(echo x $U) y", p, WRDE_UNDEF|WRDE_SHOWERR)
> 
> was "x","y", now "y" and prints an error about U. the echo is not
> executed, but wordexp succeeds. undef failure is not propagated from
> a command substitution (but it inherits -u).

I think this is contrary to the behavior as specified. The $U here is
not an expansion by the wordexp, but an expansion in the command
inside $(), which is not specified to be subject to set -u. However it
is somewhat ambiguous.

But as you've noted, if the WRDE_UNDEF should affect things inside
$(), it's still wrong because wordexp isn't failing.

I think it's clear that the behavior should be either success or an
error from wordexp, not just a silent error from the $().

> on failure rerun with sh -n to distinguish unset from syntax errors
> without side-effects. for the syntax check the string is passed
> directly as a command which can be parsed differently:

Nice, I think this approach solves the problem of double side effects.

>   wordexp("{ $U", p, 0)
>   wordexp("{ $U", p, WRDE_UNDEF)
> 
> former is "{" but latter fails with WRDE_SYNTAX, not WRDE_UNDEF.
> (note: using eval "set -n --;..." does not work on mksh, oksh.)
> we == NULL marks the rerun (ub for user code).
> 
>   wordexp("$(echo>>x) $U $(echo>>x)", p, WRDE_UNDEF)
>   wordexp("$(echo>>x) $U $(echo>>x", p, WRDE_UNDEF)
> 
> former returns WRDE_BADVAL and writes one \n to x, latter returns
> WRDE_SYNTAX and has no side-effect.
> ---
>  src/misc/wordexp.c | 36 ++++++++++++++++++++++++++----------
>  1 file changed, 26 insertions(+), 10 deletions(-)
> 
> diff --git a/src/misc/wordexp.c b/src/misc/wordexp.c
> index c4154fad..e65f2381 100644
> --- a/src/misc/wordexp.c
> +++ b/src/misc/wordexp.c
> @@ -11,10 +11,11 @@
>  #include <fcntl.h>
>  #include "pthread_impl.h"
>  
> -static void reap(pid_t pid)
> +static int reap(pid_t pid)
>  {
>  	int status;
>  	while (waitpid(pid, &status, 0) < 0 && errno == EINTR);
> +	return WIFEXITED(status) && WEXITSTATUS(status) == 0;
>  }
>  
>  static char *getword(FILE *f)
> @@ -36,6 +37,7 @@ static int do_wordexp(const char *s, wordexp_t *we, int flags)
>  	int p[2];
>  	pid_t pid;
>  	sigset_t set;
> +	int syntax_check = !we;
>  
>  	if (flags & WRDE_REUSE) wordfree(we);
>  
> @@ -91,7 +93,7 @@ static int do_wordexp(const char *s, wordexp_t *we, int flags)
>  		if (we->we_offs > SIZE_MAX/sizeof(void *)/4)
>  			goto nospace;
>  		i += we->we_offs;
> -	} else {
> +	} else if (!syntax_check) {
>  		we->we_offs = 0;
>  	}
>  
> @@ -111,9 +113,12 @@ static int do_wordexp(const char *s, wordexp_t *we, int flags)
>  			int fd = open("/dev/null", O_WRONLY|O_CLOEXEC);
>  			if (fd < 0 || dup2(fd, 2) < 0) close(2);
>  		}
> -		execl("/bin/sh", "sh", "-c",
> -			"eval \"set --;printf %s\\\\\\\\0 x $1\"",
> -			"sh", s, (char *)0);
> +		if (syntax_check)
> +			execl("/bin/sh", "sh", "-cn", "--", s, (char *)0);
> +		else
> +			execl("/bin/sh", "sh", flags & WRDE_UNDEF ? "-cu" : "-c",
> +				"eval \"set --;printf %s\\\\\\\\0 x $1\"",
> +				"sh", s, (char *)0);
>  		_exit(1);
>  	}
>  	close(p[1]);
> @@ -121,9 +126,7 @@ static int do_wordexp(const char *s, wordexp_t *we, int flags)
>  	f = fdopen(p[0], "r");
>  	if (!f) {
>  		close(p[0]);
> -		kill(pid, SIGKILL);
> -		reap(pid);
> -		goto nospace;
> +		goto killchild;
>  	}
>  
>  	l = wv ? i+1 : 0;
> @@ -131,9 +134,19 @@ static int do_wordexp(const char *s, wordexp_t *we, int flags)
>  	free(getword(f));
>  	if (feof(f)) {
>  		fclose(f);
> -		reap(pid);
> +		int success = reap(pid);
> +		if (syntax_check && success)
> +			return WRDE_BADVAL;
> +		if (flags & WRDE_UNDEF)
> +			/* rerun with set -n */
> +			return do_wordexp(s, 0, 0);
>  		return WRDE_SYNTAX;
>  	}
> +	if (syntax_check) {
> +		/* there should not be output. */
> +		fclose(f);
> +		goto killchild;
> +	}
>  
>  	while ((w = getword(f))) {
>  		if (i+1 >= l) {
> @@ -162,8 +175,11 @@ static int do_wordexp(const char *s, wordexp_t *we, int flags)
>  	}
>  	return err;
>  
> +killchild:
> +	kill(pid, SIGKILL);
> +	reap(pid);
>  nospace:
> -	if (!(flags & WRDE_APPEND)) {
> +	if (!(flags & WRDE_APPEND) && !syntax_check) {
>  		we->we_wordc = 0;
>  		we->we_wordv = 0;
>  	}
> -- 
> 2.52.0

I haven't read this in depth yet, but at a high level it looks ok.

> From 05d0c797efcb101c855bb47095261ed49dcb5a26 Mon Sep 17 00:00:00 2001
> From: Szabolcs Nagy <nsz@...t70.net>
> Date: Thu, 25 Jun 2026 20:12:48 +0000
> Subject: [PATCH 4/4] wordexp: reimplement WRDE_NOCMD
> 
> nested structures and various details were not handled so it was easy
> to pass the checks with input doing cmd sub, but the checks rejected
> a simple ${var} breaking valid use-cases.
> 
> implement enough of the shell tokenization rules to detect cmd sub
> with low false positive rate and no false negative (missed cmd sub).
> nocmd check design breakdown:
> 
> 1. \ \n and # comment handling
> 2. ' state tracking
> 3. ( ) tracking to match ))
> 4. filter impl specific, unsafe cases
> 
> without 1. cmd sub can be missed:
> 
>   $\
>   (cmd)
> 
>   #\
>   $(cmd)
> 
> 2. without ' state tracking it is possible to construct a suffix with
> missed cmd sub. suffix examples when miscalculated ' state is
> 
>   esc: '$(cmd)'
>   nop: '"}))..} '"}))..} $(cmd)\'
> 
> to track ', the relevant parse states:
> 
>   i:  initial state, where ' is esc
>   q:  in "...", where ' is nop
>   b%: in ${v%...}, where ' is esc
>   b-: in ${v-...}, where ' is esc,nop,bad of the enclosing state
>   p:  in $((...)), where ' is bad, " is bad, # is bad
> 
> b%, b-, p can nest into any other state, q can nest into b%, b- and i.
> esc means escape until the next ', nop means literal ' without special
> processing, bad means rejected. e.g. the nesting state is
> 
>   i b- b% q p b- q
> 
> when parsing is at . in
> 
>   ${v-${v%"$((${v-"."}))"}}
> 
> linear time parsing requires linear storage in input length to decide
> if ' is nop or esc. for bounded storage, the accepted language needs
> to be restricted e.g. depth limit (in posix q b- .. q is unspecified
> but that's not enough). the bad ' state covers impl specific behaviour
> 
>   $((${x-'$(cmd)'}))
> 
> ksh treats ' as esc, while bash (and posix) as nop. such cases are
> rejected at ' without further analysing the '... suffix.
> 
> 3. is about disambiguating $((arith)) and $(cmd), since cmd can be
> (subcmd) i.e. $((subcmd)). posix requires conforming scripts to use
> $( (subcmd) ) and that arith parsing has precedence and cmd is only
> considered if that fails (but not clear what is a fail). in practice
> 
>   $((cmd) )
> 
> does cmd sub, so at least we need to detect )) mismatch. there is
> 
>   $((echo hi #(
>   )
>   ))
> 
> where )) matches $((, but bash evals it to hi), so any # is rejected.
> another case is << here doc with special ' and parsing rules, but it
> cannot hide cmd sub if )) mismatch is rejected as well as ', ", ;, #.
> this seems enough to avoid missed cmd sub.
> 
> 4. impl specific cases that affect ' or $( handling are rejected:
> 
>   "${X-"$"(cmd)}"
>   $((${X-"$"(cmd)}))
> 
> is cmd sub in bash, but not in other posix sh and
> 
>   $'\'$(cmd)'\'
> 
> depends on $' support. otherwise various extensions are accepted under
> the assumption that the ' state and cmd sub are not affected by them.
> 
> this patch uses a depth limited stack and 32bit counters to track the
> ' and )) states. detection of $( or ` returns WRDE_CMDSUB without
> verifying the syntax of the suffix. WRDE_BADCHAR is returned for
> rejected cases related to chars specified to be bad in wordexp other
> rejection is WRDE_SYNTAX and it is 0 if parsing completes in i state.
> 
> the nocmd_check code is around 2k, the change adds about 1.2k .text,
> a bit of that can be dropped by changing T[*s] to *s<128U ? T[*s]:0.
> ---
>  src/misc/wordexp.c | 224 ++++++++++++++++++++++++++++++++++++---------
>  1 file changed, 182 insertions(+), 42 deletions(-)
> 
> diff --git a/src/misc/wordexp.c b/src/misc/wordexp.c
> index e65f2381..d433858e 100644
> --- a/src/misc/wordexp.c
> +++ b/src/misc/wordexp.c
> @@ -3,6 +3,7 @@
>  #include <stdio.h>
>  #include <string.h>
>  #include <limits.h>
> +#include <ctype.h>
>  #include <stdint.h>
>  #include <stdlib.h>
>  #include <sys/wait.h>
> @@ -24,11 +25,187 @@ static char *getword(FILE *f)
>  	return getdelim(&s, (size_t [1]){0}, 0, f) < 0 ? 0 : s;
>  }
>  
> +static const char *next(const char *s)
> +{
> +	s++;
> +	while (s[0]=='\\' && s[1]=='\n')
> +		s += 2;
> +	return s;
> +}
> +
> +/* $c.. is a variable */
> +static int varchar(int c)
> +{
> +	if (isalnum(c)) return 1;
> +	switch (c) {
> +	case '_':
> +	case '@':
> +	case '*':
> +	case '#':
> +	case '?':
> +	case '-':
> +	case '$':
> +	case '!':
> +		return 1;
> +	}
> +	return 0;
> +}
> +
> +static const char *skipvarname(const char *s)
> +{
> +	while (isalnum(*s) || *s=='_')
> +		s = next(s);
> +	return s;
> +}
> +
> +static int nocmd_check(const char *s)
> +{
> +	enum {
> +		INIT=0,    /* initial state */
> +		QUOTE=4,   /* in ".." */
> +		BRACE=8,   /* in ${..} */
> +		PAREN=12,  /* in $((..)) */
> +
> +		XSQ_ESC=0,  /* ' escapes in ${..} */
> +		XSQ_BAD=16, /* ' is rejected in ${..} */
> +		XD_OK=32,   /* ' has no effect, "..$" is ok  */
> +		XD_BAD=48,  /* ' has no effect, "..$" is rejected */
> +		XMASK=48
> +	};
> +	enum {ROK=1, RCMD, RBAD, RSYN, ESC, SQ, SQE, DQ, D, LP, RP, POP, WS, H};
> +	static const unsigned short T[256]={
> +#define C(c,opi,opq,opb,opp) [c] = opi<<INIT|opq<<QUOTE|opb<<BRACE|opp<<PAREN,
> +	/*  C   INIT  QUOTE BRACE PAREN */
> +	C(  0 , ROK,  RSYN, RSYN, RSYN )
> +	C( '`', RCMD, RCMD, RCMD, RCMD )
> +	C( '$', D,    D,    D,    D    )
> +	C('\\', ESC,  ESC,  ESC,  ESC  )
> +	C( '"', DQ,   POP,  DQ,   RSYN )
> +	C('\'', SQE,  0,    SQ,   RSYN )
> +	C( '#', H,    0,    0,    RSYN )
> +	C( ' ', WS,   0,    0,    0    )
> +	C('\t', WS,   0,    0,    0    )
> +	C('\n', RBAD, 0,    0,    0    )
> +	C( '|', RBAD, 0,    0,    0    )
> +	C( '&', RBAD, 0,    0,    0    )
> +	C( ';', RBAD, 0,    0,    RBAD )
> +	C( '<', RBAD, 0,    0,    0    )
> +	C( '>', RBAD, 0,    0,    0    )
> +	C( '(', RBAD, 0,    0,    LP   )
> +	C( ')', RBAD, 0,    0,    RP   )
> +	C( '{', RBAD, 0,    0,    RBAD )
> +	C( '}', RBAD, 0,    POP,  RBAD )
> +#undef C
> +	};
> +	const char *wstart = s;
> +	enum {MAXSP=1024}; /* limit on #QUOTE+#BRACE+5*#PAREN nesting depth */
> +	unsigned char stack[MAXSP];
> +	/* note: ( depth in a $((..)) is limited by 32bit np and ARG_MAX */
> +	unsigned np=0, sp=0, st=INIT, stx=XSQ_ESC, newst, newstx;
> +
> +	for (;;s++) switch (T[(unsigned char)*s]>>st & 15) {
> +	case 0: break;
> +	case ROK: return 0;
> +	case RCMD: return WRDE_CMDSUB;
> +	case RBAD: return WRDE_BADCHAR;
> +	case RSYN: return WRDE_SYNTAX;
> +	case ESC:
> +		if (*++s) break;
> +		return WRDE_SYNTAX;
> +	case SQ:
> +		if (stx == XSQ_BAD) return WRDE_SYNTAX;
> +		if (stx == XSQ_ESC) {
> +	case SQE:
> +			s = strchr(s+1, '\'');
> +			if (!s) return WRDE_SYNTAX;
> +		}
> +		break;
> +	case DQ:
> +		newst = QUOTE;
> +		newstx = stx == XSQ_ESC ? XD_OK : XD_BAD;
> +push:
> +		if (sp > MAXSP-1) return WRDE_SYNTAX;
> +		stack[sp++] = st | stx;
> +		st = newst;
> +		stx = newstx;
> +		if (st != PAREN) break;
> +		if (sp > MAXSP-4) return WRDE_SYNTAX;
> +		stack[sp++] = np;
> +		stack[sp++] = np>>8;
> +		stack[sp++] = np>>16;
> +		stack[sp++] = np>>24;
> +		np = 1; /* (( counts as 1, deal with the extra at the end */
> +		break;
> +	case D:
> +		s = next(s);
> +		if (*s=='{') {
> +			s = next(s);
> +			if (!varchar(*s)) return WRDE_SYNTAX;
> +			s = skipvarname(s+1);
> +			switch (*s) {
> +			case ':':
> +			case '-':
> +			case '+':
> +			case '=':
> +			case '?':
> +				newstx = stx;
> +				break;
> +			case '#':
> +			case '%':
> +				newstx = XSQ_ESC;
> +				break;
> +			default: /* extension, simple ${var}, eof */
> +				s--;
> +				newstx = XSQ_BAD;
> +			}
> +			newst = BRACE;
> +			goto push;
> +		}
> +		if (*s=='(') {
> +			s = next(s);
> +			if (*s!='(') return WRDE_CMDSUB;
> +			newstx = XSQ_BAD; /* $((${v-'})) */
> +			newst = PAREN;
> +			goto push;
> +		}
> +		if (*s=='\'' || (*s=='"' && st==QUOTE && stx==XD_BAD))
> +			/* reject $' and deal with "${x-"$"y}" */
> +			return WRDE_SYNTAX;
> +		if (!varchar(*s))
> +			s--; /* assume $c is not special. */
> +		break;
> +	case LP:
> +		np++;
> +		if (!np) return WRDE_SYNTAX;
> +		break;
> +	case RP:
> +		np--;
> +		if (np) break;
> +		s = next(s);
> +		if (*s != ')') return WRDE_CMDSUB; /* )) mismatch */
> +		np = (unsigned)stack[--sp]<<24;
> +		np |= stack[--sp]<<16;
> +		np |= stack[--sp]<<8;
> +		np |= stack[--sp];
> +		/* fallthrough */
> +	case POP:
> +		stx = stack[--sp];
> +		st = stx&15;
> +		stx &= XMASK;
> +		break;
> +	case WS:
> +		wstart = s = next(s);
> +		s--;
> +		break;
> +	case H:
> +		if (s != wstart) break;
> +		return strchr(s, '\n') ? WRDE_BADCHAR : 0;
> +	}
> +}
> +
>  static int do_wordexp(const char *s, wordexp_t *we, int flags)
>  {
>  	size_t i, l;
> -	int sq=0, dq=0;
> -	size_t np=0;
>  	char *w, **tmp;
>  	int err = 0;
>  	FILE *f;
> @@ -41,46 +218,9 @@ static int do_wordexp(const char *s, wordexp_t *we, int flags)
>  
>  	if (flags & WRDE_REUSE) wordfree(we);
>  
> -	if (flags & WRDE_NOCMD) for (i=0; s[i]; i++) switch (s[i]) {
> -	case '\\':
> -		if (!sq && !s[++i]) return WRDE_SYNTAX;
> -		break;
> -	case '\'':
> -		if (!dq) sq^=1;
> -		break;
> -	case '"':
> -		if (!sq) dq^=1;
> -		break;
> -	case '(':
> -		if (np) {
> -			np++;
> -			break;
> -		}
> -	case ')':
> -		if (np) {
> -			np--;
> -			break;
> -		}
> -	case '\n':
> -	case '|':
> -	case '&':
> -	case ';':
> -	case '<':
> -	case '>':
> -	case '{':
> -	case '}':
> -		if (!(sq|dq|np)) return WRDE_BADCHAR;
> -		break;
> -	case '$':
> -		if (sq) break;
> -		if (s[i+1]=='(' && s[i+2]=='(') {
> -			i += 2;
> -			np += 2;
> -			break;
> -		} else if (s[i+1] != '(') break;
> -	case '`':
> -		if (sq) break;
> -		return WRDE_CMDSUB;
> +	if (flags & WRDE_NOCMD) {
> +		err = nocmd_check(s);
> +		if (err) return err;
>  	}
>  
>  	if (flags & WRDE_APPEND) {
> -- 
> 2.52.0
> 

This needs to be done, but it's a lot to review. I think I'll start
with some examples the current code doesn't handle right and work
through how the proposed change fixes them, but it might take a while.

Rich

Powered by blists - more mailing lists

Confused about mailing lists and their use? Read about mailing lists on Wikipedia and check out these guidelines on proper formatting of your messages.