Skip to content

phar: reject extractTo() when destination parent escapes via symlink#21808

Open
iliaal wants to merge 1 commit intophp:PHP-8.4from
iliaal:fix/phar-extractto-symlink-traversal
Open

phar: reject extractTo() when destination parent escapes via symlink#21808
iliaal wants to merge 1 commit intophp:PHP-8.4from
iliaal:fix/phar-extractto-symlink-traversal

Conversation

@iliaal
Copy link
Copy Markdown
Contributor

@iliaal iliaal commented Apr 18, 2026

Fixes destination-side symlink traversal in Phar::extractTo().

extractTo() builds the target path as dest + "/" + entry_path. CWD_EXPAND validates the entry path against .. traversal but doesn't resolve symlinks. A pre-existing symlink in the destination (e.g., /dest/subdir -> /etc) causes the extracted file to land 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.

The test skips on Windows only when SeCreateSymbolicLinkPrivilege is absent. Without it, symlink() returns false without error, so no symlink exists in the destination, extractTo() succeeds, and the test outputs EXTRACTED (unexpected) instead of the PharException that --EXPECTF-- requires.

Copy link
Copy Markdown
Contributor

@LamentXU123 LamentXU123 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just my 2 cents,

Comment thread ext/phar/phar_object.c Outdated
return FAILURE;
}
size_t resolved_dest_len = strlen(resolved_dest);
if (strncmp(resolved, resolved_dest, resolved_dest_len) != 0 ||
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: could use zend_binary_strncmp

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping strncmp. phar uses it throughout for path prefix checks (util.c:1296, stream.c:615, phar.c:509). zend_binary_strncmp carries three-way-compare length semantics for userland zend_string comparison, not a prefix check on NUL-terminated realpath output.

Comment thread ext/phar/phar_object.c Outdated
}
size_t resolved_dest_len = strlen(resolved_dest);
if (strncmp(resolved, resolved_dest, resolved_dest_len) != 0 ||
(resolved[resolved_dest_len] != '/' && resolved[resolved_dest_len] != '\\' && resolved[resolved_dest_len] != '\0')) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: could use IS_SLASH

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, updated.

@iliaal iliaal force-pushed the fix/phar-extractto-symlink-traversal branch from 5029b6a to e2a8b21 Compare April 19, 2026 11:56
Copy link
Copy Markdown
Member

@Girgias Girgias left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Frankly struggling to understand the fix. Comments are useful to describe what the purpose of the code and why it is being done.

Comment thread ext/phar/phar_object.c Outdated
return SUCCESS;
}

{
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why? Why are you creating a block scope, we use C99 (heck even C11...) you can just declare a new variable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

Comment thread ext/phar/phar_object.c Outdated
}

{
const char *last_sep = zend_memrchr(fullpath + dest_len + 1, '/', strlen(fullpath) - dest_len - 1);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have the length of fullpath already.

Suggested change
const char *last_sep = zend_memrchr(fullpath + dest_len + 1, '/', strlen(fullpath) - dest_len - 1);
const char *last_sep = zend_memrchr(fullpath + dest_len + 1, '/', len - dest_len - 1);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

Comment thread ext/phar/phar_object.c Outdated
Comment on lines +4292 to +4296
if (parent_len >= MAXPATHLEN) {
spprintf(error, 4096, "Cannot extract \"%s\", path too long", entry->filename);
efree(fullpath);
return FAILURE;
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would this ever happen? This is derived from fullpath and this was already checked to fit within the maxpathlen.

Convert this to an assertion.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, you right, fixed.

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.
@iliaal iliaal force-pushed the fix/phar-extractto-symlink-traversal branch from e2a8b21 to 0ff1ded Compare April 20, 2026 16:03
@iliaal
Copy link
Copy Markdown
Contributor Author

iliaal commented Apr 20, 2026

Addressed the comments: dropped the block scope, reused len, and converted the dead parent_len >= MAXPATHLEN check to an assert.

For the overall approach: extractTo() builds the target as dest + "/" + entry_path. CWD_EXPAND sanitizes .. inside the entry path, but it doesn't resolve symlinks already sitting in dest. So if the caller's destination contains a pre-existing subdir -> /etc symlink, the write follows it out of the intended root.

The patch resolves the parent directory of the target with VCWD_REALPATH right before php_stream_open_wrapper and rejects extraction when the resolved parent doesn't stay under the resolved dest. ext/phar/tests/gh-ss008-extractto-symlink.phpt covers that case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants