[glibc/release/2.41/master] posix: Fix wordexp WRDE_APPEND to preserve state on non-NOSPACE errors (BZ 34090, CVE-2026-6368)

Aurelien Jarno via Glibc-cvs <[email protected]>
Newsgroups gmane.comp.lib.glibc.cvs
Message-ID <[email protected]>
https://sourceware.org/git/gitweb.cgi?p=glibc.git;h=dfc8ef388ef7856f318ade7dec501f92ce54f9e3

commit dfc8ef388ef7856f318ade7dec501f92ce54f9e3
Author: Adhemerval Zanella <[email protected]>
Date:   Mon Apr 13 16:33:30 2026 -0300

    posix: Fix wordexp WRDE_APPEND to preserve state on non-NOSPACE errors (BZ 34090, CVE-2026-6368)
    
    The previous implementation saved a copy of the wordexp_t struct at
    entry and blindly restored it on error via (*pwordexp = old_word).
    This is incorrect when WRDE_APPEND is set because w_addword may have
    called realloc on we_wordv during partial processing before the error
    was detected.  If realloc relocated the buffer, the saved we_wordv
    pointer is dangling; restoring it causes a use-after-free in the
    caller (e.g. via wordfree), and the relocated buffer is leaked.
    
    Fix this by duplicating the we_wordv pointer array at entry when
    WRDE_APPEND is set, so that all subsequent realloc calls inside
    w_addword operate on the copy.
    
    This change also fixes a POSIX conformance issue: if the WRDE_APPEND
    flag is specified, pwordexp->we_wordc and pwordexp->we_wordv shall
    not be modified.
    
    Also fix two pre-existing error return paths in the '"' and '\'' cases
    that returned directly from w_addword failures instead of going through
    do_error, which would leak the saved array (and previously would also
    skip the word cleanup).
    
    Checked on x86_64-linux-gnu and i686-linux-gnu.
    
    Reviewed-by: DJ Delorie <[email protected]>
    (cherry picked from commit e2cefe16c37a617df9f11407cb00a272a6098823)

Diff:
---
 posix/Makefile             |   1 +
 posix/tst-wordexp-append.c | 393 +++++++++++++++++++++++++++++++++++++++++++++
 posix/wordexp.c            |  69 ++++++--
 3 files changed, 454 insertions(+), 9 deletions(-)

diff --git a/posix/Makefile b/posix/Makefile
index c2c05b3973..5358a3f8c1 100644
--- a/posix/Makefile
+++ b/posix/Makefile
@@ -325,6 +325,7 @@ tests := \
   tst-wait3 \
   tst-wait4 \
   tst-waitid \
+  tst-wordexp-append \
   tst-wordexp-nocmd \
   tst-wordexp-reuse \
   tstgetopt \
