posix: Fix stack overflow in wordexp tilde expansion (BZ 34091, CVE-2026-6791)

Message ID 20260422123538.889818-1-adhemerval.zanella@linaro.org (mailing list archive)
State Under Review
Delegated to: Carlos O'Donell
Headers
Series posix: Fix stack overflow in wordexp tilde expansion (BZ 34091, CVE-2026-6791) |

Checks

Context Check Description
redhat-pt-bot/TryBot-apply_patch success Patch applied to master at the time it was sent
redhat-pt-bot/TryBot-32bit success Build for i686

Commit Message

Adhemerval Zanella April 22, 2026, 12:35 p.m. UTC
  The parse_tilde function previously used strndupa to allocate memory
for the parsed username on the stack, and since the input is
user-defined, this can lead to a stack overflow.

This patch fixes the issue by replacing strndupa with scratch_buffer,
by reusing the buffer used in the __getpwnam_r call.

The new “tst-wordexp-tilde.c” test is a test-container to avoid using
system-defined NSS modules.

Checked on x86_64-linux-gnu and i686-linux-gnu.
---
 posix/Makefile                                |   1 +
 posix/tst-wordexp-tilde.c                     | 244 ++++++++++++++++++
 posix/tst-wordexp-tilde.root/etc/group        |   1 +
 .../tst-wordexp-tilde.root/etc/nsswitch.conf  |   3 +
 posix/tst-wordexp-tilde.root/etc/passwd       |   1 +
 posix/wordexp.c                               |  28 +-
 6 files changed, 270 insertions(+), 8 deletions(-)
 create mode 100644 posix/tst-wordexp-tilde.c
 create mode 100644 posix/tst-wordexp-tilde.root/etc/group
 create mode 100644 posix/tst-wordexp-tilde.root/etc/nsswitch.conf
 create mode 100644 posix/tst-wordexp-tilde.root/etc/passwd
  

Comments

Florian Weimer April 22, 2026, 12:46 p.m. UTC | #1
* Adhemerval Zanella:

