Skip to content

[BUG] Reflective exec via getattr(os,'system') / getattr(builtins,'exec') evades ALL AST rules and the taint tracker #168

Description

@asadbekXodjayev

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions