Skip to content

vars's mapping proxy can expose internal dictionary even for built-in types #152405

Open

Description

@qexat

Bug report

Bug description:

By creating a type or class with a definition of __eq__ that simply returns the other object, it is possible to access the underlying dictionary behind the mapping proxy returned by vars.

This can be (ab)used to bypass mutability restrictions on built-in types, as the example below shows:

class Evil:
    def __eq__(self, other):
        return other

# less readable version of Evil:
# type("Evil", (), {"__eq__": lambda self, other: other})

# this statement adds a method `prepend` to the built-in type `list`
# 1. vars(list) == Evil() returns the underlying dictionary of `list`
# 2. we can then use the dict[name] = value statement to set the attribute `name`
(vars(list) == Evil())["prepend"] = lambda self, value: self.insert(0, value)

x = [3, 5, 2]
x.prepend(7)

print(x)  # [7, 3, 5, 2]

This can be used to cause a segmentation fault:

# reusing Evil from earlier
(vars(type) == Evil())["__instancecheck__"] = lambda *_: False
# fish: Job 1, 'python' terminated by signal SIGSEGV

No FFI, not a single line of C code required.

This also works on CPython 2.7 and 3.9. It might even be possible to do on even earlier versions.

Disclaimer
I have independently discovered this by myself (no LLM).

CPython versions tested on:

3.14, 3.12, 3.11

Operating systems tested on:

Linux

Linked PRs

Activity

  1. added
    type-bugAn unexpected behavior, bug, or error
    on Jun 27, 2026
  2. added
    interpreter-core(Objects, Python, Grammar, and Parser dirs)
    type-crashA hard crash of the interpreter, possibly with a core dump
    and removed
    type-bugAn unexpected behavior, bug, or error
    on Jun 27, 2026
  3. zainnadeem786 commented on Jun 27, 2026

    @zainnadeem786
    Contributor

    Hi @qexat @sobolevn

    I investigated this on current main and was able to reproduce both the dictionary exposure and built-in type mutation behavior.

    The root cause appears to be in mappingproxy_richcompare(). During comparison, it delegates directly with the proxied mapping as the left operand:

    PyObject_RichCompare(v->mapping, w, op)

    For a non-dict RHS, the dict comparison returns NotImplemented, after which generic rich comparison can call the reflected comparison on the RHS with the raw underlying dict. A user-defined __eq__ can therefore receive and return the internal dictionary.

    I also found a sibling issue in the mappingproxy union path. mappingproxy_or() similarly unwraps the proxy and passes the raw mapping into PyNumber_Or(), allowing __ror__ on the RHS to receive the underlying dict.

    Minimal repros:

    class Evil:
        def __eq__(self, other):
            return other
    
    leaked = vars(list) == Evil()
    print(type(leaked))  # dict
    class EvilRor:
        def __ror__(self, other):
            return other
    
    leaked = vars(list) | EvilRor()
    print(type(leaked))  # dict

    I confirmed that the leaked dict can mutate a built-in type dictionary, and I was also able to reproduce a crash in a subprocess after mutating and then removing an attribute from list.__dict__.

    This seems related to the older MappingProxyType encapsulation discussions such as GH-88004 / bpo-43838 and GH-88762. Based on that prior direction, a copy-backed delegation approach for rich comparison and union seems like the safest option because it avoids passing proxy->mapping to arbitrary user code while preserving existing comparison behavior better than simply returning NotImplemented.

    For completeness, I reproduced this on a locally built CPython debug interpreter (python_d.exe) from the latest main. After mutating the leaked built-in type dictionary through the exposed mapping, the subprocess terminated with a Windows access violation. The attached screenshot shows the resulting crash dialog and serves as supporting evidence alongside the textual reproduction.

    Image

    I’m going to prepare a focused PR with regression tests unless there is a preferred different direction.

  4. da-woods commented on Jun 28, 2026

    @da-woods
    Contributor

    The ability to use comparison operators to get at the underlying mapping is already well-know: #88004 (I'm not sure that the ability to cause a crash with it)

  5. zainnadeem786 commented on Jun 28, 2026

    @zainnadeem786
    Contributor

    Thanks for the reference @da-woods .

    Yes, during my investigation I also found the earlier MappingProxyType discussions (#88004 / bpo-43838 and related issues), which is why I tried to follow the previous copy-backed direction in the PR rather than changing comparison semantics.

    The additional point I observed was that the exposed mapping can be used to mutate built-in type dictionaries, and in my local debug build this led to a reproducible subprocess crash after the mutation. That crash behavior is what motivated the extra regression coverage in the PR.

    If the crash is considered a separate issue from the long-known encapsulation leak, I'm happy to adjust the scope of the PR accordingly.

  6. sobolevn commented on Jun 28, 2026

    @sobolevn
    Member

    I can't reproduce the crash on main:

    class Evil:
        def __eq__(self, other):
            return other
    
    # less readable version of Evil:
    # type("Evil", (), {"__eq__": lambda self, other: other})
    
    (vars(type) == Evil())["__instancecheck__"] = lambda *_: False
    
    # this statement adds a method `prepend` to the built-in type `list`
    # 1. vars(list) == Evil() returns the underlying dictionary of `list`
    # 2. we can then use the dict[name] = value statement to set the attribute `name`
    (vars(list) == Evil())["prepend"] = lambda self, value: self.insert(0, value)
    
    x = [3, 5, 2]
    x.prepend(7)
    
    print(x)  # [7, 3, 5, 2]

    Without a crash - this is not an actual issue :(

  7. zainnadeem786 commented on Jun 28, 2026

    @zainnadeem786
    Contributor

    Thanks for checking. @sobolevn

    I agree that the dictionary exposure itself is already known. The additional observation from my investigation was the crash, but I may not have described the exact reproduction sequence clearly enough.

    The crash did not occur immediately after adding the attribute. In my local Windows debug build, it occurred after mutating the built-in type dictionary, exercising the added attribute, removing it again, and then performing another attribute lookup. That resulted in an access violation in a subprocess.

    I'll reduce this to the smallest reproducible example and verify it again on the current main. If I can't reproduce it reliably, I'm happy to treat the crash separately and keep the PR focused on preventing the mapping exposure through operator dispatch.

  8. zainnadeem786 commented on Jun 28, 2026

    @zainnadeem786
    Contributor

    Thanks for checking. @sobolevn

    I re-ran the investigation from a clean checkout of the latest upstream/main (commit 95bfaffd522675287dc176d75a4d23e708435e6d) to verify the crash independently.

    You're right that the simple reproducer from the issue does not crash on current main; it only demonstrates that the underlying mapping can be exposed and mutated.

    However, I can still reproduce an access violation in a Windows debug build with a slightly different sequence:

    1. Leak the underlying list.__dict__ via vars(list) == Evil().
    2. Inject a temporary method.
    3. Call the injected method successfully.
    4. Remove the injected entry from the leaked dictionary.
    5. Perform another lookup of the removed attribute.

    Using that sequence in a subprocess (with test.support.SuppressCrashReport()), the subprocess exits consistently with 0xC0000005 (Windows fatal exception: access violation).

    So I agree that the original reproducer should not be presented as the crash reproducer. The crash appears to require the additional mutation/cleanup sequence rather than simply exposing the mapping.

    If it would be helpful, I can reduce this further into a standalone minimal reproducer and share it separately.

  9. sobolevn commented on Jun 28, 2026

    @sobolevn
    Member

    can you pease post the actual reproducer here? :)

  10. zainnadeem786 commented on Jun 28, 2026

    @zainnadeem786
    Contributor

    Sure @sobolevn I re-tested this on a clean checkout of current upstream/main.

    Commit tested: 95bfaffd522675287dc176d75a4d23e708435e6d

    The simple “add a list method and call it” example does not crash for me either. The crash requires a successful lookup of the injected name, deletion through the leaked dict, and then another lookup of the same name.

    Here is the smallest reliable subprocess reproducer I found:

    import subprocess
    import sys
    import textwrap
    
    code = r'''
    from test.support import SuppressCrashReport
    
    class Evil:
        def __eq__(self, other):
            return other
    
    with SuppressCrashReport():
        leaked = vars(list) == Evil()
        name = "__mappingproxy_crash_probe__"
        leaked[name] = lambda self: "probe"
        hasattr(list, name)
        del leaked[name]
        hasattr(list, name)
    '''
    
    p = subprocess.run(
        [sys.executable, "-X", "faulthandler", "-c", textwrap.dedent(code)],
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        timeout=10,
    )
    
    print("returncode:", p.returncode)
    print("stdout:")
    print(p.stdout)
    print("stderr:")
    print(p.stderr)

    I tested this on Windows with both debug and release builds from current main.

    Debug build: reproduced 5/5
    Release build: reproduced 5/5

    Both exited with:

    returncode: 3221225477
    

    which is 0xC0000005.

    stderr contains:

    Windows fatal exception: access violation
    

    The required sequence appears to be:

    1. leak the raw list.__dict__ through vars(list) == Evil();
    2. insert a fresh callable into the leaked dict;
    3. perform a successful lookup of that injected name;
    4. delete the injected name through the leaked dict;
    5. look up the same name again.

    The final lookup is where the subprocess crashes for me.

  11. sobolevn commented on Jun 28, 2026

    @sobolevn
    Member

    yes, I can verify that

    class Evil:
        def __eq__(self, other):
            return other
    
    leaked = vars(list) == Evil()
    name = "__mappingproxy_crash_probe__"
    leaked[name] = lambda self: "probe"
    hasattr(list, name)
    del leaked[name]
    hasattr(list, name)

    crashes. Traceback:

    * thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0xdddddddddddddeed)
        frame #0: 0x000000010013d868 python.exe`_Py_type_getattro_stackref(type=0x0000000100601d10, name=<unavailable>, suppress_missing_attribute=0x000000016fdfd51c) at typeobject.c:6617:53 [opt]
       6614     if (!PyStackRef_IsNull(attribute_ref.ref)) {
       6615         /* Implement descriptor functionality, if any */
       6616         PyObject *attr_obj = PyStackRef_AsPyObjectBorrow(attribute_ref.ref);
    -> 6617         descrgetfunc local_get = Py_TYPE(attr_obj)->tp_descr_get;
       6618
       6619         /* Release meta_attribute early since we found in local dict */
       6620         PyStackRef_CLEAR(meta_attribute_ref.ref);
    Target 0: (python.exe) stopped.
    
  12. qexat commented on Jun 28, 2026

    @qexat
    Author

    The ability to use comparison operators to get at the underlying mapping is already well-know: #88004 (I'm not sure that the ability to cause a crash with it)

    ah oops, I completely missed that, apologies

  13. qexat commented on Jun 28, 2026

    @qexat
    Author

    I can't reproduce the crash on main:

    class Evil:
        def __eq__(self, other):
            return other
    
    # less readable version of Evil:
    # type("Evil", (), {"__eq__": lambda self, other: other})
    
    (vars(type) == Evil())["__instancecheck__"] = lambda *_: False
    
    # this statement adds a method `prepend` to the built-in type `list`
    # 1. vars(list) == Evil() returns the underlying dictionary of `list`
    # 2. we can then use the dict[name] = value statement to set the attribute `name`
    (vars(list) == Evil())["prepend"] = lambda self, value: self.insert(0, value)
    
    x = [3, 5, 2]
    x.prepend(7)
    
    print(x)  # [7, 3, 5, 2]

    Without a crash - this is not an actual issue :(

    this part doesn't crash, it's only where it modifies type.__instancecheck__ that it does

  14. qexat commented on Jun 28, 2026

    @qexat
    Author

    @zainnadeem786 I'm sure we all appreciate your willingness to help on the issue but that would be great if you (or what seems to be the output of your LLM/agent) were a bit more concise and discussed a little more thoroughly with other CPython's contributors before jumping into making a PR

  15. zainnadeem786 commented on Jun 28, 2026

    @zainnadeem786
    Contributor

    Thanks for the feedback @qexat , and apologies for the noise.

    You’re right I should have kept the investigation comments more concise and waited for more discussion before opening a PR, especially for an interpreter-core issue.

    I’ll keep future comments shorter and make sure to discuss the intended direction more clearly before preparing larger changes.

  16. da-woods commented on Jun 28, 2026

    @da-woods
    Contributor

    I've put a quick 3rd proposal of how to solve it (copying free...) in #152489. That's mostly for discussion though. It doesn't have any specific tests for example...

  17. zainnadeem786 commented on Jun 28, 2026

    @zainnadeem786
  18. qexat commented on Jul 13, 2026

    @qexat
    Author

    After reading #88004 more carefully, I feel like we can consider this issue a duplicate, and close it. I'll leave the decision to a triage team member.

  19. added 2 commits that reference this issue on Jul 13, 2026
  20. serhiy-storchaka commented on Jul 17, 2026

    @serhiy-storchaka
    Member

    What is the problem? Note that intentionally written malicious code is not the problem which we should fix.

    The decision that was made in #88004 was to leave the code as is.

  21. methane commented on Aug 11, 2026

    @methane
    Member

    #88004 (comment)

    Let's add a note to MappingProxyType and close this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    interpreter-core(Objects, Python, Grammar, and Parser dirs)type-crashA hard crash of the interpreter, possibly with a core dump

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions