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 Superseded
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);
  
Adhemerval Zanella June 5, 2026, 6:33 p.m. UTC | #7
Ping.

On 18/05/26 13:32, Adhemerval Zanella Netto wrote:
> 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);
>
  
Carlos O'Donell June 15, 2026, 8:17 p.m. UTC | #8
On 4/22/26 8:35 AM, 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.

Looking forward to a v2.

There are 3 change requested:
- Update bug number.
- Parenthesis addition to fix = and != issue.
- Add back ERANGE check so fallback works for permanent errors from NSS plugins.

> ---
>   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 \

OK.

>     # 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).

Suggest:
BZ #34091

> +   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);

OK. Small 512KiB stack.

> +
> +  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.  */

OK.

> +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;

OK. Very long 1MiB > 512KiB

> +  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)

OK.

> +{
> +  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.  */

OK.

> +  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);

OK.

> +}
> +
> +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));

OK. Removes original strndupa.

> -      struct passwd pwd, *tpwd;
> -      int result;
> +      size_t userlen = i - (1 + *offset);
> +      /* tmpbuf contains both the user and the __getpwnam_r working area.  */

OK.

>         struct scratch_buffer tmpbuf;
>         scratch_buffer_init (&tmpbuf);
> +      if (!scratch_buffer_set_array_size (&tmpbuf, userlen + 1, 1))
> +	return WRDE_NOSPACE;

OK. This length is that which is computed by the individual parsers adding to the
length as they process specific elements. It is not directly controlled by the attacker
but it is influenced by the attacker's selection of elements and the expansion of those
elements.

> +      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)

The old code has an ERANGE check. See below for comments.

> -	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))

Not OK.

LLM review noted operator precedence issues here.

LLM review noted you dropped the ERANGE check.

Suggest:

while ((result = __getpwnam_r (user,
			       &pwd,
			       tmpbuf.data + userlen + 1,
			       tmpbuf.length - userlen - 1,
			       &tpwd))
        != 0 && errno == ERANGE)

Which is closer to the old code.

This way we loop, growing and preserving, and result is the result of the
function return, and if result is non-zero and the errno is anything other
than erange like EIO then we fall back to the invalid login name case
since we have a permanent error we can't recover from.

> +	{
> +	  if (!scratch_buffer_grow_preserve (&tmpbuf))
> +	    return WRDE_NOSPACE;

OK. Grow but preserve.

> +	  user = tmpbuf.data;

OK. Set the user part.

> +	}
>   
>         if (result == 0 && tpwd != NULL && pwd.pw_dir)
>   	*word = w_addstr (*word, word_length, max_length, pwd.pw_dir);

OK. If the loop exits with result != 0, and errno != ERANGE, then we fall
back to the else

  352       else
  353         {
  354           /* (invalid login name) */
  355           *word = w_addchar (*word, word_length, max_length, '~');
  356           if (*word != NULL)
  357             *word = w_addstr (*word, word_length, max_length, user);
  358         }
  

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);