Re: [PATCH] elf: Open the normalized $ORIGIN rpath in AT_SECURE programs (BZ 34360)

Florian Weimer <[email protected]>
Newsgroups gmane.comp.lib.glibc.alpha
Message-ID <[email protected]>
* Adhemerval Zanella:

> +/* Lexically normalize the NUL-terminated PATH in place, collapsing "//",
> +   "/./" and "/../" segments (a leading "/../" collapses to "/").  The
> +   normalized string is a rearrangement of a prefix of PATH: the write
> +   cursor never runs ahead of the read cursor and no trailing character is
> +   appended, so this only ever touches bytes within the original
> +   strlen (PATH) + 1 storage and can never access memory out of bounds.
> +   Returns the length of the normalized path (excluding the terminating
> +   NUL).  */
> +static size_t
> +dst_normalize_path (char *path)
>  {
> +  char *wnp = path;
> +  const char *rnp = path;
> +  while (*rnp != '\0')
>      {
> +      if (rnp[0] == '/')
>  	{
> +	  /* Collapse a run of '/' to a single one before interpreting "/."
> +	     or "/..", so that the "." or ".." is applied to the real
> +	     preceding component rather than to an empty "//" segment: skip
> +	     this '/' whenever it is immediately followed by another one.  */
> +	  if (rnp[1] == '/')
>  	    {
> +	      ++rnp;
> +	      continue;
>  	    }
>  
> +	  if (rnp[1] == '.')
>  	    {
> +	      if (rnp[2] == '.' && (rnp[3] == '/' || rnp[3] == '\0'))
> +		{
> +		  while (wnp > path && *--wnp != '/')
> +		    ;
> +		  rnp += 3;
> +		  continue;
> +		}
> +	      else if (rnp[2] == '/' || rnp[2] == '\0')
> +		{
> +		  rnp += 2;
> +		  continue;
> +		}
>  	    }
>  	}
>  
> +      *wnp++ = *rnp++;
>      }
>  
> +  *wnp = '\0';
> +  return wnp - path;
> +}

This turns "/a/.." into "", which doesn't look right to me.  "a/../"
ends up as "/", and "a/.." as "".  And of course "" remains "".  The ""
special case probably needs to be called out in the function comment.
The comment should also clarify the expected behavior regarding trailing
slashes (the caller cannot assume their presence or absence).

I suggest putting this function into a separate (header) file, so that
we can test it directly.

The integration test looks okay to me.

>  /* Given a substring starting at INPUT, just after the DST '$' start
> @@ -327,6 +342,8 @@ _dl_dst_substitute (struct link_map *l, const char *input, char *result)
>      }
>    while (*input != '\0');
>  
> +  *wp = '\0';
> +
>    /* In SUID/SGID programs, after $ORIGIN expansion the normalized
>       path must be rooted in one of the trusted directories.  The $LIB
>       and $PLATFORM DST cannot in any way be manipulated by the caller
> @@ -335,15 +352,21 @@ _dl_dst_substitute (struct link_map *l, const char *input, char *result)
>       checked for trust, the authors of the binaries themselves are
>       trusted to have designed this correctly.  Only $ORIGIN is tested in
>       this way because it may be manipulated in some ways with hard
> -     links.  */
> -  if (__glibc_unlikely (check_for_trusted)
> -      && !is_trusted_path_normalize (result, wp - result))
> -    {
> -      *result = '\0';
> -      return result;
> -    }
> +     links.
>  
> -  *wp = '\0';
> +     Checking the normalized path but opening the raw one is not enough:
> +     "a/b/../c" only names "a/c" when "b" is not a symbolic link, so an
> +     attacker who controls a component of $ORIGIN (for example by
> +     hard-linking the program into an attacker-owned directory) could
> +     otherwise redirect the lookup outside the trusted directory.  Replace
> +     the expansion with its normalized, "../"-free form, so that the path
> +     that is opened is exactly the path that was validated.  */
> +  if (__glibc_unlikely (check_for_trusted))
> +    {
> +      size_t nlen = dst_normalize_path (result);
> +      if (!path_is_trusted (result, nlen))
> +	*result = '\0';
> +    }

It turns out this fixes a potential buffer overflow as well.
Previously, we called is_trusted_path_normalize on a potential
non-null-terminated byte array.  The old loop iteration did not stop at
the specified length, but at the first null byte.  So this could end up
writing beyond the end of the stack-allocated array.

I've attached the full report below.

I've been instructed to mention: Found by AISLE in partnership with Red Hat


I think under the current rules, these two bugs do not need separate in
CVE assignment even though they are very different in nature.  They were
introduced in the same commit, and as your patch shows, it's not really
possible to fix them separately.

Thanks,
Florian
RHEL-215965.txt (text/plain, 5.7 KB)
Potential stack buffer overflow in `is_trusted_path_normalize` from
pre-termination call in `_dl_dst_substitute`

AI_ONLY_REPORT
package: glibc-2.42-11.1.hum1
------
Summary: Potential stack buffer overflow in `is_trusted_path_normalize`
from pre-termination call in `_dl_dst_substitute`: in secure execution, the
dynamic loader validates a `$ORIGIN`-expanded path before NUL-terminating
it, which can let `is_trusted_path_normalize` read past the intended string
and potentially overflow its stack buffer.
Requirements to exploit: A real attack requires local execution plus an
existing `AT_SECURE` binary that reaches `_dl_dst_substitute` with
`check_for_trusted` set, which in practice means a setuid/setgid or
otherwise secure-execution executable whose `DT_RPATH` or `DT_RUNPATH`
begins with `$ORIGIN` and is followed by `\0` or `/`. The available
materials do not establish that this SRPM ships such a privileged binary by
default. Reproducibility also depends on adjacent bytes after the expanded
string not containing an early NUL byte.
Component affected: `glibc-2.42-11.1.hum1` dynamic loader path handling in
`elf/dl-load.c` (`_dl_dst_substitute`, `is_trusted_path_normalize`)
Version affected: `glibc-2.42-11.1.hum1`
Patch available: no released package fix established; proposed patch
included below
Version fixed: unknown
Upstream coordination: Not notified.
CVSS: CVSS:3.1/AV:L/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:H - 6.8 (MEDIUM)
AV:L - Exploitation requires local execution of a binary using this
loader path.
AC:H - Triggering requires `AT_SECURE`, a leading `$ORIGIN`
`DT_RPATH`/`DT_RUNPATH`, and favorable adjacent memory contents.
PR:N - No prior privileges are required beyond running the target binary.
UI:N - No separate victim interaction is required once the attacker
invokes the target.
S:U - The impact is within the same process and security scope.
C:L - Out-of-bounds access may expose a limited amount of process memory.
I:L - Memory corruption may alter process state, but reliable arbitrary
modification is not established.
A:H - The most directly supported outcome is a loader crash or process
termination.
Impact: Moderate. This is a real memory-corruption flaw in privileged
loader code, but exploitation is not easy and depends on narrow
circumstances: local execution, secure mode, a specific `$ORIGIN` path
form, and surrounding memory layout. The available evidence supports denial
of service and limited memory corruption more clearly than easy, reliable
privilege escalation. Under Red Hat's guidance, that fits Moderate rather
than Important or Critical because the issue is harder to exploit and
appears configuration-dependent.
Embargo: no
Reason: The issue is local, configuration-sensitive, and the available
evidence does not show an easy or reliably weaponizable
privilege-escalation path. A minimal fix is straightforward and public
exposure risk appears limited.
Acknowledgement: Aisle Research
Vulnerability Details: In `elf/dl-load.c`, `_dl_dst_substitute` performs
trusted-path validation before it terminates the expanded path buffer:
```c
if (__glibc_unlikely (check_for_trusted)
&& !is_trusted_path_normalize (result, wp - result))
{
*result = '\0';
return result;
}
*wp = '\0';
```
`is_trusted_path_normalize` receives `len = wp - result`, allocates `len +
2` bytes on the stack, but iterates until a sentinel NUL byte instead of
enforcing the supplied length:
```c
if (len == 0)
return false;
char *npath = (char *) alloca (len + 2);
char *wnp = npath;
while (*path != '\0')
{
...
*wnp++ = *path++;
}
```
Observed fact: the call order is as shown above, so `result` is not yet
NUL-terminated when `is_trusted_path_normalize` starts walking it.
Reasonable inference: if bytes immediately following the intended string do
not contain `'\0'`, the copy loop can continue beyond the logical end of
`result` and then write beyond `npath`, causing stack corruption. The
provided materials clearly support out-of-bounds access and crash
potential; they do not establish a working arbitrary code-execution or
privilege-escalation exploit.
Steps to reproduce:
1. Build a test environment using `glibc-2.42-11.1.hum1` with ASan/UBSan
and debug symbols if possible.
2. Create a small setuid-root or setuid/setgid test executable whose
`DT_RUNPATH` or `DT_RPATH` starts with `$ORIGIN/`.
3. Place and execute it so the loader expands that entry under `AT_SECURE`,
reaching `_dl_dst_substitute` with `check_for_trusted` enabled.
4. Run repeatedly with varied allocator state to reduce the chance of an
accidental early NUL byte immediately after the expanded string.
5. Observe out-of-bounds access in `is_trusted_path_normalize`,
particularly around the `while (*path != '\0')` copy loop.
Mitigation: Avoid privileged executables whose `DT_RPATH` or `DT_RUNPATH`
begins with `$ORIGIN`; prefer absolute trusted library directories instead.
Where a binary does not need privileged execution, removing the
setuid/setgid bit avoids the secure-execution path that reaches this check.
Proposed Fix: NUL-terminate `result` before calling
`is_trusted_path_normalize`. As additional hardening,
`is_trusted_path_normalize` can be updated to enforce `len` as a strict
bound.
```diff
diff --git a/elf/dl-load.c b/elf/dl-load.c
— a/elf/dl-load.c
+++ b/elf/dl-load.c
@@ -352,14 +352,14 @@ _dl_dst_substitute (struct link_map *l, const char
*input, char *result)
checked for trust, the authors of the binaries themselves are
trusted to have designed this correctly. Only $ORIGIN is tested in
this way because it may be manipulated in some ways with hard
links. */
+ *wp = '\0';
+
if (__glibc_unlikely (check_for_trusted)
&& !is_trusted_path_normalize (result, wp - result))
{
*result = '\0';
return result;
}
-
*wp = '\0';

return result;
}
```
------
This report was generated using AI technology. Always review AI-generated
content prior to use
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.