From 9f81791353d8f1a321b22546fc5e9ef58fb0fc21 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Wed, 10 Jun 2026 13:11:15 +0100 Subject: [PATCH 1/4] Fix bypass --- Lib/tarfile.py | 4 ++- Lib/test/test_tarfile.py | 27 +++++++++++++++++++ ...-06-10-13-08-19.gh-issue-111111.mL74i2.rst | 3 +++ 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Security/2026-06-10-13-08-19.gh-issue-111111.mL74i2.rst diff --git a/Lib/tarfile.py b/Lib/tarfile.py index a293a049247274..c4cdc77e4092ae 100644 --- a/Lib/tarfile.py +++ b/Lib/tarfile.py @@ -2784,7 +2784,9 @@ def makelink_with_filter(self, tarinfo, targetpath, "makelink_with_filter: if filter_function is not None, " + "extraction_root must also not be None") try: - filtered = filter_function(unfiltered, extraction_root) + filtered = filter_function( + unfiltered.replace(name=tarinfo.name, deep=False), + extraction_root) except _FILTER_ERRORS as cause: raise LinkFallbackError(tarinfo, unfiltered.name) from cause if filtered is not None: diff --git a/Lib/test/test_tarfile.py b/Lib/test/test_tarfile.py index 62a262740a7efa..1658311b8e76ae 100644 --- a/Lib/test/test_tarfile.py +++ b/Lib/test/test_tarfile.py @@ -4413,6 +4413,33 @@ def test_sneaky_hardlink_fallback(self): self.expect_file("boom", symlink_to='../../link_here') self.expect_file("c", symlink_to='b') + @symlink_test + def test_sneaky_hardlink_fallback_deep(self): + with ArchiveMaker() as arc: + arc.add("a/b/s", symlink_to=os.path.join("..", "escape")) + arc.add("s", hardlink_to=os.path.join("a", "b", "s")) + + with self.check_context(arc.open(), 'data'): + if not os_helper.can_symlink() or sys.platform == "win32": + # See notes in test_sneaky_hardlink_fallback. + self.expect_exception(tarfile.LinkOutsideDestinationError) + else: + e = self.expect_exception( + tarfile.LinkFallbackError, + "link 's' would be extracted as a copy of " + + "'a/b/s', which was rejected") + self.assertIsInstance(e.__cause__, + tarfile.LinkOutsideDestinationError) + + for filter in 'tar', 'fully_trusted': + with self.subTest(filter), self.check_context(arc.open(), filter): + if not os_helper.can_symlink(): + self.expect_file("a/b/s") + self.expect_file("s") + else: + self.expect_file("a/b/s", symlink_to=os.path.join('..', 'escape')) + self.expect_file("s", symlink_to=os.path.join('..', 'escape')) + @symlink_test def test_exfiltration_via_symlink(self): # (CVE-2025-4138) diff --git a/Misc/NEWS.d/next/Security/2026-06-10-13-08-19.gh-issue-111111.mL74i2.rst b/Misc/NEWS.d/next/Security/2026-06-10-13-08-19.gh-issue-111111.mL74i2.rst new file mode 100644 index 00000000000000..74459d5680e21a --- /dev/null +++ b/Misc/NEWS.d/next/Security/2026-06-10-13-08-19.gh-issue-111111.mL74i2.rst @@ -0,0 +1,3 @@ +Fixed an vulnerability in the :mod:`tarfile` ``data`` and ``tar`` extraction +filters where crafted archives could create a symlink pointing outside the +destination directory. This was a bypass of :cve:`2025-4330`. From e529baf5c4747990fcb69b200959bda7cddddd25 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Tue, 16 Jun 2026 22:02:24 +0100 Subject: [PATCH 2/4] Update with issue number --- Lib/test/test_tarfile.py | 1 + ...mL74i2.rst => 2026-06-10-13-08-19.gh-issue-151558.mL74i2.rst} | 0 2 files changed, 1 insertion(+) rename Misc/NEWS.d/next/Security/{2026-06-10-13-08-19.gh-issue-111111.mL74i2.rst => 2026-06-10-13-08-19.gh-issue-151558.mL74i2.rst} (100%) diff --git a/Lib/test/test_tarfile.py b/Lib/test/test_tarfile.py index 1658311b8e76ae..16f3766fa99681 100644 --- a/Lib/test/test_tarfile.py +++ b/Lib/test/test_tarfile.py @@ -4415,6 +4415,7 @@ def test_sneaky_hardlink_fallback(self): @symlink_test def test_sneaky_hardlink_fallback_deep(self): + # (CVE-2026-11940) with ArchiveMaker() as arc: arc.add("a/b/s", symlink_to=os.path.join("..", "escape")) arc.add("s", hardlink_to=os.path.join("a", "b", "s")) diff --git a/Misc/NEWS.d/next/Security/2026-06-10-13-08-19.gh-issue-111111.mL74i2.rst b/Misc/NEWS.d/next/Security/2026-06-10-13-08-19.gh-issue-151558.mL74i2.rst similarity index 100% rename from Misc/NEWS.d/next/Security/2026-06-10-13-08-19.gh-issue-111111.mL74i2.rst rename to Misc/NEWS.d/next/Security/2026-06-10-13-08-19.gh-issue-151558.mL74i2.rst From 4a477d6fc1107610173006c1c50a408b7aa42fc9 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Tue, 16 Jun 2026 22:40:03 +0100 Subject: [PATCH 3/4] Seems we don't need Windows gating here? --- Lib/test/test_tarfile.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/Lib/test/test_tarfile.py b/Lib/test/test_tarfile.py index 16f3766fa99681..3c5f8aa1b1b1a2 100644 --- a/Lib/test/test_tarfile.py +++ b/Lib/test/test_tarfile.py @@ -4421,16 +4421,12 @@ def test_sneaky_hardlink_fallback_deep(self): arc.add("s", hardlink_to=os.path.join("a", "b", "s")) with self.check_context(arc.open(), 'data'): - if not os_helper.can_symlink() or sys.platform == "win32": - # See notes in test_sneaky_hardlink_fallback. - self.expect_exception(tarfile.LinkOutsideDestinationError) - else: - e = self.expect_exception( - tarfile.LinkFallbackError, - "link 's' would be extracted as a copy of " - + "'a/b/s', which was rejected") - self.assertIsInstance(e.__cause__, - tarfile.LinkOutsideDestinationError) + e = self.expect_exception( + tarfile.LinkFallbackError, + "link 's' would be extracted as a copy of " + + "'a/b/s', which was rejected") + self.assertIsInstance(e.__cause__, + tarfile.LinkOutsideDestinationError) for filter in 'tar', 'fully_trusted': with self.subTest(filter), self.check_context(arc.open(), filter): From f6bc76e6beebe2bd16d55d067f1fe3a788da4200 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Wed, 17 Jun 2026 22:15:26 +0100 Subject: [PATCH 4/4] Validate containment at the write loc but extract the original referenced member --- Lib/tarfile.py | 3 ++- Lib/test/test_tarfile.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Lib/tarfile.py b/Lib/tarfile.py index c4cdc77e4092ae..63b77bd285f4f7 100644 --- a/Lib/tarfile.py +++ b/Lib/tarfile.py @@ -2784,9 +2784,10 @@ def makelink_with_filter(self, tarinfo, targetpath, "makelink_with_filter: if filter_function is not None, " + "extraction_root must also not be None") try: - filtered = filter_function( + filter_function( unfiltered.replace(name=tarinfo.name, deep=False), extraction_root) + filtered = filter_function(unfiltered, extraction_root) except _FILTER_ERRORS as cause: raise LinkFallbackError(tarinfo, unfiltered.name) from cause if filtered is not None: diff --git a/Lib/test/test_tarfile.py b/Lib/test/test_tarfile.py index 3c5f8aa1b1b1a2..24951d85f2506b 100644 --- a/Lib/test/test_tarfile.py +++ b/Lib/test/test_tarfile.py @@ -4431,8 +4431,8 @@ def test_sneaky_hardlink_fallback_deep(self): for filter in 'tar', 'fully_trusted': with self.subTest(filter), self.check_context(arc.open(), filter): if not os_helper.can_symlink(): - self.expect_file("a/b/s") - self.expect_file("s") + self.expect_file("a/") + self.expect_file("a/b/") else: self.expect_file("a/b/s", symlink_to=os.path.join('..', 'escape')) self.expect_file("s", symlink_to=os.path.join('..', 'escape'))