Skip to content

Commit e2a8b21

Browse files
committed
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.
1 parent f7eb5ef commit e2a8b21

File tree

2 files changed

+93
-0
lines changed

2 files changed

+93
-0
lines changed

ext/phar/phar_object.c

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4282,6 +4282,40 @@ static zend_result phar_extract_file(bool overwrite, phar_entry_info *entry, cha
42824282
return SUCCESS;
42834283
}
42844284

4285+
{
4286+
const char *last_sep = zend_memrchr(fullpath + dest_len + 1, '/', strlen(fullpath) - dest_len - 1);
4287+
if (last_sep) {
4288+
char parent_path[MAXPATHLEN];
4289+
char resolved[MAXPATHLEN];
4290+
char resolved_dest[MAXPATHLEN];
4291+
size_t parent_len = last_sep - fullpath;
4292+
if (parent_len >= MAXPATHLEN) {
4293+
spprintf(error, 4096, "Cannot extract \"%s\", path too long", entry->filename);
4294+
efree(fullpath);
4295+
return FAILURE;
4296+
}
4297+
if (!VCWD_REALPATH(dest, resolved_dest)) {
4298+
spprintf(error, 4096, "Cannot extract \"%s\", could not resolve destination directory", entry->filename);
4299+
efree(fullpath);
4300+
return FAILURE;
4301+
}
4302+
memcpy(parent_path, fullpath, parent_len);
4303+
parent_path[parent_len] = '\0';
4304+
if (!VCWD_REALPATH(parent_path, resolved)) {
4305+
spprintf(error, 4096, "Cannot extract \"%s\", could not resolve parent directory", entry->filename);
4306+
efree(fullpath);
4307+
return FAILURE;
4308+
}
4309+
size_t resolved_dest_len = strlen(resolved_dest);
4310+
if (strncmp(resolved, resolved_dest, resolved_dest_len) != 0 ||
4311+
(!IS_SLASH(resolved[resolved_dest_len]) && resolved[resolved_dest_len] != '\0')) {
4312+
spprintf(error, 4096, "Cannot extract \"%s\", symlink traversal detected", entry->filename);
4313+
efree(fullpath);
4314+
return FAILURE;
4315+
}
4316+
}
4317+
}
4318+
42854319
fp = php_stream_open_wrapper(fullpath, "w+b", REPORT_ERRORS, NULL);
42864320

42874321
if (!fp) {
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
--TEST--
2+
extractTo: symlink traversal via pre-existing symlink in destination directory
3+
--EXTENSIONS--
4+
phar
5+
--SKIPIF--
6+
<?php
7+
if (PHP_OS_FAMILY === 'Windows') {
8+
if (false === include __DIR__ . '/../../standard/tests/file/windows_links/common.inc') {
9+
die('skip windows_links/common.inc is not available');
10+
}
11+
skipIfSeCreateSymbolicLinkPrivilegeIsDisabled(__FILE__);
12+
}
13+
?>
14+
--INI--
15+
phar.readonly=0
16+
--FILE--
17+
<?php
18+
$pharFile = __DIR__ . '/gh_ss008.phar';
19+
$dest = __DIR__ . '/gh_ss008_dest';
20+
$target = __DIR__ . '/gh_ss008_target';
21+
22+
@mkdir($dest, 0777, true);
23+
@mkdir($target, 0777, true);
24+
25+
$p = new Phar($pharFile);
26+
$p->addFromString('subdir/evil.txt', 'should not arrive');
27+
unset($p);
28+
29+
symlink($target, $dest . '/subdir');
30+
31+
try {
32+
$phar = new Phar($pharFile);
33+
$phar->extractTo($dest, null, true);
34+
echo "EXTRACTED (unexpected)\n";
35+
} catch (PharException $e) {
36+
echo "Caught PharException: " . $e->getMessage() . "\n";
37+
}
38+
39+
if (file_exists($target . '/evil.txt')) {
40+
echo "FAIL: target was written\n";
41+
} else {
42+
echo "OK: target not written\n";
43+
}
44+
?>
45+
--CLEAN--
46+
<?php
47+
$pharFile = __DIR__ . '/gh_ss008.phar';
48+
$dest = __DIR__ . '/gh_ss008_dest';
49+
$target = __DIR__ . '/gh_ss008_target';
50+
51+
@unlink($dest . '/subdir');
52+
@unlink($target . '/evil.txt');
53+
@rmdir($dest);
54+
@rmdir($target);
55+
@unlink($pharFile);
56+
?>
57+
--EXPECTF--
58+
Caught PharException: %s
59+
OK: target not written

0 commit comments

Comments
 (0)