From f18a599589eb3b37bd48d79f94160fb4e98b9d4b Mon Sep 17 00:00:00 2001 From: Mounir IDRASSI Date: Thu, 22 Oct 2026 08:36:42 +0900 Subject: [PATCH] Unix: honour forced read-only protection in FUSE mounts Volume::Open forces read-only protection on a partially encrypted system volume, but the FUSE backend used the requested protection and attached the loop device and mounted the filesystem read-write. Copy the effective protection into the mount options before choosing the backend. Run the linked mount test in CI, check raw writes and journal recovery in the VM test, and document that a filesystem needing journal recovery may refuse a read-only mount. --- .github/workflows/build-linux.yml | 8 ++ Tests/linux_mount_read_only_test.cpp | 88 +++++++++++++++++- Tests/test_linux_mount_read_only.py | 5 ++ Tests/test_linux_read_only_integration.py | 93 +++++++++++++++++++- doc/html/en/Command Line Usage for Unix.html | 4 +- src/Core/Unix/CoreUnix.cpp | 5 ++ src/Main/UserInterface.cpp | 3 + 7 files changed, 199 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 53dff799..c5038b19 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -25,6 +25,8 @@ on: - 'Tests/test.*.hc' - 'Tests/test_volume_read.py' - 'Tests/volume_read_test.cpp' + - 'Tests/test_linux_mount_read_only.py' + - 'Tests/linux_mount_read_only_test.cpp' - 'Tests/response_apdu_test.cpp' - '.github/workflows/build-linux.yml' pull_request: @@ -51,6 +53,8 @@ on: - 'Tests/test.*.hc' - 'Tests/test_volume_read.py' - 'Tests/volume_read_test.cpp' + - 'Tests/test_linux_mount_read_only.py' + - 'Tests/linux_mount_read_only_test.cpp' - 'Tests/response_apdu_test.cpp' - '.github/workflows/build-linux.yml' @@ -150,6 +154,10 @@ jobs: - name: Read known data from the test volumes run: python3 Tests/test_volume_read.py --build-dir src --require-thread-pool + # The package build above defaults to FUSE2 and leaves matching archives. + - name: Check Linux mount protection and fallback + run: python3 Tests/test_linux_mount_read_only.py --build-dir src --fuse-package=fuse + - name: Install and test VeraCrypt GUI .deb packages run: | sudo apt install -y /tmp/VeraCrypt_Packaging/GUI/Packaging/veracrypt-*.deb diff --git a/Tests/linux_mount_read_only_test.cpp b/Tests/linux_mount_read_only_test.cpp index c1d0607a..9fc3f289 100644 --- a/Tests/linux_mount_read_only_test.cpp +++ b/Tests/linux_mount_read_only_test.cpp @@ -10,6 +10,9 @@ using namespace VeraCrypt; static bool ReadOnly; static unsigned LoopCalls, LoopFailures, Layers, ExpectedLayers; static string VolumeFilePath; +static bool MountFlow, AuxInfoSent; +static string AuxPath, LoopFilePath; +static unsigned FuseMountCalls; struct StopBeforeDeviceIO { }; static void Require (bool condition, const char *message) @@ -31,7 +34,7 @@ extern "C" string WrapExecute (const string &program, const list &args, if (ReadOnly) expected.push_back ("-r"); expected.push_back ("--"); expected.push_back ("/dev/loop" + StringConverter::ToSingle (LoopCalls - 1)); - expected.push_back (VolumeFilePath); + expected.push_back (MountFlow ? LoopFilePath : VolumeFilePath); Require (a == expected, "loop retry changed protection or arguments"); if (LoopCalls <= LoopFailures) throw ExecutedProcessFailed (SRC_POS, "losetup", 1, "injected failure"); @@ -53,8 +56,10 @@ extern "C" string WrapExecute (const string &program, const list &args, } extern "C" string WrapFindBinary (const char *, string &) { return "/test/dmsetup"; } -extern "C" FilesystemPathType::Enum WrapGetType (const FilesystemPath *) +extern "C" FilesystemPathType::Enum WrapGetType (const FilesystemPath *path) { + if (MountFlow && string (*path).find ("/dev/loop") != 0) + return FilesystemPathType::File; return FilesystemPathType::BlockDevice; } extern "C" shared_ptr WrapAuxInfo (const DirectoryPath &) @@ -64,6 +69,22 @@ extern "C" shared_ptr WrapAuxInfo (const DirectoryPath &) return info; } +extern "C" uint64 WrapFuseMount (shared_ptr, VolumeSlotNumber, const string &path) +{ + Require (MountFlow, "unexpected FUSE service mount"); + ++FuseMountCalls; + AuxPath = path; + LoopFilePath = path + "/volume"; + return 987654321; +} + +extern "C" void WrapSendAuxInfo (const DirectoryPath &path, const DevicePath &device, const DevicePath &loop) +{ + Require (MountFlow && string (path) == AuxPath && string (device) == "/dev/loop0" + && device == loop, "unexpected FUSE loop metadata"); + AuxInfoSent = true; +} + struct TestLayout : VolumeLayoutV2Normal { uint64 GetDataOffset (uint64) const override { return 128 * 1024; } @@ -98,6 +119,37 @@ struct TestCore : CoreLinux using CoreUnix::MountAuxVolumeImage; }; +// Exercise MountVolume -> MountVolumeNative -> MountAuxVolumeImage with a +// synthetic opened volume. Only FUSE service creation and final filesystem I/O +// are intercepted; backend selection and loop arguments use production code. +struct FallbackCore : TestCore +{ + shared_ptr Opened; + mutable bool FilesystemMounted = false; + + shared_ptr OpenVolume (shared_ptr, bool, shared_ptr, int, + shared_ptr, shared_ptr, bool, VolumeProtection::Enum, + shared_ptr, int, shared_ptr, shared_ptr, + bool, VolumeType::Enum, bool, bool) const override { return Opened; } + bool IsVolumeMounted (const VolumePath &) const override { return false; } + VolumeInfoList GetMountedVolumes (const VolumePath & = VolumePath()) const override { return VolumeInfoList(); } + shared_ptr GetMountedVolume (const VolumePath &) const override + { + return shared_ptr (new VolumeInfo); + } + shared_ptr DismountVolume (shared_ptr info, bool, bool) override { return info; } + MountedFilesystemList GetMountedFilesystems (const DevicePath & = DevicePath(), + const DirectoryPath & = DirectoryPath()) const override { return MountedFilesystemList(); } + string GetTempDirectory () const override { return VolumeFilePath.substr (0, VolumeFilePath.rfind ('/')); } + void MountFilesystem (const DevicePath &device, const DirectoryPath &, const string &, + bool readOnly, const string &, bool) const override + { + Require (string (device) == "/dev/loop0" && readOnly == ReadOnly, + "FUSE filesystem lost effective protection"); + FilesystemMounted = true; + } +}; + int main (int argc, char **argv) { try @@ -153,6 +205,36 @@ int main (int argc, char **argv) Require (Layers == 0, "FUSE fallback created a native mapping"); } + MountFlow = true; + for (unsigned scenario = 0; scenario < 4; ++scenario) + for (bool noFilesystem : {false, true}) + { + const bool incomplete = scenario == 0; + const VolumeProtection::Enum protection = scenario == 1 ? VolumeProtection::HiddenVolumeReadOnly + : scenario == 3 ? VolumeProtection::None : VolumeProtection::ReadOnly; + FallbackCore fallback; + fallback.Opened.reset (new TestVolume (false, protection, incomplete)); + MountOptions mountOptions; + mountOptions.Path.reset (new VolumePath (VolumeFilePath)); + mountOptions.MountPoint.reset (new DirectoryPath (fallback.GetTempDirectory() + "/mount")); + mountOptions.SlotNumber = 64; + mountOptions.NoFilesystem = noFilesystem; + mountOptions.NoKernelCrypto = scenario >= 2; + mountOptions.FilesystemType = L"vfat"; + mountOptions.Protection = incomplete ? VolumeProtection::None : protection; + ReadOnly = protection == VolumeProtection::ReadOnly; + LoopCalls = LoopFailures = Layers = FuseMountCalls = 0; + AuxInfoSent = false; + AuxPath.clear(); + fallback.MountVolume (mountOptions); + Require (mountOptions.Protection == protection, "effective protection not propagated or hidden protection changed"); + Require (FuseMountCalls == 1 && LoopCalls == 1 && Layers == 0 && AuxInfoSent, + "FUSE fallback not completed"); + Require (fallback.FilesystemMounted == !noFilesystem, "FUSE filesystem path not exercised"); + Require (rmdir (AuxPath.c_str()) == 0, "cannot remove test auxiliary directory"); + } + MountFlow = false; + // The FUSE image caller must propagate loop setup failure as well. options.Protection = VolumeProtection::ReadOnly; VolumeFilePath = "/test/aux/volume"; @@ -166,7 +248,7 @@ int main (int argc, char **argv) } catch (LoopDeviceSetupFailed &) { } Require (LoopCalls == 256, "FUSE loop failure not exercised"); - std::cout << "PASS mapping arguments, cascades, effective protection, hidden/incomplete fallback, loop retries and failure\n"; + std::cout << "PASS mapping arguments, cascades, effective protection through FUSE fallback, hidden protection, loop retries and failure\n"; } catch (const std::exception &e) { std::cerr << e.what() << '\n'; return 1; } } diff --git a/Tests/test_linux_mount_read_only.py b/Tests/test_linux_mount_read_only.py index 04dd7cf7..7082f375 100644 --- a/Tests/test_linux_mount_read_only.py +++ b/Tests/test_linux_mount_read_only.py @@ -33,6 +33,8 @@ def main(): "Process16FindSystemBinaryE": "WrapFindBinary", "FilesystemPath7GetTypeE": "WrapGetType", "CoreUnix23ReadAuxiliaryVolumeInfoE": "WrapAuxInfo", + "FuseService5MountE": "WrapFuseMount", + "FuseService17SendAuxDeviceInfoE": "WrapSendAuxInfo", } flags = [] for fragment, wrapper in wrappers.items(): @@ -55,6 +57,9 @@ def main(): "-Wl,--start-group", *map(str, archives), "-Wl,--end-group", "-pthread", "-ldl", "-o", str(binary), ] + # WXSTATIC builds use the old libstdc++ ABI (see test_volume_read.py). + if "cxx11" not in symbols: + command.append("-D_GLIBCXX_USE_CXX11_ABI=0") command += shlex.split(subprocess.check_output( ["pkg-config", "--libs", args.fuse_package], text=True)) subprocess.run(command, check=True, timeout=120) diff --git a/Tests/test_linux_read_only_integration.py b/Tests/test_linux_read_only_integration.py index 8228ec67..fef29d13 100644 --- a/Tests/test_linux_read_only_integration.py +++ b/Tests/test_linux_read_only_integration.py @@ -1,7 +1,11 @@ #!/usr/bin/env python3 """Verify Linux read-only device protection in a dedicated disposable VM. -Requires the same tools as test_linux_loop_identity.py, plus blockdev. Run: +Requires the same tools as test_linux_loop_identity.py, plus blockdev and +debugfs. Read-only cases reject raw writes and preserve the encrypted host's +SHA-256. Dirty ext4 cases must refuse journal recovery without changing the +host or leaking devices; an explicit noload mount is the positive control. +Run: python3 Tests/test_linux_read_only_integration.py --binary /usr/bin/veracrypt To also test a virtual block device, attach a blank 16 MiB virtio disk with @@ -11,6 +15,8 @@ Use test_linux_mount_read_only.py for command/failure injection without root. """ import argparse +import errno +import hashlib import os from pathlib import Path import shutil @@ -22,6 +28,36 @@ from test_linux_loop_identity import ( ) +def host_digest(path): + digest = hashlib.sha256() + with open(path, "rb") as stream: + for block in iter(lambda: stream.read(1 << 20), b""): + digest.update(block) + return digest.hexdigest() + + +def check_write_rejected(checks, label, device): + # Establish readability separately, so a read error cannot be mistaken + # for a rejected write. Reusing the sector avoids corrupting later checks + # if read-only protection regresses. + with open(device, "rb", buffering=0) as stream: + sector = stream.read(512) + if len(sector) != 512: + raise AssertionError(f"Cannot read test sector from {device}") + descriptor = None + try: + descriptor = os.open(device, os.O_RDWR) + os.pwrite(descriptor, sector, 0) + except OSError as error: + checks.expect(label, error.errno in (errno.EROFS, errno.EPERM, errno.EACCES), + f"{device}: {error}") + else: + checks.expect(label, False, f"{device}: raw write accepted") + finally: + if descriptor is not None: + os.close(descriptor) + + def check_setup_failure(checks, program, kernel): label = f"{program}-failure-{'native' if kernel else 'fuse'}" real = checks.root / (program + ".real") @@ -77,6 +113,7 @@ def check(checks, path, kernel, readonly, filesystem, layers, effective_readonly expected_readonly = readonly if effective_readonly is None else effective_readonly if expected_readonly != readonly: label += "-access-fallback" + before_digest = host_digest(path) if expected_readonly else None before_aux = aux_mounts() before_loops = loop_devices() options = ",".join((["ro"] if readonly else []) + ([] if kernel else ["nokernelcrypto"])) @@ -104,6 +141,8 @@ def check(checks, path, kernel, readonly, filesystem, layers, effective_readonly for index, dev in enumerate(sorted(set(devices) | new_loops)): mode = checks.run(f"{label}-mode-{index}", ["blockdev", "--getro", dev]).stdout.strip() checks.expect(f"{label}-protected-{index}", mode == str(int(expected_readonly)), f"{dev}: read-only={mode}") + if expected_readonly: + check_write_rejected(checks, f"{label}-write-rejected-{index}", dev) if filesystem: flags = next(line.split()[3].split(",") for line in Path("/proc/mounts").read_text().splitlines() if line.split()[1] == str(checks.mountpoint)) @@ -117,6 +156,53 @@ def check(checks, path, kernel, readonly, filesystem, layers, effective_readonly checks.mounted = None checks.expect(label + "-released", *checks.released()) checks.expect(label + "-aux-released", aux_mounts() == before_aux, "auxiliary mount released") + if expected_readonly: + checks.expect(label + "-unchanged", host_digest(path) == before_digest, + "encrypted host SHA-256 unchanged") + finally: + checks.cleanup() + + +def check_journal_recovery(checks, path, kernel): + label = f"{path.name}-{'native' if kernel else 'fuse'}-journal" + args = ["--mount", path, checks.mountpoint, f"--slot={checks.slot}", + "--password=" + checks.password, "--pim=1", "--keyfiles=", "--protect-hidden=no"] + backend = "" if kernel else "nokernelcrypto" + try: + checks.vc(label + "-prepare", *args, "--filesystem=none", "--mount-options=" + backend) + checks.mounted = path + device = checks.listed()["device"] + checks.run(label + "-mkfs", ["mkfs.ext4", "-q", "-F", device]) + checks.run(label + "-mark-dirty", ["debugfs", "-w", "-R", "feature needs_recovery", device]) + features = checks.run(label + "-features", ["debugfs", "-R", "feature", device]).stdout.split() + if "needs_recovery" not in features: + raise AssertionError(f"Journal fixture is not marked for recovery: {features}") + checks.vc(label + "-prepare-unmount", "--unmount", path) + checks.mounted = None + before = host_digest(path) + readonly = "--mount-options=ro" + ("," + backend if backend else "") + result = checks.vc(label + "-mount", *args, "--filesystem=ext4", readonly, expected=None) + if result.returncode == 0: + checks.mounted = path + checks.expect(label + "-recovery-refused", result.returncode != 0, + f"exit {result.returncode}: {result.stdout + result.stderr}") + checks.expect(label + "-released", *checks.released()) + checks.cleanup() + checks.expect(label + "-unchanged", host_digest(path) == before, + "failed read-only mount preserved encrypted host SHA-256") + + # The filesystem is mountable when journal recovery is explicitly + # disabled. This is a test control, not an automatic recovery policy. + checks.vc(label + "-noload", *args, "--filesystem=ext4", readonly, "--fs-options=noload") + checks.mounted = path + flags = next(line.split()[3].split(",") for line in Path("/proc/mounts").read_text().splitlines() + if line.split()[1] == str(checks.mountpoint)) + checks.expect(label + "-noload-readonly", "ro" in flags, str(flags)) + checks.vc(label + "-noload-unmount", "--unmount", path) + checks.mounted = None + checks.expect(label + "-noload-released", *checks.released()) + checks.expect(label + "-noload-unchanged", host_digest(path) == before, + "explicit noload mount preserved encrypted host SHA-256") finally: checks.cleanup() @@ -130,7 +216,7 @@ def main(): args.legacy_binary = None if os.geteuid() != 0: parser.error("Run as root in a dedicated disposable VM") - missing = [tool for tool in (*TOOLS, "blockdev") if not shutil.which(tool)] + missing = [tool for tool in (*TOOLS, "blockdev", "debugfs") if not shutil.which(tool)] if missing: parser.error("Missing tools: " + ", ".join(missing)) if args.disposable_device: @@ -164,6 +250,9 @@ def main(): for kernel in (True, False): check_setup_failure(checks, "losetup", kernel) check_setup_failure(checks, "dmsetup", True) + for source in [checks.volume] + ([args.disposable_device] if args.disposable_device else []): + for kernel in (True, False): + check_journal_recovery(checks, source, kernel) if checks.failures: sys.exit("Failed: " + ", ".join(checks.failures)) print("All read-only integration checks passed") diff --git a/doc/html/en/Command Line Usage for Unix.html b/doc/html/en/Command Line Usage for Unix.html index bf9e3262..726b38d0 100644 --- a/doc/html/en/Command Line Usage for Unix.html +++ b/doc/html/en/Command Line Usage for Unix.html @@ -173,7 +173,7 @@ --fs-options=OPTIONS -Filesystem mount options passed to the system mount command with -o. This option is available on Linux and other Unix-like builds where supported, but not on macOS. +Filesystem mount options passed to the system mount command with -o. This option is available on Linux and other Unix-like builds where supported, but not on macOS. To protect the decrypted device against writes, use --mount-options=ro; filesystem-only --fs-options=ro can still allow journal recovery writes. --hash=HASH @@ -197,7 +197,7 @@ -m OPTION1[,OPTION2,...] or --mount-options=OPTION1[,OPTION2,...] -Set VeraCrypt volume mount options. Supported options are headerbak, nokernelcrypto, readonly or ro, system, and timestamp or ts. On Linux, kernelntfs enables in-kernel NTFS driver selection for the current mount when NTFS is detected and no filesystem type was supplied. +Set VeraCrypt volume mount options. Supported options are headerbak, nokernelcrypto, readonly or ro, system, and timestamp or ts. On Linux, kernelntfs enables in-kernel NTFS driver selection for the current mount when NTFS is detected and no filesystem type was supplied. Read-only protection applies to the decrypted device as well as the filesystem. A filesystem that needs journal recovery writes may refuse a read-only mount. --new-hash=HASH diff --git a/src/Core/Unix/CoreUnix.cpp b/src/Core/Unix/CoreUnix.cpp index 9e0dc9f9..ec59e501 100644 --- a/src/Core/Unix/CoreUnix.cpp +++ b/src/Core/Unix/CoreUnix.cpp @@ -1239,6 +1239,11 @@ namespace VeraCrypt break; } + // Opening a partially encrypted system volume can force read-only + // protection. Carry that effective mode into either mount backend. + if (volume->GetProtectionType() == VolumeProtection::ReadOnly) + options.Protection = VolumeProtection::ReadOnly; + if (options.Path->IsDevice()) { const uint32 devSectorSize = volume->GetFile()->GetDeviceSectorSize(); diff --git a/src/Main/UserInterface.cpp b/src/Main/UserInterface.cpp index e9798195..b3899904 100644 --- a/src/Main/UserInterface.cpp +++ b/src/Main/UserInterface.cpp @@ -1536,6 +1536,8 @@ const FileManager fileManagers[] = { " Filesystem mount options. The OPTIONS argument is passed to mount(8)\n" " command with option -o when a filesystem on a VeraCrypt volume is mounted.\n" " This option is not available on some platforms.\n" + " Use --mount-options=ro for device read-only protection; filesystem-only\n" + " ro can still allow journal recovery writes.\n" "\n" "--hash=HASH\n" " Use specified header key derivation algorithm when creating a new volume\n" @@ -1573,6 +1575,7 @@ const FileManager fileManagers[] = { " detected and no filesystem type was supplied.\n" #endif " See also option --fs-options.\n" + " A filesystem requiring journal recovery writes may refuse a read-only mount.\n" "\n" "--new-keyfiles=KEYFILE1[,KEYFILE2,KEYFILE3,...]\n" " Add specified keyfiles to a volume. This option can only be used with command\n"