>From 05d0c797efcb101c855bb47095261ed49dcb5a26 Mon Sep 17 00:00:00 2001 From: Szabolcs Nagy 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 #include #include +#include #include #include #include @@ -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<', 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