CVE-2021-3020: Improper Privilege Management (CWE-269)
value: An issue was discovered in ClusterLabs Hawk (aka HA Web Konsole) through 2.3.0-15. It ships the binary hawk_invoke (built from tools/hawk_invoke.c), intended to be used as a setuid program. This allows the hacluster user to invoke certain commands as root (with an attempt to limit this to safe combinations). This user is able to execute an interactive "shell" that isn't limited to the commands specified in hawk_invoke, allowing escalation to root.
CWE description: The product does not properly assign, modify, track, or check privileges for an actor, creating an unintended sphere of control for that actor.
CVSS v3 base score: 8.8
Published: 2022-08-26T00:15Z
Repository: https://github.com/ClusterLabs/crmsh
Language: Python
Files changed: crmsh/bootstrap.py, crmsh/utils.py
Commit date: 2021-02-19 17:09:25 +0800
Commit message: Fix: bootstrap: setup authorized ssh access for hacluster(CVE-2020-35459, bsc#1179999; CVE-2021-3020, bsc#1180571)

Diff: diff --git a/crmsh/bootstrap.py b/crmsh/bootstrap.py
index 7aaa8002..e43ec521 100644
--- a/crmsh/bootstrap.py
+++ b/crmsh/bootstrap.py
@@ -29,10 +29,11 @@ from .cibconfig import mkset_obj, cib_factory
 from . import corosync
 from . import tmpfiles
 from . import clidisplay
 from . import term
 from . import lock
+from . import userdir
 
 
 LOG_FILE = "/var/log/crmsh/ha-cluster-bootstrap.log"
 CSYNC2_KEY = "/etc/csync2/key_hagroup"
 CSYNC2_CFG = "/etc/csync2/csync2.cfg"
@@ -40,14 +41,12 @@ COROSYNC_AUTH = "/etc/corosync/authkey"
 SYSCONFIG_SBD = "/etc/sysconfig/sbd"
 SYSCONFIG_FW = "/etc/sysconfig/SuSEfirewall2"
 SYSCONFIG_FW_CLUSTER = "/etc/sysconfig/SuSEfirewall2.d/services/cluster"
 PCMK_REMOTE_AUTH = "/etc/pacemaker/authkey"
 COROSYNC_CONF_ORIG = tmpfiles.create()[1]
-RSA_PRIVATE_KEY = "/root/.ssh/id_rsa"
-RSA_PUBLIC_KEY = "/root/.ssh/id_rsa.pub"
-AUTHORIZED_KEYS_FILE = "/root/.ssh/authorized_keys"
 SERVICES_STOP_LIST = ["corosync-qdevice.service", "corosync.service", "hawk.service"]
+USER_LIST = ["root", "hacluster"]
 
 INIT_STAGES = ("ssh", "ssh_remote", "csync2", "csync2_remote", "corosync", "storage", "sbd", "cluster", "vgfs", "admin", "qdevice")
 
 
 class Context(object):
@@ -93,10 +92,11 @@ class Context(object):
         self.diskless_sbd = None
         self.stage = None
         self.args = None
         self.ui_context = None
         self.interfaces_inst = None
+        self.with_other_user = True
         self.default_nic_list = []
         self.default_ip_list = []
         self.local_ip_list = []
         self.local_network_list = []
         self.rm_list = [SYSCONFIG_SBD, CSYNC2_CFG, corosync.conf(), CSYNC2_KEY,
@@ -1132,26 +1132,70 @@ def mkdirs_owned(dirs, mode=0o777, uid=-1, gid=-1):
 def init_ssh():
     """
     Configure passwordless SSH.
     """
     utils.start_service("sshd.service", enable=True)
-    configure_local_ssh_key()
+    for user in USER_LIST:
+        configure_local_ssh_key(user)
 
 
-def configure_local_ssh_key():
+def key_files(user):
+    """
+    Find home directory for user and return key files with abspath
+    """
+    keyfile_dict = {}
+    home_dir = userdir.gethomedir(user)
+    keyfile_dict['private'] = "{}/.ssh/id_rsa".format(home_dir)
+    keyfile_dict['public'] = "{}/.ssh/id_rsa.pub".format(home_dir)
+    keyfile_dict['authorized'] = "{}/.ssh/authorized_keys".format(home_dir)
+    return keyfile_dict
+
+
+def is_nologin(user):
+    """
+    Check if user's shell is /sbin/nologin
+    """
+    with open("/etc/passwd") as f:
+        return re.search("{}:.*:/sbin/nologin".format(user), f.read())
+
+
+def change_user_shell(user):
+    """
+    To change user's login shell
+    """
+    if user != "root" and is_nologin(user):
+        if not _context.yes_to_all:
+            status("""
+User {} will be changed the login shell as /bin/bash, and
+be setted up authorized ssh access among cluster nodes""".format(user))
+            if not confirm("Continue?"):
+                _context.with_other_user = False
+                return
+        invoke("usermod -s /bin/bash {}".format(user))
+
+
+def configure_local_ssh_key(user="root"):
     """
     Configure ssh rsa key locally
 
-    If /root/.ssh/id_rsa not exist, generate a new one
-    Add /root/.ssh/id_rsa.pub to /root/.ssh/authorized_keys anyway, make sure itself authorized
+    If <home_dir>/.ssh/id_rsa not exist, generate a new one
+    Add <home_dir>/.ssh/id_rsa.pub to <home_dir>/.ssh/authorized_keys anyway, make sure itself authorized
     """
-    if not os.path.exists(RSA_PRIVATE_KEY):
-        status("Generating SSH key")
-        invoke("ssh-keygen -q -f {} -C 'Cluster Internal on {}' -N ''".format(RSA_PRIVATE_KEY, utils.this_node()))
-    if not os.path.exists(AUTHORIZED_KEYS_FILE):
-        open(AUTHORIZED_KEYS_FILE, 'w').close()
-    append_unique(RSA_PUBLIC_KEY, AUTHORIZED_KEYS_FILE)
+    change_user_shell(user)
+
+    private_key, public_key, authorized_file = key_files(user).values()
+    if not os.path.exists(private_key):
+        status("Generating SSH key for {}".format(user))
+        cmd = "ssh-keygen -q -f {} -C 'Cluster Internal on {}' -N ''".format(private_key, utils.this_node())
+        cmd = utils.add_su(cmd, user)
+        rc, _, err = invoke(cmd)
+        if not rc:
+            error("Failed to generate ssh key for {}: {}".format(user, err))
+
+    if not os.path.exists(authorized_file):
+        open(authorized_file, 'w').close()
+    append_unique(public_key, authorized_file)
 
 
 def init_ssh_remote():
     """
     Called by ha-cluster-join
@@ -1869,56 +1913,62 @@ def join_ssh(seed_host):
     """
     if not seed_host:
         error("No existing IP/hostname specified (use -c option)")
 
     utils.start_service("sshd.service", enable=True)
-    configure_local_ssh_key()
-    swap_public_ssh_key(seed_host)
+    for user in USER_LIST:
+        configure_local_ssh_key(user)
+        swap_public_ssh_key(seed_host, user)
 
     # This makes sure the seed host has its own SSH keys in its own
     # authorized_keys file (again, to help with the case where the
     # user has done manual initial setup without the assistance of
     # ha-cluster-init).
     rc, _, err = invoke("ssh root@{} crm cluster init -i {} ssh_remote".format(seed_host, _context.default_nic_list[0]))
     if not rc:
         error("Can't invoke crm cluster init -i {} ssh_remote on {}: {}".format(_context.default_nic_list[0], seed_host, err))
 
 
-def swap_public_ssh_key(remote_node):
+def swap_public_ssh_key(remote_node, user="root"):
     """
     Swap public ssh key between remote_node and local
     """
+    if user != "root" and not _context.with_other_user:
+        return
+
+    _, public_key, authorized_file = key_files(user).values()
     # Detect whether need password to login to remote_node
-    if utils.check_ssh_passwd_need(remote_node):
+    if utils.check_ssh_passwd_need(remote_node, user):
         # If no passwordless configured, paste /root/.ssh/id_rsa.pub to remote_node's /root/.ssh/authorized_keys
-        status("Configuring SSH passwordless with root@{}".format(remote_node))
+        status("Configuring SSH passwordless with {}@{}".format(user, remote_node))
         # After this, login to remote_node is passwordless
-        append_to_remote_file(RSA_PUBLIC_KEY, remote_node, AUTHORIZED_KEYS_FILE)
+        append_to_remote_file(public_key, remote_node, authorized_file)
 
     try:
         # Fetch public key file from remote_node
-        public_key_file_remote = fetch_public_key_from_remote_node(remote_node)
+        public_key_file_remote = fetch_public_key_from_remote_node(remote_node, user)
     except ValueError as err:
         warn(err)
         return
     # Append public key file from remote_node to local's /root/.ssh/authorized_keys
     # After this, login from remote_node is passwordless
     # Should do this step even passwordless is True, to make sure we got two-way passwordless
-    append_unique(public_key_file_remote, AUTHORIZED_KEYS_FILE)
+    append_unique(public_key_file_remote, authorized_file)
 
 
-def fetch_public_key_from_remote_node(node):
+def fetch_public_key_from_remote_node(node, user="root"):
     """
     Fetch public key file from remote node
     Return a temp file contains public key
     Return None if no key exist
     """
 
     # For dsa, might need to add PubkeyAcceptedKeyTypes=+ssh-dss to config file, see
     # https://superuser.com/questions/1016989/ssh-dsa-keys-no-longer-work-for-password-less-authentication
+    home_dir = userdir.gethomedir(user)
     for key in ("id_rsa", "id_ecdsa", "id_ed25519", "id_dsa"):
-        public_key_file = "/root/.ssh/{}.pub".format(key)
+        public_key_file = "{}/.ssh/{}.pub".format(home_dir, key)
         cmd = "ssh -oStrictHostKeyChecking=no root@{} 'test -f {}'".format(node, public_key_file)
         if not invokerc(cmd):
             continue
         _, temp_public_key_file = tmpfiles.create()
         cmd = "scp -oStrictHostKeyChecking=no root@{}:{} {}".format(node, public_key_file, temp_public_key_file)
@@ -2126,11 +2176,12 @@ def setup_passwordless_with_other_nodes(init_node):
     if out in cluster_nodes_list:
         cluster_nodes_list.remove(out)
 
     # Swap ssh public key between join node and other cluster nodes
     for node in cluster_nodes_list:
-        swap_public_ssh_key(node)
+        for user in USER_LIST:
+            swap_public_ssh_key(node, user)
 
 
 def join_cluster(seed_host):
     """
     Cluster configuration for joining node.
@@ -2485,11 +2536,11 @@ def bootstrap_join(context):
     _context.validate_option()
 
     check_tty()
 
     corosync_active = utils.service_is_active("corosync.service")
-    if corosync_active:
+    if corosync_active and _context.stage != "ssh":
         error("Abort: Cluster is currently active. Run this command on a node joining the cluster.")
 
     if not check_prereqs("join"):
         return
 
diff --git a/crmsh/utils.py b/crmsh/utils.py
index 93451c4b..44fa8285 100644
--- a/crmsh/utils.py
+++ b/crmsh/utils.py
@@ -342,10 +342,19 @@ def add_sudo(cmd):
     if config.core.user:
         return "sudo -E -u %s %s" % (config.core.user, cmd)
     return cmd
 
 
+def add_su(cmd, user):
+    """
+    Wrapped cmd with su -c "<cmd>" <user>
+    """
+    if user == "root":
+        return cmd
+    return "su -c \"{}\" {}".format(cmd, user)
+
+
 def chown(path, user, group):
     if isinstance(user, int):
         uid = user
     else:
         import pwd
@@ -2082,16 +2091,17 @@ def get_iplist_corosync_using():
     if rc != 0:
         raise ValueError(err)
     return re.findall(r'id\s*=\s*(.*)', out)
 
 
-def check_ssh_passwd_need(host):
+def check_ssh_passwd_need(host, user="root"):
     """
     Check whether access to host need password
     """
     ssh_options = "-o StrictHostKeyChecking=no -o EscapeChar=none -o ConnectTimeout=15"
     ssh_cmd = "ssh {} -T -o Batchmode=yes {} true".format(ssh_options, host)
+    ssh_cmd = add_su(ssh_cmd, user)
     rc, _, _ = get_stdout_stderr(ssh_cmd)
     return rc != 0
 
 
 def check_port_open(ip, port):

Vulnerable code: RSA_PRIVATE_KEY = "/root/.ssh/id_rsa"
RSA_PUBLIC_KEY = "/root/.ssh/id_rsa.pub"
AUTHORIZED_KEYS_FILE = "/root/.ssh/authorized_keys"
    configure_local_ssh_key()
def configure_local_ssh_key():
    If /root/.ssh/id_rsa not exist, generate a new one
    Add /root/.ssh/id_rsa.pub to /root/.ssh/authorized_keys anyway, make sure itself authorized
    if not os.path.exists(RSA_PRIVATE_KEY):
        status("Generating SSH key")
        invoke("ssh-keygen -q -f {} -C 'Cluster Internal on {}' -N ''".format(RSA_PRIVATE_KEY, utils.this_node()))
    if not os.path.exists(AUTHORIZED_KEYS_FILE):
        open(AUTHORIZED_KEYS_FILE, 'w').close()
    append_unique(RSA_PUBLIC_KEY, AUTHORIZED_KEYS_FILE)
    configure_local_ssh_key()
    swap_public_ssh_key(seed_host)
def swap_public_ssh_key(remote_node):
    if utils.check_ssh_passwd_need(remote_node):
        status("Configuring SSH passwordless with root@{}".format(remote_node))
        append_to_remote_file(RSA_PUBLIC_KEY, remote_node, AUTHORIZED_KEYS_FILE)
        public_key_file_remote = fetch_public_key_from_remote_node(remote_node)
    append_unique(public_key_file_remote, AUTHORIZED_KEYS_FILE)
def fetch_public_key_from_remote_node(node):
        public_key_file = "/root/.ssh/{}.pub".format(key)
        swap_public_ssh_key(node)
    if corosync_active:
def check_ssh_passwd_need(host):
Fixed code: from . import userdir
USER_LIST = ["root", "hacluster"]
        self.with_other_user = True
    for user in USER_LIST:
        configure_local_ssh_key(user)
def key_files(user):
    """
    Find home directory for user and return key files with abspath
    """
    keyfile_dict = {}
    home_dir = userdir.gethomedir(user)
    keyfile_dict['private'] = "{}/.ssh/id_rsa".format(home_dir)
    keyfile_dict['public'] = "{}/.ssh/id_rsa.pub".format(home_dir)
    keyfile_dict['authorized'] = "{}/.ssh/authorized_keys".format(home_dir)
    return keyfile_dict


def is_nologin(user):
    """
    Check if user's shell is /sbin/nologin
    """
    with open("/etc/passwd") as f:
        return re.search("{}:.*:/sbin/nologin".format(user), f.read())


def change_user_shell(user):
    """
    To change user's login shell
    """
    if user != "root" and is_nologin(user):
        if not _context.yes_to_all:
            status("""
User {} will be changed the login shell as /bin/bash, and
be setted up authorized ssh access among cluster nodes""".format(user))
            if not confirm("Continue?"):
                _context.with_other_user = False
                return
        invoke("usermod -s /bin/bash {}".format(user))


def configure_local_ssh_key(user="root"):
    If <home_dir>/.ssh/id_rsa not exist, generate a new one
    Add <home_dir>/.ssh/id_rsa.pub to <home_dir>/.ssh/authorized_keys anyway, make sure itself authorized
    change_user_shell(user)

    private_key, public_key, authorized_file = key_files(user).values()
    if not os.path.exists(private_key):
        status("Generating SSH key for {}".format(user))
        cmd = "ssh-keygen -q -f {} -C 'Cluster Internal on {}' -N ''".format(private_key, utils.this_node())
        cmd = utils.add_su(cmd, user)
        rc, _, err = invoke(cmd)
        if not rc:
            error("Failed to generate ssh key for {}: {}".format(user, err))

    if not os.path.exists(authorized_file):
        open(authorized_file, 'w').close()
    append_unique(public_key, authorized_file)
    for user in USER_LIST:
        configure_local_ssh_key(user)
        swap_public_ssh_key(seed_host, user)
def swap_public_ssh_key(remote_node, user="root"):
    if user != "root" and not _context.with_other_user:
        return

    _, public_key, authorized_file = key_files(user).values()
    if utils.check_ssh_passwd_need(remote_node, user):
        status("Configuring SSH passwordless with {}@{}".format(user, remote_node))
        append_to_remote_file(public_key, remote_node, authorized_file)
        public_key_file_remote = fetch_public_key_from_remote_node(remote_node, user)
    append_unique(public_key_file_remote, authorized_file)
def fetch_public_key_from_remote_node(node, user="root"):
    home_dir = userdir.gethomedir(user)
        public_key_file = "{}/.ssh/{}.pub".format(home_dir, key)
        for user in USER_LIST:
            swap_public_ssh_key(node, user)
    if corosync_active and _context.stage != "ssh":
def add_su(cmd, user):
    """
    Wrapped cmd with su -c "<cmd>" <user>
    """
    if user == "root":
        return cmd
    return "su -c \"{}\" {}".format(cmd, user)


def check_ssh_passwd_need(host, user="root"):
    ssh_cmd = add_su(ssh_cmd, user)