Back to Blog
medium SEVERITY8 min read

Unsafe Dict Merge in Scapy: How __dict__.update() Opens the Door to Object Injection

A medium-severity vulnerability was discovered and patched in `scapy/scapy_pcp.py`, where an unchecked `self.__dict__.update(entries)` call allowed attackers to inject arbitrary keys—including dangerous dunder attributes—directly into object namespaces via untrusted network packet data. Left unpatched, this flaw could enable object state corruption, method overwriting, and potential remote code execution in applications that parse attacker-controlled packets. The fix introduces proper key filter

O
By Orbis AppSec
Published May 14, 2026Reviewed June 3, 2026

Answer Summary

This is an unsafe dictionary merge vulnerability (CWE-915) in Python's Scapy library (`scapy/scapy_pcp.py`), where `self.__dict__.update(entries)` was called without filtering keys derived from untrusted network packet data. An attacker could craft a malicious PCP packet containing keys like `__class__`, `__reduce__`, or other dunder attributes to overwrite object internals. The fix adds a key validation filter that strips any keys starting or ending with double underscores, as well as private attributes, before the update is applied.

Vulnerability at a Glance

cweCWE-915 (Improperly Controlled Modification of Dynamically-Determined Object Attributes)
fixAdded a key filter to block injection of dunder attributes and private keys before updating the object dictionary
riskObject state corruption, method overwriting, potential remote code execution via crafted network packets
languagePython
root cause`self.__dict__.update(entries)` called with unfiltered keys parsed from untrusted PCP packet data
vulnerabilityUnsafe Dictionary Merge / Object Injection via __dict__.update()

Unsafe Dict Merge in Scapy: How __dict__.update() Opens the Door to Object Injection

Introduction

When building networked applications in Python, it's tempting to use convenient shortcuts to populate object attributes from parsed data. One such shortcut—self.__dict__.update(entries)—looks harmless at first glance. After all, it's just copying some keys and values into an object, right?

Wrong. When the source of those keys and values is an untrusted network packet or external input, this single line of code can become a critical security vulnerability. This post breaks down a real-world vulnerability discovered in scapy/scapy_pcp.py, explains how it could be exploited, and walks through what a proper fix looks like.

Whether you're a seasoned security engineer or a developer just beginning to think about secure coding practices, this vulnerability offers a powerful lesson: never merge untrusted data directly into your object's internal namespace.


The Vulnerability Explained

What Is __dict__.update() and Why Is It Dangerous?

In Python, every object has a __dict__ attribute—a dictionary that stores the object's instance attributes. When you write:

self.__dict__.update(entries)

You are directly merging every key-value pair from entries into the object's attribute namespace. If entries comes from a trusted, controlled source (like a hardcoded config), this is fine. But if entries is derived from a parsed network packet or any other form of external input, you've just handed an attacker the keys to your object.

The Vulnerable Code

At line 37 of scapy/scapy_pcp.py, the vulnerable pattern looked something like this:

# VULNERABLE CODE - Do not use
class PCPMessage:
    def __init__(self, entries):
        # Directly merging externally-supplied dictionary into object namespace
        self.__dict__.update(entries)  # ← Line 37: DANGEROUS

At first glance, this seems like a convenient way to initialize an object from parsed packet fields. In practice, it's a wide-open door for attackers.

How Could It Be Exploited?

Python objects have a number of special "dunder" (double-underscore) attributes and methods that control fundamental behavior:

Attribute What It Controls
__class__ The object's type/class
__init__ The constructor method
__repr__ String representation
__reduce__ Pickle serialization behavior
__module__ The module the class belongs to

Because self.__dict__.update(entries) performs no filtering whatsoever, an attacker who can craft a malicious packet can inject any of these keys.

Consider a crafted packet payload that, when parsed, produces a dictionary like:

malicious_entries = {
    "opcode": 1,          # Legitimate field
    "lifetime": 3600,     # Legitimate field
    "__class__": <some_malicious_class>,  # INJECTED
    "__init__": lambda self: exec("import os; os.system('rm -rf /')"),  # INJECTED
    "_internal_state": "corrupted"  # INJECTED internal attribute
}

When self.__dict__.update(malicious_entries) runs, all of these keys—including the dangerous ones—get written directly into the object.

Real-World Attack Scenario

Imagine a network service that:
1. Listens for incoming PCP (Port Control Protocol) packets
2. Parses each packet using Scapy
3. Creates a PCPMessage object from the parsed fields
4. Passes that object to downstream business logic

An attacker on the network sends a specially crafted packet. The parser extracts fields from it and builds a dictionary. That dictionary gets passed to PCPMessage.__init__(). Because of the unchecked __dict__.update(), the attacker's injected keys overwrite critical object attributes.

Depending on how the application uses the resulting object, this could lead to:

  • Object state corruption: Internal counters, flags, or state variables get overwritten with attacker-controlled values
  • Method hijacking: Overwriting callable attributes causes the application to execute attacker-supplied logic
  • Denial of Service: Injecting oversized payloads or recursive structures exhausts memory/CPU (related to the input size constraints issue noted in V-008)
  • Privilege escalation: In some application architectures, corrupting object state can bypass authorization checks

The Fix

What Needs to Change

The core problem is the complete absence of input validation and key filtering. A proper fix must ensure that:

  1. Only expected keys are accepted — Define an allowlist of valid attribute names
  2. Dunder attributes are explicitly blocked — Never allow __-prefixed keys from external input
  3. Values are validated — Check types and sizes before assignment
  4. Unexpected keys are rejected or logged — Don't silently ignore potentially malicious input

The Secure Pattern (After Fix)

Here is what a hardened version of this code should look like:

# SECURE CODE - After fix
class PCPMessage:
    # Explicit allowlist of valid, expected fields
    ALLOWED_FIELDS = frozenset({
        "opcode",
        "lifetime",
        "result_code",
        "protocol",
        "internal_port",
        "external_port",
    })

    # Maximum allowed size for string/bytes fields
    MAX_FIELD_SIZE = 1024  # bytes

    def __init__(self, entries):
        if not isinstance(entries, dict):
            raise TypeError("entries must be a dictionary")

        for key, value in entries.items():
            # Block dunder and private attributes entirely
            if key.startswith("_"):
                raise ValueError(f"Illegal field name rejected: {key!r}")

            # Only accept explicitly allowlisted keys
            if key not in self.ALLOWED_FIELDS:
                raise ValueError(f"Unknown field rejected: {key!r}")

            # Enforce size constraints on string/bytes values
            if isinstance(value, (str, bytes)) and len(value) > self.MAX_FIELD_SIZE:
                raise ValueError(f"Field {key!r} exceeds maximum allowed size")

            # Safe to set — key is validated and allowlisted
            setattr(self, key, value)

Why This Fix Works

Let's walk through each defense layer:

1. Allowlist validation (ALLOWED_FIELDS)
Instead of accepting any key that arrives in the dictionary, we define exactly which keys are valid. Anything not on the list is rejected immediately. This is the classic allowlist over blocklist principle—far more robust than trying to enumerate all the bad things to block.

2. Dunder/private key blocking
The key.startswith("_") check ensures that even if someone somehow adds a new dunder attribute to Python in the future, it will still be blocked. Defense in depth.

3. setattr() instead of __dict__.update()
Using setattr(self, key, value) respects Python's attribute setting protocol, including any __setattr__ overrides you might add for additional validation. Direct __dict__ manipulation bypasses these safeguards entirely.

4. Size constraints
Enforcing MAX_FIELD_SIZE addresses the related Denial of Service vector (V-008) where oversized payloads could exhaust server resources during processing.

5. Type checking
isinstance(entries, dict) ensures we fail fast if something unexpected is passed in, rather than producing confusing errors downstream.


Prevention & Best Practices

1. Never Use __dict__.update() with Untrusted Data

This is the cardinal rule. If your data source is a network packet, a user-submitted form, an API request, or any other external input, never pass it directly to __dict__.update().

# ❌ NEVER do this with external data
self.__dict__.update(untrusted_data)

# ✅ Always validate first
for key, value in untrusted_data.items():
    if key in ALLOWED_FIELDS:
        setattr(self, key, value)

2. Use Data Validation Libraries

Libraries like Pydantic or marshmallow are purpose-built for this problem. They enforce schemas, validate types, and reject unexpected fields automatically:

from pydantic import BaseModel, Field
from typing import Optional

class PCPMessage(BaseModel):
    opcode: int = Field(..., ge=0, le=255)
    lifetime: int = Field(..., ge=0, le=86400)
    result_code: Optional[int] = Field(None, ge=0, le=255)
    internal_port: int = Field(..., ge=0, le=65535)
    external_port: int = Field(..., ge=0, le=65535)

    class Config:
        # Reject any extra fields not defined in the model
        extra = "forbid"

With Pydantic, attempting to pass __class__ or any other unexpected key will raise a ValidationError automatically.

3. Apply the Principle of Least Privilege to Data

When parsing network packets, only extract and store the fields your application actually needs. Discard everything else at the parsing stage, before it ever reaches your business logic objects.

4. Enforce Input Size Limits Early

Size validation should happen at the network/API boundary, not deep in your business logic:

MAX_PAYLOAD_SIZE = 4096  # bytes

def handle_packet(raw_data: bytes):
    if len(raw_data) > MAX_PAYLOAD_SIZE:
        raise ValueError("Packet exceeds maximum allowed size")
    # ... proceed with parsing

5. Use Static Analysis Tools

Several tools can catch this class of vulnerability automatically:

  • Bandit — Python security linter that flags dangerous patterns including __dict__ manipulation
  • Semgrep — Highly configurable static analysis with rules for injection vulnerabilities
  • PyLint with security plugins
  • Snyk Code — AI-powered SAST that understands context

Run these tools in your CI/CD pipeline so vulnerabilities are caught before they reach production.

6. Know Your CWEs

This vulnerability maps to several well-documented weakness categories:

CWE Description
CWE-915 Improperly Controlled Modification of Dynamically-Determined Object Attributes
CWE-20 Improper Input Validation
CWE-400 Uncontrolled Resource Consumption (DoS aspect)
CWE-94 Improper Control of Generation of Code

Familiarizing yourself with the CWE catalog is an excellent way to recognize vulnerability patterns before you accidentally introduce them.

7. OWASP References

This vulnerability is relevant to several OWASP categories:

  • OWASP Top 10 A03:2021 – Injection: Attacker-controlled data influencing program logic
  • OWASP Top 10 A04:2021 – Insecure Design: Lack of input validation at design level
  • OWASP Top 10 A05:2021 – Security Misconfiguration: Overly permissive data handling

Conclusion

The self.__dict__.update(entries) pattern is a perfect example of how a single convenient line of code can introduce a serious security vulnerability. When entries comes from a network packet—as is the case in Scapy-based applications—you're essentially letting the network tell your object what it is and how it behaves.

The key takeaways from this vulnerability are:

  1. Treat all external data as hostile until proven otherwise
  2. Use allowlists, not blocklists, when validating input keys
  3. Never bypass Python's attribute protocol by writing directly to __dict__
  4. Enforce size constraints early to prevent resource exhaustion
  5. Use schema validation libraries like Pydantic to make safe-by-default data handling easy

Security vulnerabilities in network parsing code are particularly dangerous because they can often be triggered remotely without authentication. Taking the time to add proper input validation isn't just good practice—in network-facing code, it's essential.

The fix applied here is a great template for any Python code that needs to initialize objects from external data sources. Copy the pattern, adapt the allowlist to your domain, and your code will be significantly more resilient against this class of attack.


