[php-src] PHP-8.4.24: Fix GHSA-vc5h-9ppw-p5f3: phar circular symlink crash

Jakub Zelenka via Ilija Tovilo <[email protected]> Wed, 29 Jul 2026 05:23:32 +0000
Newsgroups gmane.comp.php.cvs.general
Message-ID <[email protected]>
Author: Jakub Zelenka (bukka)
Committer: Ilija Tovilo (iluuu1994)
Pusher: NattyNarwhal
Date: 2026-07-28T11:09:58+02:00

Commit: https://github.com/php/php-src/commit/16af1694dd4e0d0e4a24f90740651d3053edffa3
Raw diff: https://github.com/php/php-src/commit/16af1694dd4e0d0e4a24f90740651d3053edffa3.diff

Fix GHSA-vc5h-9ppw-p5f3: phar circular symlink crash

Prevents infinite recursion in phar_get_link_source.

Changed paths:
  A  ext/phar/tests/tar/files/circular_symlinks.tar
  A  ext/phar/tests/tar/files/circular_symlinks_long.tar
  A  ext/phar/tests/tar/files/circular_symlinks_rho.tar
  A  ext/phar/tests/tar/ghsa-vc5h-9ppw-p5f3-symlink-circular.phpt
  M  ext/phar/util.c


Diff:

diff --git a/ext/phar/tests/tar/files/circular_symlinks.tar b/ext/phar/tests/tar/files/circular_symlinks.tar
new file mode 100644
index 000000000000..2af7bb8c2384
Binary files /dev/null and b/ext/phar/tests/tar/files/circular_symlinks.tar differ
diff --git a/ext/phar/tests/tar/files/circular_symlinks_long.tar b/ext/phar/tests/tar/files/circular_symlinks_long.tar
new file mode 100644
index 000000000000..b2b4c1cb095c
Binary files /dev/null and b/ext/phar/tests/tar/files/circular_symlinks_long.tar differ
diff --git a/ext/phar/tests/tar/files/circular_symlinks_rho.tar b/ext/phar/tests/tar/files/circular_symlinks_rho.tar
new file mode 100644
index 000000000000..2ccbf17c8c63
Binary files /dev/null and b/ext/phar/tests/tar/files/circular_symlinks_rho.tar differ
diff --git a/ext/phar/tests/tar/ghsa-vc5h-9ppw-p5f3-symlink-circular.phpt b/ext/phar/tests/tar/ghsa-vc5h-9ppw-p5f3-symlink-circular.phpt
new file mode 100644
index 000000000000..cf0048c0fcd9
--- /dev/null
+++ b/ext/phar/tests/tar/ghsa-vc5h-9ppw-p5f3-symlink-circular.phpt
@@ -0,0 +1,27 @@
+--TEST--
+GHSA-vc5h-9ppw-p5f3 (circular symlinks in tar should not cause stack overflow)
+--CREDITS--
+Calvin Young - eWalker Consulting (HK) Limited
+Enoch Chow - Isomorph Cyber
+--EXTENSIONS--
+phar
+--FILE--
+<?php
+$base = dirname(__FILE__);
+
+// simple 2-cycle
+$phar = new PharData($base . '/files/circular_symlinks.tar');
+var_dump($phar['file_a']->getContent() === '');
+
+// rho-shaped cycle (tail leading into a loop)
+$phar = new PharData($base . '/files/circular_symlinks_rho.tar');
+var_dump($phar['file_a']->getContent() === '');
+
+// long cycle (400 entries)
+$phar = new PharData($base . '/files/circular_symlinks_long.tar');
+var_dump($phar['link_0']->getContent() === '');
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/phar/util.c b/ext/phar/util.c
index 2d1db6a1b3e1..d3bdf3d52a78 100644
--- a/ext/phar/util.c
+++ b/ext/phar/util.c
@@ -60,30 +60,59 @@ static char *phar_get_link_location(phar_entry_info *entry) /* {{{ */
 }
 /* }}} */
 
-phar_entry_info *phar_get_link_source(phar_entry_info *entry) /* {{{ */
+static phar_entry_info *phar_follow_one_link(phar_entry_info *entry)
 {
 	phar_entry_info *link_entry;
 	char *link;
 
-	if (!entry->link) {
-		return entry;
-	}
-
 	link = phar_get_link_location(entry);
 	if (NULL != (link_entry = zend_hash_str_find_ptr(&(entry->phar->manifest), entry->link, strlen(entry->link))) ||
 		NULL != (link_entry = zend_hash_str_find_ptr(&(entry->phar->manifest), link, strlen(link)))) {
 		if (link != entry->link) {
 			efree(link);
 		}
-		return phar_get_link_source(link_entry);
-	} else {
-		if (link != entry->link) {
-			efree(link);
+		return link_entry;
+	}
+
+	if (link != entry->link) {
+		efree(link);
+	}
+	return NULL;
+}
+
+phar_entry_info *phar_get_link_source(phar_entry_info *entry)
+{
+	phar_entry_info *slow, *fast;
+
+	if (!entry->link) {
+		return entry;
+	}
+
+	/*
+	 * Use Floyd's cycle detection algorithm to follow the symlink chain without unbounded
+	 * recursion. Each entry has at most one outgoing link, so if a cycle exists the fast pointer
+	 * will eventually meet the slow one. Otherwise the fast pointer reaches the end first.
+	 */
+	slow = fast = entry;
+	while (1) {
+		fast = phar_follow_one_link(fast);
+		if (!fast || !fast->link) {
+			return fast;
+		}
+		fast = phar_follow_one_link(fast);
+		if (!fast || !fast->link) {
+			return fast;
+		}
+
+		/* no need to check slow as it's always behind */
+		slow = phar_follow_one_link(slow);
+
+		if (slow == fast) {
+			/* circular symlink chain */
+			return NULL;
 		}
-		return NULL;
 	}
 }
-/* }}} */
 
 static php_stream *phar_get_entrypufp(const phar_entry_info *entry)
 {