[v3] libio: Fix CVE-2026-18374 heap buffer overflow in ccs= handling

Message ID 006c01dd36d1$eeef5530$cccdff90$@samsung.com (mailing list archive)
State Superseded
Headers
Series [v3] libio: Fix CVE-2026-18374 heap buffer overflow in ccs= handling |

Checks

Context Check Description
redhat-pt-bot/TryBot-apply_patch success Patch applied to master at the time it was sent
linaro-tcwg-bot/tcwg_glibc_build--master-arm success Build passed
linaro-tcwg-bot/tcwg_glibc_build--master-aarch64 success Build passed
redhat-pt-bot/TryBot-32bit success Build for i686
linaro-tcwg-bot/tcwg_glibc_check--master-aarch64 success Test passed
linaro-tcwg-bot/tcwg_glibc_check--master-arm success Test passed

Commit Message

Dongkyun Son Aug. 28, 2026, 9:45 a.m. UTC
  When fopen is called with a ,ccs= specification whose value is not empty
but becomes empty after strip() (for example "r,ccs=/,..."), the code took
the fallback branch

    __wcsmbs_named_conv (&fcts, ccs[2] == '\0' ? upstr (ccs, cs + 5) : ccs)

and upstr() copied from cs + 5 up to the terminating NUL of the whole mode
string -- past the ',' delimiter at endp -- into ccs, which is only
allocated for endp - (cs + 5) + 3 bytes.  This overflows the heap buffer.

Checking for an empty ccs= token before strip() (as an earlier attempt did)
does not help, because in the reproducer the token is only empty after
strip(), not before.

Bound the fallback copy to the [cs + 5, endp) charset token so it can no
longer read past the delimiter.  A specification that is empty after
strip() has no valid charset name and keeps failing with EINVAL from
__wcsmbs_named_conv.

A regression test that reproduces the overflow (and the trivial empty
,ccs= case) is added to libio/tst-fopenloc.c.

CVE-2026-18374 - CVSS 4.9 (AV:L/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:L)
Reported-by: AISLE in partnership with Red Hat
Signed-off-by: Dongkyun Son <dongkyun.s@samsung.com>
---
Changes since v2 (thanks to Florian Weimer for the review):
 - v2 was wrong: it rejected a ccs= token that was empty *before*
   strip().  The actual overflow happens when the token is non-empty
   but becomes empty *after* strip(), which takes the
   upstr (ccs, cs + 5) fallback and copies past the ',' delimiter.
   v3 bounds that fallback to the [cs + 5, endp) token instead.

 libio/fileops.c      | 15 ++++++++++++--
 libio/tst-fopenloc.c | 49 ++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 62 insertions(+), 2 deletions(-)
  

Comments

Florian Weimer Sept. 1, 2026, 6:29 a.m. UTC | #1
* 손동균:

> When fopen is called with a ,ccs= specification whose value is not empty
> but becomes empty after strip() (for example "r,ccs=/,..."), the code took
> the fallback branch
>
>     __wcsmbs_named_conv (&fcts, ccs[2] == '\0' ? upstr (ccs, cs + 5) : ccs)
>
> and upstr() copied from cs + 5 up to the terminating NUL of the whole mode
> string -- past the ',' delimiter at endp -- into ccs, which is only
> allocated for endp - (cs + 5) + 3 bytes.  This overflows the heap buffer.
>
> Checking for an empty ccs= token before strip() (as an earlier attempt did)
> does not help, because in the reproducer the token is only empty after
> strip(), not before.
>
> Bound the fallback copy to the [cs + 5, endp) charset token so it can no
> longer read past the delimiter.  A specification that is empty after
> strip() has no valid charset name and keeps failing with EINVAL from
> __wcsmbs_named_conv.
>
> A regression test that reproduces the overflow (and the trivial empty
> ,ccs= case) is added to libio/tst-fopenloc.c.
>
> CVE-2026-18374 - CVSS 4.9 (AV:L/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:L)
> Reported-by: AISLE in partnership with Red Hat
> Signed-off-by: Dongkyun Son <dongkyun.s@samsung.com>

Please add a reference to bug 34574 to the commit message.

