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
•Technically reviewed by Anupam Mediratta•Published May 14, 2026•Reviewed 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()

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.


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.

Prevention and further reading

Related Articles

high

file_md5() MD5 Digest Lets Agent Docs Collide (CWE-328)

A first-party maintenance script used `hashlib.md5()` inside a `file_md5(path)` helper to decide whether the per-vendor agent instruction documents in a repository were byte-equivalent. Because MD5 is collision-broken, two meaningfully different documents could be crafted to produce the same digest and pass the "these files are in sync" gate. The fix replaces the MD5 call with `hashlib.sha256()` over the same `path.read_bytes()` input.

high

clean_path() Path Traversal: rel_path Escapes USERDATA Dir

A path traversal flaw in the `clean_path()` helper let a user-controlled `rel_path` value escape the intended USERDATA directory and reach arbitrary files on disk. The function joined path segments without checking the final result, so `../` sequences in route parameters or query strings could be used to read or write files outside the sandboxed storage area. The fix normalizes the path and verifies it still resolves inside the USERDATA root before returning it, raising an error otherwise.

critical

webapp-testing Server Script: Shell Injection via Unquoted Command

A critical command injection vulnerability in a web server testing utility allowed attackers to execute arbitrary shell commands by injecting metacharacters into command-line arguments. The fix removes shell interpretation and safely tokenizes user-supplied commands using `shlex.split()`, while preserving support for legitimate directory-change workflows.

critical

Tung Tung Tracker Admin API: Missing Authentication in `/api/sync`

The Tung Tung Tracker administrative API endpoints (`/api/sync`, `/api/backfill`, `/api/show`, `/api/overlay/hide`) relied solely on localhost origin and a static `X-Tracker` header for protection, allowing any local process to execute privileged operations. The fix introduces proper session-based authentication and a random IPC secret for desktop-to-server communication.

high

esearch() SSRF: requests.get() Trusted Any Host in the URL

A citation format-conversion script used by an AI research skill built HTTP URLs from user-supplied PMIDs, DOIs, arXiv IDs, and free-text queries, then passed the resulting string straight to `requests.get()` with no check that it still pointed at an intended API host. The fix introduces an `ALLOWED_HOSTS` set containing the three real upstream APIs and an `_is_allowed_url()` helper that compares `urlparse(url).hostname` against it before the request is issued. This closes a CWE-918 server-side

critical

boardroom Server Handler Missing Authentication on HTTP Endpoints

The boardroom server's `Handler` class, extending `SimpleHTTPRequestHandler`, exposed sensitive HTTP endpoints without any caller authentication. An attacker could exploit this by making cross-origin requests to internal ports through DNS rebinding, accessing `/alerts.json`, `/dismiss`, and `/events.js` without authorization. The fix adds `is_local_origin()` checks to both `do_GET()` and `do_POST()` methods.