Skip to content

debug abort/ref leak calling __init__(<str>, <enc>) on a one-byte bytearray #153419

Closed

Description

@stestagg

Crash report

What happened?

The debug assert is triggered by:

bytearray(1).__init__()

For the refleak, we need:

b = bytearray((1, ))
b.__init__("x", "ascii")

But this hits the above assert before leaking the ref, and in a non-debug build, you leak a 1-byte Bytes object each time, so detecting this is hard without custom c hooks.

The issue is that bytearray___init___impl, if ob_bytes_object is not NULL, calls PyByteArray_Resize((PyObject *)self, 0) to reset the backing bytes object:

if (Py_SIZE(self) != 0) {
/* Empty previous contents (yes, do this first of all!) */
if (PyByteArray_Resize((PyObject *)self, 0) < 0)
return -1;
}

assuming that this resets the bytes object to be the Py_CONSTANT_EMPTY_BYTES object by virtue of being resized to 0. BUT:

PyByteArray_Resize has a fast path for when the requested size < the allocated size, but >= alloc / 2
(when alloc == 1, then 0 >= alloc / 2):

if (size + logical_offset <= alloc) {
/* Current buffer is large enough to host the requested size,
decide on a strategy. */
if (size < alloc / 2) {
/* Major downsize; resize down to exact size */
alloc = size;
}
else {
/* Minor downsize; quick exit */
Py_SET_SIZE(self, size);
/* Add mid-buffer null; end provided by bytes. */
PyByteArray_AS_STRING(self)[size] = '\0'; /* Trailing null */
return 0;
}
}

In this case, the underlying bytes object is re-used with size set to 0, which is not the same as Py_CONSTANT_EMPTY_BYTES.

However, bytearray___init___impl immediately then asserts:
`assert(self->ob_bytes_object == Py_GetConstantBorrowed(Py_CONSTANT_EMPTY_BYTES));
which causes the above issue.

For the refleak, we have to continue a bit further down this function, because we're left with a usable, but empty bytes object, which is fine as-is, but if a unicode object is passed in, there's another fast path for when the string is encoded:

if (_PyObject_IsUniquelyReferenced(encoded)
&& PyBytes_CheckExact(encoded))
{
Py_ssize_t size = Py_SIZE(encoded);
self->ob_bytes_object = encoded;

This unilaterally overwrites the ob_bytes_object which leaks the above bytes object reference. (Code assumes that the ob_bytes_object is the immortal Py_CONSTANT_EMPTY_BYTES, so this would be safe if the assert were true).

I'm not sure of the best fix here, in some ways, none of this is very important, because leaking tiny 1-byte Py_Bytes objects when you call __init__ on an object is pretty minor. and the resize fast path seems reasonable, it would be nice if there were a more explicit/simple bytearray_empty() or _clear() method that could be used with a simpler implementation, or we just remove the assert and decref the ob_bytes_object in the case where a unicode object is passed in?

CPython versions tested on:

CPython main branch

Operating systems tested on:

Linux

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

Python 3.16.0a0 (heads/main-dirty:f932a74, Jul 8 2026, 14:33:20) [Clang 22.1.6 ]

Linked PRs

Activity

  1. added
    type-crashA hard crash of the interpreter, possibly with a core dump
    on Jul 9, 2026
  2. added
    interpreter-core(Objects, Python, Grammar, and Parser dirs)
    3.15bugs and security fixes
    3.16new features, bugs and security fixes
    on Jul 9, 2026
  3. cmaloney commented on Jul 9, 2026

    @cmaloney
    Contributor

    Nice find!

    My leaning here is resize to zero should always replace with the pre-allocated empty bytes object which should make the assert pass and the later code not leak (the empty bytes object is immortal). I can write a patch for that today or happy to review one from you.

  4. stestagg commented on Jul 9, 2026

    @stestagg
    ContributorAuthor

    Thanks @cmaloney! useful feedback, I can have a go at a PR this evening

  5. stestagg commented on Jul 10, 2026

    @stestagg
    ContributorAuthor

    @cmaloney: Ok, I am looking at this, but it's a small can of worms, there are actually three separate bugs I found relating to this:

    1. The original
    2. calling __init__() with an active export also aborts() (or does bad things)
    3. calling bytearray.__new__() never inits the underlyign bytes ob, so we get:
    >>> bytearray().__new__(bytearray).append(1)
    fish: Job 1, './python.exe' terminated by signal SIGSEGV (Address boundary error)
    

    I'm proposing to kill three birds with one refactor here by:

    1. Moving the initial self->ob_bytes_object = Py_GetConstant(Py_CONSTANT_EMPTY_BYTES); etc to a new tp_new impl
    2. keep the PyByteArray_Resize(.., 0) on init
    3. make PyByteArray_Resize explicitly init to Py_CONSTANT_EMPTY_BYTES when size == 0
    4. add a check/exception for the unicode bit to ensure there are no exports before trying to reinit_from_bytes/iconcat because technically there might be an export of the empty bytearray active.
  6. cmaloney commented on Jul 10, 2026

    @cmaloney
    Contributor

    Resize and re-init should raise an exception if there are exports by calling _canresize. Could allow re-init with exactly the same size but would need a use case where that provides enough value for the extra code

    _canresize(PyByteArrayObject *self)
    {
    if (self->ob_exports > 0) {
    PyErr_SetString(PyExc_BufferError,
    "Existing exports of data: object cannot be re-sized");
    return 0;
    }
    return 1;
    }

    In CPython typically only one of __init__ or __new__ is implemented on an object (some details in https://docs.python.org/3/c-api/typeobj.html#c.PyTypeObject.tp_new). It is intentional that ob_bytes is NULL until the first __init__ call. The scope is limited by https://docs.python.org/3/c-api/lifecycle.html.

  7. stestagg commented on Jul 11, 2026

    @stestagg
    ContributorAuthor

    @cmaloney - Thanks for the feedback, I've updated the PR accordingly.

    The tp_new/tp_init thing is interesting, it feels wrong to me to leave strictly internal pointer fields uninitialized, but this new version just ensures that all the code paths expect that!

  8. cmaloney commented on Jul 11, 2026

    @cmaloney
    Contributor

    Yea, it's definitely CPython specific coding patterns. I picked up some of those pieces in https://github.com/python/cpython/pull/140128/changes#r2478883438 which I suspect will be helpful in updating this PR. Definitely recommend doing an AddressSanitizer build / run. Left some comments on the PR.

  9. flameastro commented on Jul 11, 2026

    @flameastro
  10. added 2 commits that reference this issue on Jul 24, 2026
  11. StanFromIreland commented on Jul 28, 2026

    @StanFromIreland
    Member

    This looks complete so I'm closing, please let me know if that's not the case and I'll reopen.

  12. cmaloney commented on Jul 29, 2026

    @cmaloney
    Contributor

    To close out one lingering thread: On the main PR was an ask if we needed to backport to 3.14. No backport to 3.14 is needed.

    In a local branch I backported the test change to 3.14 excluding the take_byes part and it passes there / init problems were introduced with my change to contain a PyBytesObject. Those are in 3.15+ and resolved with this PR + existing backport to 3.15.

  13. vstinner commented on Aug 7, 2026

    @vstinner
    Member

    No backport to 3.14 is needed.

    Oh ok. I planned to backport this fix to stable branches in my TODO list. So cool, I don't have to do it :-)

  14. added a commit that references this issue on Aug 14, 2026
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

    3.15bugs and security fixes3.16new features, bugs and security fixesinterpreter-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