#!/usr/bin/env python3
"""Check that Linux mounts volume files that only the requesting user can open.
Usage:
sudo python3 Tests/test_linux_requester_access.py --binary /path/to/veracrypt --user NAME [CHECK...]
Requires root, a C compiler, the libfuse3 development files and fusermount3.
NAME is an unprivileged account that may mount FUSE file systems. As NAME, the
test mounts Tests/linux_user_fuse_fixture.c without allow_other, so root is
denied, as with a GVfs share, and creates the containers without elevation.
VeraCrypt then runs as root with the SUDO_UID and SUDO_GID of NAME, as
'sudo veracrypt --text' does. The checks cover:
- writable mounting through the FUSE volume image: the requester opens the
file once, root does the I/O, one process holds the descriptor with
close-on-exec, timestamps are restored once by the requester, and the data
survives a remount; the warning is printed on stderr,
- explicit read-only mounting, and the read-only fallback when the server
denies writing or cannot open the file for writing in place,
- missing, invalid, partial and root attribution keep the original error,
- wrong passwords, special files, mount options, several mounts from one
service, hidden volume protection,
- a slow open() or timestamp change is waited for; the helper waiting in
open() runs with the requester's IDs, groups and no capabilities, and
holds only its socket besides /dev/null on descriptors 0-2.
Disposable volumes are used, and every unmount targets a test volume. The
artifacts are kept in a private temporary directory.
"""
import argparse
import hashlib
import os
import pwd
import re
import subprocess
import sys
import tempfile
import threading
import time
PASSWORD = "Disposable-requester-test-1"
HIDDEN_PASSWORD = "Disposable-requester-hidden-2"
WARNING = "opened the file with your user account's access rights"
class Checks:
def __init__(self, args):
self.binary = os.path.realpath(args.binary)
self.user = pwd.getpwnam(args.user)
self.slot = args.slot
self.root = tempfile.mkdtemp(prefix="vc-requester-test-")
os.chmod(self.root, 0o755)
self.dirs = {}
for name in ("backing", "control", "fuse", "home", "roothome", "volmnt"):
path = os.path.join(self.root, name)
os.mkdir(path)
self.dirs[name] = path
for name in ("backing", "control", "fuse", "home"):
os.chown(self.dirs[name], self.user.pw_uid, self.user.pw_gid)
self.failures = []
self.mounted_volumes = []
self.fuse_mounted = False
print("Artifacts:", self.root, flush=True)
# -- helpers ---------------------------------------------------------------
def run(self, label, args, env=None, user=False, timeout=180, input=None):
command = list(map(str, args))
if user:
command = ["runuser", "-u", self.user.pw_name, "--", "env", "HOME=" + self.dirs["home"]] + command
started = time.monotonic()
result = subprocess.run(command, capture_output=True, text=True, timeout=timeout, env=env, input=input)
result.seconds = time.monotonic() - started
with open(os.path.join(self.root, label + ".log"), "w") as log:
log.write(" ".join(command) + "\n" + result.stdout + result.stderr + "\nrc=%d\n" % result.returncode)
return result
def env(self, attributed=True, **overrides):
env = {"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "HOME": self.dirs["roothome"], "LANG": "C.UTF-8"}
if attributed:
env.update(SUDO_UID=str(self.user.pw_uid), SUDO_GID=str(self.user.pw_gid), SUDO_USER=self.user.pw_name)
env.update(overrides)
return {k: v for k, v in env.items() if v is not None}
def vc(self, label, *args, env=None, timeout=180):
return self.run(label, [self.binary, "--text", "--non-interactive", *args], env=env or self.env(), timeout=timeout)
def expect(self, label, passed, detail=""):
print(("PASS " if passed else "FAIL ") + label + (": " + detail if detail else ""), flush=True)
if not passed:
self.failures.append(label)
return passed
def path(self, name):
return os.path.join(self.dirs["fuse"], name)
def knob(self, name, enabled):
path = os.path.join(self.dirs["control"], name)
if enabled:
open(path, "w").close()
elif os.path.exists(path):
os.unlink(path)
def fixture_log(self):
path = os.path.join(self.dirs["control"], "log")
return open(path).read().splitlines() if os.path.exists(path) else []
def clear_log(self):
path = os.path.join(self.dirs["control"], "log")
if os.path.exists(path):
os.unlink(path)
def backing_times(self, name):
st = os.stat(os.path.join(self.dirs["backing"], name))
return int(st.st_atime), int(st.st_mtime)
def set_backing_times(self, name, atime, mtime):
os.utime(os.path.join(self.dirs["backing"], name), (atime, mtime))
def listed(self):
result = self.vc("list", "--list", "--verbose")
return result.stdout
def mount(self, label, volume, *extra, env=None, expect_rc=0, timeout=180, password=PASSWORD):
mountpoint = os.path.join(self.dirs["volmnt"], label)
os.makedirs(mountpoint, exist_ok=True)
result = self.vc(label, "--mount", volume, mountpoint, "--slot=%d" % self.slot, "--password=" + password,
"--pim=0", "--keyfiles=", *extra, env=env, timeout=timeout)
if result.returncode == 0:
self.mounted_volumes.append(volume)
if expect_rc is not None:
self.expect(label + " exit code", result.returncode == expect_rc,
"rc=%d %s" % (result.returncode, (result.stdout + result.stderr).strip()[-300:]))
return result, mountpoint
def unmount(self, label, volume, env=None, check=True):
result = self.vc(label, "--unmount", volume, env=env)
if check:
self.expect(label + " exit code", result.returncode == 0, (result.stdout + result.stderr).strip()[-200:])
elif result.returncode != 0:
# Cleanup only: a volume left mounted keeps the fixture busy.
result = self.vc(label + "-force", "--force", "--unmount", volume, env=env)
if result.returncode == 0 and volume in self.mounted_volumes:
self.mounted_volumes.remove(volume)
return result
def create(self, name, size="20M", extra=()):
result = self.run("create-" + name, [self.binary, "--text", "--non-interactive", "--create", self.path(name),
"--size=" + size, "--volume-type=normal", "--encryption=AES", "--hash=SHA-512",
"--filesystem=FAT", "--password=" + PASSWORD, "--pim=0", "--keyfiles=",
"--random-source=/dev/urandom", *extra], user=True)
if result.returncode != 0:
raise AssertionError("cannot create %s: %s" % (name, result.stdout + result.stderr))
def leftover_processes(self):
found = []
for pid in filter(str.isdigit, os.listdir("/proc")):
try:
status = open("/proc/%s/status" % pid).read()
name = status.split("Name:")[1].split()[0]
state = status.split("State:")[1].split()[0]
try:
exe = os.readlink("/proc/%s/exe" % pid)
except OSError:
exe = ""
if exe == self.binary or (state == "Z" and name.startswith("veracrypt")):
found.append((int(pid), name, state, exe))
except (OSError, IndexError):
pass
return found
def wait_no_processes(self, seconds=10):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
if not self.leftover_processes():
return True
time.sleep(0.2)
return False
# -- setup -----------------------------------------------------------------
def setup(self):
source = os.path.join(os.path.dirname(os.path.realpath(__file__)), "linux_user_fuse_fixture.c")
self.fixture = os.path.join(self.root, "userfs")
flags = subprocess.run(["pkg-config", "--cflags", "--libs", "fuse3"], capture_output=True, text=True, check=True).stdout.split()
subprocess.run(["cc", "-O2", "-Wall", "-o", self.fixture, source, *flags], check=True)
env_args = ["USERFS_BACKING=" + self.dirs["backing"], "USERFS_CONTROL=" + self.dirs["control"]]
result = self.run("fixture-mount", ["env", *env_args, self.fixture, self.dirs["fuse"]], user=True)
if result.returncode != 0:
raise AssertionError("cannot mount fixture: " + result.stdout + result.stderr)
self.fuse_mounted = True
for _ in range(50):
# Root cannot stat the mount point, so consult the mount table.
if any(line.split()[4] == self.dirs["fuse"] for line in open("/proc/self/mountinfo")):
break
time.sleep(0.1)
try:
os.open(os.path.join(self.dirs["fuse"], "."), os.O_RDONLY)
raise AssertionError("root can access the fixture; it must be user-only")
except PermissionError:
pass
self.create("vol.hc")
def cleanup(self):
for volume in list(self.mounted_volumes):
try:
self.unmount("cleanup", volume, check=False)
except Exception as e:
print("cleanup:", e)
self.expect("cleanup unmounted all volumes", not self.mounted_volumes, str(self.mounted_volumes))
for knob in ("hang_open", "enotsup_rw", "eacces_rw", "hang_utimens"):
self.knob(knob, False)
if self.fuse_mounted:
# The FUSE service of the last volume may still be closing its file.
self.wait_no_processes()
result = self.run("fixture-unmount", ["fusermount3", "-u", self.dirs["fuse"]], user=True)
self.expect("fixture unmounted", result.returncode == 0, (result.stdout + result.stderr).strip())
# -- checks ----------------------------------------------------------------
def check_writable_mount(self):
self.set_backing_times("vol.hc", 1500000000, 1500000100)
self.clear_log()
result, mountpoint = self.mount("rw", self.path("vol.hc"), "--protect-hidden=no")
self.expect("rw warning on stderr", WARNING in result.stderr, result.stderr.strip()[:200])
listing = self.listed()
self.expect("rw listed writable", "Read-Only: No" in listing, listing.strip()[:300])
self.expect("rw uses FUSE path", re.search(r"Virtual Device: /dev/loop\d+", listing) is not None)
# Descriptor ownership and close-on-exec in the service processes.
holders = self.descriptor_holders(self.path("vol.hc"))
self.expect("rw descriptor held by one service with O_CLOEXEC",
len(holders) == 1 and all(cloexec for _, cloexec in holders), str(holders))
data = os.urandom(3 * 1024 * 1024)
with open(os.path.join(mountpoint, "data.bin"), "wb") as f:
f.write(data)
f.flush()
os.fsync(f.fileno())
log = self.fixture_log()
opens = [l for l in log if l.startswith("open ") and "/vol.hc" in l]
self.expect("rw opened once by requester read/write",
len(opens) == 1 and "uid=%d" % self.user.pw_uid in opens[0] and int(opens[0].split("flags=")[1].split()[0], 8) & 3 == 2,
str(opens))
self.expect("rw I/O performed by root", any(l.startswith("write uid=0") for l in log), str([l for l in log if l.startswith("write")]))
self.unmount("rw-unmount", self.path("vol.hc"))
time.sleep(0.5)
times = self.backing_times("vol.hc")
self.expect("rw timestamps restored", times == (1500000000, 1500000100), str(times))
utimens = [l for l in self.fixture_log() if l.startswith("utimens ")]
self.expect("rw timestamps restored once, by requester",
len(utimens) == 1 and "uid=%d" % self.user.pw_uid in utimens[0], str(utimens))
self.expect("rw no leftover processes", self.wait_no_processes(), str(self.leftover_processes()))
# Data must be intact on a fresh mount.
result, mountpoint = self.mount("verify", self.path("vol.hc"), "--protect-hidden=no", "--mount-options=ro")
try:
with open(os.path.join(mountpoint, "data.bin"), "rb") as f:
same = hashlib.sha256(f.read()).digest() == hashlib.sha256(data).digest()
except OSError as e:
same = False
self.expect("data intact after remount", same)
self.unmount("verify-unmount", self.path("vol.hc"))
def descriptor_holders(self, path):
holders = []
for pid in filter(str.isdigit, os.listdir("/proc")):
try:
for fd in os.listdir("/proc/%s/fd" % pid):
try:
if os.readlink("/proc/%s/fd/%s" % (pid, fd)) == path:
flags = int(open("/proc/%s/fdinfo/%s" % (pid, fd)).read().split("flags:")[1].split()[0], 8)
holders.append((int(pid), bool(flags & 0o2000000)))
except OSError:
pass
except OSError:
pass
return holders
def check_read_only_modes(self):
self.clear_log()
result, _ = self.mount("ro-explicit", self.path("vol.hc"), "--protect-hidden=no", "--mount-options=ro")
listing = self.listed()
self.expect("explicit ro listed read-only", "Read-Only: Yes" in listing)
opens = [l for l in self.fixture_log() if l.startswith("open ") and "/vol.hc" in l]
self.expect("explicit ro opens read-only only", opens and all(int(l.split("flags=")[1].split()[0], 8) & 3 == 0 for l in opens), str(opens))
self.unmount("ro-explicit-unmount", self.path("vol.hc"))
for knob, label in (("eacces_rw", "server-denies-write"), ("enotsup_rw", "no-in-place-edit")):
self.knob(knob, True)
try:
result, _ = self.mount(label, self.path("vol.hc"), "--protect-hidden=no")
listing = self.listed()
self.expect(label + " falls back to read-only", "Read-Only: Yes" in listing, listing.strip()[:200])
self.unmount(label + "-unmount", self.path("vol.hc"))
finally:
self.knob(knob, False)
def check_attribution(self):
cases = [
("no-attribution", self.env(attributed=False)),
("root-attribution", self.env(SUDO_UID="0", SUDO_GID="0")),
("invalid-attribution", self.env(SUDO_UID="%dx" % self.user.pw_uid)),
("partial-attribution", self.env(SUDO_GID=None)),
("out-of-range-attribution", self.env(SUDO_UID="4294967295")),
]
for label, env in cases:
result, _ = self.mount(label, self.path("vol.hc"), "--protect-hidden=no", env=env, expect_rc=None)
output = result.stdout + result.stderr
self.expect(label + " keeps permission denied", result.returncode != 0 and "Permission denied" in output, output.strip()[-200:])
if result.returncode == 0:
self.unmount(label + "-unmount", self.path("vol.hc"))
def check_wrong_password(self):
self.set_backing_times("vol.hc", 1500000000, 1500000100)
self.clear_log()
mountpoint = os.path.join(self.dirs["volmnt"], "wrong")
os.makedirs(mountpoint, exist_ok=True)
result = self.vc("wrong-password", "--mount", self.path("vol.hc"), mountpoint, "--slot=%d" % self.slot,
"--password=wrong", "--pim=0", "--keyfiles=", "--protect-hidden=no")
output = result.stdout + result.stderr
self.expect("wrong password reported", result.returncode != 0 and "Incorrect password" in output, output.strip()[-200:])
self.expect("wrong password timestamps", self.backing_times("vol.hc") == (1500000000, 1500000100))
self.expect("wrong password no leftover processes", self.wait_no_processes(), str(self.leftover_processes()))
def check_special_files(self):
fifo = os.path.join(self.dirs["fuse"], "fifo.hc")
self.run("mkfifo", ["mkfifo", fifo], user=True)
result, _ = self.mount("fifo", fifo, "--protect-hidden=no", expect_rc=None, timeout=60)
output = result.stdout + result.stderr
self.expect("fifo rejected promptly", result.returncode != 0 and result.seconds < 30 and "Invalid argument" in output,
"%.1fs %s" % (result.seconds, output.strip()[-200:]))
directory = os.path.join(self.dirs["fuse"], "dir.hc")
self.run("mkdir", ["mkdir", directory], user=True)
result, _ = self.mount("directory", directory, "--protect-hidden=no", "--mount-options=ro", expect_rc=None)
output = result.stdout + result.stderr
self.expect("directory rejected", result.returncode != 0 and "Is a directory" in output, output.strip()[-200:])
def check_hidden_protection(self):
result = self.run("create-hidden", [self.binary, "--text", "--non-interactive", "--create", self.path("vol.hc"),
"--size=5M", "--volume-type=hidden", "--encryption=AES", "--hash=SHA-512",
"--filesystem=FAT", "--password=" + HIDDEN_PASSWORD, "--pim=0", "--keyfiles=",
"--random-source=/dev/urandom"], user=True)
if not self.expect("hidden volume created by user", result.returncode == 0, (result.stdout + result.stderr).strip()[-200:]):
return
hidden_data = os.urandom(1024 * 1024)
result, mountpoint = self.mount("hidden-write", self.path("vol.hc"), "--protect-hidden=no", password=HIDDEN_PASSWORD)
if result.returncode != 0:
return
with open(os.path.join(mountpoint, "hidden.bin"), "wb") as f:
f.write(hidden_data)
f.flush()
os.fsync(f.fileno())
self.unmount("hidden-write-unmount", self.path("vol.hc"))
result, mountpoint = self.mount("protected", self.path("vol.hc"), "--protect-hidden=yes",
"--protection-password=" + HIDDEN_PASSWORD, "--protection-pim=0", "--protection-keyfiles=")
if result.returncode != 0:
return
listing = self.listed()
self.expect("outer mounted with hidden protection", "Hidden Volume Protected: Yes\n" in listing, listing.strip()[:300])
# Fill the outer volume until a write reaches the hidden volume and is refused.
try:
with open(os.path.join(mountpoint, "fill.bin"), "wb") as f:
for _ in range(40):
f.write(os.urandom(1024 * 1024))
f.flush()
os.fsync(f.fileno())
except OSError:
pass
subprocess.run(["sync"])
listing = self.listed()
self.expect("outer writes refused by hidden protection", "Hidden Volume Protected: Yes (damage prevented!)" in listing,
listing.strip()[:300])
self.unmount("protected-unmount", self.path("vol.hc"))
result, mountpoint = self.mount("hidden-verify", self.path("vol.hc"), "--protect-hidden=no", "--mount-options=ro",
password=HIDDEN_PASSWORD)
if result.returncode != 0:
return
try:
with open(os.path.join(mountpoint, "hidden.bin"), "rb") as f:
same = f.read() == hidden_data
except OSError:
same = False
self.expect("hidden volume data intact after protected writes", same)
self.unmount("hidden-verify-unmount", self.path("vol.hc"))
def waiting_helper(self, seconds=10):
# Status fields and descriptors of the requester helper, found by its user.
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
for pid in filter(str.isdigit, os.listdir("/proc")):
try:
if os.readlink("/proc/%s/exe" % pid) != self.binary:
continue
status = dict(line.split(":", 1) for line in open("/proc/%s/status" % pid).read().splitlines())
if status["Uid"].split()[0] != str(self.user.pw_uid):
continue
fds = {int(fd): os.readlink("/proc/%s/fd/%s" % (pid, fd)) for fd in os.listdir("/proc/%s/fd" % pid)}
return {k: v.strip() for k, v in status.items()}, fds
except (OSError, KeyError):
pass
time.sleep(0.1)
return None, None
def check_slow_open(self):
# VeraCrypt waits for the server to answer, here for 12 s.
self.knob("hang_open", True)
outcome = {}
thread = threading.Thread(target=lambda: outcome.update(
result=self.mount("slow-open", self.path("vol.hc"), "--protect-hidden=no", expect_rc=None, timeout=120)[0]))
thread.start()
try:
status, fds = self.waiting_helper()
uid, gid = str(self.user.pw_uid), str(self.user.pw_gid)
groups = set(map(str, os.getgrouplist(self.user.pw_name, self.user.pw_gid)))
self.expect("helper credentials", status is not None and status["Uid"].split() == [uid] * 4
and status["Gid"].split() == [gid] * 4 and set(status["Groups"].split()) == groups
and int(status["CapPrm"], 16) == 0 and int(status["CapEff"], 16) == 0 and status["Name"] == "veracrypt",
str(status and {k: status[k] for k in ("Name", "Uid", "Gid", "Groups", "CapPrm", "CapEff")}))
self.expect("helper descriptors", fds is not None and all(fds.get(fd) == "/dev/null" for fd in (0, 1, 2))
and len(fds) == 4 and sum(target.startswith("socket:") for target in fds.values()) == 1, str(fds))
time.sleep(12)
finally:
self.knob("hang_open", False)
thread.join()
result = outcome.get("result")
self.expect("slow open succeeds", result is not None and result.returncode == 0 and result.seconds >= 12,
result and "rc=%d %.1fs %s" % (result.returncode, result.seconds, (result.stdout + result.stderr).strip()[-200:]))
if result is not None and result.returncode == 0:
self.unmount("slow-open-unmount", self.path("vol.hc"))
self.expect("slow open no leftover processes", self.wait_no_processes(), str(self.leftover_processes()))
def check_slow_timestamps(self):
# Restoring the timestamps also waits for the server, then completes.
self.mount("slow-utimens", self.path("vol.hc"), "--protect-hidden=no")
self.clear_log()
self.knob("hang_utimens", True)
timer = threading.Timer(5, self.knob, ("hang_utimens", False))
timer.start()
try:
self.unmount("slow-utimens-unmount", self.path("vol.hc"))
finally:
timer.join()
self.expect("slow restoration no leftover processes", self.wait_no_processes(15), str(self.leftover_processes()))
utimens = [l for l in self.fixture_log() if l.startswith("utimens ")]
self.expect("slow restoration done once, by requester",
len(utimens) == 1 and "uid=%d" % self.user.pw_uid in utimens[0] and "result=0" in utimens[0], str(utimens))
def check_mount_options(self):
# Timestamp preservation disabled: no restoration helper.
self.clear_log()
self.mount("no-timestamps", self.path("vol.hc"), "--protect-hidden=no", "--mount-options=timestamp")
self.unmount("no-timestamps-unmount", self.path("vol.hc"))
utimens = [l for l in self.fixture_log() if l.startswith("utimens ")]
self.expect("timestamp option skips restoration", not utimens, str(utimens))
# Embedded backup header, read through the requester-backed descriptor.
self.mount("backup-header", self.path("vol.hc"), "--protect-hidden=no", "--mount-options=headerbak,ro")
self.expect("backup header mount listed", "Read-Only: Yes" in self.listed())
self.unmount("backup-header-unmount", self.path("vol.hc"))
def check_favorites(self):
# Several mounts served by one core service process.
self.create("vol2.hc")
config = os.path.join(self.dirs["roothome"], ".config", "VeraCrypt")
os.makedirs(config, exist_ok=True)
entries = ""
for index, name in enumerate(("vol.hc", "vol2.hc")):
mountpoint = os.path.join(self.dirs["volmnt"], "fav%d" % index)
os.makedirs(mountpoint, exist_ok=True)
entries += '\t\t%s\n' % (
mountpoint, self.slot - 1 - index, self.path(name))
with open(os.path.join(config, "Favorite Volumes.xml"), "w") as f:
f.write('\n\n\t\n%s\t\n\n' % entries)
result = self.vc("favorites", "--auto-mount=favorites", "--password=" + PASSWORD, "--pim=0", "--keyfiles=", "--protect-hidden=no")
listing = self.listed()
self.mounted_volumes += [self.path(name) for name in ("vol.hc", "vol2.hc") if "Volume: %s\n" % self.path(name) in listing]
self.expect("favorites mounted by one service", result.returncode == 0 and result.stderr.count(WARNING) == 2
and listing.count("Read-Only: No") == 2, (result.stdout + result.stderr).strip()[-300:])
for name in ("vol.hc", "vol2.hc"):
self.unmount("favorite-unmount-" + name, self.path(name))
os.unlink(os.path.join(config, "Favorite Volumes.xml"))
self.expect("favorites no leftover processes", self.wait_no_processes(), str(self.leftover_processes()))
def main(self, selected):
try:
self.setup()
for name in selected:
getattr(self, "check_" + name)()
finally:
self.cleanup()
print("FAILURES:" if self.failures else "ALL PASSED", self.failures if self.failures else "", flush=True)
return 1 if self.failures else 0
ALL = ["writable_mount", "read_only_modes", "attribution", "wrong_password", "special_files",
"mount_options", "favorites", "slow_open", "slow_timestamps", "hidden_protection"]
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--binary", required=True)
parser.add_argument("--user", required=True, help="unprivileged account that mounts the FUSE fixture")
parser.add_argument("--slot", type=int, default=61)
parser.add_argument("checks", nargs="*", default=ALL)
args = parser.parse_args()
if os.geteuid() != 0:
sys.exit("run as root")
sys.exit(Checks(args).main(args.checks))