Python class pollution, as an Opengrep rule you can paste into your own ruleset

Python class pollution turns getattr and setattr into RCE and auth bypass. Here is an Opengrep rule you can paste into your own ruleset to catch it in CI.

Pyrl is the more accurate tool, and it's the reason we know about this bug class at all. We wanted the check in the scanner our customers already run, so we traded some of that accuracy for zero adoption cost.

The talk

We caught the Python class pollution talk at DEF CON. The researchers (class-pollution.github.io, paper) scanned 671K PyPI packages with a CodeQL-based tool called Pyrl and found 47 zero-days. Six became CVEs in projects people actually depend on, including Microsoft's Azure CLI and Google's Mesop.

The bug class comes from two Python features meeting. Every value is an object carrying reachable metadata, __class__, __base__, __dict__, __globals__, and getattr/setattr will happily resolve any name you hand them. If a program uses an attacker-controlled name in a reflective write, the attacker can walk out of the intended object into shared runtime state.

The canonical gadget is a recursive merge, and it looks completely reasonable:

def update(obj, data):
    for key in data:
        val = data[key]
        if isinstance(val, dict):
            update(getattr(obj, key), val)   # traverse
        else:
            setattr(obj, key, val)           # write

A payload of {"__class__": {"__getattribute__": "1337"}} corrupts the class itself. The paper demonstrates RCE by polluting os.environ.BROWSER, authentication bypass by overwriting Django's SECRET_KEY, universal stored XSS via BeautifulSoup's entity map, and DoS by corrupting decorators.

CVE Project Impact
CVE-2025-24049 Azure CLI RCE, token leakage
CVE-2025-24370 django-unicorn RCE, XSS, auth bypass, DoS
CVE-2025-30374 Taipy RCE, XSS, DoS
CVE-2025-30358 Mesop DoS
CVE-2025-6107 ComfyUI DoS
CVE-2025-5150 docarray DoS
CVE-2025-3982 sverchok Token leakage

Why we rebuilt it in Opengrep

Pyrl is the better detector. It uses interprocedural CodeQL dataflow with barrier-node analysis, it works across file boundaries, and it found 47 real bugs at ecosystem scale. Our rule does not match it and won't. The researchers published the tool, so anyone can run it today.

The problem we have isn't with Pyrl, it's with operating it. Running it means running a CodeQL pipeline alongside our existing one: another scanner to install and version, another CI job to keep green, another output format to normalize into our findings model, another queue to triage. For one bug class.

That cost is the same for every security team we talk to. The number of scanners you can operate well is small, and it's smaller than the number you'd like to have. A check that lives in a tool you already run gets used. A check that needs its own tool gets evaluated, piloted, and quietly dropped, not because it's worse, but because nobody owns the second pipeline.

So the question wasn't "can we do better than Pyrl". It was "how much of Pyrl's coverage survives being expressed as an Opengrep rule, and is what's left worth running by default for every customer". The answer to the second part is yes. The first part is where it gets interesting.

How the rule works

The paper's framing does most of the design work. Class pollution is always a get primitive (traverse to an unintended object) plus a set primitive (write to it). That splits cleanly into two rules.

class-pollution, taint mode, ERROR. Attacker-controlled data reaching the attribute or key name of a reflective write. focus-metavariable pins the sink to the name, so setattr(obj, "email", tainted_value) is correctly ignored: a tainted value is a different bug, only a tainted name pollutes. Sinks cover setattr, __setattr__, object/type.__setattr__, __dict__[...] and setdefault, vars()[...], __globals__[...], __kwdefaults__[...], plus the bulk-merge forms __dict__.update() and vars().update().

class-pollution-recursive-merge, search mode, WARNING, audit subcategory. The gadget shape itself: a function that resolves the next object with getattr() and writes to it with setattr(). It covers all three real-world variants, direct recursion, recursion through an intermediate local, and the iterative dotted-path walk.

Sources are keyed on shape, not framework

Most taint rules hardcode flask.request. That covers Flask and Django and nothing else, FastAPI's dominant idiom exposes no request object at all, since untrusted data arrives as a typed handler parameter. So the sources key on shape: request.*/req.*, self.request.* and self.get_argument() for Tornado and Django CBVs, route-decorated handler parameters, Lambda event, and deserialized payloads (json.loads, yaml.safe_load, msgpack, tomllib).

The route-decorator source needs care:

