Most of the Python ecosystem's heaviest-used packages are not really Python. numpy leans on C and Fortran for its array math, pandas builds on top of numpy and its own Cython layer, lxml links against the C libraries libxml2 and libxslt, Pillow wraps libjpeg, libpng, zlib, and freetype, and the cryptography package began rewriting parts of its C/CFFI layer — such as ASN.1 parsing — in Rust starting around 2020, specifically to move memory-unsafe code out of the parsing paths that sit alongside its OpenSSL bindings. This matters because CPython's interpreter bounds-checks every object access in pure Python code — a buffer overflow, use-after-free, or double-free is structurally impossible in Python bytecode. None of that protection extends past the boundary into a compiled .so or .pyd file. A tool built to parse Python source or bytecode — Bandit, Semgrep's Python rules, pyflakes — simply cannot see inside compiled native code, and a standard software composition analysis (SCA) scan that matches requirements.txt against a CVE database checks the Python package version, not the version of whatever C library got compiled into the wheel. Those two facts together mean a class of exploitable memory-corruption bugs can sit invisible to an entire layer of standard Python tooling. This post walks through why that gap exists and what closes it.
Why do memory-safety bugs only exist in the native layer?
Memory-safety bugs — buffer overflows, use-after-free, double-free, integer overflows that corrupt the heap — require a language that lets code read or write memory outside the bounds an object was allocated, or reuse memory after it has been freed. Pure Python code cannot do either: every list, string, and object access goes through CPython's interpreter, which checks bounds and reference counts before the operation completes. That is a structural guarantee, not a best practice teams have to follow. C and C++ offer no such guarantee — a memcpy with a miscalculated length, or a pointer used after free(), is simply undefined behavior that the language will not stop. So when a package like lxml calls into libxml2 to parse an XML document, or Pillow calls into libjpeg to decode an image, the bug classes that were architecturally impossible one function call earlier become possible again. The vulnerability isn't in the .py files a developer reads and reviews — it's in the compiled binary those files call into, often a C library the Python package's own maintainers didn't write.
How do compiled wheels hide the real native library version?
The manylinux and auditwheel projects — formalized in PEP 513 (2016) and extended through PEP 600 (2019) — exist to solve a real portability problem: a Python wheel with a compiled extension needs the right shared libraries present on the machine that installs it. auditwheel's solution is to bundle those third-party shared libraries directly into the wheel at build time, so pip install lxml doesn't also require the user to separately install a matching system libxml2. That solves distribution, but it also means the wheel's declared package version (lxml==5.2.1, say) tells you nothing on its own about which libxml2 build got vendored inside it. A version-matching SCA scan checks the package metadata against a CVE feed; it does not unpack the wheel and hash the bundled .so files to identify the actual native library and its patch level. Two installs of the same lxml version, built months apart against different libxml2 releases, can carry genuinely different vulnerability exposure that package-version matching alone cannot distinguish.
What's a concrete example of a CVE hiding in a bundled library?
libxml2, which lxml links against, had two integer/buffer-overflow issues publicly tracked as CVE-2022-40303 and CVE-2022-40304, fixed upstream in libxml2 2.10.3, released in October 2022. Neither CVE is filed against "lxml" as a package — they're filed against libxml2 itself, the C library lxml bundles into its wheels on some platforms. A team scanning requirements.txt for known-vulnerable lxml versions is depending on the scanner (or the CVE record itself) correctly mapping a libxml2 fix date back to the specific lxml release that rebuilt its wheels against the patched library — a mapping that isn't guaranteed to exist cleanly in a standard NVD-backed feed. The same structural pattern shows up with Pillow, which has shipped multiple point releases over the years tied to fixes in its bundled libjpeg, libwebp, and freetype builds rather than to bugs in Pillow's own Python code. In both cases, the vulnerability identity lives one layer below the package identity a typical SCA tool is built to track.
Why doesn't Bandit or Semgrep catch this?
Bandit and Semgrep's Python rule sets are built to analyze Python source, abstract syntax trees, or bytecode — none of which exist for a compiled .so or .pyd file. When you import lxml.etree, Python loads a compiled extension module; there is no Python-level control-flow graph running through libxml2's C parsing routines for a taint engine to walk. This is not a gap in any particular tool's rule coverage — it's a scope boundary baked into what "static analysis of Python code" means. A SAST tool doing its job correctly on the Python layer will report zero findings for a buffer overflow living inside libxml2, because from the Python interpreter's perspective, that C function is an opaque black box that either returns a value or doesn't. Catching a memory-safety bug in that layer requires tooling built for C/C++ — fuzzers, static analyzers like Coverity or the various sanitizers (ASan, UBSan) — run against the native source itself, or a supply-chain inventory precise enough to identify the exact bundled library version rather than just the wrapping Python package's version number.
What should a Python security program do differently for native-extension packages?
Treat any Python package with a compiled extension as two dependencies stacked on top of each other, not one. That means tracking the bundled native library's actual version — not just inferring it from the Python package version — when it's feasible to extract that information from the wheel, and watching CVE feeds for the upstream C project (libxml2, libjpeg-turbo, OpenSSL, zlib) in addition to the Python package's own advisory feed. It also means recognizing that pinning lxml or Pillow to "the latest version" is a reasonable default but not a complete answer, since a wheel rebuild against a newer bundled library can happen on a different release cadence than the Python package's own changelog suggests. For packages that have deliberately moved memory-unsafe code out of parsing and other hot paths — cryptography's adoption of Rust for parts of its C layer being a clear example — that shift itself is a signal worth weighing when choosing between otherwise-similar libraries, since it structurally removes a category of bug the equivalent C code can still have.