Skip to content

Variadic calls on arm64 Apple builds with a non-SDK libffi are broken #264

Description

@ambv

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_ACCESSsnprintf 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_9xfail(is_ios, reason="For an unknown reason f(1, cast(BInt, 42)) returns 36792864"): an integer vararg read from a garbage location;
  • test_FILEskipif(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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions