diff --git a/Lib/tarfile.py b/Lib/tarfile.py index f27a97030d22426..443f53e9b7386a7 100755 --- a/Lib/tarfile.py +++ b/Lib/tarfile.py @@ -2650,7 +2650,11 @@ def makelink_with_filter(self, tarinfo, targetpath, return else: if os.path.exists(tarinfo._link_target): - os.link(tarinfo._link_target, targetpath) + # Resolve the target so the hard link points to the file + # itself. Otherwise os.link() may duplicate a symlink to a + # shallower location, where it's relative target escapes the + # destination directory. (CVE-2026-82049) + os.link(os.path.realpath(tarinfo._link_target), targetpath) return except symlink_exception: keyerror_to_extracterror = True diff --git a/Lib/test/support/os_helper.py b/Lib/test/support/os_helper.py index c1b2995ef37a12c..b7fc0c30864c4cf 100644 --- a/Lib/test/support/os_helper.py +++ b/Lib/test/support/os_helper.py @@ -9,6 +9,8 @@ import unittest import warnings +from test import support + # Filename used for testing if os.name == 'java': @@ -194,6 +196,23 @@ def skip_unless_symlink(test): return test if ok else unittest.skip(msg)(test) +_can_hardlink = None + +def can_hardlink(): + global _can_hardlink + if _can_hardlink is None: + # Android blocks hard links using SELinux + # (https://stackoverflow.com/q/32365690). + _can_hardlink = hasattr(os, "link") and not support.is_android + return _can_hardlink + + +def skip_unless_hardlink(test): + ok = can_hardlink() + msg = "requires hardlink support" + return test if ok else unittest.skip(msg)(test) + + _can_xattr = None diff --git a/Lib/test/test_tarfile.py b/Lib/test/test_tarfile.py index 865d44709dff559..1c4facb0bca2acd 100644 --- a/Lib/test/test_tarfile.py +++ b/Lib/test/test_tarfile.py @@ -4105,6 +4105,24 @@ def test_sneaky_hardlink_fallback_deep(self): self.expect_file("a/b/s", symlink_to=os.path.join('..', 'escape')) self.expect_file("s", symlink_to=os.path.join('..', 'escape')) + @symlink_test + @os_helper.skip_unless_hardlink + def test_sneaky_hardlink_relocation(self): + with ArchiveMaker() as arc: + arc.add("a/escape", content="decoy") + arc.add("a/b/s", symlink_to=os.path.join("..", "escape")) + arc.add("s", hardlink_to=os.path.join("a", "b", "s")) + + for filter in 'data', 'tar': + with self.subTest(filter), self.check_context(arc.open(), filter): + self.expect_file("a/escape", content="decoy") + if os_helper.can_symlink(): + self.expect_file("a/b/s", symlink_to=os.path.join('..', 'escape')) + else: + self.expect_file("a/b/s", content="decoy") + self.expect_file("s", content="decoy") + self.assertFalse((self.destdir / "s").is_symlink()) + @symlink_test def test_exfiltration_via_symlink(self): # (CVE-2025-4138) diff --git a/Misc/NEWS.d/next/Security/2026-09-06-11-02-46.gh-issue-157190.tarhln.rst b/Misc/NEWS.d/next/Security/2026-09-06-11-02-46.gh-issue-157190.tarhln.rst new file mode 100644 index 000000000000000..c3aac4084c1859c --- /dev/null +++ b/Misc/NEWS.d/next/Security/2026-09-06-11-02-46.gh-issue-157190.tarhln.rst @@ -0,0 +1,5 @@ +Fixed a vulnerability in the :mod:`tarfile` ``data`` and ``tar`` extraction +filters where a crafted archive using a hard link to a symbolic link could +change the permissions and modification time of a file outside the +destination directory, and expose its contents inside the extracted tree. +This addresses CVE 2026-82049.