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.
This commit is contained in:
Mounir IDRASSI committed 2026-09-29 10:01:37 +02:00
1 parent c91f386f00
commit 41bc8e5f6a
11 files changed
+442 -12

No files matched your search

+17
View File
@@ -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
+12
View File
@@ -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);
+265
View File
@@ -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 <fcntl.h>
#include <membership.h>
#include <cstring>
#include <sys/acl.h>
#include <sys/mount.h>
#include <sys/stat.h>
#include <unistd.h>
#include <stdio.h>
#include <iostream>
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()
+1
View File
@@ -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<std::string> &) { ++Commands; throw std::runtime_error("unexpected disk command"); }
+10 -4
View File
@@ -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")
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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