How about the attached patches? They add a new option --absolute-links, as I imagine the change to tar's default behavior might cause trouble and the new option is a smaller hammer than --absolute-names (-P).

I haven't installed them. I'd like a bit more time to think about them as this can be a tricky area.
From e785482ad797a62871f68564264fb1aa05997be5 Mon Sep 17 00:00:00 2001
From: Paul Eggert <egg...@cs.ucla.edu>
Date: Fri, 8 Aug 2025 13:35:32 -0700
Subject: [PATCH 1/2] Refactor contains_dot_dot

* src/names.c (first_dot_dot): Rename from contains_dot_dot.
Return offset of first ".." on success, -1 on failure.
All callers changed.
---
 src/common.h  |  2 +-
 src/extract.c |  6 +++---
 src/names.c   | 11 ++++++-----
 3 files changed, 10 insertions(+), 9 deletions(-)

diff --git a/src/common.h b/src/common.h
index 9bc06523..99ceef1c 100644
--- a/src/common.h
+++ b/src/common.h
@@ -819,7 +819,7 @@ bool all_names_found (struct tar_stat_info *st);
 void add_avoided_name (char const *name);
 bool is_avoided_name (char const *name);
 
-bool contains_dot_dot (char const *name);
+ptrdiff_t first_dot_dot (char const *name);
 
 COMMON_INLINE bool
 isfound (struct name const *c)
diff --git a/src/extract.c b/src/extract.c
index 3b913c54..874d2275 100644
--- a/src/extract.c
+++ b/src/extract.c
@@ -1528,7 +1528,7 @@ extract_link (char *file_name, MAYBE_UNUSED char typeflag)
 
   link_name = current_stat_info.link_name;
 
-  if ((! absolute_names_option && contains_dot_dot (link_name))
+  if ((! absolute_names_option && 0 <= first_dot_dot (link_name))
       || find_delayed_link_source (link_name))
     return create_placeholder_file (file_name, false, &interdir_made);
 
@@ -1593,7 +1593,7 @@ extract_symlink (char *file_name, MAYBE_UNUSED char typeflag)
 
   if (! absolute_names_option
       && (IS_ABSOLUTE_FILE_NAME (current_stat_info.link_name)
-	  || contains_dot_dot (current_stat_info.link_name)))
+	  || 0 <= first_dot_dot (current_stat_info.link_name)))
     return create_placeholder_file (file_name, true, &interdir_made);
 
   while (symlinkat (current_stat_info.link_name, chdir_fd, file_name) < 0)
@@ -1818,7 +1818,7 @@ extract_archive (void)
   set_next_block_after (current_header);
 
   skip_dotdot_name = (!absolute_names_option
-		      && contains_dot_dot (current_stat_info.orig_file_name));
+		      && 0 <= first_dot_dot (current_stat_info.orig_file_name));
   if (skip_dotdot_name)
     paxerror (0, _("%s: Member name contains '..'"),
 	      quotearg_colon (current_stat_info.orig_file_name));
diff --git a/src/names.c b/src/names.c
index ebdec2e0..87d695bf 100644
--- a/src/names.c
+++ b/src/names.c
@@ -1988,21 +1988,22 @@ stripped_prefix_len (char const *file_name, idx_t num)
   return -1;
 }
 
-/* Return nonzero if NAME contains ".." as a file name component.  */
-bool
-contains_dot_dot (char const *name)
+/* Return the offset of NAME's first ".." file name component if one exists,
+   a negative number otherwise.  */
+ptrdiff_t
+first_dot_dot (char const *name)
 {
   char const *p = name + FILE_SYSTEM_PREFIX_LEN (name);
 
   for (;; p++)
     {
       if (p[0] == '.' && p[1] == '.' && (ISSLASH (p[2]) || !p[2]))
-	return 1;
+	return p - name;
 
       while (! ISSLASH (*p))
 	{
 	  if (! *p++)
-	    return 0;
+	    return -1;
 	}
     }
 }
-- 
2.48.1

From a2675e279500fe52506f43d7105c205e2e962457 Mon Sep 17 00:00:00 2001
From: Paul Eggert <egg...@cs.ucla.edu>
Date: Sat, 9 Aug 2025 02:56:02 -0700
Subject: [PATCH 2/2] New --absolute-links option

Also, default to not extracting suspicious links.
CVE-2025-45582 reported by Lior Kaplan in:
https://lists.gnu.org/r/bug-tar/2025-08/msg00000.html
Also see:
https://nvd.nist.gov/vuln/detail/CVE-2025-45582
https://github.com/i900008/vulndb/blob/main/Gnu_tar_vuln.md
* src/common.h (struct chdir_id): New struct.
* src/extract.c (extract_link, extract_symlink):
Omit suspicious links unless --absolute-links or --absolute-names.
* src/misc.c (struct wd): New members id_known, id.
(grow_wd): New function, extracted from chdir_arg.
(chdir_arg): Use it.  Initialize id_known.
(chdir_id): New function.
* src/tar.c (absolute_links_option): New var.
(ABSOLUTE_LINKS_OPTION): New constant.
(options, parse_opt): Support new option.
---
 NEWS          | 10 ++++++-
 doc/tar.texi  | 48 ++++++++++++++++++++++++--------
 src/common.h  |  2 ++
 src/extract.c | 77 +++++++++++++++++++++++++++++++++++++++++++++++----
 src/misc.c    | 52 ++++++++++++++++++++++++++--------
 src/tar.c     | 11 +++++++-
 6 files changed, 170 insertions(+), 30 deletions(-)

diff --git a/NEWS b/NEWS
index 9a10b8b8..8f8c4659 100644
--- a/NEWS
+++ b/NEWS
@@ -1,10 +1,18 @@
-GNU tar NEWS - User visible changes. 2025-07-26
+GNU tar NEWS - User visible changes. 2025-08-09
 Please send GNU tar bug reports to <bug-tar@gnu.org>
 
 version TBD
 
 * New manual section "Reproducibility", for reproducible tarballs.
 
+* New option: --absolute-links
+
+By default, tar now refuses to extract suspicious hard or symbolic
+links to names that are suspicious because they start with '/' or
+contain '..'.  This new option re-enables the traditional, less-safe
+behavior of extracting suspicious links.  The new option is implied
+by --absolute-names (-P).
+
 * New options: --set-mtime-command and --set-mtime-format
 
 Both options are valid when archiving files.
diff --git a/doc/tar.texi b/doc/tar.texi
index 2fe2c45c..77114b32 100644
--- a/doc/tar.texi
+++ b/doc/tar.texi
@@ -1938,8 +1938,10 @@ prior to the execution of the @command{tar} command.
 working directory.  @command{tar} will make all file names relative
 (by removing leading slashes when archiving or restoring files),
 unless you specify otherwise (using the @option{--absolute-names}
-option).  @xref{absolute}, for more information about
-@option{--absolute-names}.
+option).  Also, @command{tar} refuses to extract links to absolute
+names unless you specify @option{--absolute-links} or
+@option{--absolute-names}.  @xref{absolute}, for more information
+about these options.
 
 If you give the name of a directory as either a file name or a member
 name, then @command{tar} acts recursively on all the files and directories
@@ -2447,6 +2449,14 @@ exist in the archive. @xref{update}.
 
 @table @option
 
+@opsummary{absolute-links}
+@item --absolute-links
+
+Normally when extracting from an archive, @command{tar} treats links
+specially if they have initial @samp{/} or internal @samp{..}.  This
+option disables that behavior.  This option is implied by
+@option{--absolute-names}.  @xref{absolute}.
+
 @opsummary{absolute-names}
 @item --absolute-names
 @itemx -P
@@ -2454,7 +2464,8 @@ exist in the archive. @xref{update}.
 Normally when creating an archive, @command{tar} strips an initial
 @samp{/} from member names, and when extracting from an archive @command{tar}
 treats names specially if they have initial @samp{/} or internal
-@samp{..}.  This option disables that behavior.  @xref{absolute}.
+@samp{..}.  This option disables that behavior.  Also, this option
+implies @option{--absolute-links}.  @xref{absolute}.
 
 @opsummary{acls}
 @item --acls
@@ -9528,9 +9539,14 @@ The interpretation of options in file lists is disabled by
 
 By default, @GNUTAR{} drops a leading @samp{/} on
 input or output, and complains about file names containing a @file{..}
-component.  There is an option that turns off this behavior:
+component.  Two options turn off this behavior:
 
 @table @option
+@opindex absolute-links
+@item --absolute-links
+When extracting, do not omit links to file names that start with slash
+or contain a @file{..} file name component.
+
 @opindex absolute-names
 @item --absolute-names
 @itemx -P
@@ -9569,8 +9585,22 @@ Symbolic links containing @file{..} or leading @samp{/} can also cause
 problems when extracting, so @command{tar} normally extracts them last;
 it may create empty files as placeholders during extraction.
 
+Similarly, @command{tar} ordinarily refuses to extract a hard or
+symbolic link to a file name starting with @samp{/} or containing
+@file{..}, as these links are suspicious and can be used to attack
+your system.
+
+If you use @option{--absolute-links}, @command{tar} extracts
+suspicious links instead of omitting them.  This option allows
+@command{tar} to create links that might cause a later @command{tar}
+extraction in the same directory to modify files outside the
+directory, so it is unsafe to use this option when extracting from an
+untrusted archive if a later extraction will be done in the same
+directory from an untrusted archive.
+
 If you use the @option{--absolute-names} (@option{-P}) option,
-@command{tar} will do none of these transformations.
+@command{tar} does not transform file names and extracts suspicious
+links.  This option is unsafe when extracting from untrusted archives.
 
 To archive or extract files relative to the root directory, specify
 the @option{--absolute-names} (@option{-P}) option.
@@ -9589,13 +9619,7 @@ may be more convenient than switching to root.
 @FIXME{Should be an example in the tutorial/wizardry section using this
 to transfer files between systems.}
 
-@table @option
-@item --absolute-names
-Preserves full file names (including superior directory names) when
-archiving and extracting files.
-
-@end table
-
+When you specify @option{--absolute-names} (@option{-P}),
 @command{tar} prints out a message about removing the @samp{/} from
 file names.  This message appears once per @GNUTAR{}
 invocation.  It represents something which ought to be told; ignoring
diff --git a/src/common.h b/src/common.h
index 99ceef1c..e98f7b2d 100644
--- a/src/common.h
+++ b/src/common.h
@@ -102,6 +102,7 @@ extern enum archive_format archive_format;
 extern idx_t blocking_factor;
 extern idx_t record_size;
 
+extern bool absolute_links_option;
 extern bool absolute_names_option;
 
 /* Display file times in UTC */
@@ -756,6 +757,7 @@ extern idx_t chdir_current;
 extern int chdir_fd;
 idx_t chdir_arg (char const *dir);
 void chdir_do (idx_t dir);
+struct chdir_id { bool valid; dev_t dev; ino_t ino; } chdir_id (void);
 idx_t chdir_count (void);
 
 void close_diag (char const *name);
diff --git a/src/extract.c b/src/extract.c
index 874d2275..085bd2b6 100644
--- a/src/extract.c
+++ b/src/extract.c
@@ -1528,6 +1528,13 @@ extract_link (char *file_name, MAYBE_UNUSED char typeflag)
 
   link_name = current_stat_info.link_name;
 
+  if (! absolute_links_option && IS_ABSOLUTE_FILE_NAME (link_name))
+    {
+      paxerror (0, _("%s: Omitting suspicious link to %s"),
+		quotearg_colon (file_name), quote_n (1, link_name));
+      return false;
+    }
+
   if ((! absolute_names_option && 0 <= first_dot_dot (link_name))
       || find_delayed_link_source (link_name))
     return create_placeholder_file (file_name, false, &interdir_made);
@@ -1590,13 +1597,21 @@ static bool
 extract_symlink (char *file_name, MAYBE_UNUSED char typeflag)
 {
   bool interdir_made = false;
+  char const *link_name = current_stat_info.link_name;
+
+  if (! absolute_links_option && IS_ABSOLUTE_FILE_NAME (link_name))
+    {
+      paxerror (0, _("%s: Omitting suspicious symlink to %s"),
+		quotearg_colon (file_name), quote_n (1, link_name));
+      return false;
+    }
 
   if (! absolute_names_option
       && (IS_ABSOLUTE_FILE_NAME (current_stat_info.link_name)
 	  || 0 <= first_dot_dot (current_stat_info.link_name)))
     return create_placeholder_file (file_name, true, &interdir_made);
 
-  while (symlinkat (current_stat_info.link_name, chdir_fd, file_name) < 0)
+  while (symlinkat (link_name, chdir_fd, file_name) < 0)
     switch (maybe_recoverable (file_name, false, &interdir_made))
       {
       case RECOVER_OK:
@@ -1618,7 +1633,7 @@ extract_symlink (char *file_name, MAYBE_UNUSED char typeflag)
 	      }
 	    return extract_link (file_name, typeflag);
 	  }
-	symlink_error (current_stat_info.link_name, file_name);
+	symlink_error (link_name, file_name);
 	return false;
       }
 
@@ -1876,9 +1891,51 @@ extract_archive (void)
     undo_last_backup ();
 }
 
-/* Extract the link DS whose final extraction was delayed.  */
+/* Return true if a link to TARGET from SOURCE might escape
+   the extraction directory.  Use *BUF, of size *BUFSIZE,
+   for temporary heap-allocated storage.
+   Be conservative: return true if is unknown whether the link escapes.  */
+
+static bool
+link_might_escape (char const *target, char const *source,
+		   char **buf, idx_t *bufsize)
+{
+  struct chdir_id dirid = chdir_id ();
+  if (!dirid.valid)
+    return true;
+
+  idx_t slen = dir_len (source), tlen = strlen (target), size = slen + tlen + 1;
+  if (*bufsize < size)
+    {
+      free (*buf);
+      *buf = xpalloc (NULL, bufsize, size - *bufsize, -1, 1);
+    }
+  char *name = *buf;
+  memcpy (mempcpy (name, source, slen), target, tlen + 1);
+
+  for (ptrdiff_t i = slen; ; i += sizeof ".." - 1)
+    {
+      off_t dot_dot_offset = first_dot_dot (&name[i]);
+      if (dot_dot_offset < 0)
+	break;
+      i += dot_dot_offset;
+      name[i + 1] = '\0';
+
+      struct stat st;
+      if (fstatat (chdir_fd, name, &st, 0) < 0
+	  || (dirid.ino == st.st_ino && dirid.dev == st.st_dev))
+	return true;
+
+      name[i + 1] = '.';
+    }
+
+  return false;
+}
+
+/* Extract the link DS whose final extraction was delayed.
+   Use *BUF, of size *BUFSIZE, for temporary heap-allocated storage.  */
 static void
-apply_delayed_link (struct delayed_link *ds)
+apply_delayed_link (struct delayed_link *ds, char **buf, idx_t *bufsize)
 {
   struct string_list *sources = ds->sources;
   char const *valid_source = 0;
@@ -1906,6 +1963,12 @@ apply_delayed_link (struct delayed_link *ds)
 		   && (linkat (chdir_fd, valid_source, chdir_fd, source, 0)
 		       == 0))
 	    ;
+	  else if (!absolute_links_option
+		   && link_might_escape (ds->target, source, buf, bufsize))
+	    paxerror (0, _(ds->is_symlink
+			   ? "%s: Omitting suspicious symlink to %s"
+			   : "%s: Omitting suspicious link to %s"),
+		      quotearg_colon (source), quote_n (1, ds->target));
 	  else if (!ds->is_symlink)
 	    {
 	      if (linkat (chdir_fd, ds->target, chdir_fd, source, 0) < 0)
@@ -1954,8 +2017,11 @@ apply_delayed_link (struct delayed_link *ds)
 static void
 apply_delayed_links (void)
 {
+  char *buf = NULL;
+  idx_t bufsize = 0;
+
   for (struct delayed_link *ds = delayed_link_head; ds; ds = ds->next)
-    apply_delayed_link (ds);
+    apply_delayed_link (ds, &buf, &bufsize);
 
   if (false && delayed_link_table)
     {
@@ -1963,6 +2029,7 @@ apply_delayed_links (void)
 	 and freeing is more likely to cause than cure trouble.
 	 Also, the above code has not bothered to free the list
 	 in delayed_link_head.  */
+      free (buf);
       hash_free (delayed_link_table);
       delayed_link_table = NULL;
     }
diff --git a/src/misc.c b/src/misc.c
index 187e45f5..e9900409 100644
--- a/src/misc.c
+++ b/src/misc.c
@@ -913,6 +913,10 @@ struct wd
      the working directory.  If zero, the directory needs to be opened
      to be used.  */
   int fd;
+
+  /* Whether the directory's identity is known, and if so, what it is.  */
+  bool id_known;
+  struct chdir_id id;
 };
 
 /* A vector of chdir targets.  wd[0] is the initial working directory.  */
@@ -943,23 +947,29 @@ chdir_count (void)
   return wd_count - !!wd_count;
 }
 
+/* Grow the WD table by at least one entry.  */
+static void
+grow_wd (void)
+{
+  wd = xpalloc (wd, &wd_alloc, wd_alloc ? 1 : 2, -1, sizeof *wd);
+
+  if (! wd_count)
+    {
+      wd[wd_count].name = ".";
+      wd[wd_count].abspath = NULL;
+      wd[wd_count].fd = AT_FDCWD;
+      wd[wd_count].id_known = false;
+      wd_count++;
+    }
+}
+
 /* DIR is the operand of a -C option; add it to vector of chdir targets,
    and return the index of its location.  */
 idx_t
 chdir_arg (char const *dir)
 {
   if (wd_count == wd_alloc)
-    {
-      wd = xpalloc (wd, &wd_alloc, wd_alloc ? 1 : 2, -1, sizeof *wd);
-
-      if (! wd_count)
-	{
-	  wd[wd_count].name = ".";
-	  wd[wd_count].abspath = NULL;
-	  wd[wd_count].fd = AT_FDCWD;
-	  wd_count++;
-	}
-    }
+    grow_wd ();
 
   /* Optimize the common special case of the working directory,
      or the working directory as a prefix.  */
@@ -975,6 +985,7 @@ chdir_arg (char const *dir)
   wd[wd_count].name = dir;
   wd[wd_count].abspath = NULL;
   wd[wd_count].fd = 0;
+  wd[wd_count].id_known = false;
   return wd_count++;
 }
 
@@ -1046,6 +1057,25 @@ chdir_do (idx_t i)
       chdir_fd = fd;
     }
 }
+
+/* Return the identity of the current directory.  */
+struct chdir_id
+chdir_id (void)
+{
+  if (!wd)
+    grow_wd ();
+
+  struct wd *curr = &wd[chdir_current];
+  if (!curr->id_known)
+    {
+      struct stat st;
+      curr->id = ((chdir_fd < 0 ? stat (".", &st) : fstat (chdir_fd, &st)) < 0
+		  ? (struct chdir_id) { false }
+		  : (struct chdir_id) { true, st.st_dev, st.st_ino });
+      curr->id_known = true;
+    }
+  return curr->id;
+}
 
 const char *
 tar_dirname (void)
diff --git a/src/tar.c b/src/tar.c
index c58a19fa..ad633ef0 100644
--- a/src/tar.c
+++ b/src/tar.c
@@ -36,6 +36,7 @@ enum subcommand subcommand_option;
 enum archive_format archive_format;
 idx_t blocking_factor;
 idx_t record_size;
+bool absolute_links_option;
 bool absolute_names_option;
 bool utc_option;
 bool full_time_option;
@@ -352,7 +353,8 @@ tar_set_quoting_style (char *arg)
 
 enum
 {
-  ACLS_OPTION = CHAR_MAX + 1,
+  ABSOLUTE_LINKS_OPTION = CHAR_MAX + 1,
+  ACLS_OPTION,
   ATIME_PRESERVE_OPTION,
   BACKUP_OPTION,
   CHECK_DEVICE_OPTION,
@@ -827,6 +829,8 @@ static struct argp_option options[] = {
    N_("stay in local file system when creating archive"), GRID_FILE },
   {"absolute-names", 'P', 0, 0,
    N_("don't strip leading '/'s from file names"), GRID_FILE },
+  {"absolute-links", ABSOLUTE_LINKS_OPTION, 0, 0,
+   N_("extract absolute links instead of omitting"), GRID_FILE },
   {"dereference", 'h', 0, 0,
    N_("follow symlinks; archive and dump the files they point to"),
    GRID_FILE },
@@ -1734,9 +1738,14 @@ parse_opt (int key, char *arg, struct argp_state *state)
       same_permissions_option = true;
       break;
 
+    case ABSOLUTE_LINKS_OPTION:
+      absolute_links_option = true;
+      break;
+
     case 'P':
       optloc_save (OC_ABSOLUTE_NAMES, args->loc);
       absolute_names_option = true;
+      absolute_links_option = true;
       break;
 
     case 'r':
-- 
2.48.1

Reply via email to