← Back to team overview

apport-hackers team mailing list archive

[Merge] ~mdeslaur/apport:sec202205 into apport:main

 

Marc Deslauriers has proposed merging ~mdeslaur/apport:sec202205 into apport:main.

Commit message:
These are the security fixes that went into USN-5427-1.

Requested reviews:
  Benjamin Drung (bdrung)

For more details, see:
https://code.launchpad.net/~mdeslaur/apport/+git/apport/+merge/422837
-- 
Your team Apport upstream developers is subscribed to branch apport:main.
diff --git a/apport/fileutils.py b/apport/fileutils.py
index a8385c8..e050858 100644
--- a/apport/fileutils.py
+++ b/apport/fileutils.py
@@ -57,6 +57,33 @@ def allowed_to_report():
         return False
 
 
+def get_dbus_socket(dbus_addr):
+    '''Extract the socket from a DBus address.'''
+
+    if not dbus_addr:
+        return None
+
+    # Only support unix domain sockets, and only the default Ubuntu path
+    if not dbus_addr.startswith("unix:path=/run/user/"):
+        return None
+
+    # Prevent path traversal
+    if "../" in dbus_addr:
+        return None
+
+    # Don't support escaped values, multiple addresses, or multiple keys
+    # and values
+    for search in ["%", ",", ";"]:
+        if search in dbus_addr:
+            return None
+
+    parts = dbus_addr.split("=")
+    if len(parts) != 2:
+        return None
+
+    return parts[1]
+
+
 def find_package_desktopfile(package):
     '''Return a package's .desktop file.
 
@@ -370,7 +397,7 @@ def get_config(section, setting, default=None, path=None, bool=False):
     fd = None
     f = None
     if not get_config.config:
-        get_config.config = ConfigParser()
+        get_config.config = ConfigParser(interpolation=None)
 
         try:
             fd = os.open(path, os.O_NOFOLLOW | os.O_RDONLY)
@@ -432,6 +459,22 @@ def get_uid_and_gid(contents):
     return (real_uid, real_gid)
 
 
+def search_map(mapfd, uid):
+    '''Search for an ID in a map fd'''
+    for line in mapfd:
+        fields = line.split()
+        if len(fields) != 3:
+            continue
+
+        host_start = int(fields[1])
+        host_end = host_start + int(fields[2])
+
+        if uid >= host_start and uid <= host_end:
+            return True
+
+    return False
+
+
 def get_boot_id():
     '''Gets the kernel boot id'''
 
@@ -440,7 +483,18 @@ def get_boot_id():
     return boot_id
 
 
-def get_core_path(pid=None, exe=None, uid=None, timestamp=None):
+def get_process_path(proc_pid_fd=None):
+    '''Gets the process path from a proc directory file descriptor'''
+
+    if proc_pid_fd is None:
+        return 'unknown'
+    try:
+        return os.readlink('exe', dir_fd=proc_pid_fd)
+    except OSError:
+        return 'unknown'
+
+
+def get_core_path(pid=None, exe=None, uid=None, timestamp=None, proc_pid_fd=None):
     '''Get the path to a core file'''
 
     if pid is None:
@@ -453,9 +507,8 @@ def get_core_path(pid=None, exe=None, uid=None, timestamp=None):
             timestamp = get_starttime(stat_contents)
 
     if exe is None:
-        exe = 'unknown'
-    else:
-        exe = exe.replace('/', '_').replace('.', '_')
+        exe = get_process_path(proc_pid_fd)
+    exe = exe.replace('/', '_').replace('.', '_')
 
     if uid is None:
         uid = os.getuid()
@@ -474,6 +527,7 @@ def find_core_files_by_uid(uid):
     '''Searches the core file directory for files that belong to a
        specified uid. Returns a list of lists containing the filename and
        the file modification time.'''
+    uid = str(uid)
     core_files = []
     uid_files = []
 
@@ -482,7 +536,7 @@ def find_core_files_by_uid(uid):
 
     for f in core_files:
         try:
-            if f.split('.')[2] == str(uid):
+            if f.split('.')[2] == uid:
                 time = os.path.getmtime(os.path.join(core_dir, f))
                 uid_files.append([f, time])
         except (IndexError, FileNotFoundError):
@@ -497,7 +551,7 @@ def clean_core_directory(uid):
     uid_files = find_core_files_by_uid(uid)
     sorted_files = sorted(uid_files, key=itemgetter(1))
 
-    # Substract a extra one to make room for the new core file
+    # Subtract a extra one to make room for the new core file
     if len(uid_files) > max_corefiles_per_uid - 1:
         for x in range(len(uid_files) - max_corefiles_per_uid + 1):
             os.remove(os.path.join(core_dir, sorted_files[0][0]))
diff --git a/data/apport b/data/apport
index 416173e..427f340 100755
--- a/data/apport
+++ b/data/apport
@@ -200,9 +200,10 @@ def write_user_coredump(pid, timestamp, limit, from_report=None):
         return
 
     (core_name, core_path) = apport.fileutils.get_core_path(pid,
-                                                            options.exe_path,
+                                                            options.executable_path,
                                                             crash_uid,
-                                                            timestamp)
+                                                            timestamp,
+                                                            proc_pid_fd)
 
     try:
         # Limit number of core files to prevent DoS
@@ -269,12 +270,53 @@ def usable_ram():
     return (memfree + cached - writeback) * 1024
 
 
+def _run_with_output_limit_and_timeout(args, output_limit, timeout, close_fds=True, env=None):
+    '''Run command like subprocess.run() but with output limit and timeout.
+
+    Return (stdout, stderr).'''
+
+    stdout = b""
+    stderr = b""
+
+    process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+                               close_fds=close_fds, env=env)
+    try:
+        # Don't block so we don't deadlock
+        os.set_blocking(process.stdout.fileno(), False)
+        os.set_blocking(process.stderr.fileno(), False)
+
+        for _ in range(timeout):
+            alive = process.poll() is None
+
+            while len(stdout) < output_limit and len(stderr) < output_limit:
+                tempout = process.stdout.read(100)
+                if tempout:
+                    stdout += tempout
+                temperr = process.stderr.read(100)
+                if temperr:
+                    stderr += temperr
+                if not tempout and not temperr:
+                    break
+
+            if not alive or len(stdout) >= output_limit or len(stderr) >= output_limit:
+                break
+            time.sleep(1)
+    finally:
+        process.kill()
+
+    return stdout, stderr
+
+
 def is_closing_session():
     '''Check if pid is in a closing user session.
 
     During that, crashes are common as the session D-BUS and X.org are going
     away, etc. These crash reports are mostly noise, so should be ignored.
     '''
+    # Sanity check, don't do anything for root processes
+    if crash_uid == 0 or crash_gid == 0:
+        return False
+
     with open('environ', 'rb', opener=proc_pid_opener) as e:
         env = e.read().split(b'\0')
     for e in env:
@@ -285,6 +327,15 @@ def is_closing_session():
         error_log('is_closing_session(): no DBUS_SESSION_BUS_ADDRESS in environment')
         return False
 
+    dbus_socket = apport.fileutils.get_dbus_socket(dbus_addr)
+    if not dbus_socket:
+        error_log('is_closing_session(): Could not determine DBUS socket.')
+        return False
+
+    if not os.path.exists(dbus_socket):
+        error_log("is_closing_session(): DBUS socket doesn't exist.")
+        return False
+
     # We need to drop both the real and effective uid/gid before calling
     # gdbus because DBUS_SESSION_BUS_ADDRESS is untrusted and may allow
     # reading arbitrary files as a noncefile. We can't just drop effective
@@ -297,11 +348,11 @@ def is_closing_session():
     try:
         os.setresgid(crash_gid, crash_gid, real_gid)
         os.setresuid(crash_uid, crash_uid, real_uid)
-        gdbus = subprocess.Popen(['/usr/bin/gdbus', 'call', '-e', '-d',
-                                  'org.gnome.SessionManager', '-o', '/org/gnome/SessionManager', '-m',
-                                  'org.gnome.SessionManager.IsSessionRunning'], stdout=subprocess.PIPE,
-                                 stderr=subprocess.PIPE, env={'DBUS_SESSION_BUS_ADDRESS': dbus_addr})
-        (out, err) = gdbus.communicate()
+        out, err = _run_with_output_limit_and_timeout(['/usr/bin/gdbus', 'call', '-e', '-d',
+                                                       'org.gnome.SessionManager', '-o', '/org/gnome/SessionManager', '-m',
+                                                       'org.gnome.SessionManager.IsSessionRunning', '-t', '5'],
+                                                      1000, 5, env={'DBUS_SESSION_BUS_ADDRESS': dbus_addr})
+
         if err:
             error_log('gdbus call error: ' + err.decode('UTF-8'))
     except OSError as e:
@@ -384,38 +435,46 @@ def parse_arguments():
     parser.add_argument("-s", "--signal-number", help="signal number (%%s)")
     parser.add_argument("-c", "--core-ulimit", help="core ulimit (%%c)")
     parser.add_argument("-d", "--dump-mode", help="dump mode (%%d)")
-    parser.add_argument("-P", "--global-pid", nargs='?', help="pid in root namespace (%%P)")
-    parser.add_argument("-E", "--executable-path", nargs='?', help="path of executable (%%E)")
+    parser.add_argument("-P", "--global-pid", help="pid in root namespace (%%P)")
+    parser.add_argument("-u", "--uid", type=int, help="real UID (%%u)")
+    parser.add_argument("-g", "--gid", type=int, help="real GID (%%g)")
+    parser.add_argument("executable_path", nargs='*', help="path of executable (%%E)")
 
     options, rest = parser.parse_known_args()
 
-    if options.pid is not None:
-        for arg in rest:
-            error_log("Unknown argument: %s" % arg)
-
-    elif len(rest) in (4, 5, 6, 7):
+    # Legacy command line needs to remain for the scenario where a more
+    # recent apport is running inside a container with an older apport
+    # running on the host.
+    if options.pid is None and len(sys.argv) == 5:
         # Translate legacy command line
-        options.pid = rest[0]
-        options.signal_number = rest[1]
-        options.core_ulimit = rest[2]
-        options.dump_mode = rest[3]
-        try:
-            options.global_pid = rest[4]
-        except IndexError:
-            options.global_pid = None
-        try:
-            options.exe_path = rest[5].replace('!', '/')
-        except IndexError:
-            options.exe_path = None
-        try:
-            if rest[6] == '(deleted)':
-                options.exe_path += ' %s' % rest[6]
-        except IndexError:
-            pass
-    else:
+        return argparse.Namespace(
+            pid=sys.argv[1],
+            signal_number=sys.argv[2],
+            core_ulimit=sys.argv[3],
+            dump_mode=sys.argv[4],
+            global_pid=None,
+            uid=None,
+            gid=None,
+            executable_path=None,
+        )
+
+    if options.pid is None:
         parser.print_usage()
         sys.exit(1)
 
+    for arg in rest:
+        error_log("Unknown argument: %s" % arg)
+
+    # In kernels before 5.3.0, an executable path with spaces may be split
+    # into separate arguments. If options.executable_path is a list, join
+    # it back into a string. Also restore directory separators.
+    if isinstance(options.executable_path, list):
+        options.executable_path = " ".join(options.executable_path)
+    options.executable_path = options.executable_path.replace('!', '/')
+    # Sanity check to prevent trickery later on
+    if '../' in options.executable_path:
+        options.executable_path = None
+
     return options
 
 
@@ -485,32 +544,34 @@ if options.global_pid is not None:
 
         # Instead, attempt to find apport inside the container and
         # forward the process information there.
-        if not os.path.exists('/proc/%d/root/run/apport.socket' % host_pid):
-            error_log('host pid %s crashed in a container without apport support' %
-                      options.global_pid)
-            sys.exit(0)
 
         proc_host_pid_fd = os.open('/proc/%d' % host_pid, os.O_RDONLY | os.O_PATH | os.O_DIRECTORY)
 
         def proc_host_pid_opener(path, flags):
             return os.open(path, flags, dir_fd=proc_host_pid_fd)
 
+        # Validate that the target socket is owned by the user namespace of the process
+        try:
+            sock_fd = os.open("root/run/apport.socket", os.O_RDONLY | os.O_PATH, dir_fd=proc_host_pid_fd)
+            socket_uid = os.fstat(sock_fd).st_uid
+        except FileNotFoundError:
+            error_log('host pid %s crashed in a container without apport support' %
+                      options.global_pid)
+            sys.exit(0)
+
+        try:
+            with open("uid_map", "r", opener=proc_host_pid_opener) as fd:
+                if not apport.fileutils.search_map(fd, socket_uid):
+                    error_log("user is trying to trick apport into accessing a socket that doesn't belong to the container")
+                    sys.exit(0)
+        except FileNotFoundError:
+            pass
+
         # Validate that the crashed binary is owned by the user namespace of the process
         task_uid = os.stat("exe", dir_fd=proc_host_pid_fd).st_uid
         try:
             with open("uid_map", "r", opener=proc_host_pid_opener) as fd:
-                for line in fd:
-                    fields = line.split()
-                    if len(fields) != 3:
-                        continue
-
-                    host_start = int(fields[1])
-                    host_end = host_start + int(fields[2])
-
-                    if task_uid >= host_start and task_uid <= host_end:
-                        break
-
-                else:
+                if not apport.fileutils.search_map(fd, task_uid):
                     error_log("host pid %s crashed in a container with no access to the binary"
                               % options.global_pid)
                     sys.exit(0)
@@ -520,45 +581,28 @@ if options.global_pid is not None:
         task_gid = os.stat("exe", dir_fd=proc_host_pid_fd).st_gid
         try:
             with open("gid_map", "r", opener=proc_host_pid_opener) as fd:
-                for line in fd:
-                    fields = line.split()
-                    if len(fields) != 3:
-                        continue
-
-                    host_start = int(fields[1])
-                    host_end = host_start + int(fields[2])
-
-                    if task_gid >= host_start and task_gid <= host_end:
-                        break
-
-                else:
+                if not apport.fileutils.search_map(fd, task_gid):
                     error_log("host pid %s crashed in a container with no access to the binary"
                               % options.global_pid)
                     sys.exit(0)
         except FileNotFoundError:
             pass
 
-        # Chdir and chroot to the task
-        # WARNING: After this point, all "import" calls are security issues
-        __builtins__.__dict__['__import__'] = None
-
-        host_cwd = os.open('cwd', os.O_RDONLY | os.O_PATH | os.O_DIRECTORY, dir_fd=proc_host_pid_fd)
-
-        os.chdir(host_cwd)
-        # WARNING: we really should be using a file descriptor here,
-        #          but os.chroot won't take it
-        os.chroot(os.readlink('root', dir_fd=proc_host_pid_fd))
-
+        # Now open the socket
         sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
         try:
-            sock.connect('/run/apport.socket')
+            sock.connect('/proc/self/fd/%s' % sock_fd)
         except Exception:
             error_log('host pid %s crashed in a container with a broken apport' %
                       options.global_pid)
             sys.exit(0)
 
-        # Send all arguments except for the first (exec path) and last (global pid)
-        args = ' '.join(sys.argv[1:5])
+        # Send main arguments only
+        # Older apport in containers doesn't support positional arguments
+        args = "%s %s %s %s" % (options.pid,
+                                options.signal_number,
+                                options.core_ulimit,
+                                options.dump_mode)
         try:
             sock.sendmsg([args.encode()], [
                 # Send a ucred containing the global pid
@@ -567,7 +611,7 @@ if options.global_pid is not None:
                 # Send fd 0 (the coredump)
                 (socket.SOL_SOCKET, socket.SCM_RIGHTS, array.array('i', [0]))])
             sock.shutdown(socket.SHUT_RDWR)
-        except socket.timeout:
+        except Exception:
             error_log('Container apport failed to process crash within 30s')
 
         sys.exit(0)
@@ -598,16 +642,30 @@ try:
 
     get_pid_info(pid)
 
-    # Check if the process was replaced after the crash happened.
-    # Ideally we'd use the time of dump value provided by the kernel,
-    # but this would be a backwards-incompatible change that would
-    # require adding support to apport outside and inside of containers.
+    # Sanity check to make sure the process wasn't replaced after the crash
+    # happened. The start time isn't fine-grained enough to be an adequate
+    # security check.
     apport_start = get_apport_starttime()
     process_start = get_process_starttime()
     if process_start > apport_start:
         error_log('process was replaced after Apport started, ignoring')
         sys.exit(0)
 
+    # Make sure the process uid/gid match the ones provided by the kernel
+    # if available, if not, it may have been replaced
+    if (options.uid is not None) and (options.gid is not None):
+        if (options.uid != crash_uid) or (options.gid != crash_gid):
+            error_log("process uid/gid doesn't match expected, ignoring")
+            sys.exit(0)
+
+    # check if the executable was modified after the process started (e. g.
+    # package got upgraded in between).
+    exe_mtime = os.stat('exe', dir_fd=proc_pid_fd).st_mtime
+    process_mtime = os.lstat('cmdline', dir_fd=proc_pid_fd).st_mtime
+    if not os.path.exists(os.readlink('exe', dir_fd=proc_pid_fd)) or exe_mtime > process_mtime:
+        error_log('executable was modified after program start, ignoring')
+        sys.exit(0)
+
     error_log('called for pid %s, signal %s, core limit %s, dump mode %s' % (pid, signum, core_ulimit, dump_mode))
 
     try:
@@ -631,14 +689,6 @@ try:
         write_user_coredump(pid, process_start, core_ulimit)
         sys.exit(0)
 
-    # check if the executable was modified after the process started (e. g.
-    # package got upgraded in between)
-    exe_mtime = os.stat('exe', dir_fd=proc_pid_fd).st_mtime
-    process_mtime = os.lstat('cmdline', dir_fd=proc_pid_fd).st_mtime
-    if not os.path.exists(os.readlink('exe', dir_fd=proc_pid_fd)) or exe_mtime > process_mtime:
-        error_log('executable was modified after program start, ignoring')
-        sys.exit(0)
-
     info = apport.Report('Crash')
     info['Signal'] = signum
     core_size_limit = usable_ram() * 3 / 4
@@ -647,9 +697,8 @@ try:
 
     # We already need this here to figure out the ExecutableName (for scripts,
     # etc).
-
-    if options.exe_path is not None and os.path.exists(options.exe_path):
-        info['ExecutablePath'] = options.exe_path
+    if options.executable_path is not None and os.path.exists(options.executable_path):
+        info['ExecutablePath'] = options.executable_path
 
     # Drop privileges temporarily to make sure that we don't
     # include information in the crash report that the user should
@@ -739,7 +788,7 @@ try:
                 # os.O_CREAT|os.O_EXCL
                 os.unlink(report)
             else:
-                error_log('apport: report %s already exists and unseen, doing nothing to avoid disk usage DoS' % report)
+                error_log('apport: report %s already exists and unseen, skipping to avoid disk usage DoS' % report)
                 write_user_coredump(pid, process_start, core_ulimit)
                 sys.exit(0)
         # we prefer having a file mode of 0 while writing;
diff --git a/etc/init.d/apport b/etc/init.d/apport
index 4997b83..1e10d02 100755
--- a/etc/init.d/apport
+++ b/etc/init.d/apport
@@ -50,13 +50,9 @@ do_start()
 		rm -f /var/lib/pm-utils/resume-hang.log
 	fi
 
-	# Old compatibility mode, switch later to second one
-	if true; then
-		echo "|$AGENT %p %s %c %d %P %E" > /proc/sys/kernel/core_pattern
-	else
-		echo "|$AGENT -p%p -s%s -c%c -d%d -P%P -E%E" > /proc/sys/kernel/core_pattern
-	fi
+	echo "|$AGENT -p%p -s%s -c%c -d%d -P%P -u%u -g%g -- %E" > /proc/sys/kernel/core_pattern
 	echo 2 > /proc/sys/fs/suid_dumpable
+	echo 10 > /proc/sys/kernel/core_pipe_limit
 }
 
 #
@@ -70,6 +66,7 @@ do_stop()
 	#   2 if daemon could not be stopped
 	#   other if a failure occurred
 
+	echo 0 > /proc/sys/kernel/core_pipe_limit
 	echo 0 > /proc/sys/fs/suid_dumpable
 
 	# Check for a hung resume.  If we find one try and grab everything
diff --git a/test/test_fileutils.py b/test/test_fileutils.py
index 23f9984..0b973a4 100644
--- a/test/test_fileutils.py
+++ b/test/test_fileutils.py
@@ -372,8 +372,32 @@ f6423dfbc4faf022e58b4d3f5ff71a70  %s
         self.assertEqual(apport.fileutils.get_config('spethial', 'nope', 'moo'), 'moo')
         apport.fileutils.get_config.config = None  # trash cache
 
+        # interpolation
+        f.write(b'[inter]\none=1\ntwo = TWO\ntest = %(two)s\n')
+        f.flush()
+        self.assertEqual(apport.fileutils.get_config('inter', 'one'), '1')
+        self.assertEqual(apport.fileutils.get_config('inter', 'two'), 'TWO')
+        self.assertEqual(apport.fileutils.get_config('inter', 'test'), '%(two)s')
+        apport.fileutils.get_config.config = None  # trash cache
+
         f.close()
 
+    def test_get_dbus_socket(self):
+        '''get_dbus_socket()'''
+
+        tests = [("unix:path=/run/user/1000/bus", "/run/user/1000/bus"),
+                 ("unix:path=/run/user/1000/bus;unix:path=/run/user/0/bus", None),
+                 ("unix:path=%2Frun/user/1000/bus", None),
+                 ("unix:path=/run/user/1000/bus,path=/run/user/0/bus", None),
+                 ("unix:path=/etc/passwd", None),
+                 ("unix:path=/run/user/../../etc/passwd", None),
+                 ("unix:path=/run/user/1000/bus=", None),
+                 ("", None),
+                 ("tcp:host=localhost,port=8100", None)]
+
+        for (addr, result) in tests:
+            self.assertEqual(apport.fileutils.get_dbus_socket(addr), result)
+
     def test_shared_libraries(self):
         '''shared_libraries()'''
 
diff --git a/test/test_hookutils.py b/test/test_hookutils.py
index b37a357..7b0e54a 100644
--- a/test/test_hookutils.py
+++ b/test/test_hookutils.py
@@ -48,6 +48,7 @@ class T(unittest.TestCase):
         self.assertFalse(good_ko.name in nonfree)
         self.assertTrue(bad_ko.name in nonfree)
 
+<<<<<<< test/test_hookutils.py
     def test_real_module_license_evaluation(self):
         '''module licenses can be validated correctly for real module'''
         isofs_license = apport.hookutils._get_module_license('isofs')
@@ -62,6 +63,8 @@ class T(unittest.TestCase):
         nonfree = apport.hookutils.nonfree_kernel_modules(f.name)
         self.assertNotIn('isofs', nonfree)
 
+=======
+>>>>>>> test/test_hookutils.py
     @unittest.mock.patch("apport.hookutils.root_command_output")
     def test_attach_dmesg(self, root_command_output_mock):
         '''attach_dmesg()'''
diff --git a/test/test_python_crashes.py b/test/test_python_crashes.py
index 06d9769..66dcc08 100644
--- a/test/test_python_crashes.py
+++ b/test/test_python_crashes.py
@@ -14,15 +14,22 @@ import unittest, tempfile, subprocess, os, stat, shutil, atexit
 import dbus
 import unittest.mock
 
-temp_report_dir = tempfile.mkdtemp()
-os.environ['APPORT_REPORT_DIR'] = temp_report_dir
-atexit.register(shutil.rmtree, temp_report_dir)
-
 import apport.fileutils
 import apport.report
 
 
 class T(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls):
+        cls.orig_report_dir = apport.fileutils.report_dir
+        apport.fileutils.report_dir = tempfile.mkdtemp()
+        os.environ['APPORT_REPORT_DIR'] = apport.fileutils.report_dir
+        atexit.register(shutil.rmtree, apport.fileutils.report_dir)
+
+    @classmethod
+    def tearDownClass(cls):
+        apport.fileutils.report_dir = cls.orig_report_dir
+
     def tearDown(self):
         for f in apport.fileutils.get_all_reports():
             os.unlink(f)

Follow ups