From e2a8b213ecc803d8212ca98fb9859bcc5fbfac99 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Sat, 18 Apr 2026 12:28:11 -0400 Subject: [PATCH] phar: reject extractTo() when destination parent escapes via symlink Phar::extractTo() builds the target path as dest + "/" + entry_path. CWD_EXPAND validates entry_path against ".." traversal but doesn't resolve symlinks. A pre-existing symlink in the destination (e.g., /dest/subdir -> /etc) writes the extracted file outside the intended root. After creating the parent directory, resolve it with VCWD_REALPATH and verify it stays under dest. Return FAILURE if it escapes so the caller throws a PharException. --- ext/phar/phar_object.c | 34 +++++++++++ .../tests/gh-ss008-extractto-symlink.phpt | 59 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 ext/phar/tests/gh-ss008-extractto-symlink.phpt diff --git a/ext/phar/phar_object.c b/ext/phar/phar_object.c index 18db3190bb03..bab276cbb7f7 100644 --- a/ext/phar/phar_object.c +++ b/ext/phar/phar_object.c @@ -4282,6 +4282,40 @@ static zend_result phar_extract_file(bool overwrite, phar_entry_info *entry, cha return SUCCESS; } + { + const char *last_sep = zend_memrchr(fullpath + dest_len + 1, '/', strlen(fullpath) - dest_len - 1); + if (last_sep) { + char parent_path[MAXPATHLEN]; + char resolved[MAXPATHLEN]; + char resolved_dest[MAXPATHLEN]; + size_t parent_len = last_sep - fullpath; + if (parent_len >= MAXPATHLEN) { + spprintf(error, 4096, "Cannot extract \"%s\", path too long", entry->filename); + efree(fullpath); + return FAILURE; + } + if (!VCWD_REALPATH(dest, resolved_dest)) { + spprintf(error, 4096, "Cannot extract \"%s\", could not resolve destination directory", entry->filename); + efree(fullpath); + return FAILURE; + } + memcpy(parent_path, fullpath, parent_len); + parent_path[parent_len] = '\0'; + if (!VCWD_REALPATH(parent_path, resolved)) { + spprintf(error, 4096, "Cannot extract \"%s\", could not resolve parent directory", entry->filename); + efree(fullpath); + return FAILURE; + } + size_t resolved_dest_len = strlen(resolved_dest); + if (strncmp(resolved, resolved_dest, resolved_dest_len) != 0 || + (!IS_SLASH(resolved[resolved_dest_len]) && resolved[resolved_dest_len] != '\0')) { + spprintf(error, 4096, "Cannot extract \"%s\", symlink traversal detected", entry->filename); + efree(fullpath); + return FAILURE; + } + } + } + fp = php_stream_open_wrapper(fullpath, "w+b", REPORT_ERRORS, NULL); if (!fp) { diff --git a/ext/phar/tests/gh-ss008-extractto-symlink.phpt b/ext/phar/tests/gh-ss008-extractto-symlink.phpt new file mode 100644 index 000000000000..9089e0b79dbf --- /dev/null +++ b/ext/phar/tests/gh-ss008-extractto-symlink.phpt @@ -0,0 +1,59 @@ +--TEST-- +extractTo: symlink traversal via pre-existing symlink in destination directory +--EXTENSIONS-- +phar +--SKIPIF-- + +--INI-- +phar.readonly=0 +--FILE-- +addFromString('subdir/evil.txt', 'should not arrive'); +unset($p); + +symlink($target, $dest . '/subdir'); + +try { + $phar = new Phar($pharFile); + $phar->extractTo($dest, null, true); + echo "EXTRACTED (unexpected)\n"; +} catch (PharException $e) { + echo "Caught PharException: " . $e->getMessage() . "\n"; +} + +if (file_exists($target . '/evil.txt')) { + echo "FAIL: target was written\n"; +} else { + echo "OK: target not written\n"; +} +?> +--CLEAN-- + +--EXPECTF-- +Caught PharException: %s +OK: target not written