diff --git a/Tests/test_linux_requester_helper.py b/Tests/test_linux_requester_helper.py new file mode 100644 index 00000000..e9220b2e --- /dev/null +++ b/Tests/test_linux_requester_helper.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +"""Check requester-helper self-exec and fail-closed startup using production code. + +After a matching Linux build: + sudo python3 Tests/test_linux_requester_helper.py --build-dir src --user NAME + +Uses an ordinary disposable file, not encrypted volumes or secret material. +The harness links the matching Platform library and wraps execve/sendmsg only +in the test executable. It checks close_range, its older-kernel fallback, and +an AppImage-like dependency found only through LD_LIBRARY_PATH, and that the +helper refuses a socket whose peer is not root. +""" + +import argparse +import os +from pathlib import Path +import pwd +import shlex +import subprocess +import tempfile + + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#ifdef TEST_NO_CLOSE_RANGE +#undef SYS_close_range +#endif +#include "Platform/Unix/RequesterAccess.cpp" +#include +#include +#include +#include +#include +#include + +using namespace VeraCrypt; +static bool FreshHelper; +static int ExecError; +static bool ExitBeforeReady; +static int ParentOnlyMarker; +static const int InheritedFd = 70000; +#ifdef TEST_BUNDLED_LIBRARY +extern "C" int vc_requester_test_dependency (); +#endif + +extern "C" int __real_execve (const char *, char *const [], char *const []); +extern "C" int __wrap_execve (const char *path, char *const argv[], char *const envp[]) +{ + if (ExecError) { errno = ExecError; return -1; } + if (ExitBeforeReady) _exit (127); // Simulate an early dynamic-loader exit. + return __real_execve (path, argv, envp); +} + +static bool OnDevNull (int fd) +{ + struct stat st, null; + return fstat (fd, &st) == 0 && stat ("/dev/null", &null) == 0 && S_ISCHR (st.st_mode) && st.st_rdev == null.st_rdev; +} + +extern "C" ssize_t __real_sendmsg (int, const struct msghdr *, int); +extern "C" ssize_t __wrap_sendmsg (int fd, const struct msghdr *message, int flags) +{ + // Only the credential-dropped helper sends replies. An ordinary fork cannot + // pass this check: it retains the parent's marker and never re-enters main. + if (geteuid() != 0 && (!FreshHelper || ParentOnlyMarker != 0 + || getenv ("VC_REQUESTER_TEST_ENV") || prctl (PR_GET_DUMPABLE, 0, 0, 0, 0) != 0 + || !OnDevNull (0) || !OnDevNull (1) || !OnDevNull (2) + || fcntl (InheritedFd, F_GETFD) != -1 || errno != EBADF)) + { + errno = EPERM; + return -1; + } + return __real_sendmsg (fd, message, flags); +} + +static void Require (bool condition, const char *message) +{ + if (!condition) throw std::runtime_error (message); +} + +struct TestFile : File { using File::OpenAsRequester; }; + +// Only root may send requests. Here the requester listens on the socket, so the +// helper sees the requester as its peer and must exit without answering. +static void CheckNonRootPeer (uid_t userId, gid_t groupId) +{ + struct sockaddr_un address; + memset (&address, 0, sizeof (address)); + address.sun_family = AF_UNIX; + snprintf (address.sun_path + 1, sizeof (address.sun_path) - 1, "vc-requester-peer-%d", static_cast (getpid())); + socklen_t addressSize = offsetof (struct sockaddr_un, sun_path) + 1 + strlen (address.sun_path + 1); + + int ready[2]; + Require (pipe (ready) == 0, "pipe"); + pid_t listener = fork(); + Require (listener != -1, "fork listener"); + if (listener == 0) + { + close (ready[0]); + int listening = -1; + if (setgroups (1, &groupId) != 0 || setgid (groupId) != 0 || setuid (userId) != 0 + || (listening = socket (AF_UNIX, SOCK_SEQPACKET, 0)) == -1 + || bind (listening, reinterpret_cast (&address), addressSize) != 0 + || listen (listening, 1) != 0 || write (ready[1], "r", 1) != 1) + _exit (2); + int connection = accept (listening, nullptr, nullptr); + char data; + _exit (connection != -1 && recv (connection, &data, sizeof (data), 0) == 0 ? 0 : 1); + } + + close (ready[1]); + char data; + bool listening = read (ready[0], &data, 1) == 1; + close (ready[0]); + int socketFd = listening ? socket (AF_UNIX, SOCK_SEQPACKET, 0) : -1; + bool connected = socketFd != -1 + && connect (socketFd, reinterpret_cast (&address), addressSize) == 0; + int serviceStatus = -1; + if (connected) + { + pid_t service = fork(); + if (service == 0) + _exit (File::RunRequesterHelperService (std::to_string (socketFd).c_str())); + Require (service != -1 && waitpid (service, &serviceStatus, 0) == service, "run helper service"); + } + else + kill (listener, SIGKILL); + if (socketFd != -1) + close (socketFd); + int listenerStatus; + Require (waitpid (listener, &listenerStatus, 0) == listener, "wait for listener"); + Require (connected, "connect to the requester's socket"); + Require (WIFEXITED (serviceStatus) && WEXITSTATUS (serviceStatus) == 1, "helper service did not fail"); + Require (WIFEXITED (listenerStatus) && WEXITSTATUS (listenerStatus) == 0, "helper answered a non-root peer"); +} + +int main (int argc, char **argv) +{ + if (argc == 3 && strcmp (argv[1], TC_REQUESTER_HELPER_CMDLINE_OPTION) == 0) + { + FreshHelper = true; + return File::RunRequesterHelperService (argv[2]); + } + try + { + Require (argc == 4 && geteuid() == 0, "expected root: UID GID FILE"); +#ifdef TEST_BUNDLED_LIBRARY + Require (vc_requester_test_dependency() == 1, "bundled library dependency"); +#endif + ParentOnlyMarker = 123; + setenv ("VC_REQUESTER_TEST_ENV", "parent-only", 1); + int file = open (argv[3], O_RDONLY | O_CLOEXEC); + Require (file != -1, "open disposable file"); + struct rlimit originalLimit, limit; + Require (getrlimit (RLIMIT_NOFILE, &originalLimit) == 0, "get fd limit"); + limit = originalLimit; + if (limit.rlim_cur <= InheritedFd) limit.rlim_cur = InheritedFd + 1; + Require (setrlimit (RLIMIT_NOFILE, &limit) == 0, "raise soft fd limit"); + Require (fcntl (file, F_DUPFD, InheritedFd) == InheritedFd, "create inherited high fd"); + Require (setrlimit (RLIMIT_NOFILE, &originalLimit) == 0, "restore soft fd limit"); + + RequesterHelperRequest request; + memset (&request, 0, sizeof (request)); + request.OpenFile = 1; + strcpy (request.Path, argv[3]); + request.Flags = O_RDWR | O_CLOEXEC | O_NOCTTY | O_NONBLOCK; + request.UserId = static_cast (atoi (argv[1])); + request.GroupId = static_cast (atoi (argv[2])); + RequesterHelperReply reply; + int descriptor; + Require (RunRequesterHelperProcess (request, reply, descriptor) == 0 + && reply.Stage == RequesterHelperSucceeded && descriptor != -1, "fresh open helper"); + Require ((fcntl (descriptor, F_GETFD) & FD_CLOEXEC) != 0, "received fd is CLOEXEC"); + Require ((fcntl (descriptor, F_GETFL) & O_NONBLOCK) == 0, "received fd is blocking"); + std::cout << "PASS fresh open helper, minimal environment, descriptor cleanup, 0-2 on /dev/null\n"; + + RequesterHelperRequest timesRequest; + memset (×Request, 0, sizeof (timesRequest)); + strcpy (timesRequest.Path, argv[3]); + timesRequest.UserId = request.UserId; + timesRequest.GroupId = request.GroupId; + timesRequest.Times[0].tv_sec = 1500000000; + timesRequest.Times[1].tv_sec = 1500000100; + int unused; + Require (RunRequesterHelperProcess (timesRequest, reply, unused) == 0 + && reply.Stage == RequesterHelperSucceeded && unused == -1, "fresh timestamp helper"); + struct stat st; + Require (stat (argv[3], &st) == 0 && st.st_atime == timesRequest.Times[0].tv_sec + && st.st_mtime == timesRequest.Times[1].tv_sec, "timestamps not restored through the path"); + std::cout << "PASS fresh timestamp helper\n"; + + ExecError = EACCES; + timesRequest.Times[0].tv_sec += 100; + timesRequest.Times[1].tv_sec += 100; + Require (RunRequesterHelperProcess (timesRequest, reply, unused) == EACCES + && reply.Stage == RequesterHelperStartup && unused == -1, + "failed exec must not run timestamp helper"); + Require (stat (argv[3], &st) == 0 && st.st_atime == 1500000000 && st.st_mtime == 1500000100, + "failed exec changed timestamps"); + request.Flags = O_RDONLY | O_CLOEXEC | O_NOCTTY | O_NONBLOCK; + Require (RunRequesterHelperProcess (request, reply, unused) == EACCES + && reply.Stage == RequesterHelperStartup && unused == -1, + "failed exec must not run opening helper"); + ExecError = ENOENT; + Require (RunRequesterHelperProcess (request, reply, unused) == ENOENT + && reply.Stage == RequesterHelperStartup && unused == -1, "exec errno lost"); + setenv ("SUDO_UID", argv[1], 1); + setenv ("SUDO_GID", argv[2], 1); + try + { + TestFile file; + file.OpenAsRequester (FilePath (argv[3]), O_RDWR); + throw std::runtime_error ("expected startup exception"); + } + catch (const ExternalException &e) + { + string text = StringConverter::ToSingle (e.GetSubject()); + Require (text.find ("Cannot start VeraCrypt's requester-access process (/proc/self/exe)") != string::npos + && text.find (strerror (ENOENT)) != string::npos, "startup diagnostic lost context or errno"); + } + std::cout << "PASS exact startup errno, diagnostic, and no fork-only/read-only fallback\n"; + + ExecError = 0; + ExitBeforeReady = true; + Require (RunRequesterHelperProcess (request, reply, unused) != 0 + && reply.Stage == RequesterHelperStartup && unused == -1, "early exit was not a startup failure"); + try + { + TestFile file; + file.OpenAsRequester (FilePath (argv[3]), O_RDWR); + throw std::runtime_error ("expected early-exit exception"); + } + catch (const ExternalException &e) + { + Require (e.GetSubject().find (L"did not report readiness") != wstring::npos, "early-exit diagnostic"); + } + ExitBeforeReady = false; + std::cout << "PASS early exit reported as failure to become ready\n"; + + request.Flags |= O_TRUNC; + Require (RunRequesterHelperProcess (request, reply, unused) != 0 && unused == -1, + "invalid request accepted"); + Require (fstat (descriptor, &st) == 0 && st.st_size != 0, "invalid request truncated file"); + request.Flags &= ~O_TRUNC; + RequesterHelperRequest noPath = timesRequest; + noPath.Path[0] = '\0'; + Require (RunRequesterHelperProcess (noPath, reply, unused) != 0 && unused == -1, + "timestamp request without a path accepted"); + memset (request.Path, 'a', sizeof (request.Path)); + Require (RunRequesterHelperProcess (request, reply, unused) != 0 && unused == -1, + "unterminated path accepted"); + strcpy (request.Path, argv[3]); + request.UserId = 0; + Require (RunRequesterHelperProcess (request, reply, unused) != 0 && unused == -1, + "root requester accepted"); + std::cout << "PASS invalid requests rejected\n"; + + CheckNonRootPeer (static_cast (atoi (argv[1])), static_cast (atoi (argv[2]))); + std::cout << "PASS request socket from a non-root peer refused\n"; + + close (descriptor); + close (InheritedFd); + close (file); + Require (waitpid (-1, nullptr, WNOHANG) == -1 && errno == ECHILD, "unreaped helper"); + return 0; + } + catch (const std::exception &e) { std::cerr << e.what() << '\n'; return 1; } +} +''' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--build-dir", type=Path, default=ROOT / "src") + parser.add_argument("--user", required=True) + args = parser.parse_args() + if os.geteuid() != 0: + parser.error("run as root") + user = pwd.getpwnam(args.user) + if user.pw_uid == 0: + parser.error("--user must be unprivileged") + library = args.build_dir.resolve() / "Platform" / "Platform.a" + if not library.is_file(): + parser.error("build VeraCrypt first, or supply --build-dir") + with tempfile.TemporaryDirectory(prefix="vc-requester-helper-") as directory: + root = Path(directory) + root.chmod(0o755) + source = root / "helper.cpp" + source.write_text(HARNESS) + target = root / "plain-file" + target.write_text("Disposable helper test data\n") + os.chown(target, user.pw_uid, user.pw_gid) + compiler = shlex.split(os.environ.get("CXX", "c++")) + bundled = root / "lib" + bundled.mkdir() + dependency = root / "dependency.cpp" + dependency.write_text('extern "C" int vc_requester_test_dependency () { return 1; }\n') + subprocess.run(compiler + ["-shared", "-fPIC", str(dependency), "-o", + str(bundled / "libvc_requester_test.so")], check=True) + for mode in ("close-range", "fd-fallback", "bundled-library"): + binary = root / ("helper-" + mode) + command = compiler + [ + "-std=c++11", "-O2", "-Wall", "-DTC_LINUX", "-D_FILE_OFFSET_BITS=64", + "-I" + str(ROOT / "src"), str(source), str(library), "-pthread", + "-Wl,--wrap=execve", "-Wl,--wrap=sendmsg", "-o", str(binary), + ] + env = os.environ.copy() + if mode == "fd-fallback": + command.append("-DTEST_NO_CLOSE_RANGE") + if mode == "bundled-library": + command += ["-DTEST_BUNDLED_LIBRARY", "-L" + str(bundled), "-lvc_requester_test"] + env["LD_LIBRARY_PATH"] = str(bundled) + subprocess.run(command, check=True) + print("Testing", mode, flush=True) + subprocess.run([str(binary), str(user.pw_uid), str(user.pw_gid), str(target)], + check=True, timeout=40, env=env) + + +if __name__ == "__main__": + main()