diff --git a/posix/tst-wordexp-append.c b/posix/tst-wordexp-append.c
new file mode 100644
index 0000000000..87f388f0a7
--- /dev/null
+++ b/posix/tst-wordexp-append.c
@@ -0,0 +1,393 @@
+/* Test for wordexp with WRDE_APPEND flag.
+   Copyright (C) 2026 Free Software Foundation, Inc.
+   This file is part of the GNU C Library.
+
+   The GNU C Library is free software; you can redistribute it and/or
+   modify it under the terms of the GNU Lesser General Public
+   License as published by the Free Software Foundation; either
+   version 2.1 of the License, or (at your option) any later version.
+
+   The GNU C Library is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+   Lesser General Public License for more details.
+
+   You should have received a copy of the GNU Lesser General Public
+   License along with the GNU C Library; if not, see
+   <https://www.gnu.org/licenses/>.  */
+
+#include <wordexp.h>
+#include <string.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <malloc.h>
+
+#include <support/check.h>
+#include <support/support.h>
+
+static unsigned int relocating_reallocs;
+
+/* w_addword grows we_wordv with realloc, make every call guaranteed to
+   relocate the block.  This makes BZ 34090 regression more deterministic.  */
+void *
+realloc (void *ptr, size_t size)
+{
+  if (ptr == NULL)
+    return malloc (size);
+  if (size == 0)
+    {
+      free (ptr);
+      return NULL;
+    }
+
+  void *new = malloc (size);
+  if (new == NULL)
+    return NULL;
+
+  /* Copy only what is valid in the old block to avoid reading past it.  */
+  size_t old = malloc_usable_size (ptr);
+  memcpy (new, ptr, old < size ? old : size);
+  /* Clobber the old block so that a stale we_wordv pointer restored on the
+     error path reads garbage instead of the old contents, which might
+     otherwise survive intact and mask the bug.  */
+  memset (ptr, 0x5a, old);
+  free (ptr);
+  relocating_reallocs++;
+  return new;
+}
+
+/* Verify that all words in we match the expected NULL-terminated
+   array.  */
+static void
+check_words (const wordexp_t *we, const char *const *expected)
+{
+  size_t i;
+  for (i = 0; expected[i] != NULL; i++)
+    {
+      TEST_VERIFY (i < we->we_wordc);
+      TEST_COMPARE_STRING (we->we_wordv[we->we_offs + i], expected[i]);
+    }
+  TEST_COMPARE (we->we_wordc, i);
+}
+
+#define CHECK_WORDS(we, ...) \
+  do {								\
+    const char *const expected_[] = { __VA_ARGS__, NULL };	\
+    check_words (we, expected_);				\
+  } while (0)
+
+/* Test 1: WRDE_APPEND + WRDE_BADCHAR preserves we_wordc.  */
+static void
+test_append_badchar_preserves_count (void)
+{
+  printf ("info: test_append_badchar_preserves_count\n");
+  wordexp_t we = { 0 };
+
+  TEST_COMPARE (wordexp ("one two three", &we, 0), 0);
+  TEST_COMPARE (we.we_wordc, 3);
+
+  size_t saved_count = we.we_wordc;
+
+  /* ')' triggers WRDE_BADCHAR and  "extra" would be a new word if the
+     expansion succeeded, exercising the w_addword path before the error
+     is detected.  */
+  TEST_COMPARE (wordexp ("extra )", &we, WRDE_APPEND), WRDE_BADCHAR);
+  TEST_COMPARE (we.we_wordc, saved_count);
+
+  wordfree (&we);
+}
+
+/* Test 2: WRDE_APPEND + WRDE_BADCHAR preserves the we_wordv pointer even
+   when internal realloc would move the buffer.  */
+static void
+test_append_badchar_preserves_pointer (void)
+{
+  printf ("info: test_append_badchar_preserves_pointer\n");
+  wordexp_t we = { 0 };
+
+  /* Use many words so that the initial we_wordv allocation is
+     non-trivial and a later realloc is more likely to move it.  */
+  TEST_COMPARE (wordexp ("a b c d e f g h", &we, 0), 0);
+  TEST_COMPARE (we.we_wordc, 8);
+
+  char **saved_wordv = we.we_wordv;
+  size_t saved_count = we.we_wordc;
+  unsigned int saved_reallocs = relocating_reallocs;
+
+  /* The interposed realloc guarantees the internal we_wordv buffer moves
+     during parsing, so the pointer-stability check below is meaningful.  */
+  TEST_COMPARE (wordexp ("append )", &we, WRDE_APPEND), WRDE_BADCHAR);
+  /* Verify that a relocating realloc actually happened during the failed
+     call, otherwise the pointer-stability check is vacuous.  */
+  TEST_VERIFY (relocating_reallocs > saved_reallocs);
+  TEST_COMPARE (we.we_wordc, saved_count);
+  TEST_VERIFY (we.we_wordv == saved_wordv);
+
+  wordfree (&we);
+}
+
+/* Test 3: After a failed WRDE_APPEND the original words are still accessible
+   and correct.  */
+static void
+test_append_badchar_words_intact (void)
+{
+  printf ("info: test_append_badchar_words_intact\n");
+  wordexp_t we = { 0 };
+
+  TEST_COMPARE (wordexp ("alpha beta gamma", &we, 0), 0);
+  CHECK_WORDS (&we, "alpha", "beta", "gamma");
+
+  TEST_COMPARE (wordexp ("delta )", &we, WRDE_APPEND), WRDE_BADCHAR);
+
+  /* Words must still be intact.  */
+  CHECK_WORDS (&we, "alpha", "beta", "gamma");
+  /* The NULL terminator must still be present.  */
+  TEST_VERIFY (we.we_wordv[we.we_offs + we.we_wordc] == NULL);
+
+  wordfree (&we);
+}
+
+/* Test 4: Successful WRDE_APPEND still works (regression test).  */
+static void
+test_append_success (void)
+{
+  printf ("info: test_append_success\n");
+  wordexp_t we = { 0 };
+
+  TEST_COMPARE (wordexp ("hello", &we, 0), 0);
+  TEST_COMPARE (we.we_wordc, 1);
+
+  char **saved_wordv = we.we_wordv;
+
+  TEST_COMPARE (wordexp ("world", &we, WRDE_APPEND), 0);
+  TEST_COMPARE (we.we_wordc, 2);
+  /* A successful append works on a fresh copy of the array, so the
+     caller-visible pointer must have changed.  */
+  TEST_VERIFY (we.we_wordv != saved_wordv);
+  CHECK_WORDS (&we, "hello", "world");
+
+  wordfree (&we);
+}
+
+/* Test 5: Successful append after a failed append — the implementation must
+   recover and allow further use of the wordexp_t.  */
+static void
+test_append_success_after_failure (void)
+{
+  printf ("info: test_append_success_after_failure\n");
+  wordexp_t we = { 0 };
+
+  TEST_COMPARE (wordexp ("first", &we, 0), 0);
+  CHECK_WORDS (&we, "first");
+
+  TEST_COMPARE (wordexp ("bad |", &we, WRDE_APPEND), WRDE_BADCHAR);
+
+  /* State must be exactly as before the failed call.  */
+  CHECK_WORDS (&we, "first");
+
+  /* A subsequent successful append must work.  */
+  TEST_COMPARE (wordexp ("second third", &we, WRDE_APPEND), 0);
+  CHECK_WORDS (&we, "first", "second", "third");
+
+  wordfree (&we);
+}
+
+/* Test 6: Multiple consecutive failed appends do not corrupt state.  */
+static void
+test_append_multiple_failures (void)
+{
+  printf ("info: test_append_multiple_failures\n");
+  wordexp_t we = { 0 };
+
+  TEST_COMPARE (wordexp ("keep this", &we, 0), 0);
+  CHECK_WORDS (&we, "keep", "this");
+
+  size_t saved_count = we.we_wordc;
+  char **saved_wordv = we.we_wordv;
+
+  /* Each of these bad characters must leave the state unchanged.  */
+  TEST_COMPARE (wordexp ("x )", &we, WRDE_APPEND), WRDE_BADCHAR);
+  TEST_COMPARE (wordexp ("x |", &we, WRDE_APPEND), WRDE_BADCHAR);
+  TEST_COMPARE (wordexp ("x ;", &we, WRDE_APPEND), WRDE_BADCHAR);
+  TEST_COMPARE (wordexp ("x &", &we, WRDE_APPEND), WRDE_BADCHAR);
+  TEST_COMPARE (wordexp ("x <", &we, WRDE_APPEND), WRDE_BADCHAR);
+  TEST_COMPARE (wordexp ("x >", &we, WRDE_APPEND), WRDE_BADCHAR);
+
+  TEST_COMPARE (we.we_wordc, saved_count);
+  TEST_VERIFY (we.we_wordv == saved_wordv);
+  CHECK_WORDS (&we, "keep", "this");
+
+  wordfree (&we);
+}
+
+/* Test 7: WRDE_APPEND with WRDE_SYNTAX error (unterminated quote) also
+   preserves state.  */
+static void
+test_append_syntax_error (void)
+{
+  printf ("info: test_append_syntax_error\n");
+  wordexp_t we = { 0 };
+
+  TEST_COMPARE (wordexp ("original", &we, 0), 0);
+  CHECK_WORDS (&we, "original");
+
+  char **saved_wordv = we.we_wordv;
+  size_t saved_count = we.we_wordc;
+
+  /* Unterminated double quote triggers WRDE_SYNTAX.  */
+  TEST_COMPARE (wordexp ("\"unterminated", &we, WRDE_APPEND), WRDE_SYNTAX);
+
+  TEST_COMPARE (we.we_wordc, saved_count);
+  TEST_VERIFY (we.we_wordv == saved_wordv);
+  CHECK_WORDS (&we, "original");
+
+  wordfree (&we);
+}
+
+/* Test 8: Error without WRDE_APPEND still works (regression test for the
+   non-APPEND code path in do_error).  */
+static void
+test_no_append_error (void)
+{
+  printf ("info: test_no_append_error\n");
+  wordexp_t we = { 0 };
+
+  /* Simple failure without WRDE_APPEND.  */
+  TEST_COMPARE (wordexp ("bad |", &we, 0), WRDE_BADCHAR);
+
+  /* After failure without WRDE_APPEND the struct should be safe to
+     reuse — start fresh.  */
+  TEST_COMPARE (wordexp ("ok", &we, 0), 0);
+  CHECK_WORDS (&we, "ok");
+
+  wordfree (&we);
+}
+
+/* Test 9: WRDE_BADCHAR on the very first character (no partial words added
+   before the error).  */
+static void
+test_append_badchar_immediate (void)
+{
+  printf ("info: test_append_badchar_immediate\n");
+  wordexp_t we = { 0 };
+
+  TEST_COMPARE (wordexp ("hello world", &we, 0), 0);
+  CHECK_WORDS (&we, "hello", "world");
+
+  char **saved_wordv = we.we_wordv;
+  size_t saved_count = we.we_wordc;
+
+  /* The bad character is the very first byte — no w_addword call happens
+     before the error.  */
+  TEST_COMPARE (wordexp ("|", &we, WRDE_APPEND), WRDE_BADCHAR);
+  TEST_COMPARE (we.we_wordc, saved_count);
+  TEST_VERIFY (we.we_wordv == saved_wordv);
+
+  wordfree (&we);
+}
+
+/* Test 10: WRDE_APPEND into an empty wordexp_t (initial call uses WRDE_APPEND
+   with a zeroed struct — unusual but allowed).  */
+static void
+test_append_into_empty (void)
+{
+  printf ("info: test_append_into_empty\n");
+  wordexp_t we = { 0 };
+
+  /* First call with WRDE_APPEND on a zeroed struct.  The implementation
+     must handle we_wordv == NULL gracefully.  */
+  TEST_COMPARE (wordexp ("solo", &we, WRDE_APPEND), 0);
+  TEST_COMPARE (we.we_wordc, 1);
+  CHECK_WORDS (&we, "solo");
+
+  wordfree (&we);
+}
+
+/* Verify that the leading we_offs slots are all NULL.  */
+static void
+check_offs_null (const wordexp_t *we)
+{
+  for (size_t i = 0; i < we->we_offs; i++)
+    TEST_VERIFY (we->we_wordv[i] == NULL);
+}
+
+/* Test 11: successful WRDE_APPEND with WRDE_DOOFFS and a non-zero we_offs.
+   The leading offset slots must stay NULL and words must land at
+   we_wordv[we_offs + i] across both the initial and the appended call.  */
+static void
+test_dooffs_append_success (void)
+{
+  printf ("info: test_dooffs_append_success\n");
+  wordexp_t we = { 0 };
+  we.we_offs = 2;
+
+  TEST_COMPARE (wordexp ("one two", &we, WRDE_DOOFFS), 0);
+  TEST_COMPARE (we.we_offs, 2);
+  check_offs_null (&we);
+  CHECK_WORDS (&we, "one", "two");
+
+  TEST_COMPARE (wordexp ("three", &we, WRDE_APPEND | WRDE_DOOFFS), 0);
+  TEST_COMPARE (we.we_offs, 2);
+  check_offs_null (&we);
+  CHECK_WORDS (&we, "one", "two", "three");
+  /* The NULL terminator must sit right after the last word.  */
+  TEST_VERIFY (we.we_wordv[we.we_offs + we.we_wordc] == NULL);
+
+  wordfree (&we);
+}
+
+/* Test 12: failed WRDE_APPEND with WRDE_DOOFFS preserves we_wordc, the
+   we_wordv pointer, the words and the leading NULL offset slots.  This
+   exercises the we_offs arithmetic in the array duplication and in the
+   error-path cleanup (we_wordv[we_offs + --we_wordc]).  */
+static void
+test_dooffs_append_error_preserves_state (void)
+{
+  printf ("info: test_dooffs_append_error_preserves_state\n");
+  wordexp_t we = { 0 };
+  we.we_offs = 3;
+
+  TEST_COMPARE (wordexp ("alpha beta", &we, WRDE_DOOFFS), 0);
+  check_offs_null (&we);
+  CHECK_WORDS (&we, "alpha", "beta");
+
+  char **saved_wordv = we.we_wordv;
+  size_t saved_count = we.we_wordc;
+  unsigned int saved_reallocs = relocating_reallocs;
+
+  /* "gamma" is a partial word added via w_addword (forcing a relocating
+     realloc of we_wordv) before ')' triggers WRDE_BADCHAR.  */
+  TEST_COMPARE (wordexp ("gamma )", &we, WRDE_APPEND | WRDE_DOOFFS),
+		WRDE_BADCHAR);
+  TEST_VERIFY (relocating_reallocs > saved_reallocs);
+
+  TEST_COMPARE (we.we_offs, 3);
+  TEST_COMPARE (we.we_wordc, saved_count);
+  TEST_VERIFY (we.we_wordv == saved_wordv);
+  check_offs_null (&we);
+  CHECK_WORDS (&we, "alpha", "beta");
+  TEST_VERIFY (we.we_wordv[we.we_offs + we.we_wordc] == NULL);
+
+  wordfree (&we);
+}
+
+static int
+do_test (void)
+{
+  test_append_badchar_preserves_count ();
+  test_append_badchar_preserves_pointer ();
+  test_append_badchar_words_intact ();
+  test_append_success ();
+  test_append_success_after_failure ();
+  test_append_multiple_failures ();
+  test_append_syntax_error ();
+  test_no_append_error ();
+  test_append_badchar_immediate ();
+  test_append_into_empty ();
+  test_dooffs_append_success ();
+  test_dooffs_append_error_preserves_state ();
+
+  return 0;
+}
+
+#include <support/test-driver.c>
diff --git a/posix/wordexp.c b/posix/wordexp.c
index 731d1650e9..50b0d7a256 100644
--- a/posix/wordexp.c
+++ b/posix/wordexp.c
@@ -35,6 +35,7 @@
 #include <scratch_buffer.h>
 #include <_itoa.h>
 #include <assert.h>
