diff --git a/.travis.yml b/.travis.yml index 531036bb..490ccace 100644 --- a/.travis.yml +++ b/.travis.yml @@ -22,11 +22,11 @@ jobs: include: - python: '3.7' script: pylint qrexec - - install: + - install: - pip install --quiet codecov - docker build -t qrexec-test ci env: DOCKER_RUN="docker run -v $TRAVIS_BUILD_DIR:$TRAVIS_BUILD_DIR -v /tmp/.X11-unix:/tmp/.X11-unix -w $TRAVIS_BUILD_DIR -e DISPLAY=$DISPLAY -- qrexec-test" - script: $DOCKER_RUN python3 -m coverage run -m unittest discover -s qrexec/tests -t . -p '*.py' -v + script: $DOCKER_RUN ./run-tests # - python: '3.7' # script: python -m coverage run -m unittest discover -s qrexec/tests -t . -p '*.py' -v - stage: deploy diff --git a/agent/qrexec-agent.c b/agent/qrexec-agent.c index d00ec618..5fcfa783 100644 --- a/agent/qrexec-agent.c +++ b/agent/qrexec-agent.c @@ -34,6 +34,7 @@ #include #include #include +#include #include #include #include @@ -77,6 +78,9 @@ int trigger_fd; int meminfo_write_started = 0; +static const char *agent_trigger_path = QREXEC_AGENT_TRIGGER_PATH; +static const char *fork_server_path = QREXEC_FORK_SERVER_SOCKET; + void handle_server_exec_request_do(int type, int connect_domain, int connect_port, char *cmdline); void no_colon_in_cmd() @@ -160,6 +164,21 @@ void do_exec(char *cmd) signal(SIGCHLD, SIG_DFL); signal(SIGPIPE, SIG_DFL); + pw = getpwuid(geteuid()); + if (!pw) { + perror("getpwuid"); + exit(1); + } + if (!strcmp(pw->pw_name, user)) { + /* call QUBESRPC if requested */ + exec_qubes_rpc_if_requested(realcmd, environ); + + /* otherwise exec shell */ + execl("/bin/sh", "sh", "-c", realcmd, NULL); + perror("execl"); + exit(1); + } + #ifdef HAVE_PAM pw = getpwnam (user); if (! (pw && pw->pw_name && pw->pw_name[0] && pw->pw_dir && pw->pw_dir[0] @@ -355,7 +374,7 @@ void init() if (handle_handshake(ctrl_vchan) < 0) exit(1); old_umask = umask(0); - trigger_fd = get_server_socket(QREXEC_AGENT_TRIGGER_PATH); + trigger_fd = get_server_socket(agent_trigger_path); umask(old_umask); register_exec_func(do_exec); @@ -409,13 +428,16 @@ int try_fork_server(int type, int connect_domain, int connect_port, struct sockaddr_un remote; struct qrexec_cmd_info info; + if (!fork_server_path) + return -1; + strncpy(username, cmdline, cmdline_len); colon = index(username, ':'); if (!colon) return -1; *colon = '\0'; - if (asprintf(&fork_server_socket_path, QREXEC_FORK_SERVER_SOCKET, username) < 0) { + if (asprintf(&fork_server_socket_path, fork_server_path, username) < 0) { fprintf(stderr, "Memory allocation failed\n"); return -1; } @@ -934,12 +956,55 @@ void handle_terminated_fork_client(fd_set *rdset) { } } -int main() +struct option longopts[] = { + { "help", no_argument, 0, 'h' }, + { "agent-socket", required_argument, 0, 'a' }, + { "fork-server-socket", optional_argument, 0, 's' }, + { "no-fork-server", no_argument, 0, 'S' }, + { NULL, 0, 0, 0 }, +}; + +_Noreturn void usage(const char *argv0) +{ + fprintf(stderr, "usage: %s [options]\n", argv0); + fprintf(stderr, "Options:\n"); + fprintf(stderr, " -h, --help - display usage\n"); + fprintf(stderr, " --agent-socket=PATH - path to listen at, default: %s\n", + QREXEC_AGENT_TRIGGER_PATH); + fprintf(stderr, " --fork-server-socket=PATH - where to find the fork server, default: %s\n", + QREXEC_FORK_SERVER_SOCKET); + fprintf(stderr, " (set empty to disable, use %%s as username)\n"); + fprintf(stderr, " --no-fork-server - don't try to connect to fork server\n"); + exit(2); +} + +int main(int argc, char **argv) { fd_set rdset, wrset; int max; sigset_t chld_set; + int opt; + while (1) { + opt = getopt_long(argc, argv, "ha:s:S", longopts, NULL); + if (opt == -1) + break; + switch (opt) { + case 'a': + agent_trigger_path = strdup(optarg); + break; + case 's': + fork_server_path = strdup(optarg); + break; + case 'S': + fork_server_path = NULL; + break; + case 'h': + case '?': + usage(argv[0]); + } + } + init(); signal(SIGCHLD, sigchld_handler); signal(SIGPIPE, SIG_IGN); diff --git a/agent/qrexec-client-vm.c b/agent/qrexec-client-vm.c index 5ac35d86..83cd854a 100644 --- a/agent/qrexec-client-vm.c +++ b/agent/qrexec-client-vm.c @@ -43,7 +43,7 @@ void do_exec(char *cmd __attribute__((__unused__))) { exit(1); } -int connect_unix_socket(char *path) +int connect_unix_socket(const char *path) { int s, len; struct sockaddr_un remote; @@ -97,6 +97,7 @@ struct option longopts[] = { { "filter-escape-chars-stderr", no_argument, 0, 'T'}, { "no-filter-escape-chars-stdout", no_argument, 0, opt_no_filter_stdout}, { "no-filter-escape-chars-stderr", no_argument, 0, opt_no_filter_stderr}, + { "agent-socket", required_argument, 0, 'a'}, { NULL, 0, 0, 0}, }; @@ -110,6 +111,8 @@ _Noreturn void usage(const char *argv0) { fprintf(stderr, " -T, --filter-escape-chars-stderr - filter non-ASCII and control characters on stderr (default if stderr is a terminal)\n"); fprintf(stderr, " --no-filter-escape-chars-stdout - opposite to --filter-escape-chars-stdout\n"); fprintf(stderr, " --no-filter-escape-chars-stderr - opposite to --filter-escape-chars-stderr\n"); + fprintf(stderr, " --agent-socket=PATH - path to connect to, default: %s\n", + QREXEC_AGENT_TRIGGER_PATH); exit(2); } @@ -128,9 +131,10 @@ int main(int argc, char **argv) int inpipe[2], outpipe[2]; int buffer_size = 0; int opt; + const char *agent_trigger_path = QREXEC_AGENT_TRIGGER_PATH; while (1) { - opt = getopt_long(argc, argv, "+tT", longopts, NULL); + opt = getopt_long(argc, argv, "+tTa:", longopts, NULL); if (opt == -1) break; switch (opt) { @@ -149,6 +153,9 @@ int main(int argc, char **argv) case opt_no_filter_stderr: replace_chars_stderr = 0; break; + case 'a': + agent_trigger_path = strdup(optarg); + break; case '?': usage(argv[0]); } @@ -172,7 +179,7 @@ int main(int argc, char **argv) service_name_len = strlen(service_name) + 1; - trigger_fd = connect_unix_socket(QREXEC_AGENT_TRIGGER_PATH); + trigger_fd = connect_unix_socket(agent_trigger_path); hdr.type = MSG_TRIGGER_SERVICE3; hdr.len = sizeof(params) + service_name_len; diff --git a/ci/Dockerfile b/ci/Dockerfile index 9d0a28be..f7bddf39 100644 --- a/ci/Dockerfile +++ b/ci/Dockerfile @@ -1,8 +1,14 @@ FROM fedora:31 -RUN dnf install -y python3-pip python3-gobject gtk3 python3-pytest python3-coverage +RUN dnf install -y python3-pip python3-gobject gtk3 python3-pytest \ + python3-coverage python3-devel pam-devel pandoc gcc git make -ADD requirements.txt / +RUN git clone https://github.com/QubesOS/qubes-core-vchan-socket ~/qubes-core-vchan-socket +RUN make -C ~/qubes-core-vchan-socket all +RUN make -C ~/qubes-core-vchan-socket install LIBDIR=/usr/lib64 +ADD requirements.txt / RUN pip3 install -r requirements.txt +RUN useradd --create-home --shell /bin/bash travis --uid 2000 +USER travis diff --git a/ci/requirements.txt b/ci/requirements.txt index e814f785..4b19b3a2 100644 --- a/ci/requirements.txt +++ b/ci/requirements.txt @@ -5,3 +5,4 @@ pylint sphinx codecov pydbus +psutil diff --git a/daemon/Makefile b/daemon/Makefile index db8d9b04..dc347a31 100644 --- a/daemon/Makefile +++ b/daemon/Makefile @@ -2,10 +2,10 @@ CC=gcc CFLAGS+=-I. -g -O2 -Wall -Wextra -Werror -pie -fPIC `pkg-config --cflags vchan-$(BACKEND_VMM)` CFLAGS += -I../libqrexec LIBS = -L../libqrexec -LIBS=`pkg-config --libs vchan-$(BACKEND_VMM)` -lqrexec-utils +LIBS += `pkg-config --libs vchan-$(BACKEND_VMM)` -lqrexec-utils -all: qrexec-daemon qrexec-client +all: qrexec-daemon qrexec-client clean: rm -f *.o *~ qrexec-daemon qrexec-client install: @@ -21,4 +21,3 @@ qrexec-daemon: qrexec-daemon.o $(CC) -pie -g -o qrexec-daemon qrexec-daemon.o $(LIBS) qrexec-client: qrexec-client.o $(CC) -pie -g -o qrexec-client qrexec-client.o $(LIBS) - diff --git a/daemon/qrexec-daemon.c b/daemon/qrexec-daemon.c index 7a830c45..6a55efb3 100644 --- a/daemon/qrexec-daemon.c +++ b/daemon/qrexec-daemon.c @@ -30,6 +30,7 @@ #include #include #include +#include #include "qrexec.h" #include "libqrexec-utils.h" @@ -86,6 +87,10 @@ const char default_user_keyword[] = "DEFAULT:"; #define default_user_keyword_len_without_colon (sizeof(default_user_keyword)-2) int opt_quiet = 0; +int opt_direct = 0; + +const char *socket_dir = QREXEC_DAEMON_SOCKET_DIR; +const char *policy_program = QREXEC_POLICY_PROGRAM; #ifdef __GNUC__ # define UNUSED(x) UNUSED_ ## x __attribute__((__unused__)) @@ -129,9 +134,9 @@ void unlink_qrexec_socket() char link_to_socket_name[strlen(remote_domain_name) + sizeof(socket_address)]; snprintf(socket_address, sizeof(socket_address), - QREXEC_DAEMON_SOCKET_DIR "/qrexec.%d", remote_domain_id); + "%s/qrexec.%d", socket_dir, remote_domain_id); snprintf(link_to_socket_name, sizeof link_to_socket_name, - QREXEC_DAEMON_SOCKET_DIR "/qrexec.%s", remote_domain_name); + "%s/qrexec.%s", socket_dir, remote_domain_name); unlink(socket_address); unlink(link_to_socket_name); } @@ -149,9 +154,9 @@ int create_qrexec_socket(int domid, const char *domname) char link_to_socket_name[strlen(domname) + sizeof(socket_address)]; snprintf(socket_address, sizeof(socket_address), - QREXEC_DAEMON_SOCKET_DIR "/qrexec.%d", domid); + "%s/qrexec.%d", socket_dir, domid); snprintf(link_to_socket_name, sizeof link_to_socket_name, - QREXEC_DAEMON_SOCKET_DIR "/qrexec.%s", domname); + "%s/qrexec.%s", socket_dir, domname); unlink(link_to_socket_name); if (symlink(socket_address, link_to_socket_name)) { fprintf(stderr, "symlink(%s,%s) failed: %s\n", socket_address, @@ -256,53 +261,60 @@ void init(int xid) // invalid or negative number startup_timeout = MAX_STARTUP_TIME_DEFAULT; } - signal(SIGUSR1, sigusr1_handler); - signal(SIGCHLD, sigchld_parent_handler); - switch (pid=fork()) { - case -1: - perror("fork"); - exit(1); - case 0: - break; - default: - if (getenv("QREXEC_STARTUP_NOWAIT")) - exit(0); - if (!opt_quiet) - fprintf(stderr, "Waiting for VM's qrexec agent."); - for (i=0;i 3) { - fprintf(stderr, "usage: %s [-q] domainid domain-name [default user]\n", argv[0]); - exit(1); + usage(argv[0]); } remote_domain_id = atoi(argv[optind]); remote_domain_name = argv[optind+1]; diff --git a/libqrexec/Makefile b/libqrexec/Makefile index 0a6749f9..5aaa9eb7 100644 --- a/libqrexec/Makefile +++ b/libqrexec/Makefile @@ -18,7 +18,7 @@ libqrexec-utils.so: libqrexec-utils.so.$(SO_VER) %.a: $(AR) rcs $@ $^ clean: - rm -f *.o *~ *.a *.so.* + rm -f *.o *~ *.a *.so *.so.* install: mkdir -p $(DESTDIR)$(LIBDIR) @@ -27,4 +27,3 @@ install: mkdir -p $(DESTDIR)$(INCLUDEDIR) cp libqrexec-utils.h $(DESTDIR)$(INCLUDEDIR) cp qrexec.h $(DESTDIR)$(INCLUDEDIR) - diff --git a/libqrexec/qrexec.h b/libqrexec/qrexec.h index 8932cc27..ed2f7263 100644 --- a/libqrexec/qrexec.h +++ b/libqrexec/qrexec.h @@ -164,5 +164,6 @@ enum { #define MEMINFO_WRITER_PIDFILE "/var/run/meminfo-writer.pid" #define QUBES_RPC_MULTIPLEXER_PATH "/usr/lib/qubes/qubes-rpc-multiplexer" #define QREXEC_DAEMON_SOCKET_DIR "/var/run/qubes" +#define QREXEC_POLICY_PROGRAM "/usr/bin/qrexec-policy-exec" #endif /* _QREXEC_H */ diff --git a/qrexec/tests/socket/.gitignore b/qrexec/tests/socket/.gitignore new file mode 100644 index 00000000..0d20b648 --- /dev/null +++ b/qrexec/tests/socket/.gitignore @@ -0,0 +1 @@ +*.pyc diff --git a/qrexec/tests/socket/__init__.py b/qrexec/tests/socket/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/qrexec/tests/socket/agent.py b/qrexec/tests/socket/agent.py new file mode 100644 index 00000000..6fe507f4 --- /dev/null +++ b/qrexec/tests/socket/agent.py @@ -0,0 +1,323 @@ +# -*- encoding: utf-8 -*- +# +# The Qubes OS Project, http://www.qubes-os.org +# +# Copyright (C) 2020 Paweł Marczewski +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License along +# with this program; if not, see . + +import unittest +import subprocess +import os.path +import os +import tempfile +import shutil +import time +import struct +import getpass +import psutil + + +from . import qrexec + + +ROOT_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), + '..', '..', '..')) + + +@unittest.skipIf(os.environ.get('SKIP_SOCKET_TESTS'), + 'socket tests not set up') +class TestAgent(unittest.TestCase): + agent = None + domain = 42 + target_domain = 43 + target_port = 1024 + + def setUp(self): + self.tempdir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.tempdir) + + def start_agent(self): + env = os.environ.copy() + env['LD_LIBRARY_PATH'] = os.path.join(ROOT_PATH, 'libqrexec') + env['VCHAN_DOMAIN'] = str(self.domain) + env['VCHAN_SOCKET_DIR'] = self.tempdir + cmd = [ + os.path.join(ROOT_PATH, 'agent', 'qrexec-agent'), + '--no-fork-server', + '--agent-socket=' + os.path.join(self.tempdir, 'agent.sock'), + ] + if os.environ.get('USE_STRACE'): + cmd = ['strace', '-f'] + cmd + self.agent = subprocess.Popen( + cmd, + env=env, + ) + self.addCleanup(self.stop_agent) + + def stop_agent(self): + if self.agent: + self.wait_for_agent_children() + self.agent.terminate() + self.agent.wait() + self.agent = None + + def wait_for_agent_children(self): + proc = psutil.Process(self.agent.pid) + children = proc.children(recursive=True) + psutil.wait_procs(children) + + def wait_until(self, func, message, n_tries=10, delay=0.1): + for _ in range(n_tries): + if func(): + return + time.sleep(delay) + self.fail('Timed out waiting: ' + message) + + def connect_dom0(self): + dom0 = qrexec.vchan_client(self.tempdir, self.domain, 0, 512) + self.addCleanup(dom0.close) + return dom0 + + def connect_target(self): + target = qrexec.vchan_server( + self.tempdir, self.target_domain, self.domain, self.target_port) + self.addCleanup(target.close) + target.accept() + return target + + def connect_client(self): + client = qrexec.socket_client(os.path.join(self.tempdir, 'agent.sock')) + self.addCleanup(client.close) + return client + + def test_handshake(self): + self.start_agent() + + dom0 = self.connect_dom0() + dom0.handshake() + + def test_just_exec(self): + self.start_agent() + + dom0 = self.connect_dom0() + dom0.handshake() + + user = getpass.getuser().encode('ascii') + + cmd = (('touch ' + os.path.join(self.tempdir, 'new_file')) + .encode('ascii')) + dom0.send_message( + qrexec.MSG_JUST_EXEC, + struct.pack(' +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License along +# with this program; if not, see . + +import unittest +import subprocess +import os.path +import os +import tempfile +import shutil +import struct +import psutil +from typing import Tuple + +from . import qrexec + + +ROOT_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), + '..', '..', '..')) + + +@unittest.skipIf(os.environ.get('SKIP_SOCKET_TESTS'), + 'socket tests not set up') +class TestDaemon(unittest.TestCase): + daemon = None + domain = 42 + domain_name = 'domain_name' + + # Stub qrexec-policy-exec program. + # Strictly speaking, the program should also run qrexec-client in case the + # call is allowed, but we will simulate that elsewhere. + POLICY_PROGRAM = '''\ +#!/bin/sh + +echo "$@" > {tempdir}/qrexec-policy-params +exit 1 +''' + + def setUp(self): + self.tempdir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.tempdir) + + def start_daemon(self): + policy_program_path = os.path.join(self.tempdir, 'qrexec-policy-exec') + with open(policy_program_path, 'w') as f: + f.write(self.POLICY_PROGRAM.format(tempdir=self.tempdir)) + os.chmod(policy_program_path, 0o700) + + env = os.environ.copy() + env['LD_LIBRARY_PATH'] = os.path.join(ROOT_PATH, 'libqrexec') + env['VCHAN_DOMAIN'] = '0' + env['VCHAN_SOCKET_DIR'] = self.tempdir + cmd = [ + os.path.join(ROOT_PATH, 'daemon', 'qrexec-daemon'), + '--socket-dir=' + self.tempdir, + '--policy-program=' + policy_program_path, + '--direct', + str(self.domain), + self.domain_name, + ] + if os.environ.get('USE_STRACE'): + cmd = ['strace', '-f'] + cmd + self.daemon = subprocess.Popen( + cmd, + env=env, + ) + self.addCleanup(self.stop_daemon) + + def stop_daemon(self): + if self.daemon: + self.wait_for_daemon_children() + self.daemon.terminate() + self.daemon.wait() + self.daemon = None + + def wait_for_daemon_children(self): + proc = psutil.Process(self.daemon.pid) + children = proc.children(recursive=True) + psutil.wait_procs(children) + + def get_policy_program_params(self): + with open(os.path.join(self.tempdir, 'qrexec-policy-params')) as f: + return f.read().split() + + def start_daemon_with_agent(self): + agent = self.connect_agent() + self.start_daemon() + agent.accept() + return agent + + def connect_agent(self): + agent = qrexec.vchan_server( + self.tempdir, self.domain, 0, 512) + self.addCleanup(agent.close) + return agent + + def connect_client(self): + client = qrexec.socket_client( + os.path.join(self.tempdir, 'qrexec.{}'.format(self.domain))) + self.addCleanup(client.close) + return client + + def test_handshake(self): + agent = self.start_daemon_with_agent() + agent.handshake() + + def test_trigger_service_refused(self): + agent = self.start_daemon_with_agent() + agent.handshake() + + target_domain_name = 'target_domain' + ident = 'SOCKET42' + + message_type, data = self.trigger_service( + agent, target_domain_name, 'qubes.ForbiddenServiceName', ident) + self.assertEqual(message_type, qrexec.MSG_SERVICE_REFUSED) + self.assertEqual(data, struct.pack('<32s', ident.encode())) + + def trigger_service(self, + agent, + target_domain_name: str, + service_name: str, + ident: str) -> Tuple[int, bytes]: + agent.send_message( + qrexec.MSG_TRIGGER_SERVICE3, + struct.pack('<64s32s', + target_domain_name.encode(), ident.encode()) + + service_name.encode() + b'\0' + ) + message_type, data = agent.recv_message() + self.assertListEqual(self.get_policy_program_params(), [ + '--', + str(self.domain), + self.domain_name, + target_domain_name, + service_name, + ident + ]) + + return message_type, data + + def test_client_handshake(self): + agent = self.start_daemon_with_agent() + agent.handshake() + + client = self.connect_client() + client.handshake() + + def test_client_cmdline(self): + agent = self.start_daemon_with_agent() + agent.handshake() + + client = self.connect_client() + client.handshake() + + cmd = b'user:echo Hello world' + + client.send_message( + qrexec.MSG_JUST_EXEC, + struct.pack(' +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License along +# with this program; if not, see . + +import socket +import os +import struct +import time + + +# See libqrexec/qrexec.h +MSG_DATA_STDIN = 0x190 +MSG_DATA_STDOUT = 0x191 +MSG_DATA_STDERR = 0x192 +MSG_DATA_EXIT_CODE = 0x193 +MSG_EXEC_CMDLINE = 0x200 +MSG_JUST_EXEC = 0x201 +MSG_SERVICE_CONNECT = 0x202 +MSG_SERVICE_REFUSED = 0x203 +MSG_TRIGGER_SERVICE3 = 0x212 +MSG_HELLO = 0x300 +QREXEC_PROTOCOL_VERSION = 3 + + +class QrexecClient: + def __init__(self, conn): + self.conn = conn + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + + def sendall(self, data): + self.conn.sendall(data) + + def recvall(self, data_len): + data = b'' + while len(data) < data_len: + res = self.conn.recv(data_len - len(data)) + if not res: + return data + data += res + return data + + def close(self): + self.conn.close() + + def send_message(self, message_type, data): + header = struct.pack('