Contents

libc bad, or, every language should talk to the kernel itself

Almost every program running on any given Linux box today, regardless of what language it was written in, talks to the kernel by way of the same few megabytes of C. Python, Ruby, Rust, PHP, Java, Node, Perl – all of them hand their file descriptors, DNS lookups, memory allocations and character set conversions to glibc, and to a large degree glibc hands them to the kernel.

That is a monoculture, and monocultures fail.

The main argument in defense of this is that a single shared libc is a single surface that can be audited to death. That is true, and glibc has been audited probably more than most libraries in existence. And yet, glibc produced GHOST in 2015, a heap overflow in gethostbyname rated CVSS 10; then a bug in getaddrinfo in 2016, a stack overflow affecting essentially everyone on glibc 2.9 or later, which could be triggered by attacker-controlled domain names or DNS servers; then Looney Tunables in 2023, a loader bug giving root on default installs of Fedora, Ubuntu and Debian; then in iconv in 2024, an out-of-bounds write that turned into a new exploitation technique affecting the whole PHP ecosystem. In that last case the PHP project couldn’t issue a patch, because the bug wasn’t in PHP. A language ecosystem was thereby compromised by a library it didn’t write, didn’t audit, and couldn’t fix.

And then there’s the xz backdoor back in 2024 that could have compromised every sshd on the planet, which only worked on glibc-based systems, because its injection mechanism was a hijacking of glibc’s ifunc resolvers, which installed an audit hook into the dynamic linker, then waited for RSA_public_decrypt to be resolved before re-pointing it at its own code. The payload was in liblzma, but the delivery vehicle was from libc’s loader.

By comparison, the Linux syscall interface is a few hundred numbered entry points with fixed register conventions, and the kernel’s stated policy is that it does not break userspace – syscalls are expected to never change and always be available. The syscall interface is small, seccomp filters operate on them, and it is documented by the maintainers rather than by POSIX traditions with GNU extensions and BSD compatibility layers. Auditing the syscall interface is way easier than auditing glibc properly.

So here’s the proposal: each language or language implementation should ship its own base library that depends on nothing but syscalls. Not a thin wrapper around libc; a replacement for the part of libc the language actually uses.

It’s worth noting that Go and Zig do precisely this on Linux (but not on other OS’s – see below). They are getting it right. Others, not so much.

Objection!

The obvious objection is that this means every language reimplementing the same semantics, each with its own opportunity to get them subtly wrong, each a potential confused deputy. But look at what languages do anyway: Python’s socket module isn’t a neutral pass-through to libc; neither is Go’s net package, which has its own pure-Go resolver precisely so it can skip getaddrinfo. Every standard library already imposes its own interpretation on top of libc’s interpretation on top of the kernel’s. Cutting out the middle piece doesn’t add a reinterpretation, it removes one. The language’s standard library semantics become a function of the kernel’s semantics directly, rather than of whatever glibc decided.

The second objection is patching. One shared libc means that while a libc bug breaks everyone, one apt upgrade also fixes everyone. A hundred language runtimes with their own syscall layer means a hundred rebuilds and redistributions of standard libraries. But on the other hand, distributions already cope with exactly this for Go, which has made raw syscalls on Linux from the start, and they similarly cope with everything musl-based. But, wait, there’s more: they have to deal with this anyway whenever there is a bug in each language’s standard libraries anyway, so again, this is reducing the attack surface, not complicating things more than they already are. It’s a packaging and distribution problem, which are solvable.

The third objection is portability. Linux is unusual in promising a stable syscall ABI. Go used to make raw syscalls on MacOS and binaries were occasionally broken by kernel updates, so it now goes through libc there. OpenBSD has gone further and prevents syscalls from being made unless they come from libc.so, which is really weird but okay. Zig does the same split: direct syscalls on Linux, libc elsewhere, because outside Linux most operating systems don’t guarantee a stable syscall ABI. But then again, the unstable syscall ABI on other operating systems is bad practice for one, and also something that those OS’s libc vendors need to deal with anyway.

The portability argument is actually a fair one, but mostly it comes from historical decisions about how different operating systems have treated libc as something within the kernel’s trust boundary and fundamental to the operation of programs – a POSIX interface inseparable from the underlying OS. On OpenBSD for example, the OpenBSD project owns both the kernel and libc, and audits them together, which is a defensible, if somewhat weird approach. But glibc is a separate project from the Linux kernel, with a separate release cycle and a separate set of maintainers, so pretending it’s part of the kernel’s trust boundary is a mistake – and even if it were, I’d still argue it shouldn’t be.

Benefits!

There are lots of benefits though to each language implementing its own basic interface, besides the security story.

A runtime that owns its syscall layer also owns its base memory allocator, and can build one that fits the language’s actual allocation patterns instead of inheriting malloc’s tradeoffs. It also gets to decide its own thread model, its own signal handling (to some extent), and its own I/O multiplexing without going through a few different layers of compatibility. Go and Zig didn’t go this route just for security reasons; they went this way primarily because it’s faster and simpler.

None of this makes the per-language layer free of bugs. But it means the bugs are in code the language’s maintainers wrote and can fix. Each language already has to audit its standard library. The question is only whether the that library hits a syscall, or somebody else’s wrapper.

And let’s talk about that wrapper for a moment.

The glibc code is unreadable. A function that has to be correct for every caller in history ends up being an illegible mess of special cases and macros. glibc’s memcpy isn’t one function; it’s a family of hand-tuned assembly variants per microarchitecture, selected at load time by the same ifunc machinery xz abused. Add symbol versioning (two memcpys coexisting because the 2.14 one broke Flash in 2012), locale plumbing through every string function, #ifdef forests for a dozen architectures and thirty years of ABI promises, and you have a library where “audit” means something closer to archaeology. Smaller per-language layers are readable because they only have to be correct for one caller.

Also, libc’s internal behaviour isn’t a stable contract. When glibc 2.34 switched to trying clone3 by default, every application calling clone inside a Docker container started failing, because the container’s seccomp profile returned EPERM for a syscall it had never been granted or asked for. Florian Weimer proposed disabling clone3 in glibc entirely because Ubuntu’s docker package, as used by GitHub Actions, gave no way to enable it. The point being that a program linked to glibc cannot reliably enumerate its own syscalls, because glibc reserves the right to change them underneath it. A runtime that owns its syscall layer knows exactly which entry points it uses, which means its seccomp policy can be minimal, exact and stable.

The environment is a control plane. GLIBC_TUNABLES, the whole LD_* family, LANG/LC_* triggering locale file loads, MALLOC_* — libc treats the process environment as configuration, which means every setuid binary inherits a parser for attacker-supplied config it never asked for. Looney Tunables was literally that parser. A syscall-only runtime has no reason to read a single environment variable before main.

glibc is also a plugin host. NSS loads arbitrary shared objects based on /etc/nsswitch.conf the first time you call getpwnam. iconv loads gconv modules on demand – the 2024 bug was in one of them. The dynamic loader honors LD_PRELOAD and LD_AUDIT, which is often very convenient, but it also means that “linked against libc” means that it will load code I didn’t link, chosen by files I don’t control, at times I can’t predict. A static binary talking to the kernel loads nothing. This might be more of a static binary argument, but still.

Ther’s also a bunch of global state in glibc that leaks across the application boundary. There is process-wide mutable state – stdio buffers, locale, malloc arenas, atexit handlers, the environment block – and every language runtime has to coexist with it. For example, at some point around 2024 Rust made std::env::set_var an unsafe function, because setenv racing against getenv in another thread is undefined behaviour in glibc and the Rust standard library couldn’t fix it. So, a language had to degrade its own API to accommodate semantics it inherited.

I guess it’s also worth mentioning that “one libc” is fiction anyway. glibc, musl, bionic, uclibc, dietlibc – Alpine doesn’t share Debian’s libc, Android has its own, etc. The audit-once-benefit-everyone argument assumes a single implementation that doesn’t exist. What exists is several implementations of the same loosely-specified semantics, controlled by none the languages that depend on them. Per-language layers don’t introduce diversity – they move the diversity to a place where someone is accountable for it.

There are more arguments. I won’t belabor the point further. Let’s just summarize by saying that per-language layers give you:

  • More language specific semantic control
  • Smaller attack surfaces
  • Fewer uncontrolled interactions outside your runtime
  • Easier pathways for languages to address security issues
  • Potential for better performance characteristics
  • Control over ABI stability
  • Less risk of policy changes causing cascading failures

The biggest real downside is some number of portability headaches that OS vendors should be complained at for, because Linus got it right, and more or less everybody else got it wrong.

Sources