CVE-2023-1326: Improper Privilege Management (CWE-269)
value: A privilege escalation attack was found in apport-cli 2.26.0 and earlier which is similar to CVE-2023-26604. If a system is specially configured to allow unprivileged users to run sudo apport-cli, less is configured as the pager, and the terminal size can be set: a local attacker can escalate privilege. It is extremely unlikely that a system administrator would configure sudo to allow unprivileged users to perform this class of exploit.
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: 7.8
Published: 2023-04-13T23:15Z
Repository: https://github.com/canonical/apport
Language: Python
Files changed: apport/ui.py, bin/apport-cli, tests/integration/test_ui.py
Commit date: 2023-04-12 17:59:45 +0200
Commit message: fix: Do not run sensible-pager as root if using sudo/pkexec

The apport-cli supports view a crash. These features invoke the default
pager, which is likely to be less, other functions may apply.

It can be used to break out from restricted environments by spawning an
interactive system shell. If the binary is allowed to run as superuser
by sudo, it does not drop the elevated privileges and may be used to
access the file system, escalate or maintain privileged access.

apport-cli should normally not be called with sudo or pkexec. In case it
is called via sudo or pkexec execute `sensible-pager` as the original
user to avoid privilege elevation.

Proof of concept:

```
$ sudo apport-cli -c /var/crash/xxx.crash
[...]
Please choose (S/E/V/K/I/C): v
!id
uid=0(root) gid=0(root) groups=0(root)
!done  (press RETURN)
```

This fixes CVE-2023-1326.

Bug: https://launchpad.net/bugs/2016023
Signed-off-by: Benjamin Drung <benjamin.drung@canonical.com>

Diff: diff --git a/apport/ui.py b/apport/ui.py
index 2a88a48c..8f66c798 100644
--- a/apport/ui.py
+++ b/apport/ui.py
@@ -117,19 +117,23 @@ def _get_users_environ(uid: int) -> dict[str, str]:
         for key in ["DBUS_SESSION_BUS_ADDRESS", "XDG_DATA_DIRS"]
         if key in gvfsd_env
     }
 
 
-def run_as_real_user(args: list[str]) -> None:
+def run_as_real_user(
+    args: list[str], *, get_user_env: bool = False, **kwargs
+) -> None:
     """Call subprocess.run as real user if called via sudo/pkexec.
 
     If we are called through pkexec/sudo, determine the real user ID and
     run the command with it to get the user's web browser settings.
+    If get_user_env is set to True, the D-BUS address and XDG_DATA_DIRS
+    is grabbed from a running gvfsd and added to the process environment.
     """
     uid = _get_env_int("SUDO_UID", _get_env_int("PKEXEC_UID"))
     if uid is None or not get_process_user_and_group().is_root():
-        subprocess.run(args, check=False)
+        subprocess.run(args, check=False, **kwargs)
         return
 
     pwuid = pwd.getpwuid(uid)
 
     gid = _get_env_int("SUDO_GID")
@@ -138,19 +142,22 @@ def run_as_real_user(args: list[str]) -> None:
 
     env = {
         k: v
         for k, v in os.environ.items()
         if not k.startswith("SUDO_") and k != "PKEXEC_UID"
-    } | _get_users_environ(uid)
+    }
+    if get_user_env:
+        env |= _get_users_environ(uid)
     env["HOME"] = pwuid.pw_dir
     subprocess.run(
         args,
         check=False,
         env=env,
         user=uid,
         group=gid,
         extra_groups=os.getgrouplist(pwuid.pw_name, gid),
+        **kwargs,
     )
 
 
 def still_running(pid):
     try:
@@ -1815,11 +1822,11 @@ class UserInterface:
         os.setsid()
         os.close(r)
 
         try:
             try:
-                run_as_real_user(["xdg-open", url])
+                run_as_real_user(["xdg-open", url], get_user_env=True)
             except OSError:
                 # fall back to webbrowser
                 webbrowser.open(url, new=True, autoraise=True)
                 sys.exit(0)
         except Exception as error:  # pylint: disable=broad-except
diff --git a/bin/apport-cli b/bin/apport-cli
index 3a672cc0..9908ef09 100755
--- a/bin/apport-cli
+++ b/bin/apport-cli
@@ -183,13 +183,12 @@ class CLIUserInterface(apport.ui.UserInterface):
 
     def ui_update_view(self, stdout=None):
         self.in_update_view = True
         report = self._get_details()
         try:
-            subprocess.run(
+            apport.ui.run_as_real_user(
                 ["/usr/bin/sensible-pager"],
-                check=False,
                 input=report.encode("UTF-8"),
                 stdout=stdout,
             )
         except OSError as error:
             # ignore broken pipe (premature quit)
diff --git a/tests/integration/test_ui.py b/tests/integration/test_ui.py
index 32418268..1dc272b8 100644
--- a/tests/integration/test_ui.py
+++ b/tests/integration/test_ui.py
@@ -2715,11 +2715,11 @@ class T(unittest.TestCase):
             }
             with self._run_test_executable(gvfsd_mock, env=gvfsd_env):
                 with unittest.mock.patch(
                     "subprocess.run", side_effect=mock_run_calls_except_pgrep
                 ) as run_mock:
-                    run_as_real_user(["/bin/true"])
+                    run_as_real_user(["/bin/true"], get_user_env=True)
 
         run_mock.assert_called_with(
             ["/bin/true"],
             check=False,
             env={
@@ -2753,11 +2753,11 @@ class T(unittest.TestCase):
             )
         )
         with unittest.mock.patch(
             "subprocess.run", side_effect=mock_run_calls_except_pgrep
         ) as run_mock:
-            run_as_real_user(["/bin/true"])
+            run_as_real_user(["/bin/true"], get_user_env=True)
 
         run_mock.assert_called_with(
             ["/bin/true"],
             check=False,
             env={"HOME": "/home/testuser"},

Vulnerable code: def run_as_real_user(args: list[str]) -> None:
        subprocess.run(args, check=False)
    } | _get_users_environ(uid)
                run_as_real_user(["xdg-open", url])
            subprocess.run(
                check=False,
                    run_as_real_user(["/bin/true"])
            run_as_real_user(["/bin/true"])
Fixed code: def run_as_real_user(
    args: list[str], *, get_user_env: bool = False, **kwargs
) -> None:
    If get_user_env is set to True, the D-BUS address and XDG_DATA_DIRS
    is grabbed from a running gvfsd and added to the process environment.
        subprocess.run(args, check=False, **kwargs)
    }
    if get_user_env:
        env |= _get_users_environ(uid)
        **kwargs,
                run_as_real_user(["xdg-open", url], get_user_env=True)
            apport.ui.run_as_real_user(
                    run_as_real_user(["/bin/true"], get_user_env=True)
            run_as_real_user(["/bin/true"], get_user_env=True)