> diff --git a/libio/fileops.c b/libio/fileops.c
> index 9348d7c3a1..f42588a94f 100644
> --- a/libio/fileops.c
> +++ b/libio/fileops.c
> @@ -355,8 +355,19 @@ _IO_new_file_fopen (FILE *fp, const char *filename, const char *mode,
>  	  *((char *) __mempcpy (ccs, cs + 5, endp - (cs + 5))) = '\0';
>  	  strip (ccs, ccs);
>  
> -	  if (__wcsmbs_named_conv (&fcts, ccs[2] == '\0'
> -				   ? upstr (ccs, cs + 5) : ccs) != 0)
> +	  /* If strip() reduced the specification to the empty string (leaving
> +	     only the "//" that strip() always appends), fall back to the
> +	     original charset name.  Bound the copy by ENDP so we do not read
> +	     past the ',' delimiter and overflow CCS (CVE-2026-18374).  */
> +	  if (ccs[2] == '\0')
> +	    {
> +	      char *wp = ccs;
> +	      for (const char *rp = cs + 5; rp < endp; ++rp)
> +		*wp++ = __toupper_l (*rp, _nl_C_locobj_ptr);
> +	      *wp = '\0';
> +	    }

I think the idea to return EINVAL in case the ccs= argument is
(effectively) empty was the right approach.  No need to convert the
widespace to upper case.

> diff --git a/libio/tst-fopenloc.c b/libio/tst-fopenloc.c
> index ea3f7b5265..5ce3bc5abb 100644
> --- a/libio/tst-fopenloc.c
> +++ b/libio/tst-fopenloc.c
> @@ -85,6 +85,54 @@ do_bz18906 (void)
>    return EXIT_SUCCESS;
>  }
>  
> +static int
> +do_cve_2026_18374 (void)
> +{
> +  /* CVE-2026-18374 -- a ,ccs= specification that is not empty but becomes
> +     empty after strip() must not make fopen read past the ',' delimiter and
> +     overflow the heap buffer.  It has to fail cleanly with EINVAL.  */

I would recommend adding a new test, adapted from the reproducer on the
bug.  It can open /dev/null for reading, and it should expect the EINVAL
failure.

If you don't want to write an actual heap overflow, I can add the new
test in a separate commit.

Thanks,
Florian
  

Patch

diff --git a/libio/fileops.c b/libio/fileops.c
index 9348d7c3a1..f42588a94f 100644
--- a/libio/fileops.c
+++ b/libio/fileops.c
@@ -355,8 +355,19 @@  _IO_new_file_fopen (FILE *fp, const char *filename, const char *mode,
 	  *((char *) __mempcpy (ccs, cs + 5, endp - (cs + 5))) = '\0';
 	  strip (ccs, ccs);
 
-	  if (__wcsmbs_named_conv (&fcts, ccs[2] == '\0'
-				   ? upstr (ccs, cs + 5) : ccs) != 0)
+	  /* If strip() reduced the specification to the empty string (leaving
+	     only the "//" that strip() always appends), fall back to the
+	     original charset name.  Bound the copy by ENDP so we do not read
+	     past the ',' delimiter and overflow CCS (CVE-2026-18374).  */
+	  if (ccs[2] == '\0')
+	    {
+	      char *wp = ccs;
+	      for (const char *rp = cs + 5; rp < endp; ++rp)
+		*wp++ = __toupper_l (*rp, _nl_C_locobj_ptr);
+	      *wp = '\0';
+	    }
+
+	  if (__wcsmbs_named_conv (&fcts, ccs) != 0)
 	    {
 	      /* Something went wrong, we cannot load the conversion modules.
 		 This means we cannot proceed since the user explicitly asked
diff --git a/libio/tst-fopenloc.c b/libio/tst-fopenloc.c
index ea3f7b5265..5ce3bc5abb 100644
--- a/libio/tst-fopenloc.c
+++ b/libio/tst-fopenloc.c
@@ -85,6 +85,54 @@  do_bz18906 (void)
   return EXIT_SUCCESS;
 }
 
+static int
+do_cve_2026_18374 (void)
+{
+  /* CVE-2026-18374 -- a ,ccs= specification that is not empty but becomes
+     empty after strip() must not make fopen read past the ',' delimiter and
+     overflow the heap buffer.  It has to fail cleanly with EINVAL.  */
+
+  const size_t sz = 8192;
+  char *ccs = xmalloc (sz);
+  strcpy (ccs, "r,ccs=/,");
+  memset (ccs + 8, 'A', sz - 8 - 1);
+  ccs[sz - 1] = '\0';
+
+  errno = 0;
+  FILE *fp = fopen (inputfile, ccs);
+  if (fp != NULL)
+    {
+      printf ("fopen with empty-after-strip ccs= unexpectedly succeeded\n");
+      free (ccs);
+      fclose (fp);
+      return 1;
+    }
+  if (errno != EINVAL)
+    {
+      printf ("expected EINVAL, got %d\n", errno);
+      free (ccs);
+      return 1;
+    }
+  free (ccs);
+
+  /* Also check the trivially empty ,ccs= case.  */
+  errno = 0;
+  fp = fopen (inputfile, "r,ccs=");
+  if (fp != NULL)
+    {
+      printf ("fopen with empty ccs= unexpectedly succeeded\n");
+      fclose (fp);
+      return 1;
+    }
+  if (errno != EINVAL)
+    {
+      printf ("expected EINVAL, got %d\n", errno);
+      return 1;
+    }
+
+  return 0;
+}
+
 static int
 do_test (void)
 {
@@ -110,6 +158,7 @@  do_test (void)
 
   TEST_COMPARE (do_bz17916 (), 0);
   TEST_COMPARE (do_bz18906 (), 0);
+  TEST_COMPARE (do_cve_2026_18374 (), 0);
 
   return EXIT_SUCCESS;
 }