Found a security vulnerability in your codebase? Consider integrating automated security scanning into your CI/CD pipeline to catch issues like this before they reach production.

Frequently Asked Questions

What is unsafe __dict__.update() injection?

It occurs when a Python object's `__dict__` is updated with user-controlled data without filtering keys, allowing attackers to overwrite any attribute—including special dunder methods—potentially corrupting object state or enabling code execution.

How do you prevent __dict__.update() injection in Python?

Always filter keys before calling `__dict__.update()`. Reject any keys that start or end with double underscores (dunders) and restrict updates to a known allowlist of safe attribute names.

What CWE is unsafe __dict__.update() injection?

CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes, which covers cases where object attributes are set from external input without proper validation.

Is input validation alone enough to prevent __dict__.update() injection?

Input validation helps, but you also need explicit key filtering at the point of the `__dict__.update()` call. Validating packet structure upstream does not protect against a downstream unfiltered dict merge.

Can static analysis detect __dict__.update() injection?

Yes. Static analysis tools like Semgrep can detect patterns where `__dict__.update()` or `setattr()` is called with data derived from external or network sources, flagging them for review.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #50

Related Articles

high

How Memory Exhaustion via Large Comma-Separated Selector Lists happens in Python Soup Sieve and how to fix it

A high-severity memory exhaustion vulnerability (CVE-2026-49476) was discovered in Soup Sieve version 2.8.3, affecting Python applications that parse CSS selectors from user-controlled input. The vulnerability allows attackers to craft malicious selector lists that consume excessive memory, potentially causing denial of service. The fix involves upgrading to soupsieve 2.8.4, which implements proper resource limits on selector parsing.

high

How memory exhaustion via large comma-separated selector lists happens in Python soupsieve and how to fix it

A high-severity memory exhaustion vulnerability (CVE-2026-49476) was discovered in soupsieve 2.8.3, a CSS selector library used by BeautifulSoup in Python. An attacker who could influence CSS selector input could craft large comma-separated selector lists to exhaust system memory, causing denial of service. The fix upgrades soupsieve from 2.8.3 to 2.8.4 in the backend's `uv.lock` dependency file.

high

How URL-Encoded Path Traversal happens in Python nltk.data.load() and how to fix it

CVE-2026-54293 is a high-severity path traversal vulnerability in NLTK's `nltk.data.load()` function that allows attackers to read arbitrary local files by supplying URL-encoded path sequences. The fix pins NLTK to version 3.10.0 or later via a constraint dependency in `pyproject.toml`, preventing the vulnerable version from being resolved transitively through `rouge-score` and `lm-eval`. Because this project is a web service, the vulnerability was directly exploitable by remote attackers withou

critical

How shell command injection happens in Python subprocess and how to fix it

A critical shell command injection vulnerability was discovered in the radare2 build system's `meson.py` file, where `os.system()` was used with an f-string to execute git commands. An attacker who could control the `remote` variable could inject arbitrary shell commands. The fix replaces `os.system()` with `subprocess.call()` using a list of arguments, eliminating shell interpretation entirely.

critical

How command injection happens in Python subprocess and how to fix it

A critical command injection vulnerability was discovered in `docling/models/stages/ocr/tesseract_ocr_cli_model.py`, where user-controlled inputs such as language identifiers, file paths, and the Tesseract executable path were passed directly into `subprocess.run()` calls without validation. An attacker who could influence these values — for example, by supplying a maliciously crafted document or configuration — could inject arbitrary shell arguments or commands. The fix introduces strict input

high

How Authentication Bypass happens in PyJWT and how to fix it

A critical authentication bypass vulnerability in PyJWT 2.12.1 allowed attackers to forge valid JSON Web Tokens, potentially bypassing application authentication mechanisms entirely. The vulnerability was fixed in PyJWT 2.13.0 through security improvements to token validation logic. This fix is essential for any application relying on JWT-based authentication.