Split seccomp filters off into low-risk call blacklist and optional whitelist, also set no_new_privs bit on Linux.

This commit is contained in:
Dpeta 2023-01-29 17:29:17 +01:00
parent a4caa2065d
commit 7dafe38c72
3 changed files with 146 additions and 30 deletions

View file

@ -1,12 +1,14 @@
"""
Applies a seccomp filter on Linux via libseccomp's Python bindings.
Has some security benefits, but since Python and Qt use many calls
and are pretty high-level, things are prone to breaking.
"""Functions for Applying a seccomp filter on Linux.
This prevents the process from using certain system calls, which has some security benefits.
Since Python and Qt use many calls and are pretty high-level, things are prone to breaking though.
Certain features like opening links almost always break.
Libseccomp's Python bindings aren't available on the pypi, check your distro's
package manager for python-libseccomp (Arch) or python3-seccomp (Debian).
Uses libseccomp's Python bindings, which aren't available on the pypi.
Check your distro's package manager for python-libseccomp (Arch) or python3-seccomp (Debian).
For info on system calls referencing software that uses seccomp like firejail/flatpak is useful.
Bindings documentation: https://github.com/seccomp/libseccomp/blob/main/src/python/seccomp.pyx
"""
import os
import logging
@ -20,8 +22,36 @@ except ImportError:
pesterchum_log = logging.getLogger("pchumLogger")
def activate_seccomp():
"""Sets the process into seccomp filter mode."""
def load_seccomp_blacklist():
"""Applies a selective seccomp filter only disallows certain risky calls.
Should be less likely to cause issues than a full-on whitelist."""
if seccomp is None:
pesterchum_log.warning(
"Failed to import seccomp, verify you have"
" python-libseccomp (Arch) or python3-seccomp (Debian) installed"
" and aren't running a pyinstaller build."
)
return
# Allows all calls by default.
sec_filter = seccomp.SyscallFilter(defaction=seccomp.ALLOW)
# Deny all socket domains other than AF_UNIX and and AF_INET.
sec_filter.add_rule(seccomp.ERRNO(1), "socket", seccomp.Arg(0, seccomp.LT, 1))
sec_filter.add_rule(seccomp.ERRNO(1), "socket", seccomp.Arg(0, seccomp.GT, 2))
# Fully deny these calls.
for call in CALL_BLACKLIST:
try:
sec_filter.add_rule(seccomp.ERRNO(1), call)
except RuntimeError:
pesterchum_log.warning("Failed to load deny '%s' call seccomp rule.", call)
sec_filter.load()
def load_seccomp_whitelist():
"""Applies a restrictive seccomp filter that disallows most calls by default."""
if seccomp is None:
pesterchum_log.error(
"Failed to import seccomp, verify you have"
@ -32,8 +62,11 @@ def activate_seccomp():
# Violation gives "Operation not permitted".
sec_filter = seccomp.SyscallFilter(defaction=seccomp.ERRNO(1))
# Full access calls
for call in PCHUM_SYSTEM_CALLS:
sec_filter.add_rule(seccomp.ALLOW, call)
for call in CALL_WHITELIST:
try:
sec_filter.add_rule(seccomp.ALLOW, call)
except RuntimeError:
pesterchum_log.warning("Failed to load allow '%s' call seccomp rule.", call)
# Allow only UNIX and INET sockets, see the linux manual and source on socket for reference.
# Arg(0, seccomp.EQ, 1) means argument 0 must be equal to 1, 1 being the value of AF_UNIX.
@ -48,7 +81,7 @@ def activate_seccomp():
seccomp.ALLOW, "tgkill", seccomp.Arg(1, seccomp.EQ, threading.get_native_id())
)
# Allow openat as along as it's not in R+W mode.
# Allow openat as long as it's not in R+W mode.
# We can't really lock down open/openat further without breaking everything,
# even though it's one of the most important calls to lock down.
# Could probably allow breaking out of the sandbox in the case of full-on RCE/ACE.
@ -58,8 +91,8 @@ def activate_seccomp():
# Required for Pesterchum to function normally.
# Pesterchum doesn't call most of these directly, there's a lot of abstraction with Python and Qt.
PCHUM_SYSTEM_CALLS = [
# We don't call most of these directly, there's a lot of abstraction with Python and Qt.
CALL_WHITELIST = [
"access", # Files
"brk", # Required
"clone3", # Required
@ -73,7 +106,7 @@ PCHUM_SYSTEM_CALLS = [
"ftruncate", # Required
"futex", # Required
"getcwd", # Get working directory
"getdents64", # Files? Required.
"getdents", # Files? Required.
"getgid", # Audio
"getpeername", # Connect
"getpid", # Audio
@ -109,6 +142,60 @@ PCHUM_SYSTEM_CALLS = [
"write", # Required
]
# Blacklists of calls we should be able to safely deny.
# Setuid might be useful to drop privileges.
SETUID = [
"setgid",
"setgroups",
"setregid",
"setresgid",
"setresuid",
"setreuid",
"setuid",
]
SYSTEM = [
"acct",
"bpf",
"capset",
"chown",
"chroot",
"fanotify_init",
"fsconfig",
"fsmount",
"fsopen",
"fspick",
"kexec_file_load",
"kexec_load",
"lookup_dcookie",
"mount",
"move_mount",
"nfsservctl",
"open_by_handle_at",
"open_tree",
"perf_event_open",
"personality",
"pidfd_getfd",
"pivot_root",
"pivot_root",
"process_vm_readv",
"process_vm_writev",
"ptrace", # <-- Important
"quotactl",
"reboot",
"rtas",
"s390_runtime_instr",
"setdomainname",
"setfsuid",
"sethostname",
"swapoff",
"swapon",
"sys_debug_setcontext",
"umount",
"umount2",
"vhangup",
]
CALL_BLACKLIST = SETUID + SYSTEM
"""
# Optional
EXTRA_CALLS = [

View file

@ -26,17 +26,19 @@ def isOSXBundle():
def isRoot():
"""Return True if running with elevated privileges."""
# Windows
"""Return True if running as root on Linux/Mac/Misc"""
if hasattr(os, "getuid"):
return not os.getuid() # 0 if root
return False
def isAdmin():
"""Return True if running as Admin on Windows."""
try:
if isWin32():
return ctypes.windll.shell32.IsUserAnAdmin() == 1
except OSError as win_issue:
print(win_issue)
# Unix
if hasattr(os, "getuid"):
return not os.getuid() # 0 if root
# Just assume it's fine otherwise ig
return False

View file

@ -9,6 +9,7 @@ import random
import re
import time
import json
import ctypes
# Set working directory
if os.path.dirname(sys.argv[0]):
@ -110,7 +111,7 @@ parser.add_argument(
)
if ostools.isLinux():
parser.add_argument(
"--seccomp",
"--strict-seccomp",
action="store_true",
help=(
"Restrict the system calls Pesterchum is allowed to make via seccomp."
@ -1383,6 +1384,9 @@ class PesterWindow(MovingWindow):
# Silly guy prevention pt. 2
# We really shouldn't run as root.
self.root_check()
# Set no_new_privs bit on Linux.
if ostools.isLinux():
self.set_no_new_privs()
# karxi: For the record, these are set via commandline arguments. By
# default, they aren't usable any other way - you can't set them via
@ -1693,9 +1697,15 @@ class PesterWindow(MovingWindow):
self.lastCheckPing = None
# Activate seccomp on Linux if enabled
if "seccomp" in options:
if options["seccomp"]:
libseccomp.activate_seccomp()
if ostools.isLinux():
try:
libseccomp.load_seccomp_blacklist() # Load blacklist always
if "strict-seccomp" in options:
if options["strict-seccomp"]:
libseccomp.load_seccomp_whitelist() # Load whitelist if enabled
except RuntimeError:
# We probably tried to interact with a call not available on this kernel.
PchumLog.exception("")
@QtCore.pyqtSlot(QString, QString)
def updateMsg(self, ver, url):
@ -1723,11 +1733,11 @@ class PesterWindow(MovingWindow):
def root_check(self):
"""Raise a warning message box if Pesterchum has admin/root privileges."""
if ostools.isRoot():
if ostools.isRoot() or ostools.isAdmin():
msgbox = QtWidgets.QMessageBox()
msg = (
"Running with elevated privileges, "
"this is potentially a security risk."
"this is a security risk and may break certain features."
"\nThere is no valid reason to run Pesterchum as an administrator or as root."
"\n\nQuit?"
)
@ -1751,10 +1761,27 @@ class PesterWindow(MovingWindow):
self.app.quit() # Optional
sys.exit()
def set_no_new_privs(self):
"""Set no_new_privs bit on Linux, disallows gaining more privileges.
For info see: https://www.kernel.org/doc/html/latest/userspace-api/no_new_privs.html
"""
try:
libc = ctypes.CDLL(None)
# 38 is PR_SET_NO_NEW_PRIVS, see prctl.h in Linux kernel.
# To test, use PR_GET_NO_NEW_PRIVS: libc.prctl(39, 0, 0, 0, 0)
libc.prctl(38, 1, 0, 0, 0)
# Seems to work; strace output with PR_GET_NO_NEW_PRIVS calls:
# prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0) = 0
# prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) = 0
# prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0) = 1
except (ctypes.ArgumentError, OSError):
# Exception is usually not raised even when the call fails.
PchumLog.exception("Failed to set no_new_privs bit.")
@QtCore.pyqtSlot()
def checkPing(self):
"""Check if server is alive on app level,
this function is called every 15sec"""
"""Check if server is alive on app level, this function is called every 15sec"""
# Return without irc
if not hasattr(self.parent, "irc"):
self.lastCheckPing = None
@ -4569,8 +4596,8 @@ class MainProgram(QtCore.QObject):
if args.nohonk:
options["honk"] = False
if ostools.isLinux():
if args.seccomp:
options["seccomp"] = True
if args.strict_seccomp:
options["strict-seccomp"] = True
except Exception as e:
print(e)