From 41bc8e5f6a3a6331b4b12f73cef408cbd55240ce Mon Sep 17 00:00:00 2001 From: Mounir IDRASSI Date: Mon, 28 Sep 2026 16:36:55 +0200 Subject: [PATCH] macOS: isolate FUSE-T auxiliary mount paths Create a fresh private parent for each FUSE-T auxiliary mount. Elevated parents stay root-owned and grant only the original user read/search access, restricting access through the auxiliary mount path and preventing caller-controlled path replacement during elevated setup. Clear inherited ACLs and reject temporary filesystems that ignore ownership. Remove per-mount parents on setup failure and service teardown, while preserving legacy temporary directories and discovery behavior. Add regression coverage for permissions, unique parent creation, rollback, and cleanup. FUSE-T transport authentication is outside this change. --- Tests/README-macos-lifecycle.md | 17 ++ Tests/fuset_startup_faults.c | 12 ++ Tests/test_fuset_aux_directory.py | 265 ++++++++++++++++++++++++++++ Tests/test_fuset_cleanup.py | 1 + Tests/test_fuset_dismount.py | 14 +- Tests/test_macos_gui_lifecycle.py | 4 +- Tests/test_macos_gui_teardown.py | 2 +- src/Core/Unix/CoreUnix.cpp | 101 ++++++++++- src/Core/Unix/MacOSX/CoreMacOSX.cpp | 7 +- src/Driver/Fuse/FuseService.cpp | 30 +++- src/Driver/Fuse/FuseService.h | 1 + 11 files changed, 442 insertions(+), 12 deletions(-) create mode 100644 Tests/test_fuset_aux_directory.py diff --git a/Tests/README-macos-lifecycle.md b/Tests/README-macos-lifecycle.md index 66c6998a..a18306ed 100644 --- a/Tests/README-macos-lifecycle.md +++ b/Tests/README-macos-lifecycle.md @@ -14,8 +14,25 @@ build, then run: VC_TEST_BUILD_SRC=/absolute/path/to/build/src python3 Tests/test_macos_discovery.py --platform-archive "$VC_TEST_BUILD_SRC/Platform/Platform.a" python3 Tests/test_fuset_cleanup.py --build-dir "$VC_TEST_BUILD_SRC" +python3 Tests/test_fuset_aux_directory.py --build-dir "$VC_TEST_BUILD_SRC" ``` +The auxiliary-directory check exercises the production FUSE-T private-parent +helpers against disposable local directories, including concurrent creation, +inherited ACL removal, setup failure cleanup, per-mount parent removal, and +discovery ownership. The temporary filesystem must support ownership and ACLs. +The helper check simulates disabled ownership without mounting a filesystem. +An optional elevated run also checks caller access, parent immutability, and +actual uid isolation using a harmless fixture: + +```sh +sudo python3 Tests/test_fuset_aux_directory.py --build-dir "$VC_TEST_BUILD_SRC" --owner "$(id -u)" +``` + +The private parent protects auxiliary filesystem paths; these checks do not +assess FUSE-T transport authentication. Existing volumes must be dismounted and +remounted with the updated build to receive this protection. + The discovery runner compiles the production plist and batch-refresh methods with an in-memory inventory provider. It checks exact and legacy alias matches, unresolved inventory, stale-field invalidation, one query per refresh, and fresh diff --git a/Tests/fuset_startup_faults.c b/Tests/fuset_startup_faults.c index 4c2215a4..bb071561 100644 --- a/Tests/fuset_startup_faults.c +++ b/Tests/fuset_startup_faults.c @@ -93,6 +93,17 @@ static int test_stat (const char *path, struct stat *value) return stat (path, value); } +static char *test_mkdtemp (char *path) +{ + if (mode_is ("aux-child") && fixture_path (path, "/.veracrypt_aux_mnt-XXXXXXXXXXXX")) + { + mark_fault(); + errno = EACCES; + return NULL; + } + return mkdtemp (path); +} + static unsigned fault_delay (void) { const char *delay = getenv ("VC_FUSET_TEST_DELAY"); @@ -136,5 +147,6 @@ static int test_unlinkat (int fd, const char *path, int flags) INTERPOSE (test_connect, connect); INTERPOSE (test_open, open); INTERPOSE (test_stat, stat); +INTERPOSE (test_mkdtemp, mkdtemp); INTERPOSE (test_unmount, unmount); INTERPOSE (test_unlinkat, unlinkat); diff --git a/Tests/test_fuset_aux_directory.py b/Tests/test_fuset_aux_directory.py new file mode 100644 index 00000000..6e98646f --- /dev/null +++ b/Tests/test_fuset_aux_directory.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +"""Test FUSE-T's production private-parent helpers using disposable directories. + +Requires a matching macOS build, but no mounted volumes or FUSE service. Optional +--owner UID, when run as root, checks elevated access and real uid isolation +using a harmless sentinel file. No user accounts are created or modified. +""" +import argparse +import concurrent.futures +import os +from pathlib import Path +import re +import stat +import subprocess +import sys +import tempfile + +ROOT = Path(__file__).resolve().parents[1] + + +def function(source, signature): + begin = source.index("\t" + signature) + return source[begin:source.index("\n\t}", begin) + 3] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--build-dir", type=Path, required=True) + parser.add_argument("--owner", type=int, default=os.getuid()) + args = parser.parse_args() + if sys.platform != "darwin": + parser.error("macOS is required") + if args.owner != os.getuid() and os.geteuid() != 0: + parser.error("--owner requires root when it differs from the current uid") + owner = args.owner + elevated = os.geteuid() == 0 + prefix = ".veracrypt_aux_root_" if elevated else ".veracrypt_aux_" + other = 502 if owner != 502 else 503 + with tempfile.TemporaryDirectory(prefix="vc-aux-directory-", dir="/private/tmp") as temporary: + work = Path(temporary) + work.chmod(0o755) + source = (ROOT / "src/Core/Unix/CoreUnix.cpp").read_text() + helpers = function(source, "static string CreateFuseTAuxiliaryDirectory") + helpers += function(source, "static bool IsOtherUsersFuseTAuxiliaryMount") + service = (ROOT / "src/Driver/Fuse/FuseService.cpp").read_text() + helpers += function(service, "void FuseService::RemoveAuxMountParent") + begin = service.index("\tclass FuseServiceAuxDirectory") + helpers += service[begin:service.index("\n\t};", begin) + 4] + unit = work / "check.cpp" + unit.write_text(r''' +#include "Core/CoreException.h" +#include "Core/Unix/MountedFilesystem.h" +#include "Platform/StringConverter.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +namespace VeraCrypt { +static bool IgnoreOwnership=false, FailAcl=false, Mounted=false; +static int TestStatfs(int fd, struct statfs *info) { + int result=fstatfs(fd, info); + if(result==0 && IgnoreOwnership)info->f_flags |= MNT_IGNORE_OWNERSHIP; + return result; +} +static int TestSetAcl(int fd, acl_t acl, acl_type_t type) { + if(FailAcl){errno=ENOTSUP;return -1;} + return acl_set_fd_np(fd, acl, type); +} +static bool fuse_service_find_mount(const char *, fsid_t &) { return Mounted; } +struct FuseService { static void RemoveAuxMountParent(const string &, int=-1); }; +#define fstatfs TestStatfs +#define acl_set_fd_np TestSetAcl +''' + helpers + r''' +} +using namespace VeraCrypt; +int main(int argc, char **argv) { + try { + const std::string command=argv[1]; + if(command=="create" || command=="noowners" || command=="acl-failure") { + IgnoreOwnership=command=="noowners"; FailAcl=command=="acl-failure"; + std::cout << CreateFuseTAuxiliaryDirectory(argv[2], std::stoul(argv[3])) << '\n'; + } else if(command=="cleanup") { + FuseService::RemoveAuxMountParent(argv[2]); + } else if(command.find("service-")==0) { + Mounted=command=="service-mounted"; + FuseServiceAuxDirectory directory(argv[2]); + if(command=="service-removed" && rmdir(argv[2])!=0)throw std::runtime_error("fixture removal failed"); + if(command=="service-replaced-child" || command=="service-replaced-parent") { + std::string path=argv[2]; + if(command=="service-replaced-parent")path=path.substr(0, path.find_last_of('/')); + if(rename(path.c_str(), (path+".original").c_str())!=0 || mkdir(path.c_str(), 0700)!=0) + throw std::runtime_error("fixture replacement failed"); + } + } else if(command=="filter") { + MountedFilesystem mount; + mount.MountPoint = argv[2]; mount.Owner = std::stoul(argv[3]); mount.Type = argv[4]; + std::cout << IsOtherUsersFuseTAuxiliaryMount(mount, std::stoul(argv[5]), std::stoul(argv[6])) << '\n'; + } + } catch (std::exception &e) { std::cerr << e.what() << '\n'; return 1; } +} +''') + binary = work / "check" + subprocess.run(["clang++", "-std=c++11", "-DTC_UNIX", "-DTC_MACOSX", "-I" + str(ROOT / "src"), + str(unit), str(args.build_dir / "Core/Core.a"), + str(args.build_dir / "Platform/Platform.a"), "-Wl,-dead_strip", "-o", str(binary)], check=True) + + def create(base, uid=owner, succeeds=True, command="create"): + result = subprocess.run([str(binary), command, str(base), str(uid)], capture_output=True, text=True) + assert (result.returncode == 0) == succeeds, result.stdout + result.stderr + return Path(result.stdout.strip()) if succeeds else None + + def case(name): + base = work / name + base.mkdir(mode=0o755) + return base + + base = case("normal") + directory = create(base) + info = directory.stat() + assert info.st_uid == (0 if elevated else owner) and stat.S_IMODE(info.st_mode) == 0o700 + assert create(base) != directory + sentinel = directory / "sentinel" + sentinel.write_text("private directory regression fixture\n") + if os.geteuid() == 0: + os.chown(sentinel, owner, -1) + sentinel.chmod(0o644) + + def foreign(path=directory, user=other, real=other, mount_owner=0, backend="smbfs"): + result = subprocess.check_output([str(binary), "filter", str(path / ".veracrypt_aux_mnt-unit"), + str(mount_owner), backend, str(user), str(real)], text=True) + return result.strip() == "1" + + assert foreign() == elevated # Only the elevated parent name identifies the user. + assert not foreign(user=0, real=other) + assert not foreign(user=owner, real=owner) + assert not foreign(user=0, real=owner) + assert not foreign(path=base) # Legacy mounts in a shared temp directory. + legacy = case("legacy-acl") + legacy.chmod(0o700) + subprocess.run(["chmod", "+a", "everyone allow search", str(legacy)], check=True) + assert not foreign(path=legacy) # ACL access is not reflected in owner/mode bits. + assert not foreign(backend="macfuse") + assert not foreign(mount_owner=owner if owner else other) + assert foreign(path=work / f".veracrypt_aux_root_{owner}") + assert not foreign(path=work / f".veracrypt_aux_root_{owner}", user=owner, real=owner) + assert not foreign(path=work / ".veracrypt_aux_root_invalid-ABCDEFGHIJKL") + + for kind in ("symlink", "file", "directory"): + base = case(kind) + path = base / f"{prefix}{owner}" + if kind == "symlink": + path.symlink_to(directory, target_is_directory=True) + elif kind == "file": + path.write_text("unchanged") + else: + path.mkdir(mode=0o700) + before = path.lstat() + assert create(base) != path + after = path.lstat() + assert (before.st_ino, before.st_uid, before.st_mode) == (after.st_ino, after.st_uid, after.st_mode) + assert sentinel.read_text() == "private directory regression fixture\n" + + base = case("inherited-acl") + subprocess.run(["chmod", "+a", "everyone allow read,search,directory_inherit", str(base)], check=True) + inherited = create(base) + acl = subprocess.check_output(["ls", "-lde", str(inherited)], text=True) + entries = re.findall(r"^\s*\d+: (.+)$", acl, re.M) + assert len(entries) == (1 if elevated and owner else 0), acl + if entries: + assert entries[0].endswith(" allow list,search,readattr,readsecurity"), acl + + for failure in ("noowners", "acl-failure"): + base = case(failure) + create(base, succeeds=False, command=failure) + assert not list(base.iterdir()), "Failed parent setup left a directory" + + base = case("concurrent") + with concurrent.futures.ThreadPoolExecutor(max_workers=12) as pool: + paths = list(pool.map(lambda _: create(base), range(24))) + assert len(set(paths)) == 24 and set(base.iterdir()) == set(paths) + for parent in paths: + child = parent / ".veracrypt_aux_mnt-unit" + child.mkdir() + subprocess.run([str(binary), "service-mounted", str(child)], check=True) + assert child.exists() and parent.exists() + subprocess.run([str(binary), "service-cleanup", str(child)], check=True) + assert not child.exists() and not parent.exists() + # An older client can remove the child before its service exits. + parent = create(base) + child = parent / ".veracrypt_aux_mnt-unit" + child.mkdir() + subprocess.run([str(binary), "service-removed", str(child)], check=True) + assert not parent.exists() + for target in ("child", "parent"): + parent = create(base) + child = parent / ".veracrypt_aux_mnt-unit" + child.mkdir() + subprocess.run([str(binary), "service-replaced-" + target, str(child)], check=True) + assert parent.exists(), "Removed a replacement parent" + if target == "child": + assert child.exists(), "Removed a replacement child" + # The fallback removes only empty parents in the per-mount format. + parent = create(base) + subprocess.run([str(binary), "cleanup", str(parent / ".veracrypt_aux_mnt-unit")], check=True) + assert not parent.exists() + for name in ("legacy-tmp", f"{prefix}{owner}", f"{prefix}{owner}-invalid"): + parent = base / name + parent.mkdir() + child = parent / ".veracrypt_aux_mnt-unit" + child.mkdir() + subprocess.run([str(binary), "service-cleanup", str(child)], check=True) + assert parent.exists() and not child.exists() + print("PASS: unique parents, owner/mode, ACL normalization, setup rollback, discovery, legacy preservation, service cleanup") + + if os.geteuid() == 0 and owner != 0: + for uid, allowed in ((owner, True), (other, False)): + pid = os.fork() + if pid == 0: + try: + os.setgroups([]) + os.setgid(20) + os.setuid(uid) + try: + with sentinel.open("rb") as stream: + stream.read(1) + assert allowed + except PermissionError: + assert not allowed + # Read/search access must not allow path replacement. + try: + (directory / "replacement").mkdir() + raise AssertionError("caller can modify the elevated parent") + except PermissionError: + pass + try: + directory.chmod(0o777) + raise AssertionError("caller can change the elevated parent's permissions") + except PermissionError: + pass + try: + directory.rename(directory.with_name("replacement-parent")) + raise AssertionError("caller can replace the elevated parent") + except PermissionError: + pass + # Discovery also works without entering the parent. + expected = "0" if allowed else "1" + output = subprocess.check_output([str(binary), "filter", + str(directory / ".veracrypt_aux_mnt-unit"), + "0", "smbfs", str(uid), str(uid)], text=True) + assert output.strip() == expected + os._exit(0) + except BaseException: + os._exit(1) + _, status = os.waitpid(pid, 0) + assert os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0, (uid, status) + print("PASS: root owns elevated parent; caller has read/search only; unrelated uid gets EACCES; discovery scope") + + +if __name__ == "__main__": + main() diff --git a/Tests/test_fuset_cleanup.py b/Tests/test_fuset_cleanup.py index c6abcda7..fbd451ae 100644 --- a/Tests/test_fuset_cleanup.py +++ b/Tests/test_fuset_cleanup.py @@ -263,6 +263,7 @@ struct FuseService { if(FailWait)throw DismountServiceCleanupFailed("mock timeout", L"pid=1234, auxiliary mount=/in-memory"); } static void DismountLegacy(const DismountRequest &) { throw std::runtime_error("unexpected legacy path"); } + static void RemoveAuxMountParent(const std::string &) { throw std::runtime_error("unexpected directory cleanup"); } }; struct Process { static std::string Execute(const std::string &,const std::list &) { ++Commands; throw std::runtime_error("unexpected disk command"); } diff --git a/Tests/test_fuset_dismount.py b/Tests/test_fuset_dismount.py index 7ec9c6de..88cd8389 100644 --- a/Tests/test_fuset_dismount.py +++ b/Tests/test_fuset_dismount.py @@ -81,11 +81,16 @@ class DismountChecks: self.vc(label, "--mount", self.volume, self.mountpoint, "--password=" + self.password, "--pim=1", "--keyfiles=", "--protect-hidden=no", *options, binary=binary) - auxiliaries = [p.parent for p in self.tmpdir.glob(".veracrypt_aux_mnt*/control")] + auxiliaries = [p.parent for p in self.tmpdir.glob("**/.veracrypt_aux_mnt*/control")] if len(auxiliaries) != 1: raise AssertionError(f"Expected one test service, found {auxiliaries}") aux = auxiliaries[0] self.active.update(aux=aux) + if binary == self.binary: + parent = aux.parent.stat() + if (not re.fullmatch(rf"\.veracrypt_aux_{os.getuid()}-[A-Za-z0-9]{{12}}", aux.parent.name) + or parent.st_uid != os.getuid() or parent.st_mode & 0o777 != 0o700): + raise AssertionError("Auxiliary mount lacks a private parent owned by the caller") identity = aux / "shutdown" if identity.exists(): pid, serial, slot = map(int, identity.read_text().split()) @@ -173,8 +178,9 @@ class DismountChecks: endpoint = self.active.get("endpoint") state = dict(label=label, mounts=self.mounted_paths(), handles=handles.stdout, service_alive=alive, endpoint_exists=bool(endpoint and endpoint.exists()), - backend=backend, services=services, images=images) - if not any(state[key] for key in ("mounts", "handles", "service_alive", "endpoint_exists", "backend", "services", "images")): + backend=backend, services=services, images=images, + auxiliary_parents=[str(p) for p in self.tmpdir.glob(f".veracrypt_aux_{os.getuid()}-*")]) + if not any(state[key] for key in ("mounts", "handles", "service_alive", "endpoint_exists", "backend", "services", "images", "auxiliary_parents")): self.record(state) self.active = None return @@ -320,7 +326,7 @@ class DismountChecks: source = Path(__file__).with_name("fuset_startup_faults.c") self.run("build-startup-faults", ["/usr/bin/xcrun", "clang", "-dynamiclib", "-Wall", "-Wextra", "-O2", source, "-o", library]) - for fault in ("refused", "metadata", "control", "rollback-blocked"): + for fault in ("aux-child", "refused", "metadata", "control", "rollback-blocked"): label = "startup-" + fault socket_log = self.root / (label + "-socket.txt") fault_marker = self.root / (label + "-fault-reached") diff --git a/Tests/test_macos_gui_lifecycle.py b/Tests/test_macos_gui_lifecycle.py index d18ea663..59ad456d 100644 --- a/Tests/test_macos_gui_lifecycle.py +++ b/Tests/test_macos_gui_lifecycle.py @@ -71,7 +71,7 @@ def main(): vc("mount", "--mount", volume, mountpoint, "--password=" + password, "--pim=1", "--keyfiles=", "--protect-hidden=no", *(["--mount-options=ro"] if read_only else [])) - identities = list(temporary.glob(".veracrypt_aux_mnt*/shutdown")) + identities = list(temporary.glob("**/.veracrypt_aux_mnt*/shutdown")) assert len(identities) == 1, "Expected one disposable FUSE-T service" aux = identities[0].parent active = (int(identities[0].read_text().split()[0]), aux, @@ -86,7 +86,7 @@ def main(): pass else: raise AssertionError("Disposable service still running") - assert not aux.exists() and not endpoint.exists(), "Cleanup paths remain" + assert not aux.exists() and not aux.parent.exists() and not endpoint.exists(), "Cleanup paths remain" assert str(root) not in mounts(), "Disposable mount remains" handles = subprocess.run(["/usr/sbin/lsof", "-t", str(volume)], capture_output=True, text=True) assert handles.returncode == 1 and not handles.stdout.strip(), "Backing file still open" diff --git a/Tests/test_macos_gui_teardown.py b/Tests/test_macos_gui_teardown.py index 181f72e7..80af0cb2 100644 --- a/Tests/test_macos_gui_teardown.py +++ b/Tests/test_macos_gui_teardown.py @@ -167,7 +167,7 @@ def main(): slow = leg.mount(binary, "slow", password, dict( DYLD_INSERT_LIBRARIES=str(faults), VC_FUSET_TEST_FAULT="cleanup-delay", VC_FUSET_TEST_DELAY="15", VC_FUSET_TEST_ROOT=str(root), VC_FUSET_TEST_FAULT_MARKER=str(leg.root / "cleanup-delayed"))) - service = int(next(leg.temporary.glob(".veracrypt_aux_mnt*/shutdown")).read_text().split()[0]) + service = int(next(leg.temporary.glob("**/.veracrypt_aux_mnt*/shutdown")).read_text().split()[0]) leg.start_gui(bundle) assert wait_for(lambda: slow not in leg.aux_mounts(), 120), "Automatic unmount did not start" service_alive = lambda: subprocess.run(["/bin/kill", "-0", str(service)], capture_output=True).returncode == 0 diff --git a/src/Core/Unix/CoreUnix.cpp b/src/Core/Unix/CoreUnix.cpp index 99b066f4..1344f9e0 100644 --- a/src/Core/Unix/CoreUnix.cpp +++ b/src/Core/Unix/CoreUnix.cpp @@ -18,6 +18,12 @@ #include #include #include +#if defined(TC_MACOSX) && defined(VC_MACOSX_FUSET) +#include +#include +#include +#include +#endif #ifdef TC_LINUX #include #endif @@ -32,6 +38,85 @@ namespace VeraCrypt { +#if defined(TC_MACOSX) && defined(VC_MACOSX_FUSET) + static string CreateFuseTAuxiliaryDirectory (const string &tempDirectory, uid_t userId) + { + // SMB covers the mountpoint's permissions and does not preserve the + // requesting local uid. Enforce access on an unmounted parent instead, + // including when sudo removes TMPDIR or a user selects a shared TMPDIR. + const bool elevated = geteuid() == 0; + const bool userAcl = elevated && userId != 0; + const string directoryTemplate = tempDirectory + (elevated ? "/.veracrypt_aux_root_" : "/.veracrypt_aux_") + + StringConverter::ToSingle (static_cast (userId)) + "-XXXXXXXXXXXX"; + uuid_t userUuid; + if (userAcl && mbr_uid_to_uuid (userId, userUuid) != 0) + throw TemporaryDirectoryFailure (SRC_POS, StringConverter::ToWide (directoryTemplate)); + vector temporary (directoryTemplate.begin(), directoryTemplate.end()); + temporary.push_back ('\0'); + throw_sys_sub_if (mkdtemp (&temporary[0]) == NULL, tempDirectory); + bool ready = false; + finally_do_arg2 (const char *, &temporary[0], bool &, ready, { if (!finally_arg2) rmdir (finally_arg); }); + const string directory = &temporary[0]; + + const int fd = open (directory.c_str(), O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + throw_sys_sub_if (fd == -1, directory); + finally_do_arg (int, fd, { close (finally_arg); }); + struct statfs filesystem; + throw_sys_sub_if (fstatfs (fd, &filesystem) == -1, directory); + if (filesystem.f_flags & MNT_IGNORE_OWNERSHIP) + throw TemporaryDirectoryFailure (SRC_POS, StringConverter::ToWide (directory) + + L"\nThe temporary filesystem must enforce ownership."); + struct stat info; + throw_sys_sub_if (fstat (fd, &info) == -1, directory); + if (!S_ISDIR (info.st_mode) || info.st_uid != (elevated ? 0 : userId)) + throw TemporaryDirectoryFailure (SRC_POS, StringConverter::ToWide (directory)); + // Keep elevated parents root-owned so the caller cannot replace paths + // used by privileged setup. Clear inherited ACLs even without a grant. + acl_t acl = acl_init (userAcl ? 1 : 0); + throw_sys_sub_if (acl == NULL, directory); + finally_do_arg (acl_t *, &acl, { acl_free (*finally_arg); }); + if (userAcl) + { + acl_entry_t entry; + throw_sys_sub_if (acl_create_entry (&acl, &entry) == -1, directory); + throw_sys_sub_if (acl_set_tag_type (entry, ACL_EXTENDED_ALLOW) == -1, directory); + throw_sys_sub_if (acl_set_qualifier (entry, userUuid) == -1, directory); + throw_sys_sub_if (acl_set_permset_mask_np (entry, + ACL_LIST_DIRECTORY | ACL_SEARCH | ACL_READ_ATTRIBUTES | ACL_READ_SECURITY) == -1, directory); + } + throw_sys_sub_if (acl_set_fd_np (fd, acl, ACL_TYPE_EXTENDED) == -1, directory); + throw_sys_sub_if (fchmod (fd, 0700) == -1, directory); + throw_sys_sub_if (fstat (fd, &info) == -1, directory); + if ((info.st_mode & 0777) != 0700) + throw TemporaryDirectoryFailure (SRC_POS, StringConverter::ToWide (directory)); + + ready = true; + return directory; + } + + static bool IsOtherUsersFuseTAuxiliaryMount (const MountedFilesystem &mount, uid_t userId, uid_t realUserId) + { + // Preserve root's existing discovery scope; only unprivileged callers + // can be excluded by the private parent. + if (mount.Type != "smbfs" || mount.Owner != 0 || userId == 0) + return false; + const string path = mount.MountPoint; + const string parent = path.substr (0, path.find_last_of ('/')); + const string name = parent.substr (parent.find_last_of ('/') + 1); + const string prefix = ".veracrypt_aux_root_"; + if (name.compare (0, prefix.size(), prefix) == 0) + { + const string id = name.substr (prefix.size(), name.find ('-', prefix.size()) - prefix.size()); + if (!id.empty() && id.find_first_not_of ("0123456789") == string::npos) + return id != StringConverter::ToSingle (static_cast (userId)) + && id != StringConverter::ToSingle (static_cast (realUserId)); + } + // Preserve metadata discovery for legacy paths: an ACL may grant + // traversal despite a foreign-owned parent with mode 0700. + return false; + } +#endif + #ifdef TC_LINUX static string GetTmpUser (); static bool GetLinuxKernelVersion (int &kernelMajor, int &kernelMinor); @@ -563,6 +648,14 @@ namespace VeraCrypt #endif continue; +#if defined(TC_MACOSX) && defined(VC_MACOSX_FUSET) + // Elevated SMB mounts have a root mount-table owner. Their private + // parent identifies the user; an inaccessible foreign mount is not + // an unresolved mount of the current user. + if (IsOtherUsersFuseTAuxiliaryMount (mf, getuid(), GetRealUserId())) + continue; +#endif + shared_ptr mountedVol; // Introduce a retry mechanism with a timeout for control file access. // The list is already filtered to VeraCrypt auxiliary mounts; in @@ -1174,7 +1267,13 @@ namespace VeraCrypt // An older service may still be shutting down after its SMB mount has // disappeared. FUSE-T also uses the pathname during backend teardown, // so a replacement volume must have a different auxiliary path. - string mountTemplate = string (GetTempDirectory()) + "/" + GetFuseMountDirPrefix() + "-XXXXXXXXXXXX"; + // An elevated parent also needs trusted ancestors. Match the shutdown + // socket's location instead of honoring a caller-controlled TMPDIR. + const string auxiliaryParent = CreateFuseTAuxiliaryDirectory (geteuid() == 0 ? "/private/tmp" : GetTempDirectory(), GetRealUserId()); + // Cover failures before the service takes over, including child creation. + // A live mount keeps this parent nonempty; its service removes it later. + finally_do_arg (string, auxiliaryParent, { rmdir (finally_arg.c_str()); }); + string mountTemplate = auxiliaryParent + "/" + GetFuseMountDirPrefix() + "-XXXXXXXXXXXX"; vector mountDirectory (mountTemplate.begin(), mountTemplate.end()); mountDirectory.push_back ('\0'); throw_sys_if (mkdtemp (&mountDirectory[0]) == NULL); diff --git a/src/Core/Unix/MacOSX/CoreMacOSX.cpp b/src/Core/Unix/MacOSX/CoreMacOSX.cpp index 469837cd..0e4b91ca 100644 --- a/src/Core/Unix/MacOSX/CoreMacOSX.cpp +++ b/src/Core/Unix/MacOSX/CoreMacOSX.cpp @@ -415,7 +415,12 @@ namespace VeraCrypt // Current services remove their original directory. Older development // services may leave it behind; rmdir only removes an empty directory // and cannot follow a replacement symlink or remove a mounted filesystem. - rmdir (string (mountedVolume->AuxMountPoint).c_str()); + if (rmdir (string (mountedVolume->AuxMountPoint).c_str()) == 0) + { +#ifdef VC_MACOSX_FUSET + FuseService::RemoveAuxMountParent (mountedVolume->AuxMountPoint); +#endif + } return mountedVolume; } diff --git a/src/Driver/Fuse/FuseService.cpp b/src/Driver/Fuse/FuseService.cpp index b2e0ca93..006b7355 100644 --- a/src/Driver/Fuse/FuseService.cpp +++ b/src/Driver/Fuse/FuseService.cpp @@ -545,9 +545,11 @@ namespace VeraCrypt if (!fuse_service_find_mount (Path.c_str(), mountId)) { struct stat current; - if (fstatat (ParentFd, Name.c_str(), ¤t, AT_SYMLINK_NOFOLLOW) == 0 - && S_ISDIR (current.st_mode) && current.st_dev == Original.st_dev && current.st_ino == Original.st_ino) - unlinkat (ParentFd, Name.c_str(), AT_REMOVEDIR); + int status = fstatat (ParentFd, Name.c_str(), ¤t, AT_SYMLINK_NOFOLLOW); + if ((status == -1 && errno == ENOENT) + || (status == 0 && S_ISDIR (current.st_mode) && current.st_dev == Original.st_dev && current.st_ino == Original.st_ino + && unlinkat (ParentFd, Name.c_str(), AT_REMOVEDIR) == 0)) + FuseService::RemoveAuxMountParent (Path, ParentFd); } } catch (...) { } @@ -561,6 +563,28 @@ namespace VeraCrypt struct stat Original; }; + void FuseService::RemoveAuxMountParent (const string &fuseMountPoint, int parentFd) + { + const string parent = fuseMountPoint.substr (0, fuseMountPoint.find_last_of ('/')); + const string name = parent.substr (parent.find_last_of ('/') + 1); + const string prefix = name.find (".veracrypt_aux_root_") == 0 ? ".veracrypt_aux_root_" : ".veracrypt_aux_"; + const size_t separator = name.find ('-', prefix.size()); + // Only the per-mount format belongs to us. Legacy parents may be a + // shared per-user directory or an arbitrary caller-selected TMPDIR. + if (name.compare (0, prefix.size(), prefix) != 0 || separator == string::npos + || separator == prefix.size() || name.size() - separator - 1 != 12 + || name.substr (prefix.size(), separator - prefix.size()).find_first_not_of ("0123456789") != string::npos + || name.substr (separator + 1).find_first_not_of ("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") != string::npos) + return; + struct stat current, original; + if (lstat (parent.c_str(), ¤t) != 0 || !S_ISDIR (current.st_mode) || current.st_uid != geteuid()) + return; + if (parentFd != -1 && (fstat (parentFd, &original) != 0 + || current.st_dev != original.st_dev || current.st_ino != original.st_ino)) + return; + rmdir (parent.c_str()); + } + static int fuse_service_main (int argc, char *argv[], const struct fuse_operations *operations, int startupFd) { // On rollback, EOF must only reach the caller after fuse_destroy has diff --git a/src/Driver/Fuse/FuseService.h b/src/Driver/Fuse/FuseService.h index b11e55cf..8007c55a 100644 --- a/src/Driver/Fuse/FuseService.h +++ b/src/Driver/Fuse/FuseService.h @@ -61,6 +61,7 @@ namespace VeraCrypt #if defined(TC_MACOSX) && defined(VC_MACOSX_FUSET) static const char *GetShutdownPath () { return "/shutdown"; } static const char *GetShutdownSocketPath () { return "/shutdown-socket"; } + static void RemoveAuxMountParent (const string &fuseMountPoint, int parentFd = -1); #endif static string GetDeviceType () { return "veracrypt"; } static gid_t GetGroupId () { return GroupId; }