File: //usr/share/cagefs/check_params.py
#!/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:]))