diff --git a/Lib/test/test_capi/test_bytes.py b/Lib/test/test_capi/test_bytes.py index 6c19ad14b7e6c59..a500f2c702db0fb 100644 --- a/Lib/test/test_capi/test_bytes.py +++ b/Lib/test/test_capi/test_bytes.py @@ -1,7 +1,9 @@ import sys +import textwrap import unittest from test import support from test.support import import_helper +from test.support.script_helper import assert_python_failure _testlimitedcapi = import_helper.import_module('_testlimitedcapi') _testcapi = import_helper.import_module('_testcapi') @@ -316,12 +318,18 @@ def test_join(self): bytes_join(b'', NULL) +def get_data_canary(writer): + size = writer.get_size() + 1 + return writer.get_data(size) + + class BaseWriterTest: RESULT_TYPE = NotImplementedError SMALL_BUFFER = 11 # bytes assert SMALL_BUFFER < _testcapi.PyBytesWriter_small_buffer LARGE_BUFFER = _testcapi.PyBytesWriter_small_buffer + 17 # bytes NEW_BYTE = b'\xff' + CANARY_BYTE = b'\xdd' def create_writer(self, alloc=0, string=b''): raise NotImplementedError @@ -344,6 +352,7 @@ def test_get_data(self): # Test PyBytesWriter_GetData() writer = self.create_writer(6) NEW_BYTE = self.NEW_BYTE + CANARY_BYTE = self.CANARY_BYTE self.assertEqual(writer.get_data(), NEW_BYTE * 6) writer.write(0, b'abc') self.assertEqual(writer.get_data(), b'abc' + NEW_BYTE * 3) @@ -357,7 +366,7 @@ def test_get_data(self): writer.write(0, b's' * small) self.assertEqual(writer.get_data(), b's' * small) writer.resize(large) - self.assertEqual(writer.get_data(), b's' * small + NEW_BYTE * (large - small)) + self.assertEqual(writer.get_data(), b's' * small + CANARY_BYTE + NEW_BYTE * (large - small - 1)) writer.write(small, b'L' * (large - small)) self.assertEqual(writer.get_data(), b's' * small + b'L' * (large - small)) @@ -443,6 +452,47 @@ def test_resize(self): writer.resize(_testcapi.PY_SSIZE_T_MAX) self.assertEqual(writer.finish(), b'x' * size) + @unittest.skipUnless(support.Py_DEBUG, 'need debug build') + def test_resize_canary(self): + CANARY_BYTE = self.CANARY_BYTE + for size in (self.SMALL_BUFFER, self.LARGE_BUFFER): + with self.subTest(size=size): + # Truncate the last byte + data = b'x' * size + writer = self.create_writer(size) + writer.write(0, data) + self.assertEqual(get_data_canary(writer), data + CANARY_BYTE) + writer.resize(size - 1) + self.assertEqual(get_data_canary(writer), data[:-1] + CANARY_BYTE) + self.assertEqual(writer.finish(), data[:-1]) + + # Make the buffer empty + writer = self.create_writer(size) + writer.write(0, data) + writer.resize(0) + self.assertEqual(writer.get_data(), b'') + self.assertEqual(writer.finish(), b'') + + @support.nomemtest + def test_resize_error(self): + # Test PyBytesWriter_Resize() error + init = b'x' * self.LARGE_BUFFER + writer = self.create_writer(len(init)) + writer.write(0, init) + size = len(init) + 100 + try: + with self.assertRaises(MemoryError): + _testcapi.set_nomemory(0) + writer.resize(size) + finally: + _testcapi.remove_mem_hooks() + suffix = b'still working' + writer.write_bytes(suffix, -1) + self.assertEqual(writer.finish(), init + suffix) + + # Note: PyBytesWriter_Resize() leaves the buffer unchanged (no resize) + # if the new size is smaller than the allocated size + def test_grow(self): # Test PyBytesWriter_Grow() writer = self.create_writer(0) @@ -461,24 +511,6 @@ def test_grow(self): writer.grow(0) # noop self.assertEqual(writer.finish(), b'number=123') - for size in (self.SMALL_BUFFER, self.LARGE_BUFFER): - with self.subTest(size=size): - # Truncate the last byte - data = b'x' * size - writer = self.create_writer(size) - writer.write(0, data) - self.assertEqual(writer.get_data(), data) - writer.grow(-1) - self.assertEqual(writer.get_data(), data[:-1]) - self.assertEqual(writer.finish(), data[:-1]) - - # Make the buffer empty - writer = self.create_writer(size) - writer.write(0, data) - writer.grow(-size) - self.assertEqual(writer.get_data(), b'') - self.assertEqual(writer.finish(), b'') - # Switch from small buffer to large buffer writer = self.create_writer() small, large = self.SMALL_BUFFER, self.LARGE_BUFFER @@ -500,25 +532,45 @@ def test_grow(self): writer.grow(_testcapi.PY_SSIZE_T_MAX) self.assertEqual(writer.finish(), b'x' * size) + @unittest.skipUnless(support.Py_DEBUG, 'need debug build') + def test_grow_canary(self): + CANARY_BYTE = self.CANARY_BYTE + for size in (self.SMALL_BUFFER, self.LARGE_BUFFER): + with self.subTest(size=size): + # Truncate the last byte + data = b'x' * size + writer = self.create_writer(size) + writer.write(0, data) + self.assertEqual(get_data_canary(writer), data + CANARY_BYTE) + writer.grow(-1) + self.assertEqual(get_data_canary(writer), data[:-1] + CANARY_BYTE) + self.assertEqual(writer.finish(), data[:-1]) + + # Make the buffer empty + writer = self.create_writer(size) + writer.write(0, data) + writer.grow(-size) + self.assertEqual(writer.get_data(), b'') + self.assertEqual(writer.finish(), b'') + @support.nomemtest - def test_resize_error(self): - # Test PyBytesWriter_Resize() error + def test_grow_error(self): + # Test PyBytesWriter_Grow() error init = b'x' * self.LARGE_BUFFER writer = self.create_writer(len(init)) writer.write(0, init) - size = len(init) + 100 try: with self.assertRaises(MemoryError): _testcapi.set_nomemory(0) - writer.resize(size) + writer.grow(100) finally: _testcapi.remove_mem_hooks() suffix = b'still working' writer.write_bytes(suffix, -1) self.assertEqual(writer.finish(), init + suffix) - # Note: PyBytesWriter_Resize() leaves the buffer unchanged (no resize) - # if the new size is smaller than the allocated size + # Note: PyBytesWriter_Grow() leaves the buffer unchanged (no resize) + # if grow is negative. def test_format_i(self): # Test PyBytesWriter_Format() @@ -531,6 +583,49 @@ def test_format_i(self): writer.format_i(b'y=%i', 456) self.assertEqual(writer.finish(), b'x=123, y=456') + @unittest.skipUnless(support.Py_DEBUG, 'need a Python debug build') + def test_canary_byte(self): + small_buffer = _testcapi.PyBytesWriter_small_buffer + large_size = small_buffer * 10 + use_bytearray = (self.RESULT_TYPE == bytearray) + + # Test small buffer and large buffer + for size in (0, self.SMALL_BUFFER, self.LARGE_BUFFER): + with self.subTest(size=size): + code = textwrap.dedent(f""" + from test.support import SuppressCrashReport + import _testcapi + size = {size} + # Add an extra '#' byte to trigger a buffer overflow + data = b'x' * size + b'#' + use_bytearray = {use_bytearray} + writer = _testcapi.PyBytesWriter(size, use_bytearray) + with SuppressCrashReport(): + writer.write(0, data, check=False) + writer.finish() + """) + proc = assert_python_failure('-c', code) + self.assertIn(b'Buffer overflow detected in PyBytesWriter', + proc.err) + self.assertIn(f'at position {size}'.encode(), + proc.err) + + @unittest.skipUnless(support.Py_DEBUG, 'need debug build') + def test_get_data_canary(self): + # Test PyBytesWriter_GetData() + NEW_BYTE = self.NEW_BYTE + CANARY_BYTE = self.CANARY_BYTE + + writer = self.create_writer(6) + self.assertEqual(get_data_canary(writer), + NEW_BYTE * 6 + CANARY_BYTE) + writer.write(0, b'abc') + self.assertEqual(get_data_canary(writer), + b'abc' + NEW_BYTE * 3 + CANARY_BYTE) + writer.write(3, b'123') + self.assertEqual(get_data_canary(writer), + b'abc123' + CANARY_BYTE) + class BytesWriterTest(BaseWriterTest, unittest.TestCase): RESULT_TYPE = bytes diff --git a/Misc/NEWS.d/next/C_API/2026-09-04-16-41-07.gh-issue-156939.bKaQuE.rst b/Misc/NEWS.d/next/C_API/2026-09-04-16-41-07.gh-issue-156939.bKaQuE.rst new file mode 100644 index 000000000000000..57c9a0ab46e9046 --- /dev/null +++ b/Misc/NEWS.d/next/C_API/2026-09-04-16-41-07.gh-issue-156939.bKaQuE.rst @@ -0,0 +1,2 @@ +When Python is built in debug mode, :c:type:`PyBytesWriter` now detects +buffer overflow. Patch by Victor Stinner. diff --git a/Modules/_testcapi/bytes.c b/Modules/_testcapi/bytes.c index 83249a21c5a3f22..b4468dff0d0ba0d 100644 --- a/Modules/_testcapi/bytes.c +++ b/Modules/_testcapi/bytes.c @@ -135,22 +135,29 @@ writer_check(WriterObject *self) static PyObject* -writer_write(PyObject *self_raw, PyObject *args) +writer_write(PyObject *self_raw, PyObject *args, PyObject *kwargs) { WriterObject *self = (WriterObject *)self_raw; if (writer_check(self) < 0) { return NULL; } + static char *kwlist[] = {"pos", "str", "check", NULL}; Py_ssize_t pos, size; char *str; - if (!PyArg_ParseTuple(args, "ny#", &pos, &str, &size)) { + int check = 1; + if (!PyArg_ParseTupleAndKeywords(args, kwargs, + "ny#|i", kwlist, + &pos, &str, &size, &check)) { return NULL; } - if (pos < 0 || (pos + size) > PyBytesWriter_GetSize(self->writer)) { - PyErr_SetString(PyExc_ValueError, "invalid position or size"); - return NULL; + // Use check=0 to trigger a buffer overflow for example + if (check) { + if (pos < 0 || (pos + size) > PyBytesWriter_GetSize(self->writer)) { + PyErr_SetString(PyExc_ValueError, "invalid position or size"); + return NULL; + } } char *data = PyBytesWriter_GetData(self->writer); @@ -168,7 +175,7 @@ writer_write_bytes(PyObject *self_raw, PyObject *args) return NULL; } - char *bytes; + const char *bytes; Py_ssize_t unused_size, size; if (!PyArg_ParseTuple(args, "y#n", &bytes, &unused_size, &size)) { return NULL; @@ -245,15 +252,19 @@ writer_grow(PyObject *self_raw, PyObject *args) static PyObject* -writer_get_data(PyObject *self_raw, PyObject *Py_UNUSED(args)) +writer_get_data(PyObject *self_raw, PyObject *args) { WriterObject *self = (WriterObject *)self_raw; if (writer_check(self) < 0) { return NULL; } - const char *data = PyBytesWriter_GetData(self->writer); Py_ssize_t size = PyBytesWriter_GetSize(self->writer); + if (!PyArg_ParseTuple(args, "|n", &size)) { + return NULL; + } + + const char *data = PyBytesWriter_GetData(self->writer); return PyBytes_FromStringAndSize(data, size); } @@ -305,12 +316,12 @@ writer_finish_with_size(PyObject *self_raw, PyObject *args) static PyMethodDef writer_methods[] = { - {"write", _PyCFunction_CAST(writer_write), METH_VARARGS}, + {"write", _PyCFunction_CAST(writer_write), METH_VARARGS | METH_KEYWORDS}, {"write_bytes", _PyCFunction_CAST(writer_write_bytes), METH_VARARGS}, {"format_i", _PyCFunction_CAST(writer_format_i), METH_VARARGS}, {"resize", _PyCFunction_CAST(writer_resize), METH_VARARGS}, {"grow", _PyCFunction_CAST(writer_grow), METH_VARARGS}, - {"get_data", _PyCFunction_CAST(writer_get_data), METH_NOARGS}, + {"get_data", _PyCFunction_CAST(writer_get_data), METH_VARARGS}, {"get_size", _PyCFunction_CAST(writer_get_size), METH_NOARGS}, {"finish", _PyCFunction_CAST(writer_finish), METH_NOARGS}, {"finish_with_size", _PyCFunction_CAST(writer_finish_with_size), METH_VARARGS}, diff --git a/Modules/fcntlmodule.c b/Modules/fcntlmodule.c index e6a40ffc5a26144..5dd3df9bb408f0c 100644 --- a/Modules/fcntlmodule.c +++ b/Modules/fcntlmodule.c @@ -121,13 +121,14 @@ fcntl_fcntl_impl(PyObject *module, int fd, int code, PyObject *arg) return PyBytes_FromStringAndSize(buf, len); } else { - PyBytesWriter *writer = PyBytesWriter_Create(len); + PyBytesWriter *writer = PyBytesWriter_Create(len + GUARDSZ); if (writer == NULL) { PyBuffer_Release(&view); return NULL; } char *ptr = PyBytesWriter_GetData(writer); memcpy(ptr, view.buf, len); + memcpy(ptr + len, guard, GUARDSZ); PyBuffer_Release(&view); do { @@ -142,7 +143,7 @@ fcntl_fcntl_impl(PyObject *module, int fd, int code, PyObject *arg) PyBytesWriter_Discard(writer); return NULL; } - if (ptr[len] != '\0') { + if (memcmp(ptr + len, guard, GUARDSZ) != 0) { PyErr_SetString(PyExc_SystemError, "Memory corruption in fcntl() due to " "buffer overflow. " @@ -151,7 +152,8 @@ fcntl_fcntl_impl(PyObject *module, int fd, int code, PyObject *arg) PyBytesWriter_Discard(writer); return NULL; } - return PyBytesWriter_Finish(writer); + // Truncate the trailing guard bytes + return PyBytesWriter_FinishWithSize(writer, len); } #undef FCNTL_BUFSZ } @@ -316,13 +318,14 @@ fcntl_ioctl_impl(PyObject *module, int fd, unsigned long code, PyObject *arg, return PyBytes_FromStringAndSize(buf, len); } else { - PyBytesWriter *writer = PyBytesWriter_Create(len); + PyBytesWriter *writer = PyBytesWriter_Create(len + GUARDSZ); if (writer == NULL) { PyBuffer_Release(&view); return NULL; } char *ptr = PyBytesWriter_GetData(writer); memcpy(ptr, view.buf, len); + memcpy(ptr + len, guard, GUARDSZ); PyBuffer_Release(&view); do { @@ -337,7 +340,7 @@ fcntl_ioctl_impl(PyObject *module, int fd, unsigned long code, PyObject *arg, PyBytesWriter_Discard(writer); return NULL; } - if (ptr[len] != '\0') { + if (memcmp(ptr + len, guard, GUARDSZ) != 0) { PyErr_SetString(PyExc_SystemError, "Memory corruption in ioctl() due to " "buffer overflow. " @@ -346,7 +349,8 @@ fcntl_ioctl_impl(PyObject *module, int fd, unsigned long code, PyObject *arg, PyBytesWriter_Discard(writer); return NULL; } - return PyBytesWriter_Finish(writer); + // Truncate the trailing guard bytes + return PyBytesWriter_FinishWithSize(writer, len); } #undef IOCTL_BUFSZ } diff --git a/Objects/bytesobject.c b/Objects/bytesobject.c index deddb8d959b157d..10be2791a38e538 100644 --- a/Objects/bytesobject.c +++ b/Objects/bytesobject.c @@ -3586,8 +3586,13 @@ _PyBytes_RepeatBuffer(char* dest, Py_ssize_t len_dest, // --- PyBytesWriter API ----------------------------------------------------- +// Byte pattern to fill newly allocated bytes #define PyBytesWrite_NEW_BYTE 0xff +// Use a value different than NUL (0) to be able to detect overflow writing +// one extra NUL byte which is a common error. +#define PyBytesWriter_CANARY_BYTE PYMEM_DEADBYTE + static inline char* byteswriter_data(PyBytesWriter *writer) { @@ -3599,7 +3604,8 @@ static inline Py_ssize_t byteswriter_allocated(PyBytesWriter *writer) { if (writer->obj == NULL) { - return sizeof(writer->small_buffer); + // Reserve the last byte for the canary byte + return sizeof(writer->small_buffer) - 1; } else if (writer->use_bytearray) { return PyByteArray_GET_SIZE(writer->obj); @@ -3610,6 +3616,30 @@ byteswriter_allocated(PyBytesWriter *writer) } +#ifdef Py_DEBUG +static void +byteswriter_check_canary_byte(PyBytesWriter *writer) +{ + const unsigned char *data = (const unsigned char*)byteswriter_data(writer); + unsigned char canary = data[writer->size]; + if (canary != PyBytesWriter_CANARY_BYTE) { + _Py_FatalErrorFormat(__func__, + "Buffer overflow detected in PyBytesWriter %p " + "at position %zd", + writer, writer->size); + } +} + + +static void +byteswriter_write_canary_byte(PyBytesWriter *writer) +{ + unsigned char *data = (unsigned char*)byteswriter_data(writer); + data[writer->size] = PyBytesWriter_CANARY_BYTE; +} +#endif + + #ifdef MS_WINDOWS /* On Windows, overallocate by 50% is the best factor */ # define OVERALLOCATE_FACTOR 2 @@ -3718,6 +3748,7 @@ byteswriter_create(Py_ssize_t size, int use_bytearray) #ifdef Py_DEBUG memset(byteswriter_data(writer), PyBytesWrite_NEW_BYTE, byteswriter_allocated(writer)); + byteswriter_write_canary_byte(writer); #endif return writer; } @@ -3763,6 +3794,19 @@ PyBytesWriter_FinishWithSize(PyBytesWriter *writer, Py_ssize_t size) goto error; } +#ifdef Py_DEBUG + // Check for buffer overflow + byteswriter_check_canary_byte(writer); + + if (writer->obj != NULL) { + // byteswriter_write_canary_byte() can override the trailing NUL byte. + // So reset the trailing NUL byte to NUL. + Py_ssize_t allocated = byteswriter_allocated(writer); + char *data = byteswriter_data(writer); + data[allocated] = '\0'; + } +#endif + PyObject *result; if (size == 0) { result = bytes_get_empty(); @@ -3839,21 +3883,24 @@ PyBytesWriter_GetSize(PyBytesWriter *writer) int -PyBytesWriter_Resize(PyBytesWriter *writer, Py_ssize_t size) +PyBytesWriter_Resize(PyBytesWriter *writer, Py_ssize_t new_size) { - if (size < 0) { + if (new_size < 0) { PyErr_SetString(PyExc_ValueError, "size must be >= 0"); return -1; } - if (writer->size < size) { - if (byteswriter_resize(writer, size, 1) < 0) { + if (writer->size < new_size) { + if (byteswriter_resize(writer, new_size, 1) < 0) { return -1; } } else { // The buffer is already large enough. Never shrink the buffer. } - writer->size = size; + writer->size = new_size; +#ifdef Py_DEBUG + byteswriter_write_canary_byte(writer); +#endif return 0; } @@ -3878,24 +3925,30 @@ PyBytesWriter_Grow(PyBytesWriter *writer, Py_ssize_t grow) return 0; } - if (grow >= 0) { + if (grow > 0) { if (grow > PY_SSIZE_T_MAX - writer->size) { PyErr_NoMemory(); return -1; } + Py_ssize_t new_size = writer->size + grow; + + if (byteswriter_resize(writer, new_size, 1) < 0) { + return -1; + } + writer->size = new_size; } else { if (writer->size + grow < 0) { PyErr_SetString(PyExc_ValueError, "invalid size"); return -1; } + // The buffer is already large enough. Never shrink the buffer. + writer->size = writer->size + grow; } - Py_ssize_t size = writer->size + grow; - if (byteswriter_resize(writer, size, 1) < 0) { - return -1; - } - writer->size = size; +#ifdef Py_DEBUG + byteswriter_write_canary_byte(writer); +#endif return 0; } @@ -3961,5 +4014,8 @@ _PyBytesWriter_ResizeToAllocated(PyBytesWriter *writer) { Py_ssize_t allocated = byteswriter_allocated(writer); writer->size = allocated; +#ifdef Py_DEBUG + byteswriter_write_canary_byte(writer); +#endif return allocated; } diff --git a/Objects/unicodeobject.c b/Objects/unicodeobject.c index e86291347c75be0..86b9baadd0d8aa9 100644 --- a/Objects/unicodeobject.c +++ b/Objects/unicodeobject.c @@ -796,6 +796,8 @@ backslashreplace(PyBytesWriter *writer, char *str, } size += incr; } + /* subtract preallocated bytes */ + size -= (collend - collstart); str = PyBytesWriter_GrowAndUpdatePointer(writer, size, str); if (str == NULL) { @@ -871,6 +873,8 @@ xmlcharrefreplace(PyBytesWriter *writer, char *str, } size += incr; } + /* subtract preallocated bytes */ + size -= (collend - collstart); str = PyBytesWriter_GrowAndUpdatePointer(writer, size, str); if (str == NULL) { @@ -7262,8 +7266,6 @@ unicode_encode_ucs1(PyObject *unicode, break; case _Py_ERROR_BACKSLASHREPLACE: - /* subtract preallocated bytes */ - writer->size -= (collend - collstart); str = backslashreplace(writer, str, unicode, collstart, collend); if (str == NULL) @@ -7272,8 +7274,6 @@ unicode_encode_ucs1(PyObject *unicode, break; case _Py_ERROR_XMLCHARREFREPLACE: - /* subtract preallocated bytes */ - writer->size -= (collend - collstart); str = xmlcharrefreplace(writer, str, unicode, collstart, collend); if (str == NULL) @@ -7314,10 +7314,13 @@ unicode_encode_ucs1(PyObject *unicode, } } else { - /* subtract preallocated bytes */ - writer->size -= newpos - collstart; /* Only overallocate the buffer if it's not the last write */ writer->overallocate = (newpos < size); + + /* subtract preallocated bytes */ + if (PyBytesWriter_Grow(writer, -(newpos - collstart)) < 0) { + goto onError; + } } const char *rep_str;