Any _cffi_backend built on an Apple platform against a libffi other than the one shipped with the macOS SDK never calls ffi_prep_cif_var(). Variadic functions are then called through a cif prepared by ffi_prep_cif(), i.e. with the fixed-arguments calling convention. On arm64 Apple platforms variadic arguments are passed on the stack, unlike named arguments, so the callee reads garbage: integer varargs arrive as random values, and pointer varargs typically crash the process.
This affects:
- all iOS wheels published on PyPI (cffi 2.0.0 and 2.1.0; both simulator and device) — they are built against the static libffi from
beeware/cpython-apple-source-deps, per .github/workflows/ci.yaml;
- macOS builds where a Homebrew libffi's headers end up ahead of the SDK's (
setup.py actively arranges PKG_CONFIG_PATH for this in use_homebrew_for_libffi()).
Not affected: macOS wheels from PyPI (built against the SDK's libffi headers), and ctypes (CPython calls ffi_prep_cif_var() unconditionally when extra args are passed — which is also a handy control experiment on the same device).
Reproducer
On an iOS simulator (e.g. via CPython's iOS testbed) with a PyPI cffi wheel:
from cffi import FFI
ffi = FFI()
ffi.cdef("int snprintf(char *str, size_t size, const char *format, ...);")
libc = ffi.dlopen(None)
buf = ffi.new("char[256]")
s = ffi.new("char[]", b"hello")
rc = libc.snprintf(buf, 256, b"[%s] [%d]", s, ffi.cast("int", 42))
print(rc, ffi.string(buf)) # expected: 12 b'[hello] [42]'
Actual result: the process crashes with EXC_BAD_ACCESS — snprintf receives a garbage pointer for %s:
_platform_strlen +4 <- strlen(garbage)
__vfprintf
_vsnprintf
snprintf +100
_cffi_backend ffi_call_SYSV +80
_cffi_backend ffi_call_int +1580
_cffi_backend ffi_call +52
_cffi_backend cdata_call +2628
The same call via ctypes (declaring only the three fixed parameters in argtypes) works correctly in the same process.
Root cause
src/c/_cffi_backend.c:
#elif defined(__APPLE__) && defined(FFI_AVAILABLE_APPLE)
# define CFFI_CHECK_FFI_PREP_CIF_VAR __builtin_available(macos 10.15, ios 13, watchos 6, tvos 13, *)
# define CFFI_CHECK_FFI_PREP_CIF_VAR_MAYBE 1
...
#else
# define CFFI_CHECK_FFI_PREP_CIF_VAR 0
# define CFFI_CHECK_FFI_PREP_CIF_VAR_MAYBE 0
#endif
FFI_AVAILABLE_APPLE is defined only by the libffi header shipped with the macOS SDK (it carries Apple's availability annotations). Building against any other libffi — the static one used for the iOS wheels, or Homebrew's — leaves it undefined, so the build falls into the #else branch and compiles out the ffi_prep_cif_var() path entirely (along with ffi_closure_alloc() / ffi_prep_closure_loc()). The bundled libffi does provide all of these functions; they are simply never used.
Binary-level confirmation on the PyPI 2.1.0 simulator wheel — the symbol is present but has no call sites, while CPython's _ctypes (which works) calls it:
$ otool -tv _cffi_backend.cpython-313-iphonesimulator.so | grep -c 'bl.*_ffi_prep_cif_var'
0
$ otool -tv _ctypes.cpython-313-iphonesimulator.so | grep -c 'bl.*_ffi_prep_cif_var'
1
Fun detail: the cffi test suite already caught this
src/c/test_c.py contains two iOS markers for "unknown reasons" that are both
this bug:
test_call_function_9 — xfail(is_ios, reason="For an unknown reason f(1, cast(BInt, 42)) returns 36792864"): an integer vararg read from a garbage location;
test_FILE — skipif(is_ios, reason="For an unknown reason fscanf() doesn't read anything on 3.14 and crashes on 3.13"): fscanf() takes pointer varargs, hence the crash.
Any
_cffi_backendbuilt on an Apple platform against a libffi other than the one shipped with the macOS SDK never callsffi_prep_cif_var(). Variadic functions are then called through a cif prepared byffi_prep_cif(), i.e. with the fixed-arguments calling convention. On arm64 Apple platforms variadic arguments are passed on the stack, unlike named arguments, so the callee reads garbage: integer varargs arrive as random values, and pointer varargs typically crash the process.This affects:
beeware/cpython-apple-source-deps, per.github/workflows/ci.yaml;setup.pyactively arrangesPKG_CONFIG_PATHfor this inuse_homebrew_for_libffi()).Not affected: macOS wheels from PyPI (built against the SDK's libffi headers), and
ctypes(CPython callsffi_prep_cif_var()unconditionally when extra args are passed — which is also a handy control experiment on the same device).Reproducer
On an iOS simulator (e.g. via CPython's iOS testbed) with a PyPI cffi wheel:
Actual result: the process crashes with
EXC_BAD_ACCESS—snprintfreceives a garbage pointer for%s:The same call via
ctypes(declaring only the three fixed parameters inargtypes) works correctly in the same process.Root cause
src/c/_cffi_backend.c:FFI_AVAILABLE_APPLEis defined only by the libffi header shipped with the macOS SDK (it carries Apple's availability annotations). Building against any other libffi — the static one used for the iOS wheels, or Homebrew's — leaves it undefined, so the build falls into the#elsebranch and compiles out theffi_prep_cif_var()path entirely (along withffi_closure_alloc()/ffi_prep_closure_loc()). The bundled libffi does provide all of these functions; they are simply never used.Binary-level confirmation on the PyPI 2.1.0 simulator wheel — the symbol is present but has no call sites, while CPython's
_ctypes(which works) calls it:Fun detail: the cffi test suite already caught this
src/c/test_c.pycontains two iOS markers for "unknown reasons" that are boththis bug:
test_call_function_9—xfail(is_ios, reason="For an unknown reason f(1, cast(BInt, 42)) returns 36792864"): an integer vararg read from a garbage location;test_FILE—skipif(is_ios, reason="For an unknown reason fscanf() doesn't read anything on 3.14 and crashes on 3.13"):fscanf()takes pointer varargs, hence the crash.