Merge ~mdeslaur/apport:sec202205 into ~apport-hackers/apport/+git/apport-old:main

Proposed by Marc Deslauriers
Status: Merged
Merge reported by: Benjamin Drung
Merged at revision: cd7544bff0611ee8443a0051a9aa0041649abbab
Proposed branch: ~mdeslaur/apport:sec202205
Merge into: ~apport-hackers/apport/+git/apport-old:main
Diff against target: 554 lines (+228/-104)
4 files modified
apport/fileutils.py (+61/-7)
data/apport (+140/-91)
etc/init.d/apport (+3/-6)
tests/test_fileutils.py (+24/-0)
Reviewer Review Type Date Requested Status
Benjamin Drung Approve
Review via email: mp+422837@code.launchpad.net

Commit message

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

To post a comment you must log in.
~mdeslaur/apport:sec202205 updated
cd7544b... by Marc Deslauriers

Merge remote-tracking branch 'origin/main' into sec202205

Revision history for this message
Benjamin Drung (bdrung) wrote :

Merged. Thanks.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/apport/fileutils.py b/apport/fileutils.py
2index a8385c8..e050858 100644
3--- a/apport/fileutils.py
4+++ b/apport/fileutils.py
5@@ -57,6 +57,33 @@ def allowed_to_report():
6 return False
7
8
9+def get_dbus_socket(dbus_addr):
10+ '''Extract the socket from a DBus address.'''
11+
12+ if not dbus_addr:
13+ return None
14+
15+ # Only support unix domain sockets, and only the default Ubuntu path
16+ if not dbus_addr.startswith("unix:path=/run/user/"):
17+ return None
18+
19+ # Prevent path traversal
20+ if "../" in dbus_addr:
21+ return None
22+
23+ # Don't support escaped values, multiple addresses, or multiple keys
24+ # and values
25+ for search in ["%", ",", ";"]:
26+ if search in dbus_addr:
27+ return None
28+
29+ parts = dbus_addr.split("=")
30+ if len(parts) != 2:
31+ return None
32+
33+ return parts[1]
34+
35+
36 def find_package_desktopfile(package):
37 '''Return a package's .desktop file.
38
39@@ -370,7 +397,7 @@ def get_config(section, setting, default=None, path=None, bool=False):
40 fd = None
41 f = None
42 if not get_config.config:
43- get_config.config = ConfigParser()
44+ get_config.config = ConfigParser(interpolation=None)
45
46 try:
47 fd = os.open(path, os.O_NOFOLLOW | os.O_RDONLY)
48@@ -432,6 +459,22 @@ def get_uid_and_gid(contents):
49 return (real_uid, real_gid)
50
51
52+def search_map(mapfd, uid):
53+ '''Search for an ID in a map fd'''
54+ for line in mapfd:
55+ fields = line.split()
56+ if len(fields) != 3:
57+ continue
58+
59+ host_start = int(fields[1])
60+ host_end = host_start + int(fields[2])
61+
62+ if uid >= host_start and uid <= host_end:
63+ return True
64+
65+ return False
66+
67+
68 def get_boot_id():
69 '''Gets the kernel boot id'''
70
71@@ -440,7 +483,18 @@ def get_boot_id():
72 return boot_id
73
74
75-def get_core_path(pid=None, exe=None, uid=None, timestamp=None):
76+def get_process_path(proc_pid_fd=None):
77+ '''Gets the process path from a proc directory file descriptor'''
78+
79+ if proc_pid_fd is None:
80+ return 'unknown'
81+ try:
82+ return os.readlink('exe', dir_fd=proc_pid_fd)
83+ except OSError:
84+ return 'unknown'
85+
86+
87+def get_core_path(pid=None, exe=None, uid=None, timestamp=None, proc_pid_fd=None):
88 '''Get the path to a core file'''
89
90 if pid is None:
91@@ -453,9 +507,8 @@ def get_core_path(pid=None, exe=None, uid=None, timestamp=None):
92 timestamp = get_starttime(stat_contents)
93
94 if exe is None:
95- exe = 'unknown'
96- else:
97- exe = exe.replace('/', '_').replace('.', '_')
98+ exe = get_process_path(proc_pid_fd)
99+ exe = exe.replace('/', '_').replace('.', '_')
100
101 if uid is None:
102 uid = os.getuid()
103@@ -474,6 +527,7 @@ def find_core_files_by_uid(uid):
104 '''Searches the core file directory for files that belong to a
105 specified uid. Returns a list of lists containing the filename and
106 the file modification time.'''
107+ uid = str(uid)
108 core_files = []
109 uid_files = []
110
111@@ -482,7 +536,7 @@ def find_core_files_by_uid(uid):
112
113 for f in core_files:
114 try:
115- if f.split('.')[2] == str(uid):
116+ if f.split('.')[2] == uid:
117 time = os.path.getmtime(os.path.join(core_dir, f))
118 uid_files.append([f, time])
119 except (IndexError, FileNotFoundError):
120@@ -497,7 +551,7 @@ def clean_core_directory(uid):
121 uid_files = find_core_files_by_uid(uid)
122 sorted_files = sorted(uid_files, key=itemgetter(1))
123
124- # Substract a extra one to make room for the new core file
125+ # Subtract a extra one to make room for the new core file
126 if len(uid_files) > max_corefiles_per_uid - 1:
127 for x in range(len(uid_files) - max_corefiles_per_uid + 1):
128 os.remove(os.path.join(core_dir, sorted_files[0][0]))
129diff --git a/data/apport b/data/apport
130index 416173e..427f340 100755
131--- a/data/apport
132+++ b/data/apport
133@@ -200,9 +200,10 @@ def write_user_coredump(pid, timestamp, limit, from_report=None):
134 return
135
136 (core_name, core_path) = apport.fileutils.get_core_path(pid,
137- options.exe_path,
138+ options.executable_path,
139 crash_uid,
140- timestamp)
141+ timestamp,
142+ proc_pid_fd)
143
144 try:
145 # Limit number of core files to prevent DoS
146@@ -269,12 +270,53 @@ def usable_ram():
147 return (memfree + cached - writeback) * 1024
148
149
150+def _run_with_output_limit_and_timeout(args, output_limit, timeout, close_fds=True, env=None):
151+ '''Run command like subprocess.run() but with output limit and timeout.
152+
153+ Return (stdout, stderr).'''
154+
155+ stdout = b""
156+ stderr = b""
157+
158+ process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
159+ close_fds=close_fds, env=env)
160+ try:
161+ # Don't block so we don't deadlock
162+ os.set_blocking(process.stdout.fileno(), False)
163+ os.set_blocking(process.stderr.fileno(), False)
164+
165+ for _ in range(timeout):
166+ alive = process.poll() is None
167+
168+ while len(stdout) < output_limit and len(stderr) < output_limit:
169+ tempout = process.stdout.read(100)
170+ if tempout:
171+ stdout += tempout
172+ temperr = process.stderr.read(100)
173+ if temperr:
174+ stderr += temperr
175+ if not tempout and not temperr:
176+ break
177+
178+ if not alive or len(stdout) >= output_limit or len(stderr) >= output_limit:
179+ break
180+ time.sleep(1)
181+ finally:
182+ process.kill()
183+
184+ return stdout, stderr
185+
186+
187 def is_closing_session():
188 '''Check if pid is in a closing user session.
189
190 During that, crashes are common as the session D-BUS and X.org are going
191 away, etc. These crash reports are mostly noise, so should be ignored.
192 '''
193+ # Sanity check, don't do anything for root processes
194+ if crash_uid == 0 or crash_gid == 0:
195+ return False
196+
197 with open('environ', 'rb', opener=proc_pid_opener) as e:
198 env = e.read().split(b'\0')
199 for e in env:
200@@ -285,6 +327,15 @@ def is_closing_session():
201 error_log('is_closing_session(): no DBUS_SESSION_BUS_ADDRESS in environment')
202 return False
203
204+ dbus_socket = apport.fileutils.get_dbus_socket(dbus_addr)
205+ if not dbus_socket:
206+ error_log('is_closing_session(): Could not determine DBUS socket.')
207+ return False
208+
209+ if not os.path.exists(dbus_socket):
210+ error_log("is_closing_session(): DBUS socket doesn't exist.")
211+ return False
212+
213 # We need to drop both the real and effective uid/gid before calling
214 # gdbus because DBUS_SESSION_BUS_ADDRESS is untrusted and may allow
215 # reading arbitrary files as a noncefile. We can't just drop effective
216@@ -297,11 +348,11 @@ def is_closing_session():
217 try:
218 os.setresgid(crash_gid, crash_gid, real_gid)
219 os.setresuid(crash_uid, crash_uid, real_uid)
220- gdbus = subprocess.Popen(['/usr/bin/gdbus', 'call', '-e', '-d',
221- 'org.gnome.SessionManager', '-o', '/org/gnome/SessionManager', '-m',
222- 'org.gnome.SessionManager.IsSessionRunning'], stdout=subprocess.PIPE,
223- stderr=subprocess.PIPE, env={'DBUS_SESSION_BUS_ADDRESS': dbus_addr})
224- (out, err) = gdbus.communicate()
225+ out, err = _run_with_output_limit_and_timeout(['/usr/bin/gdbus', 'call', '-e', '-d',
226+ 'org.gnome.SessionManager', '-o', '/org/gnome/SessionManager', '-m',
227+ 'org.gnome.SessionManager.IsSessionRunning', '-t', '5'],
228+ 1000, 5, env={'DBUS_SESSION_BUS_ADDRESS': dbus_addr})
229+
230 if err:
231 error_log('gdbus call error: ' + err.decode('UTF-8'))
232 except OSError as e:
233@@ -384,38 +435,46 @@ def parse_arguments():
234 parser.add_argument("-s", "--signal-number", help="signal number (%%s)")
235 parser.add_argument("-c", "--core-ulimit", help="core ulimit (%%c)")
236 parser.add_argument("-d", "--dump-mode", help="dump mode (%%d)")
237- parser.add_argument("-P", "--global-pid", nargs='?', help="pid in root namespace (%%P)")
238- parser.add_argument("-E", "--executable-path", nargs='?', help="path of executable (%%E)")
239+ parser.add_argument("-P", "--global-pid", help="pid in root namespace (%%P)")
240+ parser.add_argument("-u", "--uid", type=int, help="real UID (%%u)")
241+ parser.add_argument("-g", "--gid", type=int, help="real GID (%%g)")
242+ parser.add_argument("executable_path", nargs='*', help="path of executable (%%E)")
243
244 options, rest = parser.parse_known_args()
245
246- if options.pid is not None:
247- for arg in rest:
248- error_log("Unknown argument: %s" % arg)
249-
250- elif len(rest) in (4, 5, 6, 7):
251+ # Legacy command line needs to remain for the scenario where a more
252+ # recent apport is running inside a container with an older apport
253+ # running on the host.
254+ if options.pid is None and len(sys.argv) == 5:
255 # Translate legacy command line
256- options.pid = rest[0]
257- options.signal_number = rest[1]
258- options.core_ulimit = rest[2]
259- options.dump_mode = rest[3]
260- try:
261- options.global_pid = rest[4]
262- except IndexError:
263- options.global_pid = None
264- try:
265- options.exe_path = rest[5].replace('!', '/')
266- except IndexError:
267- options.exe_path = None
268- try:
269- if rest[6] == '(deleted)':
270- options.exe_path += ' %s' % rest[6]
271- except IndexError:
272- pass
273- else:
274+ return argparse.Namespace(
275+ pid=sys.argv[1],
276+ signal_number=sys.argv[2],
277+ core_ulimit=sys.argv[3],
278+ dump_mode=sys.argv[4],
279+ global_pid=None,
280+ uid=None,
281+ gid=None,
282+ executable_path=None,
283+ )
284+
285+ if options.pid is None:
286 parser.print_usage()
287 sys.exit(1)
288
289+ for arg in rest:
290+ error_log("Unknown argument: %s" % arg)
291+
292+ # In kernels before 5.3.0, an executable path with spaces may be split
293+ # into separate arguments. If options.executable_path is a list, join
294+ # it back into a string. Also restore directory separators.
295+ if isinstance(options.executable_path, list):
296+ options.executable_path = " ".join(options.executable_path)
297+ options.executable_path = options.executable_path.replace('!', '/')
298+ # Sanity check to prevent trickery later on
299+ if '../' in options.executable_path:
300+ options.executable_path = None
301+
302 return options
303
304
305@@ -485,32 +544,34 @@ if options.global_pid is not None:
306
307 # Instead, attempt to find apport inside the container and
308 # forward the process information there.
309- if not os.path.exists('/proc/%d/root/run/apport.socket' % host_pid):
310- error_log('host pid %s crashed in a container without apport support' %
311- options.global_pid)
312- sys.exit(0)
313
314 proc_host_pid_fd = os.open('/proc/%d' % host_pid, os.O_RDONLY | os.O_PATH | os.O_DIRECTORY)
315
316 def proc_host_pid_opener(path, flags):
317 return os.open(path, flags, dir_fd=proc_host_pid_fd)
318
319+ # Validate that the target socket is owned by the user namespace of the process
320+ try:
321+ sock_fd = os.open("root/run/apport.socket", os.O_RDONLY | os.O_PATH, dir_fd=proc_host_pid_fd)
322+ socket_uid = os.fstat(sock_fd).st_uid
323+ except FileNotFoundError:
324+ error_log('host pid %s crashed in a container without apport support' %
325+ options.global_pid)
326+ sys.exit(0)
327+
328+ try:
329+ with open("uid_map", "r", opener=proc_host_pid_opener) as fd:
330+ if not apport.fileutils.search_map(fd, socket_uid):
331+ error_log("user is trying to trick apport into accessing a socket that doesn't belong to the container")
332+ sys.exit(0)
333+ except FileNotFoundError:
334+ pass
335+
336 # Validate that the crashed binary is owned by the user namespace of the process
337 task_uid = os.stat("exe", dir_fd=proc_host_pid_fd).st_uid
338 try:
339 with open("uid_map", "r", opener=proc_host_pid_opener) as fd:
340- for line in fd:
341- fields = line.split()
342- if len(fields) != 3:
343- continue
344-
345- host_start = int(fields[1])
346- host_end = host_start + int(fields[2])
347-
348- if task_uid >= host_start and task_uid <= host_end:
349- break
350-
351- else:
352+ if not apport.fileutils.search_map(fd, task_uid):
353 error_log("host pid %s crashed in a container with no access to the binary"
354 % options.global_pid)
355 sys.exit(0)
356@@ -520,45 +581,28 @@ if options.global_pid is not None:
357 task_gid = os.stat("exe", dir_fd=proc_host_pid_fd).st_gid
358 try:
359 with open("gid_map", "r", opener=proc_host_pid_opener) as fd:
360- for line in fd:
361- fields = line.split()
362- if len(fields) != 3:
363- continue
364-
365- host_start = int(fields[1])
366- host_end = host_start + int(fields[2])
367-
368- if task_gid >= host_start and task_gid <= host_end:
369- break
370-
371- else:
372+ if not apport.fileutils.search_map(fd, task_gid):
373 error_log("host pid %s crashed in a container with no access to the binary"
374 % options.global_pid)
375 sys.exit(0)
376 except FileNotFoundError:
377 pass
378
379- # Chdir and chroot to the task
380- # WARNING: After this point, all "import" calls are security issues
381- __builtins__.__dict__['__import__'] = None
382-
383- host_cwd = os.open('cwd', os.O_RDONLY | os.O_PATH | os.O_DIRECTORY, dir_fd=proc_host_pid_fd)
384-
385- os.chdir(host_cwd)
386- # WARNING: we really should be using a file descriptor here,
387- # but os.chroot won't take it
388- os.chroot(os.readlink('root', dir_fd=proc_host_pid_fd))
389-
390+ # Now open the socket
391 sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
392 try:
393- sock.connect('/run/apport.socket')
394+ sock.connect('/proc/self/fd/%s' % sock_fd)
395 except Exception:
396 error_log('host pid %s crashed in a container with a broken apport' %
397 options.global_pid)
398 sys.exit(0)
399
400- # Send all arguments except for the first (exec path) and last (global pid)
401- args = ' '.join(sys.argv[1:5])
402+ # Send main arguments only
403+ # Older apport in containers doesn't support positional arguments
404+ args = "%s %s %s %s" % (options.pid,
405+ options.signal_number,
406+ options.core_ulimit,
407+ options.dump_mode)
408 try:
409 sock.sendmsg([args.encode()], [
410 # Send a ucred containing the global pid
411@@ -567,7 +611,7 @@ if options.global_pid is not None:
412 # Send fd 0 (the coredump)
413 (socket.SOL_SOCKET, socket.SCM_RIGHTS, array.array('i', [0]))])
414 sock.shutdown(socket.SHUT_RDWR)
415- except socket.timeout:
416+ except Exception:
417 error_log('Container apport failed to process crash within 30s')
418
419 sys.exit(0)
420@@ -598,16 +642,30 @@ try:
421
422 get_pid_info(pid)
423
424- # Check if the process was replaced after the crash happened.
425- # Ideally we'd use the time of dump value provided by the kernel,
426- # but this would be a backwards-incompatible change that would
427- # require adding support to apport outside and inside of containers.
428+ # Sanity check to make sure the process wasn't replaced after the crash
429+ # happened. The start time isn't fine-grained enough to be an adequate
430+ # security check.
431 apport_start = get_apport_starttime()
432 process_start = get_process_starttime()
433 if process_start > apport_start:
434 error_log('process was replaced after Apport started, ignoring')
435 sys.exit(0)
436
437+ # Make sure the process uid/gid match the ones provided by the kernel
438+ # if available, if not, it may have been replaced
439+ if (options.uid is not None) and (options.gid is not None):
440+ if (options.uid != crash_uid) or (options.gid != crash_gid):
441+ error_log("process uid/gid doesn't match expected, ignoring")
442+ sys.exit(0)
443+
444+ # check if the executable was modified after the process started (e. g.
445+ # package got upgraded in between).
446+ exe_mtime = os.stat('exe', dir_fd=proc_pid_fd).st_mtime
447+ process_mtime = os.lstat('cmdline', dir_fd=proc_pid_fd).st_mtime
448+ if not os.path.exists(os.readlink('exe', dir_fd=proc_pid_fd)) or exe_mtime > process_mtime:
449+ error_log('executable was modified after program start, ignoring')
450+ sys.exit(0)
451+
452 error_log('called for pid %s, signal %s, core limit %s, dump mode %s' % (pid, signum, core_ulimit, dump_mode))
453
454 try:
455@@ -631,14 +689,6 @@ try:
456 write_user_coredump(pid, process_start, core_ulimit)
457 sys.exit(0)
458
459- # check if the executable was modified after the process started (e. g.
460- # package got upgraded in between)
461- exe_mtime = os.stat('exe', dir_fd=proc_pid_fd).st_mtime
462- process_mtime = os.lstat('cmdline', dir_fd=proc_pid_fd).st_mtime
463- if not os.path.exists(os.readlink('exe', dir_fd=proc_pid_fd)) or exe_mtime > process_mtime:
464- error_log('executable was modified after program start, ignoring')
465- sys.exit(0)
466-
467 info = apport.Report('Crash')
468 info['Signal'] = signum
469 core_size_limit = usable_ram() * 3 / 4
470@@ -647,9 +697,8 @@ try:
471
472 # We already need this here to figure out the ExecutableName (for scripts,
473 # etc).
474-
475- if options.exe_path is not None and os.path.exists(options.exe_path):
476- info['ExecutablePath'] = options.exe_path
477+ if options.executable_path is not None and os.path.exists(options.executable_path):
478+ info['ExecutablePath'] = options.executable_path
479
480 # Drop privileges temporarily to make sure that we don't
481 # include information in the crash report that the user should
482@@ -739,7 +788,7 @@ try:
483 # os.O_CREAT|os.O_EXCL
484 os.unlink(report)
485 else:
486- error_log('apport: report %s already exists and unseen, doing nothing to avoid disk usage DoS' % report)
487+ error_log('apport: report %s already exists and unseen, skipping to avoid disk usage DoS' % report)
488 write_user_coredump(pid, process_start, core_ulimit)
489 sys.exit(0)
490 # we prefer having a file mode of 0 while writing;
491diff --git a/etc/init.d/apport b/etc/init.d/apport
492index 4997b83..1e10d02 100755
493--- a/etc/init.d/apport
494+++ b/etc/init.d/apport
495@@ -50,13 +50,9 @@ do_start()
496 rm -f /var/lib/pm-utils/resume-hang.log
497 fi
498
499- # Old compatibility mode, switch later to second one
500- if true; then
501- echo "|$AGENT %p %s %c %d %P %E" > /proc/sys/kernel/core_pattern
502- else
503- echo "|$AGENT -p%p -s%s -c%c -d%d -P%P -E%E" > /proc/sys/kernel/core_pattern
504- fi
505+ echo "|$AGENT -p%p -s%s -c%c -d%d -P%P -u%u -g%g -- %E" > /proc/sys/kernel/core_pattern
506 echo 2 > /proc/sys/fs/suid_dumpable
507+ echo 10 > /proc/sys/kernel/core_pipe_limit
508 }
509
510 #
511@@ -70,6 +66,7 @@ do_stop()
512 # 2 if daemon could not be stopped
513 # other if a failure occurred
514
515+ echo 0 > /proc/sys/kernel/core_pipe_limit
516 echo 0 > /proc/sys/fs/suid_dumpable
517
518 # Check for a hung resume. If we find one try and grab everything
519diff --git a/tests/test_fileutils.py b/tests/test_fileutils.py
520index 23f9984..0b973a4 100644
521--- a/tests/test_fileutils.py
522+++ b/tests/test_fileutils.py
523@@ -372,8 +372,32 @@ f6423dfbc4faf022e58b4d3f5ff71a70 %s
524 self.assertEqual(apport.fileutils.get_config('spethial', 'nope', 'moo'), 'moo')
525 apport.fileutils.get_config.config = None # trash cache
526
527+ # interpolation
528+ f.write(b'[inter]\none=1\ntwo = TWO\ntest = %(two)s\n')
529+ f.flush()
530+ self.assertEqual(apport.fileutils.get_config('inter', 'one'), '1')
531+ self.assertEqual(apport.fileutils.get_config('inter', 'two'), 'TWO')
532+ self.assertEqual(apport.fileutils.get_config('inter', 'test'), '%(two)s')
533+ apport.fileutils.get_config.config = None # trash cache
534+
535 f.close()
536
537+ def test_get_dbus_socket(self):
538+ '''get_dbus_socket()'''
539+
540+ tests = [("unix:path=/run/user/1000/bus", "/run/user/1000/bus"),
541+ ("unix:path=/run/user/1000/bus;unix:path=/run/user/0/bus", None),
542+ ("unix:path=%2Frun/user/1000/bus", None),
543+ ("unix:path=/run/user/1000/bus,path=/run/user/0/bus", None),
544+ ("unix:path=/etc/passwd", None),
545+ ("unix:path=/run/user/../../etc/passwd", None),
546+ ("unix:path=/run/user/1000/bus=", None),
547+ ("", None),
548+ ("tcp:host=localhost,port=8100", None)]
549+
550+ for (addr, result) in tests:
551+ self.assertEqual(apport.fileutils.get_dbus_socket(addr), result)
552+
553 def test_shared_libraries(self):
554 '''shared_libraries()'''
555

Subscribers

People subscribed via source and target branches