[php-src] Issue #21992: symlink() on dangling symlink returns true and creates link at wrong location

[email protected] (moosy)
Newsgroups php.bugs
Message-ID <[email protected]>
Issue: https://github.com/php/php-src/issues/21992
Author: moosy

### Description

## Description

When calling symlink() where the $link parameter is an existing dangling (dead) symlink,
the function returns true instead of false, and creates the new symlink at the target
location of the dangling symlink instead of at the specified link path.

The PHP documentation states: "The function fails, and issues E_WARNING, if link already exists."
This does not happen correctly for dangling symlinks.

## PHP Version

PHP 8.5.6 (cli) (built: May  6 2026 14:32:18) (NTS)

## Operating System

Linux 6.1.26-2-lts, btrfs filesystem

## Steps To Reproduce

Setup: create a dangling symlink

    mkdir /tmp/bugtest
    mkdir /tmp/bugtest/target_dir
    ln -s /tmp/bugtest/target_dir/ghost.txt /tmp/bugtest/dead_link.txt
    # dead_link.txt is now a dangling symlink pointing to a non-existent file

    <?php
    var_dump(symlink('/tmp/something', '/tmp/bugtest/dead_link.txt'));
    
    // Check what happened:
    // ls -la /tmp/bugtest/target_dir/
    // -> ghost.txt -> /tmp/something   (NEW symlink created at wrong location!)
    // ls -la /tmp/bugtest/dead_link.txt
    // -> still points to /tmp/bugtest/target_dir/ghost.txt (unchanged)

## Expected Behavior

- Returns false
- Issues E_WARNING: symlink(): File exists
- No new symlink is created

## Actual Behavior

- Returns true (no error!)
- Creates new symlink ghost.txt -> /tmp/something inside target_dir/
- Original dangling symlink dead_link.txt remains unchanged

## Comparison With Python

Python calls the same kernel syscall (symlink(2)) and behaves correctly:

    import os
    os.symlink('/tmp/something', '/tmp/bugtest/dead_link.txt')
    # Raises: FileExistsError: [Errno 17] File exists
    # No symlink created

Also ln -s behaves correctly:

    ln -s /tmp/something /tmp/bugtest/dead_link.txt
    # ln: failed to create symbolic link '/tmp/bugtest/dead_link.txt': File exists

Since both Python and ln call the same underlying syscall and behave correctly,
the bug is in PHP's handling of the path before calling the symlink(2) syscall.

## Security Impact

This bug has serious implications in scenarios where symlinks are used to organize
or reference files across directory boundaries.

When symlink() is called on a dangling symlink, it silently creates a new symlink
in a directory that should not be written to. The caller has no indication that
this happened since the function returns true.

In our case, we use symlinks to create a chronological view of a photo library:
- Bilder_chronologisch/2000-01-January/photo.jpg -> Bilder/Subfolder_A/photo.jpg
- When Bilder/Subfolder_A/photo.jpg was deleted, the symlink in Bilder_chronologisch
  became dangling
- A subsequent symlink() call to update the chronological link instead created a new
  symlink inside Bilder/Subfolder_A/ - a directory that should never be written to
  by this code path
- The write into Bilder/Subfolder_A/ was completely silent - true was returned,
  no warning, no indication of the wrong location

Any application that uses symlink() to manage symlinks across directory trees is
potentially vulnerable to unexpected writes into directories it never intended to modify.

## Workaround

Check with is_link() before calling symlink(), since is_link() returns true
for dangling symlinks while file_exists() does not:

    if (!file_exists($linkPath) && !is_link($linkPath)) {
        symlink($target, $linkPath);
    }

## Root Cause Analysis

Disclaimer: This section was assisted by claude.ai:

The bug is in ext/standard/link.c in PHP_FUNCTION(symlink):

    if (!expand_filepath(frompath, source_p)) {
        php_error_docref(NULL, E_WARNING, "No such file or directory");
        RETURN_FALSE;
    }

frompath is the $link parameter (the path where the new symlink should be created).
expand_filepath() resolves symlinks in the path - so when $link is a dangling symlink
like /tmp/bugtest/dead_link.txt pointing to /tmp/bugtest/target_dir/ghost.txt,
expand_filepath() resolves it to /tmp/bugtest/target_dir/ghost.txt.

PHP then calls the kernel's symlink(2) syscall with the resolved path instead of
the original path - creating the new symlink at target_dir/ghost.txt instead of
replacing dead_link.txt.

## Suggested Fix

In ext/standard/link.c, replace:

    if (!expand_filepath(frompath, source_p)) {

with a variant that does not follow the final symlink component, for example using
CWD_FILEPATH instead of CWD_REALPATH mode in expand_filepath_ex():

    if (!expand_filepath_with_mode(frompath, source_p, NULL, 0, CWD_FILEPATH)) {

CWD_FILEPATH expands the path without resolving symlinks, which is the correct
behavior for the link target in symlink().


### PHP Version

```plain
PHP 8.5.6 (cli) (built: May  6 2026 14:32:18) (NTS)

Linux 6.1.26-2-lts, btrfs filesystem
```

### Operating System

Arch Linux
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.