pin vs stream confusion leads to undefined behavior and Asan diagnostic
Port, board and/or hardware
unix port, coverage variant w/address sanitizer, x86_64 linux
MicroPython version
MicroPython v1.26.0-preview.524.g255d74b5a8 on 2025-08-06; linux [GCC 12.2.0] version
Reproduction
Build with . tools/ci.sh; ci_unix_sanitize_address_build. Then, run the following code (I did it at the repl):
>>> import machine
>>> import ssl
>>> ctx = ssl.SSLContext(1)
>>> p = machine.PinBase()
>>> ctx.wrap_socket(p)
Expected behaviour
ctx.wrap_socket should raise an exception, because a PinBase does not implement the stream protocol. For instance, ctx.wrap_socket(1) throws OSError: stream operation not supported.
Observed behaviour
>>> ctx.wrap_socket(p)
=================================================================
==2165677==ERROR: AddressSanitizer: global-buffer-overflow on address 0x5585c4169a28 at pc 0x5585c3f6a4fb bp 0x7ffe114fe930 sp 0x7ffe114fe928
READ of size 8 at 0x5585c4169a28 thread T0
#0 0x5585c3f6a4fa in mp_get_stream_raise ../../py/stream.c:102
#1 0x5585c3fb01cc in ssl_socket_make_new ../../extmod/modtls_mbedtls.c:623
#2 0x5585c3fb0a30 in ssl_context_wrap_socket ../../extmod/modtls_mbedtls.c:519
#3 0x5585c3f3c338 in fun_builtin_var_call ../../py/objfun.c:118
#4 0x5585c3f1c0d8 in mp_call_function_n_kw ../../py/runtime.c:727
#5 0x5585c3f1ca56 in mp_call_method_n_kw ../../py/runtime.c:743
#6 0x5585c3f7e435 in mp_execute_bytecode ../../py/vm.c:1069
#7 0x5585c3f3c700 in fun_bc_call ../../py/objfun.c:295
#8 0x5585c3f1c0d8 in mp_call_function_n_kw ../../py/runtime.c:727
#9 0x5585c3f1ca56 in mp_call_method_n_kw ../../py/runtime.c:743
#10 0x5585c3f7e435 in mp_execute_bytecode ../../py/vm.c:1069
#11 0x5585c3f3c700 in fun_bc_call ../../py/objfun.c:295
#12 0x5585c3f1c0d8 in mp_call_function_n_kw ../../py/runtime.c:727
#13 0x5585c3f2006a in mp_call_function_0 ../../py/runtime.c:701
#14 0x5585c4091d84 in execute_from_lexer /home/jepler/src/micropython/ports/unix/main.c:162
#15 0x5585c40923fb in do_repl /home/jepler/src/micropython/ports/unix/main.c:273
#16 0x5585c4093b3d in main_ /home/jepler/src/micropython/ports/unix/main.c:753
#17 0x5585c4093d6d in main /home/jepler/src/micropython/ports/unix/main.c:494
#18 0x7f12f5e46249 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
#19 0x7f12f5e46304 in __libc_start_main_impl ../csu/libc-start.c:360
#20 0x5585c3ec0bf0 in _start (/home/jepler/src/micropython/ports/unix/build-coverage/micropython+0x1a7bf0)
0x5585c4169a28 is located 56 bytes to the left of global variable 'pinbase_singleton' defined in '../../extmod/machine_pinbase.c:43:27' (0x5585c4169a60) of size 8
0x5585c4169a28 is located 0 bytes to the right of global variable 'pinbase_pin_p' defined in '../../extmod/machine_pinbase.c:75:25' (0x5585c4169a20) of size 8
SUMMARY: AddressSanitizer: global-buffer-overflow ../../py/stream.c:102 in mp_get_stream_raise
Pin objects and stream objects both fill the protocol spot. However, their protocol definitions are different and incompatible.
Without ASan, this resulted in an exception (yay!) but this is by luck, because mp_get_stream_raise can't tell that the protocol slot of the object is an incompatible "Pin" protocol (with only ioctl) rather than the standard "Stream" protocol (with read/write/ioctl).
Additional Information
Somewhat related: https://github.com/micropython/micropython/issues/17714 (can pass an object without protocol to machine.time_pulse_us; missing check whether the protocol slot is filled at all).
There's a third leg to the problem, in which this calls read a bunch of times on sys.stdout (due to incorrectly calling through the read function of mp_stream_p_t):
>>> machine.time_pulse_us(sys.stdout, .001)
-2
For once this wasn't from fuzzing. 😜
Code of Conduct
Yes, I agree
unix: Introduce sanitize_undefined variant, use during CI.
Summary
gcc's "undefined behavior" sanitizer can catch a range of misbehaviors at runtime that normally go unnoticed. These include integer and pointer operations that are "undefined" per the relevant C specification. Over time, most of these problems have been fixed through other PRs but if micropython desn't have regular CI time checks, regressions are inevitable.
This PR fixes current undefined behavior detected under gcc 12.2.0 (debian stable/bookworm) on an x64 system, then enables it during a new unix "coverage-like" build.
Testing
I built and ran the unix tests locally, iterating until there were no remaining diagnostics.
Trade-offs and Alternatives
#15303 is an alternate implementation, but it's become stale and did not cleanly merge with current micropython. That's why I'm re-opening my version of the changes.
Not all gcc sanitizers can be enabled simultaneously. So, a choice has to be made (mainly between -fsanitize=undefined and -fsanitize=memory). I chose the undefined checker, but implemented it so that an override of the makefile variable is possible.
-fsanitize=memory is may be error free now and can be added as a separate PR.
I read that a future C specifiction will make e.g., memset(NULL, 0, 0) (setting zero bytes of a NULL pointer) not-undefined-behavior. For this reason, and because my inspection didn't find any current incorrect optimizations due to -fdelete-null-pointer-checks [for sites that hit UB sanitizer messages], I chose to simplify this PR by disabling the use of nonnull-pointer annotations by the sanitizer.
Different gcc versions might be too different in what they affect, as any diagnostics will make make test fail due to the unexpected output.
Pattern-based tests which might inadvertently skip over output text that comes from the sanitizer, giving false negatives.