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