Skip to content

gh-148286: Fix undefined behaviour in io.StringIO.read() after an overseek - #154913

Open
matthiasgoergens wants to merge 1 commit into
python:mainfrom
matthiasgoergens:stringio-overseek-ub
Open

gh-148286: Fix undefined behaviour in io.StringIO.read() after an overseek#154913
matthiasgoergens wants to merge 1 commit into
python:mainfrom
matthiasgoergens:stringio-overseek-ub

Conversation

@matthiasgoergens

@matthiasgoergens matthiasgoergens commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

_io_StringIO_read_impl() clamps the read size to zero when the position is past the end of the buffer, but still forms the pointer self->buf + self->pos before returning. Nothing is read through it, but forming it is undefined behaviour on its own, and since self->buf is a Py_UCS4 * the byte offset is self->pos * 4 — so for self->pos == 2**62 - 1 the multiplication wraps and yields a pointer four bytes below self->buf.

io.StringIO("abc").seek(2**62 - 1)   # then .read()
stringio.c:352: addition of unsigned offset to 0x... overflowed to 0x...

_stringio_readline() already guards exactly this condition a few lines below, with the comment "In case of overseek, return the empty string", so this applies the same guard on the read path. self->pos += size is a no-op here because size is zero, and ENSURE_REALIZED() is deliberately left ahead of the guard so the state transition and its error path are unchanged.

The existing test_io suite already covers this path — that is how UBSan found it — so no new test is needed. With the Modules/_io/stringio.c entry removed from Tools/ubsan/suppressions.txt, test_io passes under UBSAN_OPTIONS=halt_on_error=1, where before it aborts at stringio.c:352.

…an overseek

_io_StringIO_read_impl() clamps the read size to zero when the position is
past the end of the buffer, but still formed the pointer

    output = self->buf + self->pos;

before returning the empty string. Nothing is read through that pointer,
but forming it is undefined behaviour on its own, and because self->buf is
a Py_UCS4 *, the byte offset is self->pos * 4: for self->pos == 2**62 - 1
the multiplication wraps and yields a pointer four bytes BELOW self->buf.
That is what UBSan reported:

    io.StringIO("abc").seek(2**62 - 1) followed by .read()
    stringio.c:352: addition of unsigned offset to 0x... overflowed to 0x...

Return the empty string early, exactly as _stringio_readline() already
does for the same overseek condition. self->pos += size is a no-op here
since size is zero, and ENSURE_REALIZED() is kept ahead of the guard so
the state transition and its error path are unchanged.

The existing test_io suite already covers this path, which is how UBSan
found it. The Modules/_io/stringio.c entry in Tools/ubsan/suppressions.txt
is no longer needed; with it removed, test_io passes under
UBSAN_OPTIONS=halt_on_error=1, where before it aborts at stringio.c:352.
@@ -0,0 +1,7 @@
Fix undefined behaviour in :meth:`!io.StringIO.read` after an overseek.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same idea as with the ctypes PR, avoid implementation/infra details.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants