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.
This commit is contained in:
Mounir IDRASSI
2026-10-22 10:54:22 +09:00
parent 5ef04ad2d0
commit f18a599589
7 changed files with 199 additions and 7 deletions
+8
View File
@@ -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
+85 -3
View File
@@ -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<string> &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<string> &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<VolumeInfo> WrapAuxInfo (const DirectoryPath &)
@@ -64,6 +69,22 @@ extern "C" shared_ptr<VolumeInfo> WrapAuxInfo (const DirectoryPath &)
return info;
}
extern "C" uint64 WrapFuseMount (shared_ptr<Volume>, 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<Volume> Opened;
mutable bool FilesystemMounted = false;
shared_ptr<Volume> OpenVolume (shared_ptr<VolumePath>, bool, shared_ptr<VolumePassword>, int,
shared_ptr<Pkcs5Kdf>, shared_ptr<KeyfileList>, bool, VolumeProtection::Enum,
shared_ptr<VolumePassword>, int, shared_ptr<Pkcs5Kdf>, shared_ptr<KeyfileList>,
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<VolumeInfo> GetMountedVolume (const VolumePath &) const override
{
return shared_ptr<VolumeInfo> (new VolumeInfo);
}
shared_ptr<VolumeInfo> DismountVolume (shared_ptr<VolumeInfo> 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; }
}
+5
View File
@@ -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)
+91 -2
View File
@@ -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")
+2 -2
View File
@@ -173,7 +173,7 @@
</tr>
<tr>
<td><em>--fs-options=OPTIONS</em></td>
<td>Filesystem mount options passed to the system mount command with <code>-o</code>. This option is available on Linux and other Unix-like builds where supported, but not on macOS.</td>
<td>Filesystem mount options passed to the system mount command with <code>-o</code>. 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 <code>--mount-options=ro</code>; filesystem-only <code>--fs-options=ro</code> can still allow journal recovery writes.</td>
</tr>
<tr>
<td><em>--hash=HASH</em></td>
@@ -197,7 +197,7 @@
</tr>
<tr>
<td><em>-m OPTION1[,OPTION2,...]</em> or <em>--mount-options=OPTION1[,OPTION2,...]</em></td>
<td>Set VeraCrypt volume mount options. Supported options are <em>headerbak</em>, <em>nokernelcrypto</em>, <em>readonly</em> or <em>ro</em>, <em>system</em>, and <em>timestamp</em> or <em>ts</em>. On Linux, <em>kernelntfs</em> enables in-kernel NTFS driver selection for the current mount when NTFS is detected and no filesystem type was supplied.</td>
<td>Set VeraCrypt volume mount options. Supported options are <em>headerbak</em>, <em>nokernelcrypto</em>, <em>readonly</em> or <em>ro</em>, <em>system</em>, and <em>timestamp</em> or <em>ts</em>. On Linux, <em>kernelntfs</em> 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.</td>
</tr>
<tr>
<td><em>--new-hash=HASH</em></td>
+5
View File
@@ -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();
+3
View File
@@ -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"