Skip to content

Commit b5a7fc3

Browse files
alexpilottiader1990alexcoman
committed
Resets service user password at each execution
In a cloud environment instance images are typically cloned. This implies that the credentials used by the Cloudbase-Init service, even if randomly generated, are identical across instances of the same image, unless replaced during boot, e.g. by the post-sysprep specialize actions. Since this cannot be controlled in cases in which sysprep or similar mechanisms are not used (e.g. a Nova image snapshot), this patch adds a mechanism to reset the Cloudbase-Init service password at each execution. This avoids potential "pass the hash" type of attacks executed from user-data across instances booted from the same image. Change-Id: Ib778acc4c01f476c600e15aa77ed777523a77538 Closes-Bug: #1631567 Co-Authored-By: Adrian Vladu <avladu@cloudbasesolutions.com> Co-Authored-By: Alexandru Coman <acoman@cloudbasesolutions.com>
1 parent 14d923c commit b5a7fc3

File tree

7 files changed

+254
-5
lines changed

7 files changed

+254
-5
lines changed

cloudbaseinit/conf/default.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,14 @@ def __init__(self, config):
121121
'the password is a clear text password, coming from the '
122122
'metadata. The last option is `no`, when the user is '
123123
'never forced to change the password.'),
124+
cfg.BoolOpt(
125+
'reset_service_password', default=True,
126+
help='If set to True, the service user password will be '
127+
'reset at each execution with a new random value of '
128+
'appropriate length and complexity, unless the user is '
129+
'a built-in or domain account.'
130+
'This is needed to avoid "pass the hash" attacks on '
131+
'Windows cloned instances.'),
124132
cfg.ListOpt(
125133
'metadata_services',
126134
default=[

cloudbaseinit/init.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,9 @@ def configure_host(self):
117117
LOG.info('Cloudbase-Init version: %s', version.get_version())
118118

119119
osutils = osutils_factory.get_os_utils()
120+
if CONF.reset_service_password:
121+
# Avoid pass the hash attacks from cloned instances
122+
osutils.reset_service_password()
120123
osutils.wait_for_boot_completion()
121124

122125
reboot_required = self._handle_plugins_stage(

cloudbaseinit/osutils/base.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ def get_config_value(self, name, section=None):
8282
def wait_for_boot_completion(self):
8383
pass
8484

85+
def reset_service_password(self):
86+
return False
87+
8588
def terminate(self):
8689
pass
8790

@@ -118,3 +121,11 @@ def set_timezone(self, timezone):
118121
def change_password_next_logon(self, username):
119122
"""Force the given user to change his password at the next login."""
120123
raise NotImplementedError()
124+
125+
def set_service_credentials(self, service_name, username, password):
126+
"""Set the username and password for a given service."""
127+
raise NotImplementedError()
128+
129+
def get_service_username(self, service_name):
130+
"""Retrieve the username under which a service runs."""
131+
raise NotImplementedError()

cloudbaseinit/osutils/windows.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import time
2222

2323
from oslo_log import log as oslo_logging
24+
import pywintypes
2425
import six
2526
from six.moves import winreg
2627
from tzlocal import windows_tz
@@ -29,6 +30,8 @@
2930
import win32netcon
3031
import win32process
3132
import win32security
33+
import win32service
34+
import winerror
3235
import wmi
3336

3437
from cloudbaseinit import exception
@@ -681,7 +684,14 @@ def _get_service(self, service_name):
681684
return service_list[0]
682685

683686
def check_service_exists(self, service_name):
684-
return self._get_service(service_name) is not None
687+
try:
688+
with self._get_service_handle(service_name):
689+
return True
690+
except pywintypes.error as ex:
691+
print(ex)
692+
if ex.winerror == winerror.ERROR_SERVICE_DOES_NOT_EXIST:
693+
return False
694+
raise
685695

686696
def get_service_status(self, service_name):
687697
service = self._get_service(service_name)
@@ -721,6 +731,70 @@ def stop_service(self, service_name):
721731
' %(ret_val)d' % {'service_name': service_name,
722732
'ret_val': ret_val})
723733

734+
@staticmethod
735+
@contextlib.contextmanager
736+
def _get_service_handle(service_name,
737+
service_access=win32service.SERVICE_QUERY_CONFIG,
738+
scm_access=win32service.SC_MANAGER_CONNECT):
739+
hscm = win32service.OpenSCManager(None, None, scm_access)
740+
hs = None
741+
try:
742+
hs = win32service.OpenService(hscm, service_name, service_access)
743+
yield hs
744+
finally:
745+
if hs:
746+
win32service.CloseServiceHandle(hs)
747+
win32service.CloseServiceHandle(hscm)
748+
749+
def set_service_credentials(self, service_name, username, password):
750+
LOG.debug('Setting service credentials: %s', service_name)
751+
with self._get_service_handle(
752+
service_name, win32service.SERVICE_CHANGE_CONFIG) as hs:
753+
win32service.ChangeServiceConfig(
754+
hs,
755+
win32service.SERVICE_NO_CHANGE,
756+
win32service.SERVICE_NO_CHANGE,
757+
win32service.SERVICE_NO_CHANGE,
758+
None,
759+
None,
760+
False,
761+
None,
762+
username,
763+
password,
764+
None)
765+
766+
def get_service_username(self, service_name):
767+
LOG.debug('Getting service username: %s', service_name)
768+
with self._get_service_handle(service_name) as hs:
769+
cfg = win32service.QueryServiceConfig(hs)
770+
return cfg[7]
771+
772+
def reset_service_password(self):
773+
"""This is needed to avoid pass the hash attacks."""
774+
if not self.check_service_exists(self._service_name):
775+
LOG.info("Service does not exist: %s", self._service_name)
776+
return False
777+
778+
service_username = self.get_service_username(self._service_name)
779+
# Ignore builtin accounts
780+
if "\\" not in service_username:
781+
LOG.info("Skipping password reset, service running as a built-in "
782+
"account: %s", service_username)
783+
return False
784+
domain, username = service_username.split('\\')
785+
if domain != ".":
786+
LOG.info("Skipping password reset, service running as a domain "
787+
"account: %s", service_username)
788+
return False
789+
790+
LOG.debug('Resetting password for service user: %s', service_username)
791+
maximum_length = self.get_maximum_password_length()
792+
password = self.generate_random_password(maximum_length)
793+
self.set_user_password(username, password)
794+
self.set_service_credentials(
795+
self._service_name, service_username, password)
796+
return True
797+
724798
def terminate(self):
725799
# Wait for the service to start. Polling the service "Started" property
726800
# is not enough

cloudbaseinit/tests/fake.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,10 @@ class FakeComError(Exception):
1818
def __init__(self):
1919
super(FakeComError, self).__init__()
2020
self.excepinfo = [None, None, None, None, None, -2144108544]
21+
22+
23+
class FakeError(Exception):
24+
25+
def __init__(self, msg="Fake error."):
26+
super(FakeError, self).__init__(msg)
27+
self.winerror = None

cloudbaseinit/tests/osutils/test_windows.py

Lines changed: 148 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,16 @@ class TestWindowsUtils(testutils.CloudbaseInitTestBase):
5050

5151
def setUp(self):
5252
self._pywintypes_mock = mock.MagicMock()
53+
self._pywintypes_mock.error = fake.FakeError
5354
self._pywintypes_mock.com_error = fake.FakeComError
5455
self._win32com_mock = mock.MagicMock()
5556
self._win32process_mock = mock.MagicMock()
5657
self._win32security_mock = mock.MagicMock()
5758
self._win32net_mock = mock.MagicMock()
5859
self._win32netcon_mock = mock.MagicMock()
60+
self._win32service_mock = mock.MagicMock()
61+
self._winerror_mock = mock.MagicMock()
62+
self._winerror_mock.ERROR_SERVICE_DOES_NOT_EXIST = 0x424
5963
self._wmi_mock = mock.MagicMock()
6064
self._wmi_mock.x_wmi = WMIError
6165
self._moves_mock = mock.MagicMock()
@@ -73,6 +77,8 @@ def setUp(self):
7377
'win32security': self._win32security_mock,
7478
'win32net': self._win32net_mock,
7579
'win32netcon': self._win32netcon_mock,
80+
'win32service': self._win32service_mock,
81+
'winerror': self._winerror_mock,
7682
'wmi': self._wmi_mock,
7783
'six.moves': self._moves_mock,
7884
'six.moves.xmlrpc_client': self._xmlrpc_client_mock,
@@ -884,14 +890,152 @@ def test_get_service(self):
884890
self.assertEqual('fake name', response)
885891

886892
@mock.patch('cloudbaseinit.osutils.windows.WindowsUtils'
887-
'._get_service')
888-
def test_check_service_exists(self, mock_get_service):
889-
mock_get_service.return_value = 'not None'
893+
'._get_service_handle')
894+
def test_check_service(self, mock_get_service_handle):
895+
mock_context_manager = mock.MagicMock()
896+
mock_context_manager.__enter__.return_value = "fake name"
897+
mock_get_service_handle.return_value = mock_context_manager
898+
899+
self.assertTrue(self._winutils.check_service_exists("fake_name"))
900+
901+
@mock.patch('cloudbaseinit.osutils.windows.WindowsUtils'
902+
'._get_service_handle')
903+
def test_check_service_fail(self, mock_get_service_handle):
904+
exc = self._pywintypes_mock.error("ERROR_SERVICE_DOES_NOT_EXIST")
905+
exc.winerror = self._winerror_mock.ERROR_SERVICE_DOES_NOT_EXIST
906+
907+
exc2 = self._pywintypes_mock.error("NOT ERROR_SERVICE_DOES_NOT_EXIST")
908+
exc2.winerror = None
909+
910+
mock_context_manager = mock.MagicMock()
911+
mock_context_manager.__enter__.side_effect = [exc, exc2]
912+
mock_get_service_handle.return_value = mock_context_manager
913+
914+
self.assertFalse(self._winutils.check_service_exists("fake_name"))
915+
self.assertRaises(self._pywintypes_mock.error,
916+
self._winutils.check_service_exists,
917+
"fake_name")
918+
919+
def test_get_service_handle(self):
920+
open_scm = self._win32service_mock.OpenSCManager
921+
open_scm.return_value = mock.sentinel.hscm
922+
open_service = self._win32service_mock.OpenService
923+
open_service.return_value = mock.sentinel.hs
924+
close_service = self._win32service_mock.CloseServiceHandle
925+
args = ("fake_name", mock.sentinel.service_access,
926+
mock.sentinel.scm_access)
927+
928+
with self._winutils._get_service_handle(*args) as hs:
929+
self.assertIs(hs, mock.sentinel.hs)
930+
931+
open_scm.assert_called_with(None, None, mock.sentinel.scm_access)
932+
open_service.assert_called_with(mock.sentinel.hscm, "fake_name",
933+
mock.sentinel.service_access)
934+
close_service.assert_has_calls([mock.call(mock.sentinel.hs),
935+
mock.call(mock.sentinel.hscm)])
936+
937+
@mock.patch('cloudbaseinit.osutils.windows.WindowsUtils'
938+
'._get_service_handle')
939+
def test_set_service_credentials(self, mock_get_service):
940+
self._win32service_mock.SERVICE_CHANGE_CONFIG = mock.sentinel.change
941+
self._win32service_mock.SERVICE_NO_CHANGE = mock.sentinel.no_change
942+
mock_change_service = self._win32service_mock.ChangeServiceConfig
943+
mock_context_manager = mock.MagicMock()
944+
mock_context_manager.__enter__.return_value = mock.sentinel.hs
945+
mock_get_service.return_value = mock_context_manager
946+
947+
self._winutils.set_service_credentials(
948+
mock.sentinel.service, mock.sentinel.user, mock.sentinel.password)
949+
950+
mock_get_service.assert_called_with(mock.sentinel.service,
951+
mock.sentinel.change)
952+
mock_change_service.assert_called_with(
953+
mock.sentinel.hs, mock.sentinel.no_change, mock.sentinel.no_change,
954+
mock.sentinel.no_change, None, None, False, None,
955+
mock.sentinel.user, mock.sentinel.password, None)
956+
957+
@mock.patch('cloudbaseinit.osutils.windows.WindowsUtils'
958+
'._get_service_handle')
959+
def test_get_service_username(self, mock_get_service):
960+
mock_context_manager = mock.MagicMock()
961+
mock_context_manager.__enter__.return_value = mock.sentinel.hs
962+
mock_get_service.return_value = mock_context_manager
963+
mock_query_service = self._win32service_mock.QueryServiceConfig
964+
mock_query_service.return_value = [mock.sentinel.value] * 8
965+
966+
response = self._winutils.get_service_username(mock.sentinel.service)
967+
968+
mock_get_service.assert_called_with(mock.sentinel.service)
969+
mock_query_service.assert_called_with(mock.sentinel.hs)
970+
self.assertIs(response, mock.sentinel.value)
971+
972+
@mock.patch('cloudbaseinit.osutils.windows.WindowsUtils'
973+
'.set_service_credentials')
974+
@mock.patch('cloudbaseinit.osutils.windows.WindowsUtils'
975+
'.set_user_password')
976+
@mock.patch('cloudbaseinit.osutils.windows.WindowsUtils'
977+
'.generate_random_password')
978+
@mock.patch('cloudbaseinit.osutils.windows.WindowsUtils'
979+
'.get_service_username')
980+
@mock.patch('cloudbaseinit.osutils.windows.WindowsUtils'
981+
'.check_service_exists')
982+
def _test_reset_service_password(self, mock_service_exists,
983+
mock_get_username, mock_generate_password,
984+
mock_set_password, mock_set_credentials,
985+
service_exists, service_username):
986+
mock_service_exists.return_value = service_exists
987+
mock_get_username.return_value = service_username
988+
mock_generate_password.return_value = mock.sentinel.password
989+
990+
with self.snatcher:
991+
response = self._winutils.reset_service_password()
890992

891-
response = self._winutils.check_service_exists('fake name')
993+
if not service_exists:
994+
self.assertEqual(
995+
["Service does not exist: %s" % self._winutils._service_name],
996+
self.snatcher.output)
997+
self.assertFalse(response)
998+
return
999+
1000+
if "\\" not in service_username:
1001+
self.assertEqual(
1002+
["Skipping password reset, service running as a built-in "
1003+
"account: %s" % service_username], self.snatcher.output)
1004+
self.assertFalse(response)
1005+
return
8921006

1007+
domain, username = service_username.split('\\')
1008+
if domain != ".":
1009+
self.assertEqual(
1010+
["Skipping password reset, service running as a domain "
1011+
"account: %s" % service_username], self.snatcher.output)
1012+
self.assertFalse(response)
1013+
return
1014+
1015+
mock_set_password.assert_called_once_with(username,
1016+
mock.sentinel.password)
1017+
mock_set_credentials.assert_called_once_with(
1018+
self._winutils._service_name, service_username,
1019+
mock.sentinel.password)
1020+
self.assertEqual(mock_generate_password.call_count, 1)
8931021
self.assertTrue(response)
8941022

1023+
def test_reset_service_password(self):
1024+
self._test_reset_service_password(
1025+
service_exists=True, service_username="EXAMPLE.COM\\username")
1026+
1027+
def test_reset_service_password_no_service(self):
1028+
self._test_reset_service_password(service_exists=False,
1029+
service_username=None)
1030+
1031+
def test_reset_service_password_built_in_account(self):
1032+
self._test_reset_service_password(service_exists=True,
1033+
service_username="username")
1034+
1035+
def test_reset_service_password_domain_account(self):
1036+
self._test_reset_service_password(service_exists=True,
1037+
service_username=".\\username")
1038+
8951039
@mock.patch('cloudbaseinit.osutils.windows.WindowsUtils'
8961040
'._get_service')
8971041
def test_get_service_status(self, mock_get_service):

cloudbaseinit/tests/test_init.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,8 @@ def _test_configure_host(self, mock_get_metadata_service,
217217
self._init.configure_host()
218218
self.assertEqual(expected_logging, snatcher.output)
219219
mock_check_latest_version.assert_called_once_with()
220+
if CONF.reset_service_password:
221+
self.osutils.reset_service_password.assert_called_once_with()
220222
self.osutils.wait_for_boot_completion.assert_called_once_with()
221223
mock_get_metadata_service.assert_called_once_with()
222224
fake_service.get_name.assert_called_once_with()

0 commit comments

Comments
 (0)