- patterns:
    - pattern-inside: |
        @$APP.$ROUTE(...)
        def $HANDLER(..., $PARAM, ...):
          ...
    - metavariable-regex:
        metavariable: $ROUTE
        regex: ^(route|url|get|post|put|patch|delete|head|options|websocket|api_route|add_route|endpoint)$
    - pattern: $PARAM

The metavariable-regex is load-bearing. Opengrep resolves imported names, so an unconstrained $ROUTE reads @lru_cache(...) as functools.lru_cache and matches it, which taints every parameter of every memoized function. In one file that was 91 tainted lines. Any time a metavariable sits in the position of a dotted name, it matches more than the text suggests.

Guards are sanitizers, not structural exclusions

Every real upstream fix for these CVEs is a guard clause, check, then raise/return/continue:

if name.startswith("_"):
    raise AttributeError(name)
setattr(obj, name, value)

Opengrep's pattern-inside can't express "control flow already left", so the obvious encoding, a function-scoped pattern-not-inside that excludes any function containing a dunder check, is available but wrong. It asks "is there a dunder check anywhere in this function", which means an unrelated check elsewhere in a long function silently masks a genuinely unguarded write. It also breaks under cross-function taint: once the sink is in the callee, the guard sits in the caller and the exclusion stops applying.

A by-side-effect sanitizer avoids both problems by clearing the specific name at the point it's checked:

- by-side-effect: true
  patterns:
    - pattern-either:
        - patterns:
            - pattern-either:
                - pattern: $NAME.startswith($AFFIX)
                - pattern: $NAME.endswith($AFFIX)
            - metavariable-regex:
                metavariable: $AFFIX
                regex: ^\(?\s*['"]_+['"]
        - patterns:
            - pattern: $AFFIX in $NAME
            - metavariable-regex:
                metavariable: $AFFIX
                regex: ^['"]_+['"]$
    - focus-metavariable: $NAME

This is flow- and value-sensitive, and the clean state travels across a call boundary in both directions. It's what lets the rule see Mesop's three real sinks.

The bulk-merge sink is the exception and keeps its function-scoped guards, because a sanitizer on a loop key can't clean the container being merged.

Taint follows function calls, but only within one file

The taint rule opts into Opengrep's intra-file inter-procedural analysis per rule, rather than with the --taint-intrafile CLI flag:

- id: class-pollution
  mode: taint
  options:
    taint_intrafile: true

Per-rule is deliberate. Enabling it globally across our ~489 taint rules added 5 findings on Django, two of them false positives from imprecise sinks in unrelated rules, interprocedural reach doesn't create bad sinks, it amplifies the ones you already have. Cost on Django (2,928 files) was +13% wall clock and +43% CPU. Scoping it to one rule keeps both effects contained.

What "intra-file" buys you: taint crosses function boundaries as long as those functions live in the same file. Put the traversal and the write in different modules and the taint rule is blind. That's the rule's main limitation and there's no rule-level workaround for it, details below.

If you edit this rule

Four Opengrep behaviours account for most of the redundant patterns we had to strip out, and they're easy to trip over again:

  • An argument ... matches zero arguments, so getattr($OBJ, $KEY, ...) already covers getattr($OBJ, $KEY).
  • def matches async def, so a separate async block is dead weight.
  • Taint propagates through calls and indexing, so request.$ANYTHING(...) and sys.argv[...] add nothing over request.$ANYTHING and sys.argv.
  • $SELF matches a module name, so $SELF.request.$ANYTHING already subsumes flask.request.*, bottle.request.* and quart.request.*.

One testing trap, since the rule ships with a fixture you'll want to extend: multi-line sequence patterns anchor their match on the first line of the pattern. A # ok: or # ruleid: annotation placed below that anchor asserts nothing and passes silently.

Verification

Each project scanned from a clean git clone --depth 1 --branch <tag>, then again at its actual upstream fix. Which rule fires matters, so it's broken out per rule.

CVE Project @ tag class-pollution (taint) class-pollution-recursive-merge (audit) After upstream fix
CVE-2025-24370 django-unicorn 0.61.0 0 3 clean at 0.67.0
CVE-2025-30358 mesop v0.14.0 3 1 clean at v0.14.1
CVE-2025-6107 ComfyUI v0.3.40 0 1 clean
CVE-2025-5150 docarray v0.40.1 0 1 still flagged, no fix exists

Zero findings on Django, Flask, and patched django-unicorn 0.67.0.

The django-unicorn scan also surfaced a second merge site in views/utils.py that 0.62.0 didn't patch. Not a false positive, upstream added an _is_public(name) guard there in 0.67.0, and the rule goes quiet at that version. Both pinned as regression cases.

The fixture carries 47 ruleid and 33 ok annotations, each cross-checked against a real scan to confirm it fires, or doesn't, for the right rule, with no unannotated findings.

Where Opengrep falls short

On three of the four CVEs, the ERROR-severity taint rule finds nothing. Every detection comes from the WARNING-severity audit rule. If you filter CI to ERROR only, you get three zeroes. The plain pattern rule is indifferent to file boundaries, which is exactly why it holds up where taint doesn't.

Taint analysis is intra-file only, and cross-file is the remaining ceiling. The rule reasons about one file at a time: within a file it follows taint through as many function calls as you like, but it cannot follow a value from a caller in one module into a callee in another. pydash (CVE-2023-26145) needs exactly that and nothing else will do, base_set lives in helpers.py, every caller is in objects.py.

Cross-file taint isn't something we can express in a rule; it's engine work, and it isn't in a released Opengrep yet. opengrep/opengrep#779 "Interfile taint analysis" is open on the opengrep org's own branch: a project-wide call graph and type index built in-process, taint signatures propagated from callees into callers across files, --taint-interfile-depth bounding the walk. Its opt-in is options: taint_interfile: true, exactly parallel to the taint_intrafile line above, so once that PR merges and ships, cross-file detection is a one-line change plus a re-test for us, and until then it simply doesn't work. We're not building it ourselves, the --pro, --interfile-timeout and --diff-depth flags visible in opengrep --help are inherited stubs that error out, gated behind a proprietary engine Opengrep doesn't ship, and mode: join is rejected outright, so there's no interim workaround worth writing.

For the other two misses, the engine isn't the problem, the source list is.

  • sverchok (CVE-2025-3982), get_object and process are in the same file, so reach is fine. The blocker is the source: self.prop_name, a Blender StringProperty.
  • Azure CLI (CVE-2025-24049), set_properties, _find_property and _update_instance are all in arm.py. Appending a synthetic json.loads driver to the real file makes the rule reach the genuine sink at arm.py:512 through the _split_key_value_pair_get_name_path_find_property chain. The blocker is argparse-derived CLI args not being a recognized source. Its 2.69.0 fix is a hardened getprop helper in util.py, cross-file, and it guards the traversal rather than the sink name, so patched and vulnerable are indistinguishable to us even with a source. The guard itself is if name.startswith('_'): raise AttributeError(name), which the sanitizer above matches exactly. It's the file boundary that defeats us, not the shape.

Three smaller ones:

  • Custom guard predicates are matched by name regex (_is_public, is_allowed, and similar). A guard with an unusual name isn't recognized, pydash 6.0.0's fix calls _raise_if_restricted_key(key), which the regex misses.
  • yaml.safe_load is a source regardless of where the bytes came from, so merging a local config file still fires. Deliberate, the paper's threat model counts data files as attacker-influenceable, and documented in the fixture rather than silently excluded.
  • Inheritance isn't yet supported by Opengrep's cross-function taint, so taint entering through a base-class field is a false negative.

The rule

Two rules, one file. Copy it into your own ruleset as class-pollution.yaml.

Two things to know before you run it. It needs Opengrep 1.20.0 or newer, because that's where taint_intrafile landed. And the taint rule works intra-file only, cross-function within a single file, never across modules, so a gadget whose traversal and write live in separate files won't be caught until Opengrep ships interfile taint (#779).

rules:
  - id: class-pollution
    mode: taint
    options:
      taint_intrafile: true
    languages:
      - python
    severity: ERROR
    metadata:
      category: security
      confidence: HIGH
      cwe:
        - 'CWE-915: Improperly Controlled Modification of Dynamically-Determined Object
          Attributes'
      impact: HIGH
      likelihood: MEDIUM
      owasp:
        - A08:2021 - Software and Data Integrity Failures
      references:
        - https://class-pollution.github.io
        - https://jackfromeast.github.io/assets/Pyrl.pdf
        - https://github.com/jackfromeast/python-class-pollution
        - https://blog.abdulrah33m.com/prototype-pollution-in-python/
      subcategory:
        - vuln
      technology:
        - python
      source: fencer
    message: |
      Attacker-controlled data is used as the attribute or key name of a
      reflective write (class pollution). By supplying dunder names such as
      `__class__`, `__globals__`, or `__init__`, an attacker can traverse out of
      the intended object and overwrite shared runtime state, class attributes,
      module globals, function defaults, leading to RCE, authentication bypass,
      XSS, or denial of service.
      Reject names starting with `_` or validate against an explicit allowlist
      before writing.
    pattern-sources:
      - patterns:
          - pattern-either:
              - patterns:
                  - pattern-either:
                      - pattern: request.$ANYTHING
                      - pattern: req.$ANYTHING
                  - pattern-not: request.build_absolute_uri
              - pattern: $SELF.request.$ANYTHING
              - pattern: $SELF.get_argument(...)
              - patterns:
                  - pattern-inside: |
                      @$APP.$ROUTE(...)
                      def $HANDLER(..., $PARAM, ...):
                        ...
                  - metavariable-regex:
                      metavariable: $ROUTE
                      regex: ^(route|url|get|post|put|patch|delete|head|options|websocket|api_route|add_route|endpoint)$
                  - pattern: $PARAM
              - patterns:
                  - pattern-inside: |
                      def $HANDLER(event, context):
                        ...
                  - pattern: event
              - pattern: json.loads(...)
              - pattern: json.load(...)
              - pattern: yaml.safe_load(...)
              - pattern: msgpack.loads(...)
              - pattern: msgpack.unpackb(...)
              - pattern: tomllib.loads(...)
              - pattern: sys.argv
              - pattern: os.environ[...]
              - pattern: os.environ.get(...)
              - pattern: input(...)
    pattern-sanitizers:
      - patterns:
          - pattern-either:
              - pattern-inside: |
                  if "__" not in $NAME:
                    ...
              - pattern-inside: |
                  if $NAME in $ALLOWED:
                    ...
              - pattern-inside: |
                  if $NAME.isidentifier():
                    ...
          - pattern: $NAME
      - by-side-effect: true
        patterns:
          - pattern-either:
              - patterns:
                  - pattern-either:
                      - pattern: $NAME.startswith($AFFIX)
                      - pattern: $NAME.endswith($AFFIX)
                  - metavariable-regex:
                      metavariable: $AFFIX
                      regex: ^\(?\s*['"]_+['"]
              - patterns:
                  - pattern: $AFFIX in $NAME
                  - metavariable-regex:
                      metavariable: $AFFIX
                      regex: ^['"]_+['"]$
          - focus-metavariable: $NAME
      - pattern: int(...)
    pattern-sinks:
      - patterns:
          - pattern-either:
              - pattern: setattr($OBJ, $NAME, $VAL)
              - pattern: $OBJ.__setattr__($NAME, $VAL)
              - pattern: object.__setattr__($OBJ, $NAME, $VAL)
              - pattern: type.__setattr__($OBJ, $NAME, $VAL)
              - pattern: $OBJ.__dict__[$NAME] = $VAL
              - pattern: $OBJ.__dict__.setdefault($NAME, $VAL)
              - pattern: vars($OBJ)[$NAME] = $VAL
              - pattern: operator.setitem($OBJ.__dict__, $NAME, $VAL)
              - pattern: operator.setitem(vars($OBJ), $NAME, $VAL)
              - pattern: $FUNC.__globals__[$NAME] = $VAL
              - pattern: $FUNC.__kwdefaults__[$NAME] = $VAL
          - focus-metavariable: $NAME
      - patterns:
          - pattern-either:
              - pattern: $OBJ.__dict__.update(...)
              - pattern: vars($OBJ).update(...)
              - pattern: $FUNC.__globals__.update(...)
          - pattern-not-inside: |
              def $F(...):
                ...
                if <... $ANY.startswith("_") ...>:
                  ...
                ...
          - pattern-not-inside: |
              def $F(...):
                ...
                if <... "__" in $ANY ...>:
                  ...
                ...
  - id: class-pollution-recursive-merge
    languages:
      - python
    severity: WARNING
    metadata:
      category: security
      confidence: MEDIUM
      cwe:
        - 'CWE-915: Improperly Controlled Modification of Dynamically-Determined Object
          Attributes'
      impact: HIGH
      likelihood: MEDIUM
      owasp:
        - A08:2021 - Software and Data Integrity Failures
      references:
        - https://class-pollution.github.io
        - https://jackfromeast.github.io/assets/Pyrl.pdf
      subcategory:
        - audit
      technology:
        - python
      source: fencer
    message: |
      Attribute-merge gadget: this function resolves the next object with
      `getattr()` using a caller-supplied name and writes to it with `setattr()`
      (or `__dict__[...]`). If the names ever come from untrusted input, an
      attacker can walk `__class__`/`__globals__`/`__init__` out of the intended
      object and pollute shared runtime state (class pollution).
      Reject names starting with `_` before traversing, or resolve names against
      an explicit allowlist.
    patterns:
      - pattern-inside: |
          def $FUNC(...):
            ...
            setattr($OBJ, $NAME, $VAL)
            ...
      - pattern-either:
          - pattern: $FUNC(..., getattr(...), ...)
          - pattern: |
              $ATTR = getattr($OBJ, $KEY, ...)
              ...
              $FUNC($ATTR, ...)
          - pattern: $OBJ = getattr($OBJ, $KEY, ...)
      - pattern-not: $FUNC(..., getattr($O, "..."), ...)
      - pattern-not: $OBJ = getattr($OBJ, "...", ...)
      - pattern-not: |
          $ATTR = getattr($OBJ, "...", ...)
          ...
          $FUNC($ATTR, ...)
      - pattern-not-inside: |
          def $FUNC(...):
            ...
            if <... $ANY.startswith("_") ...>:
              ...
            ...
      - pattern-not-inside: |
          def $FUNC(...):
            ...
            if <... $ANY.startswith("__") ...>:
              ...
            ...
      - pattern-not-inside: |
          def $FUNC(...):
            ...
            if <... $ANY.endswith("__") ...>:
              ...
            ...
      - pattern-not-inside: |
          def $FUNC(...):
            ...
            if <... "__" in $ANY ...>:
              ...
            ...
      - pattern-not-inside: |
          def $FUNC(...):
            ...
            if $NAME in $ALLOWED:
              ...
            ...
      - pattern-not-inside:
          patterns:
            - pattern: |
                def $FUNC(...):
                  ...
                  if <... $O.$CHECK($ANY) ...>:
                    ...
                  ...
            - metavariable-regex:
                metavariable: $CHECK
                regex: (?i)^_?(is_)?(public|private|allowed|permitted|safe|valid|reserved|restricted)
      - pattern-not-inside:
          patterns:
            - pattern: |
                def $FUNC(...):
                  ...
                  if <... $CHECK($ANY) ...>:
                    ...
                  ...
            - metavariable-regex:
                metavariable: $CHECK
                regex: (?i)^_?(is_)?(public|private|allowed|permitted|safe|valid|reserved|restricted)

Run it as-is:

git clone --depth 1 --branch 0.61.0 https://github.com/adamghill/django-unicorn.git
cd django-unicorn
opengrep scan --config /path/to/class-pollution.yaml .

Three findings on views/utils.py and action_parsers/utils.py. Nothing on 0.67.0.

What we learned

Coverage you run beats coverage you don't. Pyrl finds more than our rule does. But our rule runs on every customer scan by default, and most of the work getting it there had nothing to do with class pollution, it was about Opengrep's matching semantics. A less capable check in the tool you already operate is often the one that actually catches something.

The audit rule earns its place. Pattern rules are unfashionable next to taint analysis, but the plain one carries three of the four CVEs here precisely because it doesn't care about file boundaries. A WARNING-severity gadget finder is worth shipping alongside the ERROR-severity taint rule, not instead of it.

Encode guards as sanitizers wherever you can. Structural exclusions (pattern-not-inside a whole function) look equivalent and aren't: they mask real findings when an unrelated check exists nearby, and they silently stop applying the moment taint crosses a function boundary.

Green tests don't mean live patterns. opengrep test passing tells you the rule fires. It doesn't tell you which of your sixty pattern alternatives did the firing, or whether an annotation is asserting anything at all. Ablate one alternative at a time and rescan.

Verify the zero. A scan that reports no findings because it pointed at the wrong directory looks identical to one that reports no findings because the code is clean. Check paths.scanned against the target's real file count.

Both rules are live in Fencer today, on by default for every customer, no configuration needed. If you don't use Fencer, the YAML above is the whole thing; drop it in your ruleset.

Run it knowing what it does and doesn't reach: cross-function taint within a file, yes; across files, not yet. Interfile analysis only becomes available to us once opengrep#779 is merged and released upstream. When it is, we'll add taint_interfile: true, retest, and pydash becomes testable for the first time. If you extend the source list, or find a guard shape we mishandle, we'd like to hear about it.

And if you want the thorough version rather than the convenient one, run Pyrl against your code. Thanks to the researchers for the paper, the tool, and the talk.

You might also be interested in:

Take Fencer for a spin

See what security handled from code to cloud looks like.
Start a free trial in minutes, or book a demo for a guided tour.