mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-22 02:48:15 +00:00
Review flagged this as critical and it is: the composer builds manager.py
by interpolating payload values into source text, /api/install writes
that file into plugins_dir, and the plugin loader imports and executes
it. The ast.parse check further down rejects only *invalid* syntax, and
an injected `import os` is perfectly valid.
Confirmed against the code before this commit. A plugin name carrying a
triple quote closes the module docstring and everything after it becomes
module-level code:
generated manager.py parses: True
injected module-level statements: ['import os', 'PWNED = os.getuid()']
and a geometry value is interpolated verbatim, because the parameter is
annotated int but arrives as JSON:
_compute_pos_expr('0 or __import__("os").system("id")', 'right', 'width')
-> 'width - 0 or __import__("os").system("id")'
generated source: x=0 or __import__("os").system("id"),
Three fixes. _safe_int coerces and optionally clamps, and
_compute_pos_expr applies it to its own argument -- which covers all
twenty-odd call sites at once rather than patching each. _rgb_expr does
the same for the eight colour interpolations, clamping channels to
0-255. Line endpoints and widths go through it too.
For the docstring, _reject_source_breaking refuses a plugin name
containing a quote, backslash or newline. Rejecting rather than escaping:
these are display names, none of that belongs in one, and a clear "Plugin
name cannot contain a double quote." beats silently mangling what the
user typed.
Verified: all three exploits now refused or neutered, and each defence
mutation-checked separately --
coercion removed in _compute_pos_expr -> 8 failed
docstring guard removed -> 5 failed
colour channels interpolated raw -> 13 failed
87 tests, covering seven expression payloads across seven geometry
fields and three colour channels, five literal-breaking names, and the
clean case asserting a normal payload still yields no module-level
statements at all.
One aside: the first version of this test file put the exploit string
in its own module docstring, which closed it and made the file a syntax
error -- the same bug, one level up. It now describes the payload rather
than embedding it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
118 lines
4.5 KiB
Python
118 lines
4.5 KiB
Python
"""The composer generates Python that the plugin loader imports and executes.
|
|
|
|
/api/install writes the generated manager.py into plugins_dir and the loader
|
|
imports it, so anything the payload can splice into that source runs on the
|
|
device. The ast.parse check in _generate_plugin_files rejects only *invalid*
|
|
syntax -- an injected `import os` is perfectly valid and passed it.
|
|
|
|
Two ways in, both confirmed against the code before it was fixed:
|
|
|
|
metadata.name = a name containing a triple-quote, a newline, then
|
|
`import os; PWNED = os.getuid()`, then another triple-quote
|
|
-> closes the module docstring; the rest became module-level statements
|
|
(spelled out rather than shown literally -- writing the payload into
|
|
this docstring closes *this* file's docstring, which is the bug)
|
|
|
|
element x = '0 or __import__("os").system("id")'
|
|
-> f-string interpolated it verbatim: x=0 or __import__("os").system("id")
|
|
"""
|
|
import ast
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from web_interface.blueprints import composer as C # noqa: E402
|
|
|
|
BASE_META = {"id": "test-plugin", "name": "Clock", "author": "a",
|
|
"version": "1.0.0", "description": "d"}
|
|
|
|
#: Values that terminate a Python expression and start a new statement.
|
|
EXPR_PAYLOADS = [
|
|
'0 or __import__("os").system("id")',
|
|
'0);import os;os.system("id");(',
|
|
'__import__("subprocess").run(["id"])',
|
|
"0 if False else exec('x=1')",
|
|
"1e999", "nan", "0x41", "0__0",
|
|
]
|
|
|
|
#: Values that close a string literal in the generated source.
|
|
LITERAL_PAYLOADS = [
|
|
'Clock"""\nimport os; PWNED = os.getuid()\n"""',
|
|
"Clock'''\nimport os\n'''",
|
|
'Clock" + __import__("os").system("id") + "',
|
|
"Clock\\", "Clock\nimport os",
|
|
]
|
|
|
|
|
|
def _payload(**over):
|
|
p = {"metadata": dict(BASE_META), "elements": [], "config_vars": []}
|
|
p["metadata"].update(over.pop("metadata", {}))
|
|
p.update(over)
|
|
return p
|
|
|
|
|
|
def _generated(payload):
|
|
return C._generate_plugin_files(payload)["manager.py"]
|
|
|
|
|
|
def _module_level_code(src):
|
|
"""Statements at module level that are not the docstring/imports/classes."""
|
|
tree = ast.parse(src)
|
|
out = []
|
|
for node in tree.body:
|
|
if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.ImportFrom)):
|
|
continue
|
|
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant):
|
|
continue # the docstring
|
|
out.append(ast.unparse(node))
|
|
return out
|
|
|
|
|
|
@pytest.mark.parametrize("payload", LITERAL_PAYLOADS)
|
|
def test_a_name_that_breaks_out_of_a_literal_is_refused(payload):
|
|
with pytest.raises(C.ComposerInputError):
|
|
_generated(_payload(metadata={"name": payload}))
|
|
|
|
|
|
@pytest.mark.parametrize("evil", EXPR_PAYLOADS)
|
|
@pytest.mark.parametrize("field", ["x", "y", "x0", "y0", "x1", "y1", "lineWidth"])
|
|
def test_a_non_numeric_geometry_value_cannot_reach_the_source(evil, field):
|
|
el = {"type": "line", "id": "l1", "x0": 0, "y0": 0, "x1": 10, "y1": 10,
|
|
"anchor_x": "right", "anchor_y": "bottom"}
|
|
el[field] = evil
|
|
src = _generated(_payload(elements=[el]))
|
|
assert "__import__" not in src, f"{field}={evil!r} reached the generated source"
|
|
assert "os.system" not in src
|
|
assert not _module_level_code(src), \
|
|
f"{field}={evil!r} produced module-level statements: {_module_level_code(src)}"
|
|
|
|
|
|
@pytest.mark.parametrize("evil", EXPR_PAYLOADS)
|
|
@pytest.mark.parametrize("channel", ["r", "g", "b"])
|
|
def test_a_non_numeric_colour_channel_cannot_reach_the_source(evil, channel):
|
|
el = {"type": "text", "id": "t1", "x": 0, "y": 0, "text": "hi",
|
|
"font": "press_start", "r": 255, "g": 255, "b": 255}
|
|
el[channel] = evil
|
|
src = _generated(_payload(elements=[el]))
|
|
assert "__import__" not in src and "os.system" not in src
|
|
assert not _module_level_code(src)
|
|
|
|
|
|
def test_colour_channels_are_clamped_to_a_byte():
|
|
el = {"type": "text", "id": "t1", "x": 0, "y": 0, "text": "hi",
|
|
"font": "press_start", "r": 99999, "g": -5, "b": 128}
|
|
src = _generated(_payload(elements=[el]))
|
|
assert "(255, 0, 128)" in src, "channels were not clamped to 0-255"
|
|
|
|
|
|
def test_the_generated_module_still_has_no_top_level_statements():
|
|
"""The clean case: a normal payload produces only imports and a class."""
|
|
el = {"type": "text", "id": "t1", "x": 4, "y": 4, "text": "hi",
|
|
"font": "press_start", "r": 1, "g": 2, "b": 3}
|
|
src = _generated(_payload(elements=[el]))
|
|
assert not _module_level_code(src)
|
|
assert "(1, 2, 3)" in src
|