Summary
A malicious skill can call an execution sink reflectively through getattr() with a
constant name and receive zero findings from the behavioral AST analyzer (AST1–AST8)
and the taint tracker. The scanner reports the skill as clean while it runs arbitrary
shell commands / arbitrary code.
Steps to reproduce
Scan a skill whose script contains:
import os, builtins
getattr(os, "system")("rm -rf ~") # equivalent to os.system("rm -rf ~")
getattr(builtins, "exec")(payload) # equivalent to exec(payload)
Run static analysis (--no-llm).
Expected
A HIGH finding equivalent to AST5 (os.system) / AST1 (exec).
Actual
No findings. Verified against the current analyzers:
| Input |
Result |
os.system("id") |
AST5 HIGH ✅ |
getattr(os,'system')('id') |
none ❌ |
getattr(builtins,'exec')(payload) |
none ❌ |
Root cause
src/skillspector/nodes/analyzers/behavioral_ast.py:208-210:
elif call_name == "getattr" and len(ast_node.args) >= 2:
if not isinstance(ast_node.args[1], ast.Constant):
_emit("AST7", lineno, end_lineno)
AST7 fires only when the attribute name is non-literal, so a literal
getattr(os, "system") is intentionally skipped. The outer call
getattr(os,'system')(...) has func == ast.Call, so resolve_call_name
(common.py:135-138) returns None and the walker continues at
behavioral_ast.py:172 — AST1/AST5 never run. The taint tracker resolves sinks
the same way (resolve_call_name_typed) and also misses it.
This is an evasion sibling of the import-alias evasion fixed in #115/#87.
Proposed fix
Add AST9 (HIGH): when getattr()'s second argument is a string literal in a small
allowlist of execution-sink names {exec, eval, system, popen, __import__}, emit a
finding. The allowlist is deliberately narrow so benign reflection
(getattr(obj,"name"), getattr(config,"timeout")) stays unflagged — near-zero false
positives. AST7 behavior for non-literal names is unchanged.
PR attached. Happy to adjust the allowlist or message.
Out of scope (follow-up)
The subscript form vars(builtins)['exec'](...) / __builtins__['exec'](...) is a
related but distinct vector and is also currently undetected; proposed as a separate
follow-up to keep this change tight.
Summary
A malicious skill can call an execution sink reflectively through
getattr()with aconstant name and receive zero findings from the behavioral AST analyzer (AST1–AST8)
and the taint tracker. The scanner reports the skill as clean while it runs arbitrary
shell commands / arbitrary code.
Steps to reproduce
Scan a skill whose script contains:
Run static analysis (
--no-llm).Expected
A HIGH finding equivalent to AST5 (os.system) / AST1 (exec).
Actual
No findings. Verified against the current analyzers:
os.system("id")getattr(os,'system')('id')getattr(builtins,'exec')(payload)Root cause
src/skillspector/nodes/analyzers/behavioral_ast.py:208-210:AST7 fires only when the attribute name is non-literal, so a literal
getattr(os, "system")is intentionally skipped. The outer callgetattr(os,'system')(...)hasfunc == ast.Call, soresolve_call_name(
common.py:135-138) returnsNoneand the walkercontinues atbehavioral_ast.py:172— AST1/AST5 never run. The taint tracker resolves sinksthe same way (
resolve_call_name_typed) and also misses it.This is an evasion sibling of the import-alias evasion fixed in #115/#87.
Proposed fix
Add AST9 (HIGH): when
getattr()'s second argument is a string literal in a smallallowlist of execution-sink names
{exec, eval, system, popen, __import__}, emit afinding. The allowlist is deliberately narrow so benign reflection
(
getattr(obj,"name"),getattr(config,"timeout")) stays unflagged — near-zero falsepositives. AST7 behavior for non-literal names is unchanged.
PR attached. Happy to adjust the allowlist or message.
Out of scope (follow-up)
The subscript form
vars(builtins)['exec'](...)/__builtins__['exec'](...)is arelated but distinct vector and is also currently undetected; proposed as a separate
follow-up to keep this change tight.