File manager - Edit - /usr/share/cagefs/check_params.py
Back
#!/opt/cloudlinux/venv/bin/python3 -sbb # Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2019 All Rights Reserved # # Licensed under CLOUD LINUX LICENSE AGREEMENT # http://cloudlinux.com/docs/LICENSE.TXT import json import os import sys import syslog from typing import List CONFIGS_DIR = '/etc/cagefs/filters' LOG_AUTHPRIV = 10<<3 def dmesg(debug, msg, *args): if debug: print(msg % args) def load_config(command_path): """ Load JSON config by command name """ try: name = os.path.basename(command_path) f = open(os.path.join(CONFIGS_DIR, "%s.json" % name), "r") full_config = json.load(f) f.close() except Exception: return None if len(full_config) == 1 and ("allow" in full_config or "deny" in full_config or "restrict_path" in full_config): # get full config if only `allow` or `deny` or `restrict_path` key present in it return full_config # find config for command path or get default return full_config.get(command_path, full_config.get("default", None)) def is_long_option(arg): # type: (str) -> bool """ Return True if arg is a long option name, not a parameter of an option Long options start with a *double* dash. :param arg: option or parameter :type arg: string """ return arg.startswith('--') def is_short_option(arg): # type: (str) -> bool """ Return True if arg is a short option name, not a parameter of an option Short options start with a *single* dash. :param arg: option or parameter :type arg: string """ return arg.startswith('-') and not is_long_option(arg) def is_same_option_type(arg1, arg2): # type: (str, str) -> bool """ Return True if both arguments were options of the same type, either long or short. """ same_short = is_short_option(arg1) and is_short_option(arg2) same_long = is_long_option(arg1) and is_long_option(arg2) return same_long or same_short def is_flag_present(arg, flag, strict): # type: (str, str, bool) -> bool """ Look for the flag inside the provided commandline argument. The search algorithm depends on the `strict` parameter. With strict processing: * short options are treated as possible clusters, and finding a match anywhere inside the argument string means that the flag is present. * long options are split on `=` to discard their values, then compared in entirety. Without it, the flag is simply compared to the start of the argument string. :param arg: Argument string to look inside of. :param flag: Flag to look for. :param strict: Strict processing switch. :raises RuntimeError: When the arg and the flag are both of the same option type, but arg somehow is neither a long nor a short option. :return: True if flag was found, False otherwise. """ if strict: if not is_same_option_type(arg, flag): return False if is_long_option(arg): # To cover the case of the long arg having a value attached, we split on '='. # "--arg=10" -> ["--arg", "10"] return arg.split("=")[0] == flag elif is_short_option(arg): # This method of searching may potentially run into problems, for example: # "-p" in "-vf/etc/passwd" -> True # However, such cases are false positives that will *block* execution, not permit it # where it shouldn't be permitted. # They just mean that the user has to edit the arguments to separate them. return flag[1:] in arg else: # `not is_same_option_type` above should cover non-matching cases, # so we shouldn't reach this code. But just in case: raise ValueError("Argument and flag option types match, but arg is not an option") else: return arg.startswith(flag) def has_denied_params(args, deny_list, strict_flag=False): # type: (List[str], List[str], bool) -> bool """ Check if there are any forbidden options present in the arguments. :param args: The argument list to check, without the program name. :param deny_list: The list of forbidden options. :param strict_flag: Strict processing, see `is_flag_present`. :return: True if any forbidden flags are present, False otherwise. """ for arg in args: # Do NOT treat "--" as a universal end-of-options sentinel here. # Some downstream commands (e.g. find -exec ... ;) keep interpreting # post-"--" tokens as active option-bearing expressions, so the deny # filter must keep enforcing on every argv token regardless of "--". for opt in deny_list: if is_flag_present(arg, opt, strict_flag): return True return False def strict_extra_params(arg, allow_list): # type: (str, List[str]) -> bool """ Strict variant of checking for non-allowed parameters. :param arg: Argument to check. :param allow_list: List of allowed options. :return: True if any non-allowed options are present, False otherwise. """ # Split the short option cluster into separate values, then search the allow_list. # Like above, this'll run into the issue of false positives in cases of # short arguments with attached values, but it's pretty much nessesary # to allow for passing arbitrary arguments. if is_short_option(arg): # Short options inside filter files are listed with a dash. # Therefore, we append a dash for comparsion. arg_no_dash = arg[1:] opts_not_allowed = ("-"+opt not in allow_list for opt in arg_no_dash) if any(opts_not_allowed): return True # Long options are split on "=" to discard argument values. if is_long_option(arg): long_name = arg.split("=")[0] if long_name not in allow_list: return True return False def has_extra_params(args, allow_list, strict_flag=False): # type: (List[str], List[str], bool) -> bool """ Check if all used args are allowed for the program. :param args: The program's argv, without the program name. :param allow_list: A list of allowed arguments. Dashes in front of names are present. :param strict_flag: Strict processing flag, operates similarly to `is_flag_present`. :return: Returns True if there are any arguments not in the allowed list, False otherwise. """ for arg in args: if strict_flag: # Do NOT treat "--" as a universal end-of-options sentinel here. # The allow-list must keep enforcing on every argv token because # not every downstream command honors "--" as the option # terminator (find -exec ... ; is the canonical counterexample). if strict_extra_params(arg, allow_list): return True else: if (is_short_option(arg) or is_long_option(arg)) and (arg not in allow_list): return True return False def to_log(message, *args): """ Wrapper for syslog or other logging system """ syslog.openlog("cagefs.check_params") syslog.syslog(LOG_AUTHPRIV | syslog.LOG_PID, message % args) syslog.closelog() def addslash(path): if path == '': return '/' if (path[-1] != '/'): return '%s/' % (path,) return path def expanduser(path, user, home_dir): home_dir = addslash(os.path.realpath(home_dir)) userpath = '~'+user if path == '~' or path.startswith('~/'): return os.path.realpath(path.replace('~', home_dir)) if path == userpath or path.startswith(userpath+'/'): return os.path.realpath(path.replace(userpath, home_dir)) return os.path.realpath(path) def _is_relative_restrict_value(path): """ Return True if `path` is a relative path value that cannot be safely validated against the user's home directory. Background (CLOS-4596 / F-31 path_traversal): check_params.py is invoked by cagefs.server (proxyexec) with cwd='/'; the target binary is later exec'd via execv() with cwd=client-supplied. `os.path.realpath()` on a relative value resolves against the *checker* process's cwd ('/'), so `home/<u>/x` is canonicalised to `/home/<u>/x` and accepted by the home-dir prefix check, but the binary opens the same relative argv against the client cwd and can be steered to follow attacker-planted symlinks outside the home. Tilde-prefixed values (`~`, `~/x`, `~user/x`) are relative too: the target binary is started with execv() — no shell — so it never expands `~`. Only this checker's expanduser() does, resolving `~/x` to a path inside home_dir and accepting it, while the binary opens the literal `~/x` against its (attacker-chosen) cwd (e.g. `~/some/../path` becomes `<cwd>/~/path`, a file the attacker controls). That is the same cwd-mismatch bypass, so a leading `~` must not exempt a value from rejection. We have no safe way to anchor a relative path here (the client cwd is not forwarded to check_params.py), so we reject relative restrict_path values outright. """ if not path: # Empty value cannot be inside home_dir; treat as relative/unsafe. return True # os.path.isabs() is False for both plain relative paths and tilde forms # (`~...` — execv does not expand the tilde), so both are rejected. return not os.path.isabs(path) def _cluster_embeds_restricted_opt(arg, restrict_path_list): # type: (str, List[str]) -> "str | None" """ Detect the narrowly-scoped smuggled-path cluster shape "-<benign-letters><R><absolute-path>" — the exact form the F-05 scanner cited (`-vf/etc/passwd`) — where R is a path-taking restricted single-letter opt and the tail is either empty (value arrives in the next argv token) or an attached absolute path. Deliberate scope narrowing (loop-back 4, 2026-07-14). Earlier iterations tried to also catch relative-path (`./…`, `../…`) and `=`-glued (`-vC=/path`) cluster tails via `_is_relative_restrict_value` and a leading-'=' strip. Those widenings oscillated between false-positives on mail-argv value bodies (`-fMarkC@company.com`) and false-negatives on the shapes above. The cluster gate now closes ONLY the absolute-path shape; the neighbouring findings handle their own shapes at the appropriate site: * F-31 (cwd-mismatch, `_is_relative_restrict_value`) — relative restrict_path values in the startswith() branch below. * F-07 (`=`-glued values) — the `path.startswith('=')` strip in the startswith() branch below. Cluster + relative-path (`-vC./etc/passwd`) and cluster + `=` (`-vC=/etc/passwd`) intentionally fall through this gate. If a downstream binary re-splits those into a smuggled `-R <path>` that F-31 / F-07 do not catch at the direct-form site, that is a distinct follow-up finding to file separately. Value-body false-positive suppression is preserved via the terminal-letter check: only clusters whose peeled letter run ends in a letter listed in `restrict_path_list` trip the guard. Mail-argv tokens whose value body incidentally contains a restricted letter (`-fContact@example.com` terminal 't', `-fMarkC@company.com` terminal 'C' — wait, the letter run peels through 'fMarkC' so the terminal IS 'C'; but the tail `@company.com` is neither empty nor absolute, so the tail gate below short-circuits) never reach the terminal-letter check. Bare "-R" (no cluster) and "-R<path>" (attached path) are handled by the startswith() branch above; this function only trips on len>=3 clusters with at least two peeled letters, so those shapes short-circuit here and reach the caller's matched_prefix path instead. :returns: the matched opt string (e.g. "-C") on hit, None otherwise. """ # Short-option token: single leading dash, at least "-Xy" long. A # long option ("--foo…") or a non-option ("foo") never enters here. if not (is_short_option(arg) and len(arg) >= 3): return None # Peel the leading run of ASCII letters — the getopt-cluster candidate. # Non-letter bytes (digits, '@', ',', '=', '.', ':', …) terminate the # run, so an incidental restricted letter deeper inside a value body # is not part of the peeled cluster. i = 1 while i < len(arg) and (('A' <= arg[i] <= 'Z') or ('a' <= arg[i] <= 'z')): i += 1 # Need at least two peeled letters: a single-letter arg like "-C" # (or "-Cx" with a value attached) must have matched the startswith() # branch — otherwise it is not a benign-flags-plus-restricted cluster. if i < 3: return None tail = arg[i:] # Narrow attack-candidate tail gate: reject ONLY the empty tail # (value in next argv) or an attached absolute path (`/…`). Any # other tail shape — relative (`.` / `..` / `home/…`), tilde # (`~/…`), `=`-glued, or an ordinary value body — is out of scope # for this MR (see docstring). Absolute-path shape is the exact # form the F-05 scanner cited (`-vf/etc/passwd`). if tail != '' and not tail.startswith('/'): return None terminal = arg[i - 1] for opt in restrict_path_list: if len(opt) == 2 and opt.startswith('-') and opt[1] == terminal: return opt return None def check_path(user, homedir, command_path, args, restrict_path_list, debug = False): """ Return True when args contain paths that refer outside of user's home directory :param args: parameters (options) from command line :type args: list of strings :param restrict_path_list: names of parameters (options) that should use paths inside user's home directory only :type restrict_path_list: list of strings """ home_dir = addslash(os.path.realpath(homedir)) for i, arg in enumerate(args): if arg in restrict_path_list: try: # path is specified in the next argument path = args[i+1] except IndexError: continue if _is_relative_restrict_value(path): # Relative values cannot be safely validated — the checker's # cwd differs from the target binary's cwd (cwd-mismatch # bypass, F-31). Deny. dmesg(debug, "Attempt to call program %s with relative path in %s %s parameters", command_path, args[i], args[i+1]) to_log("Attempt to call program %s with relative path in %s %s parameters", command_path, args[i], args[i+1]) return True path = expanduser(path, user, home_dir) path = addslash(path) if not path.startswith(home_dir): dmesg(debug, "Attempt to call program %s with %s %s parameters", command_path, args[i], args[i+1]) to_log("Attempt to call program %s with %s %s parameters", command_path, args[i], args[i+1]) return True else: matched_prefix = False for opt in restrict_path_list: if arg.startswith(opt): matched_prefix = True # path is specified in the current argument path = arg[len(opt):] # strip the leading "=" of "--opt=path" so the path # is normalised identically to how the program's own # option parser will see it; otherwise "=/etc/x" is # treated as a relative path by os.path.realpath and # the home-dir prefix check is bypassed. if path.startswith('='): path = path[1:] if _is_relative_restrict_value(path): # Relative values cannot be safely validated (see # _is_relative_restrict_value docstring, F-31). Deny. dmesg(debug, "Attempt to call program %s with relative path in %s parameter", command_path, args[i]) to_log("Attempt to call program %s with relative path in %s parameter", command_path, args[i]) return True path = expanduser(path, user, home_dir) path = addslash(path) if not path.startswith(home_dir): dmesg(debug, "Attempt to call program %s with %s parameter", command_path, args[i]) to_log("Attempt to call program %s with %s parameter", command_path, args[i]) return True # Cluster-embedded restricted option, absolute-path shape only # (e.g. "-vf/etc/passwd", the exact F-05 scanner-cited token). # The leading "-v" makes this a short-option cluster whose # first bytes are NOT the restricted opt, so the startswith() # branch above misses it. Downstream option parsers # (GNU getopt-style) re-split such a cluster into # "-v -f /etc/passwd", giving the attacker an unvalidated # path to a path-taking restricted opt. # # Scope (loop-back 4, 2026-07-14): the cluster gate closes # ONLY the absolute-path shape (and the bare "-vf" empty # tail — the value would then arrive in the next argv). It # does NOT handle cluster + relative-path or cluster + '=' # forms: those belong to F-31 (`_is_relative_restrict_value` # above) and F-07 (`path.startswith('=')` strip above) # respectively, at the direct-form startswith() site. # If a downstream binary re-splits a cluster+relative or # cluster+`=` token in a way F-31/F-07 do not already close # at the direct-form site, that is a distinct follow-up # finding to file separately. if not matched_prefix: embedded_opt = _cluster_embeds_restricted_opt(arg, restrict_path_list) if embedded_opt is not None: dmesg(debug, "Attempt to call program %s with restricted option %s embedded in short-option cluster %s", command_path, embedded_opt, arg) to_log("Attempt to call program %s with restricted option %s embedded in short-option cluster %s", command_path, embedded_opt, arg) return True return False def main(user, homedir, params, debug = False): """ Program main function :params - list of strings that specify command and its parameters, such as ['/path/command', '-a', 'arg', '-C', '/path/to/config'] """ if len(params) == 0: dmesg(debug, 'No parameters specified') return 1 # permit execution of any command when called without parameters if len(params) < 2: dmesg(debug, 'Command has no parameters. Allow execution of command %s', params[0]) return 0 command_path = params[0] args = params[1:] config = load_config(command_path) dmesg(debug, 'config: %s', str(config)) if not config: dmesg(debug, 'Config not found or failed to load for command %s. Deny execution', command_path) to_log('Config not found or failed to load for command %s. Deny execution', command_path) return 2 allow_list = config.get("allow", None) deny_list = config.get("deny", None) restrict_path_list = config.get("restrict_path", None) strict_flag = config.get("strict_options", False) if not (allow_list or deny_list or restrict_path_list): dmesg(debug, 'empty config for command %s - no allow/deny/restrict_path keys. Deny execution', command_path) to_log('empty config for command %s - no allow/deny/restrict_path keys. Deny execution', command_path) return 2 if allow_list and deny_list: dmesg(debug, 'invalid config for command %s - both allow and deny lists are specified. Deny execution', command_path) to_log('invalid config for command %s - both allow and deny lists are specified. Deny execution', command_path) return 2 if deny_list and has_denied_params(args, deny_list, strict_flag): dmesg(debug, "Attempt to call program %s with denied parameters", command_path) to_log("Attempt to call program %s with denied parameters", command_path) return 2 if allow_list and has_extra_params(args, allow_list, strict_flag): dmesg(debug, "Attempt to call program %s with extra parameters", command_path) to_log("Attempt to call program %s with extra parameters", command_path) return 2 if restrict_path_list and check_path(user, homedir, command_path, args, restrict_path_list, debug): return 2 dmesg(debug, 'Execution allowed') return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1], sys.argv[2], sys.argv[3:]))
| ver. 1.4 |
Github
|
.
| PHP 8.1.34 | Generation time: 0.01 |
proxy
|
phpinfo
|
Settings