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
@@ -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()
|
||||
Reference in new issue
Block a user