mirror of
https://github.com/veracrypt/VeraCrypt.git
synced 2026-10-05 16:46:35 -05:00
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:
11 files changed
+442
-12
No files matched your search
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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()
|
||||
@@ -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"); }
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user