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

Adhemerval Zanella adhemerval.zanella@linaro.org
Mon Aug 17 18:15:32 GMT 2026


For AT_SECURE programs the loader honors $ORIGIN in DT_RPATH only when the
expansion is rooted in a trusted directory, but it validated the lexically
normalized path while opening the raw expansion.  As "a/b/../c" only names
"a/c" when "b" is not a symlink, an attacker who controls a component of
$ORIGIN -- e.g. by hard-linking the setuid binary into an attacker-owned
directory -- can make the opened path escape the trusted directory even
though the check passed, loading an attacker-controlled object.

Normalize the expansion in place and open that, so the path that is opened
is exactly the path that was validated.  _dl_normalize_path rewrites the
string in place without ever advancing its write cursor past its read
cursor or appending, so it stays within the original storage.

Add elf/tst-origin-secure as a regression test.
--
Changes from v1:
* Extracted the in-place normalizer from dl-load.c into a new header
  elf/dl-path-normalize.h.
* Added unit test elf/tst-dl-path-normalize.c.
* Fix the elf/tst-origin-secure.c secure handling.
---
 elf/Makefile                             |  18 ++
 elf/dl-load.c                            |  85 +++-------
 elf/dl-path-normalize.h                  | 114 +++++++++++++
 elf/libtst-origin-secure-mod.c           |  25 +++
 elf/tst-dl-path-normalize.c              | 142 ++++++++++++++++
 elf/tst-origin-secure-evilmod.c          |  28 ++++
 elf/tst-origin-secure-victim.c           |  43 +++++
 elf/tst-origin-secure.c                  | 203 +++++++++++++++++++++++
 elf/tst-origin-secure.h                  |  41 +++++
 elf/tst-origin-secure.root/postclean.req |   0
 10 files changed, 641 insertions(+), 58 deletions(-)
 create mode 100644 elf/dl-path-normalize.h
 create mode 100644 elf/libtst-origin-secure-mod.c
 create mode 100644 elf/tst-dl-path-normalize.c
 create mode 100644 elf/tst-origin-secure-evilmod.c
 create mode 100644 elf/tst-origin-secure-victim.c
 create mode 100644 elf/tst-origin-secure.c
 create mode 100644 elf/tst-origin-secure.h
 create mode 100644 elf/tst-origin-secure.root/postclean.req

diff --git a/elf/Makefile b/elf/Makefile
index dbc6cfec7fe..530776a3719 100644
--- a/elf/Makefile
+++ b/elf/Makefile
@@ -566,6 +566,7 @@ tests-internal += \
   tst-audit19a \
   tst-create_format1 \
   tst-dl-hwcaps_split \
+  tst-dl-path-normalize \
   tst-dl_find_object \
   tst-dl_find_object-threads \
   tst-dlmopen2 \
@@ -591,6 +592,7 @@ tests-container += \
   tst-dlopen-self-container \
   tst-dlopen-tlsmodid-container \
   tst-ldconfig-cache \
+  tst-origin-secure \
   tst-pldd \
   tst-preload-pthread-libc \
   tst-ptrguard-static-dlopen \
@@ -599,6 +601,7 @@ tests-container += \
   # tests-container
 
 test-srcs = \
+  tst-origin-secure-victim \
   tst-pathopt \
   tst-sprof-basic \
   # tests-srcs
@@ -839,6 +842,7 @@ modules-names += \
   libtracemod3-1 \
   libtracemod4-1 \
   libtracemod5-1 \
+  libtst-origin-secure-mod \
   ltglobmod1 \
   ltglobmod2 \
   neededobj1 \
@@ -1034,6 +1038,7 @@ modules-names += \
   tst-nodeps2-mod \
   tst-non-directory-mod \
   tst-null-argv-lib \
+  tst-origin-secure-evilmod \
   tst-p_alignmod-base \
   tst-p_alignmod3 \
   tst-ptrguard-static-dlopen-mod \
@@ -3766,3 +3771,16 @@ $(objpfx)tst-dl-debug-exclude.out: tst-dl-debug-exclude.sh \
 		 $(objpfx)tst-recursive-tls > $@; \
 	$(evaluate-test)
 endif
+
+LDFLAGS-libtst-origin-secure-mod.so += -Wl,-soname,libtst-origin-secure-mod.so
+LDFLAGS-tst-origin-secure-evilmod.so += -Wl,-soname,libtst-origin-secure-mod.so
+$(objpfx)tst-origin-secure-victim: $(objpfx)libtst-origin-secure-mod.so
+# The number of "../" here must match the layout invariants described in
+# tst-origin-secure.c.
+LDFLAGS-tst-origin-secure-victim += \
+  -Wl,--no-as-needed \
+  -Wl,-rpath,\$$ORIGIN/sub/../../../../..$(slibdir)/tst-origin-secure \
+  -Wl,--disable-new-dtags
+$(objpfx)tst-origin-secure.out: $(objpfx)tst-origin-secure-victim \
+			       $(objpfx)libtst-origin-secure-mod.so \
+			       $(objpfx)tst-origin-secure-evilmod.so
diff --git a/elf/dl-load.c b/elf/dl-load.c
index 95404adae94..471478649b7 100644
--- a/elf/dl-load.c
+++ b/elf/dl-load.c
@@ -34,6 +34,7 @@
 #include <gnu/lib-names.h>
 #include <dl-tunables.h>
 #include <dl-scratch-buffer.h>
+#include <dl-path-normalize.h>
 
 #include "dynamic-link.h"
 #include "get-dynamic-info.h"
@@ -91,67 +92,30 @@ static const size_t system_dirs_len[] =
 };
 #define nsystem_dirs_len array_length (system_dirs_len)
 
+/* Return true if the normalized path NPATH of length NLEN is rooted in one of
+   the trusted system directories.  The system_dirs entries carry a trailing
+   '/'; NPATH matches an entry when it shares the entry's leading component
+   sequence and then either ends or continues with '/'.  For instance,
+   "/lib64" and "/lib64/x" match "/lib64/" but "/lib64x" does not.  */
 static bool
-is_trusted_path_normalize (const char *path, size_t len)
+path_is_trusted (const char *npath, size_t nlen)
 {
-  if (len == 0)
-    return false;
-
-  struct dl_scratch_buffer scratch = dl_scratch_buffer_init ();
-  dl_scratch_buffer_allocate (&scratch, len + 2, 0);
-  char *npath = scratch.data;
-  char *wnp = npath;
-  while (*path != '\0')
-    {
-      if (path[0] == '/')
-	{
-	  if (path[1] == '.')
-	    {
-	      if (path[2] == '.' && (path[3] == '/' || path[3] == '\0'))
-		{
-		  while (wnp > npath && *--wnp != '/')
-		    ;
-		  path += 3;
-		  continue;
-		}
-	      else if (path[2] == '/' || path[2] == '\0')
-		{
-		  path += 2;
-		  continue;
-		}
-	    }
-
-	  if (wnp > npath && wnp[-1] == '/')
-	    {
-	      ++path;
-	      continue;
-	    }
-	}
-
-      *wnp++ = *path++;
-    }
-
-  if (wnp == npath || wnp[-1] != '/')
-    *wnp++ = '/';
-
-  bool result = false;
   const char *trun = system_dirs;
 
   for (size_t idx = 0; idx < nsystem_dirs_len; ++idx)
     {
-      if (wnp - npath >= system_dirs_len[idx]
-	  && memcmp (trun, npath, system_dirs_len[idx]) == 0)
-	{
-	  /* Found it.  */
-	  result = true;
-	  break;
-	}
+      /* Compare against the entry without its trailing '/'.  */
+      size_t dirlen = system_dirs_len[idx] - 1;
+
+      if (nlen >= dirlen
+	  && memcmp (trun, npath, dirlen) == 0
+	  && (npath[dirlen] == '/' || npath[dirlen] == '\0'))
+	return true;
 
       trun += system_dirs_len[idx] + 1;
     }
 
-  dl_scratch_buffer_free (&scratch);
-  return result;
+  return false;
 }
 
 /* Given a substring starting at INPUT, just after the DST '$' start
@@ -335,16 +299,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.
+
+     _dl_normalize_path replaces the expansion with its normalized form
+     in place, so that the path that is opened is exactly the path that
+     was validated.  */
 
   *wp = '\0';
 
+  if (__glibc_unlikely (check_for_trusted))
+    {
+      size_t nlen = _dl_normalize_path (result);
+      if (!path_is_trusted (result, nlen))
+	*result = '\0';
+    }
+
   return result;
 }
 
diff --git a/elf/dl-path-normalize.h b/elf/dl-path-normalize.h
new file mode 100644
index 00000000000..96e04f5d14e
--- /dev/null
+++ b/elf/dl-path-normalize.h
@@ -0,0 +1,114 @@
+/* In-place lexical path normalization for the dynamic loader.
+   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/>.  */
+
+#ifndef _DL_PATH_NORMALIZE_H
+#define _DL_PATH_NORMALIZE_H
+
+#include <stddef.h>
+
+/* Lexically normalize the null-terminated PATH in place and return the
+   length of the result (excluding the terminating NUL byte):
+
+   - Runs of '/' are collapsed to a single '/'.
+
+   - "." components are removed.
+
+   - A ".." component removes the preceding component if there is one and it
+     is not itself a preserved "..".  In an absolute path a surplus ".." at
+     the root is dropped ("/../a" normalizes to "/a"), in a relative path
+     leading ".." components are preserved ("../a" stays "../a", "a/../../b"
+     normalizes to "../b").
+
+   - The result has no trailing '/' except for the root path "/" itself
+     ("/a/" normalizes to "/a").
+
+   - The result is empty if and only if every component cancels or is removed
+     ("", ".", "a/.." all normalize to "").
+
+   The PATH is written in place, and the internal write cursor never runs
+   ahead of the read cursor.  Only bytes within the strlen (PATH) + 1 storage
+   are accessed.  */
+static inline size_t
+_dl_normalize_path (char *path)
+{
+  /* The root '/' of an absolute path is not removed.  */
+  char *pstart = path + (path[0] == '/');
+  const char *rnp = pstart;
+  char *wnp = pstart;
+  /* End of the prefix a ".." may not remove, the root '/' plus any preserved
+     leading ".." components of a relative path.  */
+  char *limit = pstart;
+
+  while (*rnp != '\0')
+    {
+      /* Collapse consecutive separators.  */
+      if (*rnp == '/')
+	{
+	  ++rnp;
+	  continue;
+	}
+
+      /* [RNP, REND) is the next input component.  */
+      const char *rend = rnp;
+      while (*rend != '\0' && *rend != '/')
+	++rend;
+      size_t clen = rend - rnp;
+
+      /* Drop '.' component.  */
+      if (clen == 1 && rnp[0] == '.')
+	;
+      else if (clen == 2 && rnp[0] == '.' && rnp[1] == '.')
+	{
+	  if (wnp > limit)
+	    {
+	      /* Remove the last component along with the '/' separating it
+		 from its predecessor (the root '/' of an absolute path is
+		 retained).  */
+	      while (wnp > limit && wnp[-1] != '/')
+		--wnp;
+	      if (wnp > pstart)
+		--wnp;
+	    }
+	  else if (pstart == path)
+	    {
+	      /* No component is left: keep the unresolvable ".." for a
+		 relative path (it becomes part of the preserved prefix),
+		 drop it at the root of an absolute one.  */
+	      if (wnp > pstart)
+		*wnp++ = '/';
+	      *wnp++ = '.';
+	      *wnp++ = '.';
+	      limit = wnp;
+	    }
+	}
+      else
+	{
+	  if (wnp > pstart)
+	    *wnp++ = '/';
+	  while (rnp < rend)
+	    *wnp++ = *rnp++;
+	}
+
+      rnp = rend;
+    }
+
+  *wnp = '\0';
+  return wnp - path;
+}
+
+#endif /* _DL_PATH_NORMALIZE_H */
diff --git a/elf/libtst-origin-secure-mod.c b/elf/libtst-origin-secure-mod.c
new file mode 100644
index 00000000000..8d7f3720944
--- /dev/null
+++ b/elf/libtst-origin-secure-mod.c
@@ -0,0 +1,25 @@
+/* Module for tst-origin-secure (the "good" copy).
+   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 "tst-origin-secure.h"
+
+int
+origin_secure_id (void)
+{
+  return ORIGIN_SECURE_ID_TRUSTED;
+}
diff --git a/elf/tst-dl-path-normalize.c b/elf/tst-dl-path-normalize.c
new file mode 100644
index 00000000000..f6d45d654fd
--- /dev/null
+++ b/elf/tst-dl-path-normalize.c
@@ -0,0 +1,142 @@
+/* Unit tests for dl-path-normalize.h.
+   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 <dl-path-normalize.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <string.h>
+#include <support/check.h>
+#include <support/next_to_fault.h>
+
+static void
+check_one_guarded (const char *input, const char *expected, bool before)
+{
+  size_t size = strlen (input) + 1;
+  struct support_next_to_fault ntf
+    = before ? support_next_to_fault_allocate_before (size)
+	     : support_next_to_fault_allocate (size);
+  memcpy (ntf.buffer, input, size);
+
+  size_t len = _dl_normalize_path (ntf.buffer);
+
+  TEST_COMPARE (len, strlen (ntf.buffer));
+  TEST_COMPARE_STRING (ntf.buffer, expected);
+
+  support_next_to_fault_free (&ntf);
+}
+
+static void
+check_one (const char *input, const char *expected)
+{
+  /* Check that _dl_normalize_path does not access the string outside the
+     input argument.  It checks for both over-runs and under-runs (the
+     latter for the case of '..' expansions).  */
+  check_one_guarded (input, expected, false);
+  check_one_guarded (input, expected, true);
+}
+
+static int
+do_test (void)
+{
+  /* Absolute paths.  */
+  check_one ("/", "/");
+  check_one ("//", "/");
+  check_one ("///", "/");
+  check_one ("////", "/");
+  check_one ("/a", "/a");
+  check_one ("/a/", "/a");
+  check_one ("/a//", "/a");
+  check_one ("//a//b//", "/a/b");
+  check_one ("/.", "/");
+  check_one ("/./", "/");
+  check_one ("/./a", "/a");
+  check_one ("/a/./b", "/a/b");
+  check_one ("/a/.", "/a");
+  check_one ("/..", "/");
+  check_one ("/../", "/");
+  check_one ("/../a", "/a");
+  check_one ("/a/..", "/");
+  check_one ("/a/../", "/");
+  check_one ("/a/../..", "/");
+  check_one ("/a/../b", "/b");
+  check_one ("/a/../../b", "/b");
+  check_one ("/a/b/../../c", "/c");
+  check_one ("/a/b/../c", "/a/c");
+  check_one ("/a/b/c/../..", "/a");
+  check_one ("/usr/lib/../lib64", "/usr/lib64");
+  check_one ("/usr/lib/../lib64/", "/usr/lib64");
+  check_one ("/usr/lib/..//lib64/", "/usr/lib64");
+  check_one ("/usr/lib/../../lib64/", "/lib64");
+
+  /* "." and ".." are special only as complete components.  */
+  check_one ("/a..", "/a..");
+  check_one ("/..a", "/..a");
+  check_one ("/a/...", "/a/...");
+  check_one ("/.../a", "/.../a");
+  check_one ("/a./b", "/a./b");
+  check_one (".a", ".a");
+  check_one ("a.", "a.");
+  check_one ("..a", "..a");
+  check_one ("...", "...");
+
+  /* Relative paths.  */
+  check_one ("", "");
+  check_one (".", "");
+  check_one ("./", "");
+  check_one ("..", "..");
+  check_one ("../", "..");
+  check_one ("a", "a");
+  check_one ("a/", "a");
+  check_one ("a//b", "a/b");
+  check_one ("a..", "a..");
+  check_one ("./a", "a");
+  check_one ("./.", "");
+  check_one ("./..", "..");
+
+  check_one ("a/..", "");
+  check_one ("a/../", "");
+  check_one ("ab/..", "");
+  check_one (".a/..", "");
+  check_one ("a./..", "");
+  check_one (".../..", "");
+  check_one ("a/./..", "");
+  check_one ("a/b/..", "a");
+  check_one ("abc/def/..", "abc");
+
+  /* Appending a component to an emptied relative output must not produce a
+     leading '/' (the path must stay relative).  */
+  check_one ("a/../b", "b");
+  check_one ("a/.././b", "b");
+  check_one ("a/../lib64/b", "lib64/b");
+
+  /* Leading ".." components of a relative path are preserved and stack
+     instead of cancelling each other; ordinary components may follow and be
+     removed again afterwards.  */
+  check_one ("../a", "../a");
+  check_one ("../..", "../..");
+  check_one ("../../..", "../../..");
+  check_one ("../../a", "../../a");
+  check_one ("../a/..", "..");
+  check_one ("../../a/..", "../..");
+  check_one ("a/../../b", "../b");
+  check_one ("a/b/../../..", "..");
+
+  return 0;
+}
+
+#include <support/test-driver.c>
diff --git a/elf/tst-origin-secure-evilmod.c b/elf/tst-origin-secure-evilmod.c
new file mode 100644
index 00000000000..0913e32e21b
--- /dev/null
+++ b/elf/tst-origin-secure-evilmod.c
@@ -0,0 +1,28 @@
+/* Module for tst-origin-secure (the attacker-controlled copy).
+   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 "tst-origin-secure.h"
+
+/* If the victim reports this copy, the loader opened the un-normalized rpath
+   and resolved it through the attacker's symlink -- i.e. the trusted-path
+   check was bypassed (bug 34360).  */
+int
+origin_secure_id (void)
+{
+  return ORIGIN_SECURE_ID_ATTACKER;
+}
diff --git a/elf/tst-origin-secure-victim.c b/elf/tst-origin-secure-victim.c
new file mode 100644
index 00000000000..601265aaa40
--- /dev/null
+++ b/elf/tst-origin-secure-victim.c
@@ -0,0 +1,43 @@
+/* Victim program for tst-origin-secure.
+   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 "tst-origin-secure.h"
+
+extern int __libc_enable_secure;
+
+/* Report both which module was loaded and whether the loader ran in secure
+   mode, so the driver can tell a genuine trusted-path bypass apart from a run
+   that simply was not secure.
+
+   The exit status is the combination of the ORIGIN_SECURE_STATUS_* bits:
+
+     _NONE                    trusted copy, not secure
+     _ATTACKER                attacker copy, not secure  (the control run)
+     _SECURE                  trusted copy, secure       (a fixed loader)
+     _SECURE | _ATTACKER      attacker copy, secure      (the bug: the raw
+                                                          rpath was opened)  */
+int
+main (void)
+{
+  int status = ORIGIN_SECURE_STATUS_NONE;
+  if (origin_secure_id () == ORIGIN_SECURE_ID_ATTACKER)
+    status |= ORIGIN_SECURE_STATUS_ATTACKER;
+  if (__libc_enable_secure != 0)
+    status |= ORIGIN_SECURE_STATUS_SECURE;
+  return status;
+}
diff --git a/elf/tst-origin-secure.c b/elf/tst-origin-secure.c
new file mode 100644
index 00000000000..f304823b498
--- /dev/null
+++ b/elf/tst-origin-secure.c
@@ -0,0 +1,203 @@
+/* Test that AT_SECURE $ORIGIN rpath entries are looked up using the
+   normalized (trusted) path, not the raw expansion (bug 34360).
+
+   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/>.  */
+
+
+/* For a SUID/SGID program the loader only honors $ORIGIN in DT_RPATH when
+   the normalized expansion is rooted in a trusted directory.  If the loader
+   opens the un-normalized string (that contains "../"), it might disagree
+   as soon as a path component is a symbolic link.
+
+   This test builds the executable with the rpath:
+
+     $ORIGIN/sub/../../../../..SLIBDIR/tst-origin-secure
+
+   and runs it as BASE/a/b/victim, so $ORIGIN is BASE/a/b.  Lexically the
+   five "../" pop $ORIGIN/sub back to "/" (BASE is /tmp/tst-origin-secure,
+   so $ORIGIN/sub is the five components tmp, tst-origin-secure, a, b, sub),
+   and the entry normalizes to the trusted SLIBDIR/tst-origin-secure.  But
+   "sub" is a symlink pointing six levels deep under BASE, so opening the raw
+   string makes the kernel resolve the "../" through the symlink and land in
+   BASE/x1 SLIBDIR/tst-origin-secure instead.
+
+   The "../" count in the rpath (see the Makefile) is therefore
+   depth(BASE) + 2 (for the "a/b" of $ORIGIN) + 1 (for "sub"); it is
+   independent of SLIBDIR, which is appended whole on both the raw and the
+   normalized side.
+
+   A trusted copy of the module (ORIGIN_SECURE_ID_TRUSTED) is installed in
+   SLIBDIR/tst-origin-secure; an attacker copy (ORIGIN_SECURE_ID_ATTACKER) is
+   placed at the symlink-diverted location.  The trusted subdirectory is
+   rooted under SLIBDIR (so it passes the trusted-path check) but is not
+   itself a default loader search directory.
+
+   The victim reports, in its exit status, both which module it loaded and
+   whether it ran in secure mode.
+
+   Secure mode is forced with glibc.rtld.enable_secure=1 so that no real
+   SUID/SGID binary is required.  */
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <unistd.h>
+#include <sys/wait.h>
+
+#include <support/capture_subprocess.h>
+#include <support/check.h>
+#include <support/support.h>
+#include <support/xunistd.h>
+#include "tst-origin-secure.h"
+
+#define BASE   "/tmp/tst-origin-secure"
+#define SONAME "libtst-origin-secure-mod.so"
+/* Subdirectory of the trusted SLIBDIR that the rpath normalizes to.  It is
+   trusted (rooted under SLIBDIR) but not a default search directory.  */
+#define SUBDIR "tst-origin-secure"
+
+static int
+run_victim (char *const *envp)
+{
+  const char *victim = BASE "/a/b/victim";
+  char *const argv[] = { (char *) victim, NULL };
+
+  struct support_capture_subprocess res
+    = support_capture_subprogram (victim, argv, envp);
+  /* The victim itself prints nothing; forward any loader diagnostics to
+     the test log.  */
+  if (res.err.length > 0)
+    printf ("info: victim stderr: %s\n", res.err.buffer);
+  int status = res.status;
+  support_capture_subprocess_free (&res);
+  return WIFEXITED (status) ? WEXITSTATUS (status) : -1;
+}
+
+/* With SLIBDIR "/lib64" the container layout is:
+
+     /lib64/tst-origin-secure/libtst-origin-secure-mod.so   trusted copy (id 1)
+     BASE/a/b/victim                                        the executable
+     BASE/a/b/sub -> BASE/x1/x2/x3/x4/x5/x6                 six levels deep
+     BASE/x1/lib64/tst-origin-secure/libtst-origin-secure-mod.so
+                                                            attacker copy (id 2)
+     BASE/x1/x2/x3/x4/x5/x6/                                the symlink target
+
+   The victim's rpath is $ORIGIN/sub + five "../" + /lib64/tst-origin-secure.  */
+static void
+do_prepare (int argc, char **argv)
+{
+  const char *slibdir = support_slibdir_prefix;
+  const char *objelf = support_objdir_root;
+
+  char *good_src = xasprintf ("%s/elf/libtst-origin-secure-mod.so", objelf);
+  char *evil_src = xasprintf ("%s/elf/tst-origin-secure-evilmod.so", objelf);
+  char *victim_src = xasprintf ("%s/elf/tst-origin-secure-victim", objelf);
+
+  xmkdirp (BASE "/a/b", 0755);
+  xmkdirp (BASE "/x1/x2/x3/x4/x5/x6", 0755);
+
+  /* Where the trusted copy lives (reached only via the normalized rpath,
+     SLIBDIR/SUBDIR) ...  */
+  char *good_dir = xasprintf ("%s/%s", slibdir, SUBDIR);
+  char *good_dst = xasprintf ("%s/%s", good_dir, SONAME);
+  xmkdirp (good_dir, 0755);
+  /* ... and where the raw, symlink-diverted lookup lands.  */
+  char *evil_dir = xasprintf ("%s/x1%s/%s", BASE, slibdir, SUBDIR);
+  char *evil_dst = xasprintf ("%s/%s", evil_dir, SONAME);
+  xmkdirp (evil_dir, 0755);
+
+  /* support_copy_file preserves the source mode, so the victim stays
+     executable and the modules readable; no chmod is needed.  */
+  support_copy_file (good_src, good_dst);
+  support_copy_file (evil_src, evil_dst);
+  support_copy_file (victim_src, BASE "/a/b/victim");
+
+  unlink (BASE "/a/b/sub");
+  xsymlink (BASE "/x1/x2/x3/x4/x5/x6", BASE "/a/b/sub");
+
+  free (good_src);
+  free (evil_src);
+  free (victim_src);
+  free (good_dir);
+  free (good_dst);
+  free (evil_dir);
+  free (evil_dst);
+}
+#define PREPARE do_prepare
+
+static int
+do_test (void)
+{
+  /* Control run: in normal mode $ORIGIN is honored without the trusted check,
+     so the raw rpath resolves through "sub" and the attacker copy is
+     loaded.  */
+  {
+    char *const env[] = { NULL };
+    int rc = run_victim (env);
+    if (rc != ORIGIN_SECURE_STATUS_ATTACKER)
+      FAIL_EXIT1 ("control run returned status %d, expected %d (attacker "
+		  "copy, not secure): the $ORIGIN layout does not reproduce "
+		  "the divergence between the raw and the normalized rpath",
+		  rc, ORIGIN_SECURE_STATUS_ATTACKER);
+  }
+
+  /* Secure run: force AT_SECURE.  A fixed loader normalizes the rpath to the
+     trusted SLIBDIR/SUBDIR and loads the trusted copy; a loader with the bug
+     opens the raw path, resolves "sub", and loads the attacker copy.  */
+  {
+    char *const env[] = { (char *) "GLIBC_TUNABLES=glibc.rtld.enable_secure=1",
+			  NULL };
+    int rc = run_victim (env);
+    switch (rc)
+      {
+      /* Secure, trusted copy loaded via the normalized rpath: fixed.  */
+      case ORIGIN_SECURE_STATUS_SECURE:
+	break;
+
+      /* Secure, attacker copy loaded: the raw rpath was opened.  */
+      case ORIGIN_SECURE_STATUS_SECURE | ORIGIN_SECURE_STATUS_ATTACKER:
+	FAIL_EXIT1 ("secure-mode loader resolved the un-normalized rpath "
+		    "through the attacker symlink (bug 34360)");
+
+      /* Not secure, attacker copy: exactly what the control run produced, so
+	 the tunable did not engage and this run says nothing about the
+	 trusted-path handling.  */
+      case ORIGIN_SECURE_STATUS_ATTACKER:
+	FAIL_UNSUPPORTED ("glibc.rtld.enable_secure=1 did not enable "
+			  "secure mode (victim status %d)", rc);
+
+      /* Not secure, yet the trusted copy was loaded, which is reachable only
+	 through the normalized rpath, and only a secure loader normalizes it.
+	 Fail rather than report UNSUPPORTED.  */
+      case ORIGIN_SECURE_STATUS_NONE:
+	FAIL_EXIT1 ("secure run loaded the trusted copy but the victim "
+		    "reports not being secure: __libc_enable_secure is no "
+		    "longer a valid proxy for secure mode");
+
+      /* Neither copy loaded: since the trusted copy is reachable only through
+	 the normalized rpath, this means the rpath entry was not honored at
+	 all.  */
+      default:
+	FAIL_EXIT1 ("secure run did not load the module via the normalized "
+		    "rpath (victim status %d)", rc);
+      }
+  }
+
+  return 0;
+}
+
+#include <support/test-driver.c>
diff --git a/elf/tst-origin-secure.h b/elf/tst-origin-secure.h
new file mode 100644
index 00000000000..2e2b9446072
--- /dev/null
+++ b/elf/tst-origin-secure.h
@@ -0,0 +1,41 @@
+/* Definitions shared by the tst-origin-secure test, its victim and modules.
+   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/>.  */
+
+#ifndef _TST_ORIGIN_SECURE_H
+#define _TST_ORIGIN_SECURE_H 1
+
+enum
+  {
+    ORIGIN_SECURE_ID_TRUSTED = 1,  /* The copy installed in the trusted
+				      SLIBDIR.  */
+    ORIGIN_SECURE_ID_ATTACKER = 2, /* The copy reachable only by resolving the
+				      "sub" symlink.  */
+  };
+
+extern int origin_secure_id (void);
+
+enum
+  {
+    ORIGIN_SECURE_STATUS_NONE = 0,
+    ORIGIN_SECURE_STATUS_ATTACKER = 1 << 0,  /* The victim loaded attacker
+						rather than the trusted.  */
+    ORIGIN_SECURE_STATUS_SECURE = 1 << 1,    /* The loader ran the victim in
+						secure mode.  */
+  };
+
+#endif
diff --git a/elf/tst-origin-secure.root/postclean.req b/elf/tst-origin-secure.root/postclean.req
new file mode 100644
index 00000000000..e69de29bb2d
-- 
2.53.0



More information about the Libc-alpha mailing list