A symbolic link is a filesystem object containing a target pathname. Following the link resolves that pathname at the time of the operation; it does not bind the link permanently to the target’s inode. This distinction explains why links can cross filesystem boundaries, become dangling, or start reaching a different object even though their stored target text has not changed.

When it breaks

A relative target is interpreted from the directory containing the symbolic link, not from the calling process’s working directory. Moving the link can therefore change its meaning. Moving or renaming the target can instead leave the link dangling. A deployment that rearranges directories without accounting for these bases can silently change which file a familiar link reaches.

Existence checks answer different questions. An operation that follows the link can report a missing target while an operation inspecting the directory entry still finds a symbolic link. Likewise, a permissive pathname-resolution function may produce an absolute string even when its final target is missing. A normalized string is not proof that a usable file currently exists.

Checking a path and later opening it creates a time-of-check/time-of-use gap when another actor can change the relevant directory entries. Quoting the pathname protects shell argument parsing, but does not freeze filesystem resolution. O_NOFOLLOW on ordinary open rejects a symbolic link in the final component; it does not automatically prohibit links in earlier directory components.

Check it quickly

Save this script as symlinks.py and run python3 symlinks.py in a disposable Linux environment. The recorded run used Linux 6.12.76 and Python 3.11.2. It creates harmless temporary files and changes its own links deliberately, so the observations are deterministic and require no concurrent attacker or access to unrelated paths:

from pathlib import Path
from tempfile import TemporaryDirectory
import errno,os

def read_no_follow(path):
    fd=os.open(path,os.O_RDONLY|os.O_NOFOLLOW)
    try:return os.read(fd,100)
    finally:os.close(fd)
with TemporaryDirectory(prefix='xbcatm-symlinks-') as directory:
    root=Path(directory);first=root/'first';second=root/'second';first.mkdir();second.mkdir()
    (first/'data').write_text('first');(second/'data').write_text('second')
    link=first/'link';link.symlink_to('data')
    assert os.readlink(link)=='data' and link.read_text()=='first'
    assert link.lstat().st_ino!=link.stat().st_ino
    moved=second/'link';link.rename(moved)
    assert os.readlink(moved)=='data' and moved.read_text()=='second'
    print('moving a relative symlink changes its target because resolution starts at its containing directory')
    (second/'data').rename(second/'renamed')
    assert moved.is_symlink() and os.path.lexists(moved) and not moved.exists()
    assert moved.resolve(strict=False)==second/'data'
    try:moved.resolve(strict=True)
    except FileNotFoundError:pass
    else:raise AssertionError('strict resolution unexpectedly found missing target')
    moved.unlink();assert (second/'renamed').read_text()=='second'
    print('dangling link exists as a link; permissive resolution can name a missing target; unlink removes only the link')
    safe=root/'safe';outside=root/'outside';safe.write_bytes(b'approved');outside.write_bytes(b'replaced')
    current=root/'current';current.symlink_to('safe');checked=current.resolve(strict=True)
    assert checked==safe
    opened=os.open(current,os.O_RDONLY)
    try:
        replacement=root/'next';replacement.symlink_to('outside');replacement.replace(current)
        assert current.read_bytes()==b'replaced' and os.read(opened,100)==b'approved'
    finally:os.close(opened)
    print('a checked path string can resolve differently later; an already opened descriptor keeps the prior object')
    try:read_no_follow(current)
    except OSError as error:assert error.errno==errno.ELOOP,error
    else:raise AssertionError('O_NOFOLLOW accepted a final-component symlink')
    alias=root/'parent-alias';alias.symlink_to(first.name,target_is_directory=True)
    assert read_no_follow(alias/'data')==b'first'
    print('O_NOFOLLOW rejects the final symlink but still allows symlinks in earlier path components')

The first directory contains a file named data and a link whose stored text is simply data. Reading through the link returns first. lstat inspects the link itself, while stat follows it and reports the target; the fixture confirms these are different inode identities.

After moving the link into the second directory, readlink still returns the same text, but reading through it returns second. The target is now the second directory’s data file. This result demonstrates the resolution base directly, without relying on a changed process working directory to explain the different bytes.

Renaming that target leaves a dangling link. is_symlink and lexists remain true, whereas exists, which follows the link, becomes false. Python’s permissive resolve(strict=False) returns the expected missing target path; strict resolution raises FileNotFoundError. Removing the dangling link leaves the renamed target intact, because unlinking the link does not unlink the target it once referenced.

Next, the script resolves a link to a harmless approved file and opens it. It then replaces the link with one pointing at another fixture file. A new pathname read returns replaced, while the already opened descriptor still returns approved. The earlier check did not constrain the later lookup; the existing descriptor retained the earlier object.

Opening the final link with O_NOFOLLOW fails with ELOOP. However, using that same flag to open parent-alias/data succeeds when parent-alias is a directory symlink. Both cases matter: testing only the final-component rejection could otherwise encourage an incorrect claim that this flag protects the entire path from symbolic-link traversal.

Choose the object and the traversal policy

Use readlink when the question concerns stored link text, lstat when it concerns the link object, and following operations when it concerns the current target. Record which identity a diagnostic reports. A log showing an absolute resolved pathname is helpful context, but is still an observation at a particular time.

For operations within a trusted directory boundary, design traversal around directory descriptors and restrictions appropriate to the threat model. Linux openat2 offers resolution policies such as rejecting symbolic links or constraining traversal beneath a directory. Their exact behavior and kernel support must be checked; merely joining a trusted prefix with untrusted text does not establish containment.

An opened descriptor stabilizes the referenced object against later pathname replacement, but it does not make the object’s contents immutable or establish that opening it was authorized. Validate the selected object and perform the intended operation through the appropriate reference. Keep path-selection guarantees, access permissions, and content consistency as separate properties of the design.

Sources

  • symlink(7), stored target strings, relative resolution, and dangling links, checked 2026-09-12.
  • open(2), O_NOFOLLOW and descriptor references after pathname changes, checked 2026-09-12.
  • path_resolution(7), component-by-component traversal and directory permissions, checked 2026-09-12.
  • openat2(2), kernel-enforced resolution restrictions for controlled traversal, checked 2026-09-12.