diff -u apport-2.20.1/debian/changelog apport-2.20.1/debian/changelog --- apport-2.20.1/debian/changelog +++ apport-2.20.1/debian/changelog @@ -1,3 +1,18 @@ +apport (2.20.1-0ubuntu2.11) xenial-security; urgency=medium + + * SECURITY UPDATE: When /proc/sys/fs/suid_dumpable is enabled, do not assume + that a process with a file owner of the UID and GID of the user that started + the process is a non-tainted process. Rather check the dump mode of the core + file that would be created and do not write a core file if it's value is 2. + Thanks to Sander Bos for discovering this issue! (LP: #1726372) + * SECURITY UPDATE: Change the method for determining if a crash is from a + container so that there are not false positives from host software using + pidns. Additionally, utilize a ucred when sending the crash to apport + inside the container. Thanks to Sander Bos for discovering this issue! + (LP: #1726372) + + -- Brian Murray Thu, 09 Nov 2017 15:50:08 -0800 + apport (2.20.1-0ubuntu2.10) xenial-security; urgency=medium * SECURITY UPDATE: code execution through path traversial in diff -u apport-2.20.1/test/test_signal_crashes.py apport-2.20.1/test/test_signal_crashes.py --- apport-2.20.1/test/test_signal_crashes.py +++ apport-2.20.1/test/test_signal_crashes.py @@ -8,7 +8,7 @@ # the full text of the license. import tempfile, shutil, os, subprocess, signal, time, stat, sys -import resource, errno, grp, unittest, socket, array +import resource, errno, grp, unittest, socket, array, pwd import apport.fileutils test_executable = '/usr/bin/yes' @@ -88,7 +88,7 @@ test_proc = self.create_test_process() try: - app = subprocess.Popen([apport_path, str(test_proc), '42', '0'], + app = subprocess.Popen([apport_path, str(test_proc), '42', '0', '1'], stdin=subprocess.PIPE, stderr=subprocess.PIPE) app.stdin.close() assert app.wait() == 0, app.stderr.read() @@ -155,12 +155,12 @@ test_proc = self.create_test_process() test_proc2 = self.create_test_process(False, '/bin/dd') try: - app = subprocess.Popen([apport_path, str(test_proc), '42', '0'], + app = subprocess.Popen([apport_path, str(test_proc), '42', '0', '1'], stdin=subprocess.PIPE, stderr=subprocess.PIPE) time.sleep(0.5) # give it some time to grab the lock - app2 = subprocess.Popen([apport_path, str(test_proc2), '42', '0'], + app2 = subprocess.Popen([apport_path, str(test_proc2), '42', '0', '1'], stdin=subprocess.PIPE, stderr=subprocess.PIPE) # app should wait indefinitely for stdin, while app2 should terminate @@ -204,7 +204,7 @@ test_proc = self.create_test_process() try: - app = subprocess.Popen([apport_path, str(test_proc), '42', '0'], + app = subprocess.Popen([apport_path, str(test_proc), '42', '0', '1'], stdin=subprocess.PIPE, stderr=subprocess.PIPE) app.stdin.write(b'boo') app.stdin.close() @@ -445,7 +445,7 @@ test_proc = self.create_test_process() try: - app = subprocess.Popen([apport_path, str(test_proc), '42', '0'], + app = subprocess.Popen([apport_path, str(test_proc), '42', '0', '1'], stdin=subprocess.PIPE, stderr=subprocess.PIPE) # pipe an entire total memory size worth of spaces into it, which must be # bigger than the 'usable' memory size. apport should digest that and the @@ -526,7 +526,7 @@ time.sleep(1.1) os.utime(myexe, None) - app = subprocess.Popen([apport_path, str(test_proc), '42', '0'], + app = subprocess.Popen([apport_path, str(test_proc), '42', '0', '1'], stdin=subprocess.PIPE, stderr=subprocess.PIPE) err = app.communicate(b'foo')[1] self.assertEqual(app.returncode, 0, err) @@ -550,7 +550,7 @@ try: env = os.environ.copy() env['APPORT_LOG_FILE'] = log - app = subprocess.Popen([apport_path, str(test_proc), '42', '0'], + app = subprocess.Popen([apport_path, str(test_proc), '42', '0', '1'], stdin=subprocess.PIPE, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) @@ -587,7 +587,7 @@ try: env = os.environ.copy() env['APPORT_LOG_FILE'] = '/not/existing/apport.log' - app = subprocess.Popen([apport_path, str(test_proc), '42', '0'], + app = subprocess.Popen([apport_path, str(test_proc), '42', '0', '1'], stdin=subprocess.PIPE, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) @@ -676,6 +676,43 @@ uid=8) self.assertEqual(apport.fileutils.get_all_reports(), []) + @unittest.skipUnless(os.path.exists('/bin/ping'), 'this test needs /bin/ping') + @unittest.skipIf(os.geteuid() != 0, 'this test needs to be run as root') + def test_crash_setuid_drop_and_kill(self): + '''process started by root as another user, killed by that user no core''' + # override expected report file name + self.test_report = os.path.join( + apport.fileutils.report_dir, '%s.%i.crash' % + ('_usr_bin_crontab', os.getuid())) + # edit crontab as user "mail" + resource.setrlimit(resource.RLIMIT_CORE, (-1, -1)) + + if suid_dumpable: + user = pwd.getpwuid(8) + # if a user can crash a suid root binary, it should not create core files + orig_editor = os.getenv('EDITOR') + os.environ['EDITOR'] = '/usr/bin/yes' + self.do_crash(command='/usr/bin/crontab', args=['-e', '-u', user[0]], + expect_corefile=False, core_location='/var/spool/cron/', + killer_id=8) + if orig_editor is not None: + os.environ['EDITOR'] = orig_editor + + # check crash report + reports = apport.fileutils.get_all_reports() + self.assertEqual(len(reports), 1) + report = reports[0] + st = os.stat(report) + os.unlink(report) + self.assertEqual(stat.S_IMODE(st.st_mode), 0o640, 'report has correct permissions') + # this must be owned by root as it is a setuid binary + self.assertEqual(st.st_uid, 0, 'report has correct owner') + else: + # no cores/dump if suid_dumpable == 0 + self.do_crash(False, command='/bin/ping', args=['127.0.0.1'], + uid=8) + self.assertEqual(apport.fileutils.get_all_reports(), []) + @unittest.skipIf(os.geteuid() != 0, 'this test needs to be run as root') def test_crash_setuid_unpackaged(self): '''report generation for unpackaged setuid program''' @@ -760,7 +797,7 @@ fd.write(b'hel\x01lo') fd.flush() fd.seek(0) - args = '%s 11 0' % test_proc + args = '%s 11 0 1' % test_proc fd_msg = (socket.SOL_SOCKET, socket.SCM_RIGHTS, array.array('i', [fd.fileno()])) client.sendmsg([args.encode()], [fd_msg]) os._exit(0) @@ -837,7 +874,9 @@ def do_crash(self, expect_coredump=True, expect_corefile=False, sig=signal.SIGSEGV, check_running=True, sleep=0, command=test_executable, uid=None, - expect_corefile_owner=None, args=[]): + expect_corefile_owner=None, + core_location=None, + killer_id=False, args=[]): '''Generate a test crash. This runs command (by default test_executable) in cwd, lets it crash, @@ -852,7 +891,21 @@ pid = self.create_test_process(check_running, command, uid=uid, args=args) if sleep > 0: time.sleep(sleep) - os.kill(pid, sig) + if killer_id: + user = pwd.getpwuid(killer_id) + # testing different editors via VISUAL= didn't help + kill = subprocess.Popen(['sudo', '-s', '/bin/bash', '-c', + "/bin/kill -s %i %s" % (sig, pid), + '-u', user[0]]) # 'mail']) + kill.communicate() + # need to clean up system state + if command == '/usr/bin/crontab': + os.system('stty sane') + if kill.returncode != 0: + self.fail("Couldn't kill process %s as user %s." % + (pid, user[0])) + else: + os.kill(pid, sig) # wait max 5 seconds for the process to die timeout = 50 while timeout >= 0: @@ -865,7 +918,10 @@ os.kill(pid, signal.SIGKILL) os.waitpid(pid, 0) self.fail('test process does not die on signal %i' % sig) - + if command == '/usr/bin/crontab': + subprocess.Popen(['sudo', '-s', '/bin/bash', '-c', + "/usr/bin/pkill -9 -f crontab", + '-u', 'mail']) self.assertFalse(os.WIFEXITED(result), 'test process did not exit normally') self.assertTrue(os.WIFSIGNALED(result), 'test process died due to signal') self.assertEqual(os.WCOREDUMP(result), expect_coredump) @@ -886,18 +942,22 @@ self.assertEqual(subprocess.call(['pidof', command]), 1, 'no running test executable processes') + core_path = '%s/' % os.getcwd() + if core_location: + core_path = '%s/' % core_location + core_path += 'core' if expect_corefile: - self.assertTrue(os.path.exists('core'), 'leaves wanted core file') + self.assertTrue(os.path.exists(core_path), 'leaves wanted core file') try: # check core file permissions - st = os.stat('core') + st = os.stat(core_path) self.assertEqual(stat.S_IMODE(st.st_mode), 0o600, 'core file has correct permissions') if expect_corefile_owner is not None: self.assertEqual(st.st_uid, expect_corefile_owner, 'core file has correct owner') # check that core file is valid gdb = subprocess.Popen(['gdb', '--batch', '--ex', 'bt', - command, 'core'], + command, core_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE) (out, err) = gdb.communicate() @@ -905,15 +965,15 @@ out = out.decode() err = err.decode().strip() finally: - os.unlink('core') + os.unlink(core_path) else: - if os.path.exists('core'): + if os.path.exists(core_path): try: - os.unlink('core') + os.unlink(core_path) except OSError as e: sys.stderr.write( - 'WARNING: cannot clean up core file %s/core: %s\n' % - (os.getcwd(), str(e))) + 'WARNING: cannot clean up core file %s: %s\n' % + (core_path, str(e))) self.fail('leaves unexpected core file behind') only in patch2: unchanged: --- apport-2.20.1.orig/data/apport +++ apport-2.20.1/data/apport @@ -131,7 +131,7 @@ error_log('Got signal %i, aborting; frame:' % sgn) for s in inspect.stack(): error_log(str(s)) - except: + except Exception: pass sys.exit(1) @@ -297,6 +297,14 @@ return False +def is_container_pid(pid): + if not os.path.exists('/proc/self/ns/pid') or os.readlink('/proc/%s/ns/pid' % pid) == os.readlink('/proc/self/ns/pid'): + # Crashed process is in the same namespace as apport, not a container + return False + + return True + + ################################################################# # # main @@ -337,9 +345,9 @@ sys.argv += msg.decode().split() # Normal startup -if len(sys.argv) not in (4, 5): +if len(sys.argv) not in (5, 6): try: - print('Usage: %s [global pid]' % sys.argv[0]) + print('Usage: %s [global pid]' % sys.argv[0]) print('The core dump is read from stdin.') except IOError: # sys.stderr might not actually exist, expecially not when being called @@ -352,45 +360,28 @@ # Check if we received a valid global PID (kernel >= 3.12). If we do, # then compare it with the local PID. If they don't match, it's an # indication that the crash originated from another PID namespace. -# Attempt to forward it to the container -if len(sys.argv) == 5 and sys.argv[4].isdigit() and sys.argv[4] != sys.argv[1]: - host_pid = int(sys.argv[4]) +# Simply log an entry in the host error log and exit 0. +if len(sys.argv) in (5, 6): + host_pid = int(sys.argv[-1]) - if not os.path.exists('/proc/%d/root/run/apport.socket' % host_pid): + if is_container_pid(host_pid): error_log('host pid %s crashed in a container without apport support' % - sys.argv[4]) - sys.exit(0) - - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - try: - sock.connect('/proc/%d/root/run/apport.socket' % host_pid) - except: - error_log('host pid %s crashed in a container with a broken apport' % - sys.argv[4]) + host_pid) sys.exit(0) - args = '%s %s %s' % (sys.argv[1], sys.argv[2], sys.argv[3]) - - try: - sock.sendmsg([args.encode()], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, array.array('i', [0]))]) - sock.shutdown(socket.SHUT_RDWR) - except socket.timeout: - error_log('Container apport failed to process crash within 30s') - sys.exit(0) - check_lock() try: setup_signals() - (pid, signum, core_ulimit) = sys.argv[1:4] + (pid, signum, core_ulimit, dump_mode) = sys.argv[1:5] get_pid_info(pid) # Partially drop privs to gain proper os.access() checks drop_privileges(True) - error_log('called for pid %s, signal %s, core limit %s' % (pid, signum, core_ulimit)) + error_log('called for pid %s, signal %s, core limit %s, dump mode %s' % (pid, signum, core_ulimit, dump_mode)) try: core_ulimit = int(core_ulimit) @@ -403,6 +394,11 @@ error_log('ignoring implausibly big core limit, treating as unlimited') core_ulimit = -1 + if dump_mode == '2': + error_log('not creating core for pid with dump mode of %s' % (dump_mode)) + # a report should be created but not a core file + core_ulimit = 0 + # ignore SIGQUIT (it's usually deliberately generated by users) if signum == str(int(signal.SIGQUIT)): drop_privileges() only in patch2: unchanged: --- apport-2.20.1.orig/etc/init.d/apport +++ apport-2.20.1/etc/init.d/apport @@ -50,7 +50,7 @@ rm -f /var/lib/pm-utils/resume-hang.log fi - echo "|$AGENT %p %s %c %P" > /proc/sys/kernel/core_pattern + echo "|$AGENT %p %s %c %d %P" > /proc/sys/kernel/core_pattern echo 2 > /proc/sys/fs/suid_dumpable } only in patch2: unchanged: --- apport-2.20.1.orig/use-local +++ apport-2.20.1/use-local @@ -4,4 +4,4 @@ # making changes to bin/apport and want to test them without copying them to # /usr/share/apport/ every time. -echo "|$(readlink -f $(dirname $0)/data/apport) %p %s %c %P" > /proc/sys/kernel/core_pattern +echo "|$(readlink -f $(dirname $0)/data/apport) %p %s %c %d %P" > /proc/sys/kernel/core_pattern