+#include <intprops.h>
 
 /*
  * This is a recursive-descent-style word expansion routine.
@@ -2224,6 +2225,12 @@ wordexp (const char *words, wordexp_t *pwordexp, int flags)
   char ifs_white[4];
   wordexp_t old_word = *pwordexp;
 
+  /* When WRDE_APPEND is set we work on a copy of the we_wordv array so that
+     the caller's original pointer is never invalidated by realloc inside
+     w_addword.  The saved_wordv keeps the original; on success we free it,
+     on non-NOSPACE error we free the working copy and restore the original.  */
+  char **saved_wordv = NULL;
+
   if (flags & WRDE_REUSE)
     {
       /* Minimal implementation of WRDE_REUSE for now */
@@ -2258,6 +2265,23 @@ wordexp (const char *words, wordexp_t *pwordexp, int flags)
 	  pwordexp->we_offs = 0;
 	}
     }
+  else if (pwordexp->we_wordv != NULL)
+    {
+      /* WRDE_APPEND with an existing word list: duplicate the array so that
+	 realloc during parsing does not invalidate the caller's pointer.  The
+	 strings themselves are shared.  */
+      size_t num_p;
+      char **dup;
+      if (INT_ADD_WRAPV (pwordexp->we_offs, pwordexp->we_wordc, &num_p)
+	  || INT_ADD_WRAPV (num_p, 1, &num_p))
+	return WRDE_NOSPACE;
+      dup = __libc_reallocarray (NULL, num_p, sizeof *dup);
+      if (dup == NULL)
+	return WRDE_NOSPACE;
+      memcpy (dup, pwordexp->we_wordv, num_p * sizeof *dup);
+      saved_wordv = pwordexp->we_wordv;
+      pwordexp->we_wordv = dup;
+    }
 
   /* Find out what the field separators are.
    * There are two types: whitespace and non-whitespace.
@@ -2338,7 +2362,7 @@ wordexp (const char *words, wordexp_t *pwordexp, int flags)
 	    error = w_addword (pwordexp, NULL);
 
 	    if (error)
-	      return error;
+	      goto do_error;
 	  }
 
 	break;
@@ -2356,7 +2380,7 @@ wordexp (const char *words, wordexp_t *pwordexp, int flags)
 	    error = w_addword (pwordexp, NULL);
 
 	    if (error)
-	      return error;
+	      goto do_error;
 	  }
 
 	break;
@@ -2422,10 +2446,18 @@ wordexp (const char *words, wordexp_t *pwordexp, int flags)
 
   /* There was a word separator at the end */
   if (word == NULL) /* i.e. w_newword */