> +      /* tmpbuf contains both the user and the __getpwnam_r working area.  */
>        struct scratch_buffer tmpbuf;
>        scratch_buffer_init (&tmpbuf);
> +      if (!scratch_buffer_set_array_size (&tmpbuf, userlen + 1, 1))
> +	return WRDE_NOSPACE;
> +      char *user = tmpbuf.data;
> +      memcpy (user, &words[1 + *offset], userlen);
> +      user[userlen] = '\0';
>  
> +      struct passwd pwd, *tpwd;
> +      int result;
> +      while ((result = __getpwnam_r (user,
> +				     &pwd,
> +				     tmpbuf.data + userlen + 1,
> +				     tmpbuf.length - userlen - 1,
> +				     &tpwd)

While this is technically correct, this looks like a bit of overkill.
Maybe just use __strndup?  There is no reason to optimize this with an
on-stack allocation.

Thanks,
Florian
  
Adhemerval Zanella April 22, 2026, 1:02 p.m. UTC | #2
On 22/04/26 09:46, Florian Weimer wrote:
> * Adhemerval Zanella:
> 
>> +      /* tmpbuf contains both the user and the __getpwnam_r working area.  */
>>        struct scratch_buffer tmpbuf;
>>        scratch_buffer_init (&tmpbuf);
>> +      if (!scratch_buffer_set_array_size (&tmpbuf, userlen + 1, 1))
>> +	return WRDE_NOSPACE;
>> +      char *user = tmpbuf.data;
>> +      memcpy (user, &words[1 + *offset], userlen);
>> +      user[userlen] = '\0';
>>  
>> +      struct passwd pwd, *tpwd;
>> +      int result;
>> +      while ((result = __getpwnam_r (user,
>> +				     &pwd,
>> +				     tmpbuf.data + userlen + 1,
>> +				     tmpbuf.length - userlen - 1,
>> +				     &tpwd)
> 
> While this is technically correct, this looks like a bit of overkill.
> Maybe just use __strndup?  There is no reason to optimize this with an
> on-stack allocation.

It keeps the same performance characteristic and is one less buffer to manage
(cleanup is done exclusive by scratch_buffer_free).
  
Florian Weimer April 22, 2026, 1:07 p.m. UTC | #3
* Adhemerval Zanella Netto:

> On 22/04/26 09:46, Florian Weimer wrote:
>> * Adhemerval Zanella:
>> 
>>> +      /* tmpbuf contains both the user and the __getpwnam_r working area.  */
>>>        struct scratch_buffer tmpbuf;
>>>        scratch_buffer_init (&tmpbuf);
>>> +      if (!scratch_buffer_set_array_size (&tmpbuf, userlen + 1, 1))
>>> +	return WRDE_NOSPACE;
>>> +      char *user = tmpbuf.data;
>>> +      memcpy (user, &words[1 + *offset], userlen);
>>> +      user[userlen] = '\0';
>>>  
>>> +      struct passwd pwd, *tpwd;
>>> +      int result;
>>> +      while ((result = __getpwnam_r (user,
>>> +				     &pwd,
>>> +				     tmpbuf.data + userlen + 1,
>>> +				     tmpbuf.length - userlen - 1,
>>> +				     &tpwd)
>> 
>> While this is technically correct, this looks like a bit of overkill.
>> Maybe just use __strndup?  There is no reason to optimize this with an
>> on-stack allocation.
>
> It keeps the same performance characteristic and is one less buffer to manage
> (cleanup is done exclusive by scratch_buffer_free). 

The cost is that it turns a standard _r retry loop into a non-standard
one.

I don't have a strong opinion about this.  I hope to get rid of internal
use of those _r functions eventually, at which point we can switch this
to strndup.

Thanks,
Florian
  
Adhemerval Zanella April 22, 2026, 1:10 p.m. UTC | #4
On 22/04/26 10:07, Florian Weimer wrote:
> * Adhemerval Zanella Netto:
> 
>> On 22/04/26 09:46, Florian Weimer wrote:
>>> * Adhemerval Zanella:
>>>
>>>> +      /* tmpbuf contains both the user and the __getpwnam_r working area.  */
>>>>        struct scratch_buffer tmpbuf;
>>>>        scratch_buffer_init (&tmpbuf);
>>>> +      if (!scratch_buffer_set_array_size (&tmpbuf, userlen + 1, 1))
>>>> +	return WRDE_NOSPACE;
>>>> +      char *user = tmpbuf.data;
>>>> +      memcpy (user, &words[1 + *offset], userlen);
>>>> +      user[userlen] = '\0';
>>>>  
>>>> +      struct passwd pwd, *tpwd;
>>>> +      int result;
>>>> +      while ((result = __getpwnam_r (user,
>>>> +				     &pwd,
>>>> +				     tmpbuf.data + userlen + 1,
>>>> +				     tmpbuf.length - userlen - 1,
>>>> +				     &tpwd)
>>>
>>> While this is technically correct, this looks like a bit of overkill.
>>> Maybe just use __strndup?  There is no reason to optimize this with an
>>> on-stack allocation.
>>
>> It keeps the same performance characteristic and is one less buffer to manage
>> (cleanup is done exclusive by scratch_buffer_free). 
> 
> The cost is that it turns a standard _r retry loop into a non-standard
> one.
> 

The extra cost is from scratch_buffer_grow_preserve, but since this should
be not the usual path I don't think this is a problem.  The wordexp
already uses a lot of dynamic allocation, I think we should try to avoid
one extra if possible.

> I don't have a strong opinion about this.  I hope to get rid of internal
> use of those _r functions eventually, at which point we can switch this
> to strndup.
> 
> Thanks,
> Florian
>
  
Florian Weimer April 27, 2026, 1:46 p.m. UTC | #5
* Adhemerval Zanella Netto:

> On 22/04/26 10:07, Florian Weimer wrote:
>> * Adhemerval Zanella Netto:
>> 
>>> On 22/04/26 09:46, Florian Weimer wrote:
>>>> * Adhemerval Zanella:
>>>>
>>>>> +      /* tmpbuf contains both the user and the __getpwnam_r working area.  */
>>>>>        struct scratch_buffer tmpbuf;
>>>>>        scratch_buffer_init (&tmpbuf);
>>>>> +      if (!scratch_buffer_set_array_size (&tmpbuf, userlen + 1, 1))
>>>>> +	return WRDE_NOSPACE;
>>>>> +      char *user = tmpbuf.data;
>>>>> +      memcpy (user, &words[1 + *offset], userlen);
>>>>> +      user[userlen] = '\0';
>>>>>  
>>>>> +      struct passwd pwd, *tpwd;
>>>>> +      int result;
>>>>> +      while ((result = __getpwnam_r (user,
>>>>> +				     &pwd,
>>>>> +				     tmpbuf.data + userlen + 1,
>>>>> +				     tmpbuf.length - userlen - 1,
>>>>> +				     &tpwd)
>>>>
>>>> While this is technically correct, this looks like a bit of overkill.
>>>> Maybe just use __strndup?  There is no reason to optimize this with an
>>>> on-stack allocation.
>>>
>>> It keeps the same performance characteristic and is one less buffer to manage
>>> (cleanup is done exclusive by scratch_buffer_free). 
>> 
>> The cost is that it turns a standard _r retry loop into a non-standard
>> one.
>> 
>
> The extra cost is from scratch_buffer_grow_preserve, but since this should
> be not the usual path I don't think this is a problem.  The wordexp
> already uses a lot of dynamic allocation, I think we should try to avoid
> one extra if possible.

Not an objection from me then.  We can change it to something else once
the need arises.

Thanks,
Florian
  
Adhemerval Zanella May 18, 2026, 4:32 p.m. UTC | #6
Ping.

On 22/04/26 09:35, Adhemerval Zanella wrote:
> The parse_tilde function previously used strndupa to allocate memory
> for the parsed username on the stack, and since the input is
> user-defined, this can lead to a stack overflow.
> 
> This patch fixes the issue by replacing strndupa with scratch_buffer,
> by reusing the buffer used in the __getpwnam_r call.
> 
> The new “tst-wordexp-tilde.c” test is a test-container to avoid using
> system-defined NSS modules.
> 
> Checked on x86_64-linux-gnu and i686-linux-gnu.
> ---
>  posix/Makefile                                |   1 +
>  posix/tst-wordexp-tilde.c                     | 244 ++++++++++++++++++
>  posix/tst-wordexp-tilde.root/etc/group        |   1 +
>  .../tst-wordexp-tilde.root/etc/nsswitch.conf  |   3 +
>  posix/tst-wordexp-tilde.root/etc/passwd       |   1 +
>  posix/wordexp.c                               |  28 +-
>  6 files changed, 270 insertions(+), 8 deletions(-)
>  create mode 100644 posix/tst-wordexp-tilde.c
>  create mode 100644 posix/tst-wordexp-tilde.root/etc/group
>  create mode 100644 posix/tst-wordexp-tilde.root/etc/nsswitch.conf
>  create mode 100644 posix/tst-wordexp-tilde.root/etc/passwd
> 
> diff --git a/posix/Makefile b/posix/Makefile
> index a5e5162c61..b2de242321 100644
> --- a/posix/Makefile
> +++ b/posix/Makefile
> @@ -359,6 +359,7 @@ tests-internal := \
>  tests-container := \
>    bug-ga2 \
>    tst-vfork3 \
> +  tst-wordexp-tilde \
>    # tests-container
>  
>  tests-time64 := \
> diff --git a/posix/tst-wordexp-tilde.c b/posix/tst-wordexp-tilde.c
> new file mode 100644
> index 0000000000..35f200a0b7
> --- /dev/null
> +++ b/posix/tst-wordexp-tilde.c
> @@ -0,0 +1,244 @@
> +/* Test wordexp tilde expansion with large usernames (BZ #XXXXX).
> +   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 <pwd.h>
> +#include <stdio.h>
> +#include <string.h>
> +#include <wordexp.h>
> +#include <stdlib.h>
> +#include <sys/resource.h>
> +
> +#include <support/check.h>
> +#include <support/support.h>
> +#include <support/xunistd.h>
> +#include <support/namespace.h>
> +
> +typedef void (*func_callback_t)(void);
> +
> +static void
> +subprocess_small_stack (void *closure)
> +{
> +  struct rlimit rl;
> +  TEST_COMPARE (getrlimit (RLIMIT_STACK, &rl), 0);
> +  rl.rlim_cur = 512 * 1024;
> +  TEST_COMPARE (setrlimit (RLIMIT_STACK, &rl), 0);
> +
> +  func_callback_t func_test = closure;
> +  func_test ();
> +}
> +
> +/* Build a string "~<padding>/tail" where <padding> is LEN bytes of the
> +   character CH.  The caller must free the result.  */
> +static char *
> +make_tilde_input (char ch, size_t len, const char *tail)
> +{
> +  /* ~  +  len  +  /  +  tail  +  \0 */
> +  size_t taillen = tail != NULL ? strlen (tail) : 0;
> +  size_t total = 1 + len + 1 + taillen + 1;
> +  char *buf = xmalloc (total);
> +  buf[0] = '~';
> +  memset (buf + 1, ch, len);
> +  buf[1 + len] = '/';
> +  if (tail != NULL)
> +    memcpy (buf + 1 + len + 1, tail, taillen);
> +  buf[total - 1] = '\0';
> +  return buf;
> +}
> +
> +/* Test 1: A very long username must not crash.  The username will not match
> +   any real user, so wordexp returns ~<long>/rest.  */
> +static void
> +test_long_username (void)
> +{
> +  printf ("info: test_long_username_no_crash\n");
> +
> +  static const char REST[] = "rest";
> +
> +  /* 1 MiB username — well beyond any reasonable stack frame.  */
> +  const size_t long_len = 1024 * 1024;
> +  char *input = make_tilde_input ('A', long_len, REST);
> +
> +  wordexp_t we = { 0 };
> +  int ret = wordexp (input, &we, 0);
> +  /* The (non-existent) username is invalid, so wordexp falls back to
> +     literal output: ~AAA…/rest.  */
> +  TEST_COMPARE (ret, 0);
> +  TEST_COMPARE (we.we_wordc, 1);
> +
> +  /* Verify prefix: '~' followed by long_len 'A's.  */
> +  const char *result = we.we_wordv[0];
> +  TEST_COMPARE (result[0], '~');
> +  TEST_COMPARE (strlen (result),
> +	        1 /* ~ */ + long_len + sizeof (REST));
> +  for (size_t j = 1; j <= long_len; j++)
> +    if (result[j] != 'A')
> +      {
> +	printf ("  mismatch at position %zu: expected 'A', got '%c'\n",
> +		j, result[j]);
> +	support_record_failure ();
> +	break;
> +      }
> +  /* Verify the tail after the username.  */
> +  TEST_COMPARE_STRING (result + 1 + long_len, "/rest");
> +
> +  wordfree (&we);
> +  free (input);
> +}
> +
> +/* Test 2: A username that just exceeds the default scratch_buffer inline
> +   size (1024 bytes) exercises the scratch_buffer_set_array_size growth path
> +   without being excessively large.  */
> +static void
> +test_scratch_buffer_growth (void)
> +{
> +  printf ("info: test_scratch_buffer_growth\n");
> +
> +  const size_t len = 2048;
> +  char *input = make_tilde_input ('x', len, NULL);
> +
> +  wordexp_t we = { 0 };
> +  int ret = wordexp (input, &we, 0);
> +  TEST_COMPARE (ret, 0);
> +  TEST_COMPARE (we.we_wordc, 1);
> +
> +  /* ~xxx…/ — the trailing slash makes a separate empty component, but
> +     wordexp merges it into the single token ~xxx…/.  */
> +  const char *result = we.we_wordv[0];
> +  TEST_COMPARE (result[0], '~');
> +  for (size_t j = 1; j <= len; j++)
> +    if (result[j] != 'x')
> +      {
> +	printf ("  mismatch at position %zu\n", j);
> +	support_record_failure ();
> +	break;
> +      }
> +  TEST_COMPARE (result[1 + len], '/');
> +
> +  wordfree (&we);
> +  free (input);
> +}
> +
> +/* Test 3: ~root still resolves to the correct home directory through the
> +   __getpwnam_r path.  */
> +static void
> +test_known_user (void)
> +{
> +  printf ("info: test_known_user\n");
> +
> +  /* Look up root's home directory for comparison.  */
> +  struct passwd *pw = getpwnam ("root");
> +  if (pw == NULL || pw->pw_dir == NULL)
> +    {
> +      printf ("  SKIP: cannot look up root\n");
> +      return;
> +    }
> +
> +  char *expected = xasprintf ("%s/file", pw->pw_dir);
> +
> +  wordexp_t we = { 0 };
> +  TEST_COMPARE (wordexp ("~root/file", &we, 0), 0);
> +  TEST_COMPARE (we.we_wordc, 1);
> +  TEST_COMPARE_STRING (we.we_wordv[0], expected);
> +
> +  wordfree (&we);
> +  free (expected);
> +}
> +
> +/* Test 4: Bare tilde expands to $HOME.  */
> +static void
> +test_bare_tilde (void)
> +{
> +  printf ("info: test_bare_tilde\n");
> +
> +  const char *home = getenv ("HOME");
> +  if (home == NULL)
> +    {
> +      printf ("  SKIP: HOME is not set\n");
> +      return;
> +    }
> +
> +  wordexp_t we = { 0 };
> +  TEST_COMPARE (wordexp ("~", &we, 0), 0);
> +  TEST_COMPARE (we.we_wordc, 1);
> +  TEST_COMPARE_STRING (we.we_wordv[0], home);
> +
> +  wordfree (&we);
> +}
> +
> +/* Test 5: Short non-existent username falls back to literal ~username output,
> +   exercising the invalid-login-name path.  */
> +static void
> +test_unknown_user (void)
> +{
> +  printf ("info: test_unknown_user\n");
> +
> +  /* Pick a username that is extremely unlikely to exist.  */
> +  wordexp_t we = { 0 };
> +  TEST_COMPARE (wordexp ("~no_such_user_xyzzy42", &we, 0), 0);
> +  TEST_COMPARE (we.we_wordc, 1);
> +  TEST_COMPARE_STRING (we.we_wordv[0], "~no_such_user_xyzzy42");
> +
> +  wordfree (&we);
> +}
> +
> +/* Test 6: Tilde with username and WRDE_APPEND — exercises parse_tilde's
> +   interaction with the WRDE_APPEND word list.  */
> +static void
> +test_tilde_with_append (void)
> +{
> +  printf ("info: test_tilde_with_append\n");
> +
> +  const char *home = getenv ("HOME");
> +  if (home == NULL)
> +    {
> +      printf ("  SKIP: HOME is not set\n");
> +      return;
> +    }
> +
> +  wordexp_t we = { 0 };
> +  TEST_COMPARE (wordexp ("first", &we, 0), 0);
> +
> +  TEST_COMPARE (wordexp ("~/path", &we, WRDE_APPEND), 0);
> +  TEST_COMPARE (we.we_wordc, 2);
> +  TEST_COMPARE_STRING (we.we_wordv[0], "first");
> +
> +  char *expected = xasprintf ("%s/path", home);
> +  TEST_COMPARE_STRING (we.we_wordv[1], expected);
> +
> +  wordfree (&we);
> +  free (expected);
> +}
> +
> +static int
> +do_test (void)
> +{
> +  test_known_user ();
> +  test_bare_tilde ();
> +  test_unknown_user ();
> +  test_tilde_with_append ();
> +
> +  support_isolate_in_subprocess (subprocess_small_stack,
> +				 test_long_username);
> +
> +  support_isolate_in_subprocess (subprocess_small_stack,
> +				 test_scratch_buffer_growth);
> +
> +  return 0;
> +}
> +
> +#include <support/test-driver.c>
> diff --git a/posix/tst-wordexp-tilde.root/etc/group b/posix/tst-wordexp-tilde.root/etc/group
> new file mode 100644
> index 0000000000..1dbf9013ee
> --- /dev/null
> +++ b/posix/tst-wordexp-tilde.root/etc/group
> @@ -0,0 +1 @@
> +root:x:0:
> diff --git a/posix/tst-wordexp-tilde.root/etc/nsswitch.conf b/posix/tst-wordexp-tilde.root/etc/nsswitch.conf
> new file mode 100644
> index 0000000000..098a8d5938
> --- /dev/null
> +++ b/posix/tst-wordexp-tilde.root/etc/nsswitch.conf
> @@ -0,0 +1,3 @@
> +passwd: files
> +group:  files
> +shadow: files
> diff --git a/posix/tst-wordexp-tilde.root/etc/passwd b/posix/tst-wordexp-tilde.root/etc/passwd
> new file mode 100644
> index 0000000000..eb85a552ad
> --- /dev/null
> +++ b/posix/tst-wordexp-tilde.root/etc/passwd
> @@ -0,0 +1 @@
> +root:x:0:0:root:/root:/bin/sh
> diff --git a/posix/wordexp.c b/posix/wordexp.c
> index 4a8541add4..a6b9391906 100644
> --- a/posix/wordexp.c
> +++ b/posix/wordexp.c
> @@ -335,17 +335,29 @@ parse_tilde (char **word, size_t *word_length, size_t *max_length,
>    else
>      {
>        /* Look up user name in database to get home directory */
> -      char *user = strndupa (&words[1 + *offset], i - (1 + *offset));
> -      struct passwd pwd, *tpwd;
> -      int result;
> +      size_t userlen = i - (1 + *offset);
> +      /* tmpbuf contains both the user and the __getpwnam_r working area.  */
>        struct scratch_buffer tmpbuf;
>        scratch_buffer_init (&tmpbuf);
> +      if (!scratch_buffer_set_array_size (&tmpbuf, userlen + 1, 1))
> +	return WRDE_NOSPACE;
> +      char *user = tmpbuf.data;
> +      memcpy (user, &words[1 + *offset], userlen);
> +      user[userlen] = '\0';
>  
> -      while ((result = __getpwnam_r (user, &pwd, tmpbuf.data, tmpbuf.length,
> -				     &tpwd)) != 0
> -	     && errno == ERANGE)
> -	if (!scratch_buffer_grow (&tmpbuf))
> -	  return WRDE_NOSPACE;
> +      struct passwd pwd, *tpwd;
> +      int result;
> +      while ((result = __getpwnam_r (user,
> +				     &pwd,
> +				     tmpbuf.data + userlen + 1,
> +				     tmpbuf.length - userlen - 1,
> +				     &tpwd)
> +	      != 0))
> +	{
> +	  if (!scratch_buffer_grow_preserve (&tmpbuf))
> +	    return WRDE_NOSPACE;
> +	  user = tmpbuf.data;
> +	}
>  
>        if (result == 0 && tpwd != NULL && pwd.pw_dir)
>  	*word = w_addstr (*word, word_length, max_length, pwd.pw_dir);
  

Patch

diff --git a/posix/Makefile b/posix/Makefile
index a5e5162c61..b2de242321 100644
--- a/posix/Makefile
+++ b/posix/Makefile
@@ -359,6 +359,7 @@  tests-internal := \
 tests-container := \
   bug-ga2 \
   tst-vfork3 \
+  tst-wordexp-tilde \
   # tests-container
 
 tests-time64 := \
diff --git a/posix/tst-wordexp-tilde.c b/posix/tst-wordexp-tilde.c
new file mode 100644
index 0000000000..35f200a0b7
--- /dev/null
+++ b/posix/tst-wordexp-tilde.c
@@ -0,0 +1,244 @@ 
+/* Test wordexp tilde expansion with large usernames (BZ #XXXXX).
+   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 <pwd.h>
+#include <stdio.h>
+#include <string.h>
+#include <wordexp.h>
+#include <stdlib.h>
+#include <sys/resource.h>
+
+#include <support/check.h>
+#include <support/support.h>
+#include <support/xunistd.h>
+#include <support/namespace.h>
+
+typedef void (*func_callback_t)(void);
+
+static void
+subprocess_small_stack (void *closure)
+{
+  struct rlimit rl;
+  TEST_COMPARE (getrlimit (RLIMIT_STACK, &rl), 0);
+  rl.rlim_cur = 512 * 1024;
+  TEST_COMPARE (setrlimit (RLIMIT_STACK, &rl), 0);
+
+  func_callback_t func_test = closure;
+  func_test ();
+}
+
+/* Build a string "~<padding>/tail" where <padding> is LEN bytes of the
+   character CH.  The caller must free the result.  */
+static char *
+make_tilde_input (char ch, size_t len, const char *tail)
+{
+  /* ~  +  len  +  /  +  tail  +  \0 */
+  size_t taillen = tail != NULL ? strlen (tail) : 0;
+  size_t total = 1 + len + 1 + taillen + 1;
+  char *buf = xmalloc (total);
+  buf[0] = '~';
+  memset (buf + 1, ch, len);
+  buf[1 + len] = '/';
+  if (tail != NULL)
+    memcpy (buf + 1 + len + 1, tail, taillen);
+  buf[total - 1] = '\0';
+  return buf;
+}
+
+/* Test 1: A very long username must not crash.  The username will not match
+   any real user, so wordexp returns ~<long>/rest.  */
+static void
+test_long_username (void)
+{
+  printf ("info: test_long_username_no_crash\n");
+
+  static const char REST[] = "rest";
+
+  /* 1 MiB username — well beyond any reasonable stack frame.  */
+  const size_t long_len = 1024 * 1024;
+  char *input = make_tilde_input ('A', long_len, REST);
+
+  wordexp_t we = { 0 };
+  int ret = wordexp (input, &we, 0);
+  /* The (non-existent) username is invalid, so wordexp falls back to
+     literal output: ~AAA…/rest.  */
+  TEST_COMPARE (ret, 0);
+  TEST_COMPARE (we.we_wordc, 1);
+
+  /* Verify prefix: '~' followed by long_len 'A's.  */
+  const char *result = we.we_wordv[0];
+  TEST_COMPARE (result[0], '~');
+  TEST_COMPARE (strlen (result),
+	        1 /* ~ */ + long_len + sizeof (REST));
+  for (size_t j = 1; j <= long_len; j++)
+    if (result[j] != 'A')
+      {
+	printf ("  mismatch at position %zu: expected 'A', got '%c'\n",
+		j, result[j]);
+	support_record_failure ();
+	break;
+      }
+  /* Verify the tail after the username.  */
+  TEST_COMPARE_STRING (result + 1 + long_len, "/rest");
+
+  wordfree (&we);
+  free (input);
+}
+
+/* Test 2: A username that just exceeds the default scratch_buffer inline
+   size (1024 bytes) exercises the scratch_buffer_set_array_size growth path
+   without being excessively large.  */
+static void
+test_scratch_buffer_growth (void)
+{
+  printf ("info: test_scratch_buffer_growth\n");
+
+  const size_t len = 2048;
+  char *input = make_tilde_input ('x', len, NULL);
+
+  wordexp_t we = { 0 };
+  int ret = wordexp (input, &we, 0);
+  TEST_COMPARE (ret, 0);
+  TEST_COMPARE (we.we_wordc, 1);
+
+  /* ~xxx…/ — the trailing slash makes a separate empty component, but
+     wordexp merges it into the single token ~xxx…/.  */
+  const char *result = we.we_wordv[0];
+  TEST_COMPARE (result[0], '~');
+  for (size_t j = 1; j <= len; j++)
+    if (result[j] != 'x')
+      {
+	printf ("  mismatch at position %zu\n", j);
+	support_record_failure ();
+	break;
+      }
+  TEST_COMPARE (result[1 + len], '/');
+
+  wordfree (&we);
+  free (input);
+}
+
+/* Test 3: ~root still resolves to the correct home directory through the
+   __getpwnam_r path.  */
+static void
+test_known_user (void)
+{
+  printf ("info: test_known_user\n");
+
+  /* Look up root's home directory for comparison.  */
+  struct passwd *pw = getpwnam ("root");
+  if (pw == NULL || pw->pw_dir == NULL)
+    {
+      printf ("  SKIP: cannot look up root\n");
+      return;
+    }
+
+  char *expected = xasprintf ("%s/file", pw->pw_dir);
+
+  wordexp_t we = { 0 };
+  TEST_COMPARE (wordexp ("~root/file", &we, 0), 0);
+  TEST_COMPARE (we.we_wordc, 1);
+  TEST_COMPARE_STRING (we.we_wordv[0], expected);
+
+  wordfree (&we);
+  free (expected);
+}
+
+/* Test 4: Bare tilde expands to $HOME.  */
+static void
+test_bare_tilde (void)
+{
+  printf ("info: test_bare_tilde\n");
+
+  const char *home = getenv ("HOME");
+  if (home == NULL)
+    {
+      printf ("  SKIP: HOME is not set\n");
+      return;
+    }
+
+  wordexp_t we = { 0 };
+  TEST_COMPARE (wordexp ("~", &we, 0), 0);
+  TEST_COMPARE (we.we_wordc, 1);
+  TEST_COMPARE_STRING (we.we_wordv[0], home);
+
+  wordfree (&we);
+}
+
+/* Test 5: Short non-existent username falls back to literal ~username output,
+   exercising the invalid-login-name path.  */
+static void
+test_unknown_user (void)
+{
+  printf ("info: test_unknown_user\n");
+
+  /* Pick a username that is extremely unlikely to exist.  */
+  wordexp_t we = { 0 };
+  TEST_COMPARE (wordexp ("~no_such_user_xyzzy42", &we, 0), 0);
+  TEST_COMPARE (we.we_wordc, 1);
+  TEST_COMPARE_STRING (we.we_wordv[0], "~no_such_user_xyzzy42");
+
+  wordfree (&we);
+}
+
+/* Test 6: Tilde with username and WRDE_APPEND — exercises parse_tilde's
+   interaction with the WRDE_APPEND word list.  */
+static void
+test_tilde_with_append (void)
+{
+  printf ("info: test_tilde_with_append\n");
+
+  const char *home = getenv ("HOME");
+  if (home == NULL)
+    {
+      printf ("  SKIP: HOME is not set\n");
+      return;
+    }
+
+  wordexp_t we = { 0 };
+  TEST_COMPARE (wordexp ("first", &we, 0), 0);
+
+  TEST_COMPARE (wordexp ("~/path", &we, WRDE_APPEND), 0);
+  TEST_COMPARE (we.we_wordc, 2);
+  TEST_COMPARE_STRING (we.we_wordv[0], "first");
+
+  char *expected = xasprintf ("%s/path", home);
+  TEST_COMPARE_STRING (we.we_wordv[1], expected);
+
+  wordfree (&we);
+  free (expected);
+}
+
+static int
+do_test (void)
+{
+  test_known_user ();
+  test_bare_tilde ();
+  test_unknown_user ();
+  test_tilde_with_append ();
+
+  support_isolate_in_subprocess (subprocess_small_stack,
+				 test_long_username);
+
+  support_isolate_in_subprocess (subprocess_small_stack,
+				 test_scratch_buffer_growth);
+
+  return 0;
+}
+
+#include <support/test-driver.c>
diff --git a/posix/tst-wordexp-tilde.root/etc/group b/posix/tst-wordexp-tilde.root/etc/group
new file mode 100644
index 0000000000..1dbf9013ee
--- /dev/null
+++ b/posix/tst-wordexp-tilde.root/etc/group
@@ -0,0 +1 @@ 
+root:x:0:
diff --git a/posix/tst-wordexp-tilde.root/etc/nsswitch.conf b/posix/tst-wordexp-tilde.root/etc/nsswitch.conf
new file mode 100644
index 0000000000..098a8d5938
--- /dev/null
+++ b/posix/tst-wordexp-tilde.root/etc/nsswitch.conf
@@ -0,0 +1,3 @@ 
+passwd: files
+group:  files
+shadow: files
diff --git a/posix/tst-wordexp-tilde.root/etc/passwd b/posix/tst-wordexp-tilde.root/etc/passwd
new file mode 100644
index 0000000000..eb85a552ad
--- /dev/null
+++ b/posix/tst-wordexp-tilde.root/etc/passwd
@@ -0,0 +1 @@ 
+root:x:0:0:root:/root:/bin/sh
diff --git a/posix/wordexp.c b/posix/wordexp.c
index 4a8541add4..a6b9391906 100644
--- a/posix/wordexp.c
+++ b/posix/wordexp.c
@@ -335,17 +335,29 @@  parse_tilde (char **word, size_t *word_length, size_t *max_length,
   else
     {
       /* Look up user name in database to get home directory */
-      char *user = strndupa (&words[1 + *offset], i - (1 + *offset));
-      struct passwd pwd, *tpwd;
-      int result;
+      size_t userlen = i - (1 + *offset);
+      /* tmpbuf contains both the user and the __getpwnam_r working area.  */
       struct scratch_buffer tmpbuf;
       scratch_buffer_init (&tmpbuf);
+      if (!scratch_buffer_set_array_size (&tmpbuf, userlen + 1, 1))
+	return WRDE_NOSPACE;
+      char *user = tmpbuf.data;
+      memcpy (user, &words[1 + *offset], userlen);
+      user[userlen] = '\0';
 
-      while ((result = __getpwnam_r (user, &pwd, tmpbuf.data, tmpbuf.length,
-				     &tpwd)) != 0
-	     && errno == ERANGE)
-	if (!scratch_buffer_grow (&tmpbuf))
-	  return WRDE_NOSPACE;
+      struct passwd pwd, *tpwd;
+      int result;
+      while ((result = __getpwnam_r (user,
+				     &pwd,
+				     tmpbuf.data + userlen + 1,
+				     tmpbuf.length - userlen - 1,
+				     &tpwd)
+	      != 0))
+	{
+	  if (!scratch_buffer_grow_preserve (&tmpbuf))
+	    return WRDE_NOSPACE;
+	  user = tmpbuf.data;
+	}
 
       if (result == 0 && tpwd != NULL && pwd.pw_dir)
 	*word = w_addstr (*word, word_length, max_length, pwd.pw_dir);