diff -u apport-2.14.1/data/apport apport-2.14.1/data/apport --- apport-2.14.1/data/apport +++ apport-2.14.1/data/apport @@ -273,15 +273,22 @@ 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 # ################################################################# -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 @@ -295,16 +302,19 @@ # then compare it with the local PID. If they don't match, it's an # indication that the crash originated from another PID namespace. # Simply log an entry in the host error log and exit 0. -if len(sys.argv) == 5 and sys.argv[4].isdigit() and sys.argv[4] != sys.argv[1]: - error_log('pid %s crashed in a container' % sys.argv[4]) - sys.exit(0) +if len(sys.argv) in (5, 6): + host_pid = int(sys.argv[-1]) + + if is_container_pid(host_pid): + error_log('pid %s crashed in a container' % host_pid) + 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] # drop our process priority level to not disturb userspace so much try: @@ -317,7 +327,7 @@ # Partially drop privs to gain proper os.access() checks drop_privileges(pid, 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) @@ -333,6 +343,11 @@ if core_ulimit > 0: core_ulimit *= 1024 + 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(signal.SIGQUIT): drop_privileges(pid) diff -u apport-2.14.1/debian/apport.upstart apport-2.14.1/debian/apport.upstart --- apport-2.14.1/debian/apport.upstart +++ apport-2.14.1/debian/apport.upstart @@ -33,7 +33,7 @@ rm -f /var/lib/pm-utils/resume-hang.log fi - echo "|/usr/share/apport/apport %p %s %c %P" > /proc/sys/kernel/core_pattern + echo "|/usr/share/apport/apport %p %s %c %d %P" > /proc/sys/kernel/core_pattern echo 2 > /proc/sys/fs/suid_dumpable end script diff -u apport-2.14.1/debian/changelog apport-2.14.1/debian/changelog --- apport-2.14.1/debian/changelog +++ apport-2.14.1/debian/changelog @@ -1,3 +1,16 @@ +apport (2.14.1-0ubuntu3.26) trusty-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. Thanks to Sander Bos for discovering this issue! (LP: #1726372) + + -- Brian Murray Mon, 13 Nov 2017 08:54:04 -0800 + apport (2.14.1-0ubuntu3.25) trusty-security; urgency=medium * SECURITY UPDATE: code execution through path traversal in diff -u apport-2.14.1/etc/init.d/apport apport-2.14.1/etc/init.d/apport --- apport-2.14.1/etc/init.d/apport +++ apport-2.14.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 } # diff -u apport-2.14.1/test/test_signal_crashes.py apport-2.14.1/test/test_signal_crashes.py --- apport-2.14.1/test/test_signal_crashes.py +++ apport-2.14.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 +import resource, errno, grp, unittest, pwd import apport.fileutils test_executable = '/usr/bin/yes' @@ -32,6 +32,15 @@ if orig_home is not None: os.environ['HOME'] = orig_home +# did we enable suid_dumpable? +suid_dumpable = False +try: + with open('/proc/sys/fs/suid_dumpable') as f: + if f.read().strip() != '0': + suid_dumpable = True +except IOError: + pass + class T(unittest.TestCase): def setUp(self): @@ -80,7 +89,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() @@ -147,12 +156,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 @@ -196,7 +205,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() @@ -432,7 +441,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 @@ -511,7 +520,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) app.stdin.write(b'boo') app.stdin.close() @@ -532,7 +541,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) @@ -569,7 +578,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) @@ -667,6 +676,43 @@ # there should not be a crash report 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_nonwritable_cwd(self): '''report generation and core dump for setuid program, non-writable cwd''' @@ -739,7 +785,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, @@ -754,7 +802,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) result = os.waitpid(pid, 0)[1] self.assertFalse(os.WIFEXITED(result), 'test process did not exit normally') self.assertTrue(os.WIFSIGNALED(result), 'test process died due to signal') @@ -772,22 +834,30 @@ time.sleep(0.2) timeout -= 1 self.assertGreater(timeout, 0) + if command == '/usr/bin/crontab': + subprocess.Popen(['sudo', '-s', '/bin/bash', '-c', + "/usr/bin/pkill -9 -f crontab", + '-u', 'mail']) if check_running: 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() @@ -795,15 +865,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.14.1.orig/use-local +++ apport-2.14.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