-    return 0;
+    {
+      free (saved_wordv);
+      return 0;
+    }
 
-  /* There was no field separator at the end */
-  return w_addword (pwordexp, word);
+  /* There was no field separator at the end.  The only possible error
+     from w_addword is WRDE_NOSPACE.  */
+  error = w_addword (pwordexp, word);
+  if (error != 0)
+    goto do_error;
+  free (saved_wordv);
+  return 0;
 
 do_error:
   /* Error:
@@ -2436,11 +2468,30 @@ do_error:
   free (word);
 
   if (error == WRDE_NOSPACE)
-    return WRDE_NOSPACE;
+    {
+      /* we_wordc and we_wordv are updated to reflect any words that were
+	 successfully expanded.  The old array is obsolete.  */
+      free (saved_wordv);
+      return WRDE_NOSPACE;
+    }
 
-  if ((flags & WRDE_APPEND) == 0)
-    wordfree (pwordexp);
+  if (flags & WRDE_APPEND)
+    {
+      /* POSIX 2024 states that for in other error cases, if the WRDE_APPEND
+	 flag was specified, we_wordc and we_wordv shall not be modified.
+
+	 Free strings appended during this call, discard the working copy of
+	 we_wordv, and restore the caller's original pointer.  */
+      while (pwordexp->we_wordc > old_word.we_wordc)
+	free (pwordexp->we_wordv[pwordexp->we_offs + --pwordexp->we_wordc]);
+      free (pwordexp->we_wordv);
+      pwordexp->we_wordv = saved_wordv;
+    }
+  else
+    {
+      wordfree (pwordexp);
+      *pwordexp = old_word;
+    }
 
-  *pwordexp = old_word;
   return error;
 }
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.