Skip to content

syscall: Munge symlinks in do_symlink_at() when munge_symlinks is enabled - #1152

Open
seks99x wants to merge 1 commit into
RsyncProject:masterfrom
seks99x:fix-backup-symlink-munge
Open

seks99x wants to merge 1 commit into
RsyncProject:masterfrom
seks99x:fix-backup-symlink-munge

Conversation

@seks99x

@seks99x seks99x commented Nov 8, 2026 •

Copy link
Copy Markdown
Member

When make_backup_inner() in backup.c backs up a symlink, it constructs a file_struct via make_file() and attempts link_or_rename(). If link_or_rename() fails (such as during a parent-directory swap race) make_backup_inner() falls back to recreating the symlink on disk via do_symlink_at(). Because F_SYMLINK(file) holds the unmunged target and do_symlink_at() did not perform munging, its created with the daemons running UID. root-owned/trsuted symlink could occur on the disk/daemon-module which could break the trusted symlink idea.

Fix: Centralize Munging in do_symlink_at()
Rather than scattering SYMLINK_PREFIX checks across individual callers like make_backup_inner(), we need to centralize symlink munging directly at the syscall wrapper boundary in do_symlink_at():

  • Whenever munge_symlinks is active ( which is active by default in non-chroot daemon modules ), do_symlink_at() check whether the target lnk already begins with SYMLINK_PREFIX (/rsyncd-munged/).
  • If lnk is not already prefixed, the wrapper prepends SYMLINK_PREFIX before invoking symlinkat()(returning ENAMETOOLONG if the combined length exceeds MAXPATHLEN).
  • Links that were already munged by recv_file_entry() are not double-munged, while any internal code path that creates a symlink from an in-memory file_struct (such as make_backup_inner()) is guaranteed to never write an unmunged symlink to disk.

Tests

  • Added testsuite/backup-munge-symlinks_test.py to verify that backup symlinks created via the make_backup_inner() fallback under a parent-swap race are properly prefixed with /rsyncd-munged/.
  • Updated testsuite/filter-leak_test.py and testsuite/files-from-leak_test.py so their setup preconditions treat munged root-owned backup symlinks as mitigated rather than a test setup failure.

When make_backup_inner() falls back to recreating a symlink via
do_symlink_at() (such as during a parent-swap race when link_or_rename()
fails), it passed the unmunged link target read by make_file() directly
to symlinkat(). In a non-chrooted daemon with munge_symlinks enabled,
this created an unmunged root-owned backup symlink on disk.

Ensure do_symlink_at() prepend SYMLINK_PREFIX whenever
munge_symlinks is active and the target is not already prefixed, and add
a regression test.
Comment thread syscall.c
* symlink on disk (e.g., via make_backup_inner() copying an unmunged
* local symlink), while avoiding double-munging links already prefixed
* by recv_file_entry(). */
if (munge_symlinks) {

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.

This is after early returns through do_symlink(), after the fake-super write and outside the no-AT_FDCWD fallback while do_symlink_atfd() still bypasses it. Put target munging in one helper used by every symlink-creation wrapper. Also use < not <= -- or an exact /rsyncd-munged/ target is prefixed twice.

# there's nothing to assert -- skip rather than fail.
test_skipped("race did not produce a root-owned backup symlink (no atomic flipper?)")
print("race did not produce a root-owned backup symlink (no atomic flipper?)")
sys.exit(0)

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.

Ah. Dont do that. Preserve the expected-skip result or isolate only the racy daemon section so the remaining assertions still run.

@seks99x seks99x Nov 8, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We have no other choice here ig. The previous filter/files-from leak regressions that happened require a trusted symlink in the daemon (which why it was depending on this remaining old bug), if the race did not happen and the link is munged, then we don't have a trusted symlink or the first chain , so its safe to pass.

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.

A verified munged link proves that part of the chain was blocked. Not winning the race does not. Test munging separately and seed a trusted symlink directly for the filter/files-from confinement regressions so fixing creation does not stop testing the read-side protection. The later local checks must still run.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Aha makes sense. I'll let it log that the race did not succeeded and follow the attack with independent symlink previously created by the test.

Comment on lines +163 to +165
backed_up_link = root_owned_backup_symlink()
if backed_up_link:
break

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.

Also this immediately finds the symlink left by case 3 and breaks before build(pre_munge_dest=True) runs. Reset the fixture before checking for a result and prove that the already-munged backup path was actually exercised.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants