From d82afc87f1671f5e91393480c1272524341a2acd Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith [Google LLC]" Date: Wed, 9 Nov 2022 16:17:02 -0800 Subject: [PATCH 01/12] Authenticate the forkserver control socket. This adds authentication. In the past only filesystem permissions protected this socket from code injection into the forkserver process by limiting access to the same UID, which didn't exist when Linux abstract namespace sockets were used (see issue) meaning that any process in the same system network namespace could inject code. This reuses the hmac based shared key auth already used on multiprocessing sockets used for other purposes. Doing this is useful so that filesystem permissions are not relied upon and trust isn't implied by default between all processes running as the same UID. --- Lib/multiprocessing/connection.py | 7 ++++ Lib/multiprocessing/forkserver.py | 55 +++++++++++++++++++++++++++---- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/Lib/multiprocessing/connection.py b/Lib/multiprocessing/connection.py index b08144f7a1a169..141872a46e6b7f 100644 --- a/Lib/multiprocessing/connection.py +++ b/Lib/multiprocessing/connection.py @@ -178,6 +178,13 @@ def close(self): finally: self._handle = None + def _detach(self): + """Stop managing the underlying file descriptor or handle.""" + try: + return self._handle + finally: + self._handle = None + def send_bytes(self, buf, offset=0, size=None): """Send the bytes data from a bytes-like object""" self._check_closed() diff --git a/Lib/multiprocessing/forkserver.py b/Lib/multiprocessing/forkserver.py index 22a911a7a29cdc..31af3340f97d39 100644 --- a/Lib/multiprocessing/forkserver.py +++ b/Lib/multiprocessing/forkserver.py @@ -24,6 +24,7 @@ MAXFDS_TO_SEND = 256 SIGNED_STRUCT = struct.Struct('q') # large enough for pid_t +_authkey_len = 32 # <= PIPEBUF so it fits a single write to an empty pipe. # # Forkserver class @@ -32,6 +33,7 @@ class ForkServer(object): def __init__(self): + self._forkserver_authkey = None self._forkserver_address = None self._forkserver_alive_fd = None self._forkserver_pid = None @@ -58,6 +60,7 @@ def _stop_unlocked(self): if not util.is_abstract_socket_namespace(self._forkserver_address): os.unlink(self._forkserver_address) self._forkserver_address = None + self._forkserver_authkey = None def set_forkserver_preload(self, modules_names): '''Set list of module names to try to load in forkserver process.''' @@ -92,6 +95,16 @@ def connect_to_new_process(self, fds): resource_tracker.getfd()] allfds += fds try: + if self._forkserver_authkey: + client.setblocking(True) + wrapped_client = connection.Connection(client.fileno()) + try: + connection.answer_challenge( + wrapped_client, self._forkserver_authkey) + connection.deliver_challenge( + wrapped_client, self._forkserver_authkey) + finally: + wrapped_client._detach() reduction.sendfds(client, allfds) return parent_r, parent_w except: @@ -119,6 +132,7 @@ def ensure_running(self): return # dead, launch it again os.close(self._forkserver_alive_fd) + self._forkserver_authkey = None self._forkserver_address = None self._forkserver_alive_fd = None self._forkserver_pid = None @@ -129,9 +143,9 @@ def ensure_running(self): if self._preload_modules: desired_keys = {'main_path', 'sys_path'} data = spawn.get_preparation_data('ignore') - data = {x: y for x, y in data.items() if x in desired_keys} + main_kws = {x: y for x, y in data.items() if x in desired_keys} else: - data = {} + main_kws = {} with socket.socket(socket.AF_UNIX) as listener: address = connection.arbitrary_address('AF_UNIX') @@ -143,19 +157,31 @@ def ensure_running(self): # all client processes own the write end of the "alive" pipe; # when they all terminate the read end becomes ready. alive_r, alive_w = os.pipe() + # A short lived pipe to initialize the forkserver authkey. + authkey_r, authkey_w = os.pipe() try: - fds_to_pass = [listener.fileno(), alive_r] + fds_to_pass = [listener.fileno(), alive_r, authkey_r] + main_kws['authkey_r'] = authkey_r cmd %= (listener.fileno(), alive_r, self._preload_modules, - data) + main_kws) exe = spawn.get_executable() args = [exe] + util._args_from_interpreter_flags() args += ['-c', cmd] pid = util.spawnv_passfds(exe, args, fds_to_pass) except: os.close(alive_w) + os.close(authkey_w) raise finally: os.close(alive_r) + os.close(authkey_r) + # Prevent access from processes not in our process tree that + # have the same shared key for this forkserver. + try: + self._forkserver_authkey = os.urandom(_authkey_len) + os.write(authkey_w, self._forkserver_authkey) + finally: + os.close(authkey_w) self._forkserver_address = address self._forkserver_alive_fd = alive_w self._forkserver_pid = pid @@ -164,8 +190,18 @@ def ensure_running(self): # # -def main(listener_fd, alive_r, preload, main_path=None, sys_path=None): - '''Run forkserver.''' +def main(listener_fd, alive_r, preload, main_path=None, sys_path=None, + *, authkey_r=None): + """Run forkserver.""" + if authkey_r is not None: + # If there is no authkey, the parent closes the pipe without writing + # anything resulting in an empty authkey of b'' here. + authkey = os.read(authkey_r, _authkey_len) + assert len(authkey) == _authkey_len or not authkey + os.close(authkey_r) + else: + authkey = b'' + if preload: if '__main__' in preload and main_path is not None: process.current_process()._inheriting = True @@ -254,6 +290,13 @@ def sigchld_handler(*_unused): if listener in rfds: # Incoming fork request with listener.accept()[0] as s: + if authkey: + wrapped_s = connection.Connection(s.fileno()) + try: + connection.deliver_challenge(wrapped_s, authkey) + connection.answer_challenge(wrapped_s, authkey) + finally: + wrapped_s._detach() # Receive fds from client fds = reduction.recvfds(s, MAXFDS_TO_SEND + 1) if len(fds) > MAXFDS_TO_SEND: From 72f3843fb9c0145f6fac7f1fea9520a4382387e5 Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith [Google LLC]" Date: Thu, 10 Nov 2022 16:56:44 -0800 Subject: [PATCH 02/12] improve some error handling and add a test. --- Lib/multiprocessing/forkserver.py | 26 +++++++---- Lib/multiprocessing/reduction.py | 8 +--- Lib/test/test_multiprocessing_forkserver.py | 51 +++++++++++++++++++++ 3 files changed, 70 insertions(+), 15 deletions(-) diff --git a/Lib/multiprocessing/forkserver.py b/Lib/multiprocessing/forkserver.py index 31af3340f97d39..98f74b4f05aa7d 100644 --- a/Lib/multiprocessing/forkserver.py +++ b/Lib/multiprocessing/forkserver.py @@ -8,6 +8,7 @@ import threading import warnings +from . import AuthenticationError from . import connection from . import process from .context import reduction @@ -290,15 +291,22 @@ def sigchld_handler(*_unused): if listener in rfds: # Incoming fork request with listener.accept()[0] as s: - if authkey: - wrapped_s = connection.Connection(s.fileno()) - try: - connection.deliver_challenge(wrapped_s, authkey) - connection.answer_challenge(wrapped_s, authkey) - finally: - wrapped_s._detach() - # Receive fds from client - fds = reduction.recvfds(s, MAXFDS_TO_SEND + 1) + try: + if authkey: + wrapped_s = connection.Connection(s.fileno()) + try: + connection.deliver_challenge( + wrapped_s, authkey) + connection.answer_challenge( + wrapped_s, authkey) + finally: + wrapped_s._detach() + # Receive fds from client + fds = reduction.recvfds(s, MAXFDS_TO_SEND + 1) + except (EOFError, OSError, AuthenticationError): + # broken pipe or failed authentication + s.close() + continue if len(fds) > MAXFDS_TO_SEND: raise RuntimeError( "Too many ({0:n}) fds to send".format( diff --git a/Lib/multiprocessing/reduction.py b/Lib/multiprocessing/reduction.py index 5593f0682f7fce..c282d865a85df3 100644 --- a/Lib/multiprocessing/reduction.py +++ b/Lib/multiprocessing/reduction.py @@ -139,15 +139,12 @@ def detach(self): __all__ += ['DupFd', 'sendfds', 'recvfds'] import array - # On MacOSX we should acknowledge receipt of fds -- see Issue14669 - ACKNOWLEDGE = sys.platform == 'darwin' - def sendfds(sock, fds): '''Send an array of fds over an AF_UNIX socket.''' fds = array.array('i', fds) msg = bytes([len(fds) % 256]) sock.sendmsg([msg], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, fds)]) - if ACKNOWLEDGE and sock.recv(1) != b'A': + if sock.recv(1) != b'A': raise RuntimeError('did not receive acknowledgement of fd') def recvfds(sock, size): @@ -158,8 +155,7 @@ def recvfds(sock, size): if not msg and not ancdata: raise EOFError try: - if ACKNOWLEDGE: - sock.send(b'A') + sock.send(b'A') # Acknowledge if len(ancdata) != 1: raise RuntimeError('received %d items of ancdata' % len(ancdata)) diff --git a/Lib/test/test_multiprocessing_forkserver.py b/Lib/test/test_multiprocessing_forkserver.py index 6ad5faf9e8a329..14c3cdee9d4e46 100644 --- a/Lib/test/test_multiprocessing_forkserver.py +++ b/Lib/test/test_multiprocessing_forkserver.py @@ -1,6 +1,8 @@ import unittest +from unittest import mock import test._test_multiprocessing +import os import sys from test import support @@ -10,7 +12,56 @@ if sys.platform == "win32": raise unittest.SkipTest("forkserver is not available on Windows") +import multiprocessing +import multiprocessing.connection +import multiprocessing.forkserver + test._test_multiprocessing.install_tests_in_module_dict(globals(), 'forkserver') + +class TestForkserverControlAuthentication(unittest.TestCase): + def setUp(self): + super().setUp() + self.context = multiprocessing.get_context("forkserver") + self.pool = self.context.Pool(processes=1, maxtasksperchild=4) + self.assertEqual(self.pool.apply(eval, ("2+2",)), 4) + self.forkserver = multiprocessing.forkserver._forkserver + self.addr = self.forkserver._forkserver_address + self.assertTrue(self.addr) + self.authkey = self.forkserver._forkserver_authkey + self.assertGreater(len(self.authkey), 15) + self.assertTrue(self.forkserver._forkserver_pid) + + def tearDown(self): + self.pool.terminate() + self.pool.join() + super().tearDown() + + def test_auth_works(self): + """FYI: An 'EOFError: ran out of input' from a worker is normal.""" + # First, demonstrate that a raw auth handshake as Client makes + # does not raise. + client = multiprocessing.connection.Client( + self.addr, authkey=self.authkey) + client.close() + + # Now use forkserver code to do the same thing and more. + status_r, data_w = self.forkserver.connect_to_new_process([]) + # It is normal for this to trigger an EOFError on stderr from the + # process... it is expecting us to send over a pickle of a Process + # instance to tell it what to do. + # If the authentication handshake and subsequent file descriptor + # sending dance had failed, an exception would've been raised. + os.close(data_w) + os.close(status_r) + + def test_no_auth_fails(self): + with mock.patch.object(self.forkserver, '_forkserver_authkey', None): + # With no authkey set, the connection this makes will fail to + # do the file descriptor transfer over the pipe. + with self.assertRaisesRegex(RuntimeError, 'not receive ack'): + status_r, data_w = self.forkserver.connect_to_new_process([]) + + if __name__ == '__main__': unittest.main() From c83193dc5645de8b6f9d33d5fe7a013a1bf88b6e Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith [Google LLC]" Date: Thu, 10 Nov 2022 17:16:47 -0800 Subject: [PATCH 03/12] NEWS entry. --- .../2022-11-10-17-16-45.gh-issue-97514.kzA0zl.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2022-11-10-17-16-45.gh-issue-97514.kzA0zl.rst diff --git a/Misc/NEWS.d/next/Library/2022-11-10-17-16-45.gh-issue-97514.kzA0zl.rst b/Misc/NEWS.d/next/Library/2022-11-10-17-16-45.gh-issue-97514.kzA0zl.rst new file mode 100644 index 00000000000000..2de191f8e15a97 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2022-11-10-17-16-45.gh-issue-97514.kzA0zl.rst @@ -0,0 +1,10 @@ +Authentication was added to the :mod:`multiprocessing` forkserver start +method control socket so that only processes with the authentication key +generated by the process that spawned the forkserver can control it. This +is an enhancement over the other :gh:`97514` fixes so that access is no +longer limited only by filesystem permissions. + +The file descriptor exchange of control pipes with the worker forked worker +process now requires an explicit acknowledgement byte to be sent over the +socket after the exchange on all forkserver supporting platforms. That makes +testing the above much easier. From 14f6f4d58caab9b9854b38b5d66626f5a3b3682a Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith [Google LLC]" Date: Sat, 12 Nov 2022 19:48:34 -0800 Subject: [PATCH 04/12] Fix refleaks in the test. I can't add new testcases to test_multiprocessing_forkserver itself, i had to put them within an existing _test_multiprocessing test class. I don't know why, but refleaks are fragile and that test suite is... rediculiously complicated with all that it does. --- Lib/multiprocessing/forkserver.py | 1 + Lib/test/_test_multiprocessing.py | 66 ++++++++++++++++++++- Lib/test/test_multiprocessing_forkserver.py | 51 ---------------- 3 files changed, 65 insertions(+), 53 deletions(-) diff --git a/Lib/multiprocessing/forkserver.py b/Lib/multiprocessing/forkserver.py index 98f74b4f05aa7d..8e5d132e79f3db 100644 --- a/Lib/multiprocessing/forkserver.py +++ b/Lib/multiprocessing/forkserver.py @@ -106,6 +106,7 @@ def connect_to_new_process(self, fds): wrapped_client, self._forkserver_authkey) finally: wrapped_client._detach() + del wrapped_client reduction.sendfds(client, allfds) return parent_r, parent_w except: diff --git a/Lib/test/_test_multiprocessing.py b/Lib/test/_test_multiprocessing.py index a66f4f5b897cd3..c82a8c9e20f4c0 100644 --- a/Lib/test/_test_multiprocessing.py +++ b/Lib/test/_test_multiprocessing.py @@ -777,8 +777,8 @@ def test_error_on_stdio_flush_2(self): finally: setattr(sys, stream_name, old_stream) - @classmethod - def _sleep_and_set_event(self, evt, delay=0.0): + @staticmethod + def _sleep_and_set_event(evt, delay=0.0): time.sleep(delay) evt.set() @@ -829,6 +829,68 @@ def test_forkserver_sigkill(self): if os.name != 'nt': self.check_forkserver_death(signal.SIGKILL) + @staticmethod + def _exit_process(): + sys.exit(0) + + def test_forkserver_auth_is_enabled(self): + if self.TYPE == "threads": + self.skipTest(f"test not appropriate for {self.TYPE}") + if multiprocessing.get_start_method() != "forkserver": + self.skipTest("forkserver start method specific") + + forkserver = multiprocessing.forkserver._forkserver + forkserver.ensure_running() + self.assertTrue(forkserver._forkserver_pid) + authkey = forkserver._forkserver_authkey + self.assertTrue(authkey) + self.assertGreater(len(authkey), 15) + addr = forkserver._forkserver_address + self.assertTrue(addr) + + # First, demonstrate that a raw auth handshake as Client makes + # does not raise an error. + client = multiprocessing.connection.Client(addr, authkey=authkey) + client.close() + + # That worked, now launch a quick process. + proc = self.Process(target=self._exit_process) + proc.start() + proc.join() + self.assertEqual(proc.exitcode, 0) + + def test_forkserver_without_auth_fails(self): + if self.TYPE == "threads": + self.skipTest(f"test not appropriate for {self.TYPE}") + if multiprocessing.get_start_method() != "forkserver": + self.skipTest("forkserver start method specific") + + forkserver = multiprocessing.forkserver._forkserver + forkserver.ensure_running() + self.assertTrue(forkserver._forkserver_pid) + authkey_len = len(forkserver._forkserver_authkey) + with unittest.mock.patch.object( + forkserver, '_forkserver_authkey', None): + # With no auth handshake, the connection this makes to the + # forkserver will fail to do the file descriptor transfer + # over the pipe as the forkserver is expecting auth. + proc = self.Process(target=self._exit_process) + with self.assertRaisesRegex(RuntimeError, 'not receive ack'): + proc.start() + del proc + + # With an incorrect authkey we should get an auth rejection + # rather than the above protocol error. + forkserver._forkserver_authkey = b'T'*authkey_len + proc = self.Process(target=self._exit_process) + with self.assertRaises(multiprocessing.AuthenticationError): + proc.start() + del proc + + # authkey restored, launching processes should work again. + proc = self.Process(target=self._exit_process) + proc.start() + proc.join() # # diff --git a/Lib/test/test_multiprocessing_forkserver.py b/Lib/test/test_multiprocessing_forkserver.py index 14c3cdee9d4e46..6ad5faf9e8a329 100644 --- a/Lib/test/test_multiprocessing_forkserver.py +++ b/Lib/test/test_multiprocessing_forkserver.py @@ -1,8 +1,6 @@ import unittest -from unittest import mock import test._test_multiprocessing -import os import sys from test import support @@ -12,56 +10,7 @@ if sys.platform == "win32": raise unittest.SkipTest("forkserver is not available on Windows") -import multiprocessing -import multiprocessing.connection -import multiprocessing.forkserver - test._test_multiprocessing.install_tests_in_module_dict(globals(), 'forkserver') - -class TestForkserverControlAuthentication(unittest.TestCase): - def setUp(self): - super().setUp() - self.context = multiprocessing.get_context("forkserver") - self.pool = self.context.Pool(processes=1, maxtasksperchild=4) - self.assertEqual(self.pool.apply(eval, ("2+2",)), 4) - self.forkserver = multiprocessing.forkserver._forkserver - self.addr = self.forkserver._forkserver_address - self.assertTrue(self.addr) - self.authkey = self.forkserver._forkserver_authkey - self.assertGreater(len(self.authkey), 15) - self.assertTrue(self.forkserver._forkserver_pid) - - def tearDown(self): - self.pool.terminate() - self.pool.join() - super().tearDown() - - def test_auth_works(self): - """FYI: An 'EOFError: ran out of input' from a worker is normal.""" - # First, demonstrate that a raw auth handshake as Client makes - # does not raise. - client = multiprocessing.connection.Client( - self.addr, authkey=self.authkey) - client.close() - - # Now use forkserver code to do the same thing and more. - status_r, data_w = self.forkserver.connect_to_new_process([]) - # It is normal for this to trigger an EOFError on stderr from the - # process... it is expecting us to send over a pickle of a Process - # instance to tell it what to do. - # If the authentication handshake and subsequent file descriptor - # sending dance had failed, an exception would've been raised. - os.close(data_w) - os.close(status_r) - - def test_no_auth_fails(self): - with mock.patch.object(self.forkserver, '_forkserver_authkey', None): - # With no authkey set, the connection this makes will fail to - # do the file descriptor transfer over the pipe. - with self.assertRaisesRegex(RuntimeError, 'not receive ack'): - status_r, data_w = self.forkserver.connect_to_new_process([]) - - if __name__ == '__main__': unittest.main() From 8c5f7f4dded4fbb8d485bc0302384f0118c2f07b Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith [Google LLC]" Date: Sat, 12 Nov 2022 19:57:55 -0800 Subject: [PATCH 05/12] minor news wording tweak. --- .../Library/2022-11-10-17-16-45.gh-issue-97514.kzA0zl.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Misc/NEWS.d/next/Library/2022-11-10-17-16-45.gh-issue-97514.kzA0zl.rst b/Misc/NEWS.d/next/Library/2022-11-10-17-16-45.gh-issue-97514.kzA0zl.rst index 2de191f8e15a97..10c56edb8c7303 100644 --- a/Misc/NEWS.d/next/Library/2022-11-10-17-16-45.gh-issue-97514.kzA0zl.rst +++ b/Misc/NEWS.d/next/Library/2022-11-10-17-16-45.gh-issue-97514.kzA0zl.rst @@ -4,7 +4,7 @@ generated by the process that spawned the forkserver can control it. This is an enhancement over the other :gh:`97514` fixes so that access is no longer limited only by filesystem permissions. -The file descriptor exchange of control pipes with the worker forked worker -process now requires an explicit acknowledgement byte to be sent over the -socket after the exchange on all forkserver supporting platforms. That makes -testing the above much easier. +The file descriptor exchange of control pipes with the forked worker process +now requires an explicit acknowledgement byte to be sent over the socket after +the exchange on all forkserver supporting platforms. That makes testing the +above much easier. From ca47b6f9ab9e8966cac41438a14dd17015087d33 Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" Date: Sun, 13 Nov 2022 01:23:22 -0800 Subject: [PATCH 06/12] fix the hang on macOS by removing part of the test. I'm not sure _why_ the hang happened, the forkserver process wasn't exiting when the alive_w fd was closed in the parent during tearDownModule(), instead it remained in its selector() loop. regardless the part of the test this removes fixes it and it only happened on macOS. --- Lib/multiprocessing/forkserver.py | 4 ++-- Lib/test/_test_multiprocessing.py | 8 -------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/Lib/multiprocessing/forkserver.py b/Lib/multiprocessing/forkserver.py index 8e5d132e79f3db..a84302a70b3190 100644 --- a/Lib/multiprocessing/forkserver.py +++ b/Lib/multiprocessing/forkserver.py @@ -302,10 +302,10 @@ def sigchld_handler(*_unused): wrapped_s, authkey) finally: wrapped_s._detach() + del wrapped_s # Receive fds from client fds = reduction.recvfds(s, MAXFDS_TO_SEND + 1) - except (EOFError, OSError, AuthenticationError): - # broken pipe or failed authentication + except (EOFError, BrokenPipeError, AuthenticationError): s.close() continue if len(fds) > MAXFDS_TO_SEND: diff --git a/Lib/test/_test_multiprocessing.py b/Lib/test/_test_multiprocessing.py index c82a8c9e20f4c0..022f5a576b7c33 100644 --- a/Lib/test/_test_multiprocessing.py +++ b/Lib/test/_test_multiprocessing.py @@ -871,14 +871,6 @@ def test_forkserver_without_auth_fails(self): authkey_len = len(forkserver._forkserver_authkey) with unittest.mock.patch.object( forkserver, '_forkserver_authkey', None): - # With no auth handshake, the connection this makes to the - # forkserver will fail to do the file descriptor transfer - # over the pipe as the forkserver is expecting auth. - proc = self.Process(target=self._exit_process) - with self.assertRaisesRegex(RuntimeError, 'not receive ack'): - proc.start() - del proc - # With an incorrect authkey we should get an auth rejection # rather than the above protocol error. forkserver._forkserver_authkey = b'T'*authkey_len From 6f8e22fb016810712d20f70013b718cef9c24a6f Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith [Google LLC]" Date: Mon, 14 Nov 2022 16:43:32 -0800 Subject: [PATCH 07/12] clear up some comments and an assert --- Lib/multiprocessing/forkserver.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Lib/multiprocessing/forkserver.py b/Lib/multiprocessing/forkserver.py index a84302a70b3190..60602df435af29 100644 --- a/Lib/multiprocessing/forkserver.py +++ b/Lib/multiprocessing/forkserver.py @@ -177,8 +177,8 @@ def ensure_running(self): finally: os.close(alive_r) os.close(authkey_r) - # Prevent access from processes not in our process tree that - # have the same shared key for this forkserver. + # Authenticate our control socket to prevent access from + # processes we have not shared this key with. try: self._forkserver_authkey = os.urandom(_authkey_len) os.write(authkey_w, self._forkserver_authkey) @@ -196,10 +196,8 @@ def main(listener_fd, alive_r, preload, main_path=None, sys_path=None, *, authkey_r=None): """Run forkserver.""" if authkey_r is not None: - # If there is no authkey, the parent closes the pipe without writing - # anything resulting in an empty authkey of b'' here. authkey = os.read(authkey_r, _authkey_len) - assert len(authkey) == _authkey_len or not authkey + assert len(authkey) == _authkey_len, f'{len(authkey)} < {_authkey_len}' os.close(authkey_r) else: authkey = b'' From ab9f93dada34e02ebedbabc4ef007b99d545ac52 Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith [Google LLC]" Date: Tue, 24 Sep 2024 00:25:38 +0000 Subject: [PATCH 08/12] Add a comment about the fd recv acks. --- Lib/multiprocessing/reduction.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Lib/multiprocessing/reduction.py b/Lib/multiprocessing/reduction.py index c282d865a85df3..fcccd3eef86cc7 100644 --- a/Lib/multiprocessing/reduction.py +++ b/Lib/multiprocessing/reduction.py @@ -155,6 +155,10 @@ def recvfds(sock, size): if not msg and not ancdata: raise EOFError try: + # We send/recv an Ack byte after the fds to work around an old + # macOS bug; it isn't clear if this is still required but it + # makes unit testing fd sending easier. + # See: https://github.com/python/cpython/issues/58874 sock.send(b'A') # Acknowledge if len(ancdata) != 1: raise RuntimeError('received %d items of ancdata' % From 6bb9db443429301f3c3df245d6d003e473609795 Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" Date: Sat, 9 Nov 2024 17:34:34 -0800 Subject: [PATCH 09/12] Address review comments: simplify & comment. Thanks Eric! --- Lib/multiprocessing/connection.py | 5 +---- Lib/multiprocessing/forkserver.py | 26 +++++++++++++++----------- Lib/test/_test_multiprocessing.py | 4 ++-- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/Lib/multiprocessing/connection.py b/Lib/multiprocessing/connection.py index c0a96294e5e486..996887cb713942 100644 --- a/Lib/multiprocessing/connection.py +++ b/Lib/multiprocessing/connection.py @@ -183,10 +183,7 @@ def close(self): def _detach(self): """Stop managing the underlying file descriptor or handle.""" - try: - return self._handle - finally: - self._handle = None + self._handle = None def send_bytes(self, buf, offset=0, size=None): """Send the bytes data from a bytes-like object""" diff --git a/Lib/multiprocessing/forkserver.py b/Lib/multiprocessing/forkserver.py index bbcc4421547a67..dee2909fdb0e2e 100644 --- a/Lib/multiprocessing/forkserver.py +++ b/Lib/multiprocessing/forkserver.py @@ -97,17 +97,19 @@ def connect_to_new_process(self, fds): resource_tracker.getfd()] allfds += fds try: - if self._forkserver_authkey: - client.setblocking(True) - wrapped_client = connection.Connection(client.fileno()) - try: - connection.answer_challenge( - wrapped_client, self._forkserver_authkey) - connection.deliver_challenge( - wrapped_client, self._forkserver_authkey) - finally: - wrapped_client._detach() - del wrapped_client + assert self._forkserver_authkey + client.setblocking(True) + wrapped_client = connection.Connection(client.fileno()) + # The other side of this exchange happens in the child as + # implemented in main(). + try: + connection.answer_challenge( + wrapped_client, self._forkserver_authkey) + connection.deliver_challenge( + wrapped_client, self._forkserver_authkey) + finally: + wrapped_client._detach() + del wrapped_client reduction.sendfds(client, allfds) return parent_r, parent_w except: @@ -296,6 +298,8 @@ def sigchld_handler(*_unused): try: if authkey: wrapped_s = connection.Connection(s.fileno()) + # The other side of this exchange happens in + # in connect_to_new_process(). try: connection.deliver_challenge( wrapped_s, authkey) diff --git a/Lib/test/_test_multiprocessing.py b/Lib/test/_test_multiprocessing.py index c124bfc7514e5b..590894d8baaf87 100644 --- a/Lib/test/_test_multiprocessing.py +++ b/Lib/test/_test_multiprocessing.py @@ -914,8 +914,8 @@ def test_forkserver_auth_is_enabled(self): addr = forkserver._forkserver_address self.assertTrue(addr) - # First, demonstrate that a raw auth handshake as Client makes - # does not raise an error. + # Demonstrate that a raw auth handshake, as Client performs, does not + # raise an error. client = multiprocessing.connection.Client(addr, authkey=authkey) client.close() From 9c22c06736fb84bc9cbc1c3ec47d938e07f6c38c Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" Date: Sat, 9 Nov 2024 17:48:34 -0800 Subject: [PATCH 10/12] Add a whatsnew entry. --- Doc/whatsnew/3.14.rst | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst index b9d2c27eb9a321..df512a57237d2f 100644 --- a/Doc/whatsnew/3.14.rst +++ b/Doc/whatsnew/3.14.rst @@ -352,6 +352,17 @@ json (Contributed by Trey Hunner in :gh:`122873`.) +multiprocessing +--------------- + +* :mod:`multiprocessing`'s ``"forkserver"`` start method gains authentication + on its control sockets so that it isn't solely reliant on filesystem + permissions to control what other processes can cause the fork server to + spawn workers and run code. + This improves the security story behind gh:`97514`. + (Contributed by Gregory P. Smith.) + + operator -------- From 07c01d459f8b27453f24f50f1c52369ae14304b1 Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" Date: Sat, 9 Nov 2024 17:50:22 -0800 Subject: [PATCH 11/12] missing : sphinx-lint --- Doc/whatsnew/3.14.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst index df512a57237d2f..5b0eb7e1bf2aa2 100644 --- a/Doc/whatsnew/3.14.rst +++ b/Doc/whatsnew/3.14.rst @@ -359,7 +359,7 @@ multiprocessing on its control sockets so that it isn't solely reliant on filesystem permissions to control what other processes can cause the fork server to spawn workers and run code. - This improves the security story behind gh:`97514`. + This improves the security story behind :gh:`97514`. (Contributed by Gregory P. Smith.) From a53c01fc0f5cecbd006e5a37526fb9357386cfa1 Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" Date: Sun, 10 Nov 2024 12:11:34 -0800 Subject: [PATCH 12/12] Minor edits per @.picnixz code review comments. --- Doc/whatsnew/3.14.rst | 11 +++++------ Lib/multiprocessing/forkserver.py | 14 ++++++++------ Lib/test/_test_multiprocessing.py | 12 ++++-------- 3 files changed, 17 insertions(+), 20 deletions(-) diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst index 5b0eb7e1bf2aa2..3f8ec9c1917be6 100644 --- a/Doc/whatsnew/3.14.rst +++ b/Doc/whatsnew/3.14.rst @@ -355,12 +355,11 @@ json multiprocessing --------------- -* :mod:`multiprocessing`'s ``"forkserver"`` start method gains authentication - on its control sockets so that it isn't solely reliant on filesystem - permissions to control what other processes can cause the fork server to - spawn workers and run code. - This improves the security story behind :gh:`97514`. - (Contributed by Gregory P. Smith.) +* :mod:`multiprocessing`'s ``"forkserver"`` start method now authenticates + its control socket to avoid solely relying on filesystem permissions + to restrict what other processes could cause the forkserver to spawn workers + and run code. + (Contributed by Gregory P. Smith for :gh:`97514`.) operator diff --git a/Lib/multiprocessing/forkserver.py b/Lib/multiprocessing/forkserver.py index dee2909fdb0e2e..df9b9be9d1898b 100644 --- a/Lib/multiprocessing/forkserver.py +++ b/Lib/multiprocessing/forkserver.py @@ -26,7 +26,7 @@ MAXFDS_TO_SEND = 256 SIGNED_STRUCT = struct.Struct('q') # large enough for pid_t -_authkey_len = 32 # <= PIPEBUF so it fits a single write to an empty pipe. +_AUTHKEY_LEN = 32 # <= PIPEBUF so it fits a single write to an empty pipe. # # Forkserver class @@ -87,6 +87,7 @@ def connect_to_new_process(self, fds): process data. ''' self.ensure_running() + assert self._forkserver_authkey if len(fds) + 4 >= MAXFDS_TO_SEND: raise ValueError('too many fds') with socket.socket(socket.AF_UNIX) as client: @@ -97,7 +98,6 @@ def connect_to_new_process(self, fds): resource_tracker.getfd()] allfds += fds try: - assert self._forkserver_authkey client.setblocking(True) wrapped_client = connection.Connection(client.fileno()) # The other side of this exchange happens in the child as @@ -183,7 +183,7 @@ def ensure_running(self): # Authenticate our control socket to prevent access from # processes we have not shared this key with. try: - self._forkserver_authkey = os.urandom(_authkey_len) + self._forkserver_authkey = os.urandom(_AUTHKEY_LEN) os.write(authkey_w, self._forkserver_authkey) finally: os.close(authkey_w) @@ -199,9 +199,11 @@ def main(listener_fd, alive_r, preload, main_path=None, sys_path=None, *, authkey_r=None): """Run forkserver.""" if authkey_r is not None: - authkey = os.read(authkey_r, _authkey_len) - assert len(authkey) == _authkey_len, f'{len(authkey)} < {_authkey_len}' - os.close(authkey_r) + try: + authkey = os.read(authkey_r, _AUTHKEY_LEN) + assert len(authkey) == _AUTHKEY_LEN, f'{len(authkey)} < {_AUTHKEY_LEN}' + finally: + os.close(authkey_r) else: authkey = b'' diff --git a/Lib/test/_test_multiprocessing.py b/Lib/test/_test_multiprocessing.py index 590894d8baaf87..1c486274da75f1 100644 --- a/Lib/test/_test_multiprocessing.py +++ b/Lib/test/_test_multiprocessing.py @@ -895,10 +895,6 @@ def test_forkserver_sigkill(self): if os.name != 'nt': self.check_forkserver_death(signal.SIGKILL) - @staticmethod - def _exit_process(): - sys.exit(0) - def test_forkserver_auth_is_enabled(self): if self.TYPE == "threads": self.skipTest(f"test not appropriate for {self.TYPE}") @@ -920,7 +916,7 @@ def test_forkserver_auth_is_enabled(self): client.close() # That worked, now launch a quick process. - proc = self.Process(target=self._exit_process) + proc = self.Process(target=sys.exit) proc.start() proc.join() self.assertEqual(proc.exitcode, 0) @@ -939,14 +935,14 @@ def test_forkserver_without_auth_fails(self): forkserver, '_forkserver_authkey', None): # With an incorrect authkey we should get an auth rejection # rather than the above protocol error. - forkserver._forkserver_authkey = b'T'*authkey_len - proc = self.Process(target=self._exit_process) + forkserver._forkserver_authkey = b'T' * authkey_len + proc = self.Process(target=sys.exit) with self.assertRaises(multiprocessing.AuthenticationError): proc.start() del proc # authkey restored, launching processes should work again. - proc = self.Process(target=self._exit_process) + proc = self.Process(target=sys.exit) proc.start() proc.join()