Invoking ld-linux.so

A Radical Solution for Distributing ELF to Non-FHS Compliant System

The Curse of NixOS

Poeple seem to have a love-hate relationship with NixOS. The Curse of NixOS is an excellent article by Wesley Aptekar-Cassels, which tells the good and the bad of NixOS, and serves as a great reading for background on this article.

Neither am I a user of NixOS, nor I want to be. Still, I got stormed by issues on GitHub from NixOS users, which can be summarized by a quote from NixOS Wiki:

Downloading and attempting to run a binary on NixOS will almost never work.


Here is a demonstration:

[nix-shell:~]# ./dart --version
bash: ./dart: No such file or directory

[nix-shell:~]# ls -l dart
-rwxr-xr-x 1 root root 5154328 May 31 23:59 dart

Running the dart executable downloaded from dart.dev would fail in a very confusing way on NixOS. At a glance it may look like bash is complaining about file ./dart does not exist. However, checking with ls would reject that idea immediately. This is why many NixOS users get lost, where they have no choice but to seek help from the developer. It is pretty common for developers to have no cue given the limited information that appear to be contradictory. I don’t blame puzzled NixOS users, as I would probably have no idea without my previous experience of dealing with three different libc on a single Linux system.

Executable and Linking Format

Linux executable files are usually in Executable and Linking Format (ELF). For dynamically linked programs, the loading of shared libraries is performed by ld-linux.so, the dynamic linker, whose location is hard coded in PT_INTERP program header. Most of the Linux distributions have the dynamic linker in a common location, so that programs compiled on one distribution can run on other distributions, as long as all dependencies are satisfied.

[nix-shell:~]# ldd dart
	linux-vdso.so.1 (0x00007ffd53b6b000)
	libdl.so.2 => /nix/store/4nlgxhb09sdr51nc9hdm8az5b08vzkgx-glibc-2.35-163/lib/libdl.so.2 (0x00007f6b42fc7000)
	libpthread.so.0 => /nix/store/4nlgxhb09sdr51nc9hdm8az5b08vzkgx-glibc-2.35-163/lib/libpthread.so.0 (0x00007f6b42fc2000)
	libm.so.6 => /nix/store/4nlgxhb09sdr51nc9hdm8az5b08vzkgx-glibc-2.35-163/lib/libm.so.6 (0x00007f6b42ee2000)
	libc.so.6 => /nix/store/4nlgxhb09sdr51nc9hdm8az5b08vzkgx-glibc-2.35-163/lib/libc.so.6 (0x00007f6b42cd9000)
	/lib64/ld-linux-x86-64.so.2 => /nix/store/4nlgxhb09sdr51nc9hdm8az5b08vzkgx-glibc-2.35-163/lib64/ld-linux-x86-64.so.2 (0x00007f6b43677000)

[nix-shell:~]# ls -l /lib64/ld-linux-x86-64.so.2
ls: cannot access '/lib64/ld-linux-x86-64.so.2': No such file or directory

NixOS is different that the ld-linux.so does not exist at its common location hence the “no such file or directory” error. This issue comes from a deliberate decision from Nix to ignore the Filesystem Hierarchy Standard (FHS), and NixOS created a tool called patchelf to rectify the consequences.

[nix-shell:~]# patchelf --set-interpreter /nix/store/4nlgxhb09sdr51nc9hdm8az5b08vzkgx-glibc-2.35-163/lib64/ld-linux-x86-64.so.2 ./dart

[nix-shell:~]# ldd dart
	linux-vdso.so.1 (0x00007fff717bc000)
	libdl.so.2 => /nix/store/4nlgxhb09sdr51nc9hdm8az5b08vzkgx-glibc-2.35-163/lib/libdl.so.2 (0x00007f523a6ed000)
	libpthread.so.0 => /nix/store/4nlgxhb09sdr51nc9hdm8az5b08vzkgx-glibc-2.35-163/lib/libpthread.so.0 (0x00007f523a6e8000)
	libm.so.6 => /nix/store/4nlgxhb09sdr51nc9hdm8az5b08vzkgx-glibc-2.35-163/lib/libm.so.6 (0x00007f523a608000)
	libc.so.6 => /nix/store/4nlgxhb09sdr51nc9hdm8az5b08vzkgx-glibc-2.35-163/lib/libc.so.6 (0x00007f523a3ff000)
	/nix/store/4nlgxhb09sdr51nc9hdm8az5b08vzkgx-glibc-2.35-163/lib64/ld-linux-x86-64.so.2 (0x00007f523ada6000)

[nix-shell:~]# ./dart --version
Dart SDK version: 2.19.6 (stable) (Tue Mar 28 13:41:04 2023 +0000) on "linux_x64"

Once PT_INTERP program header is modified to the correct location for NixOS, the dart executable now works as expected. However, even with the tools being available, it’s still a trouble for developers who distribute precompiled Linux binaries. On NixOS, the location of ld-linux.so changes every time glibc is updated, therefore distributing already modified ELF is unreliable. Patching during at the runtime is also undependable as patchelf may not be available.

Invoking ld-linux.so

The dynamic linker is a shared library, but it is a special runnable one, which can be explicitly invoked to execute an ELF.

[nix-shell:~]# ./dart --version
bash: ./dart: No such file or directory

[nix-shell:~]# /nix/store/4nlgxhb09sdr51nc9hdm8az5b08vzkgx-glibc-2.35-163/lib64/ld-linux-x86-64.so.2 ./dart --version
Dart SDK version: 2.19.6 (stable) (Tue Mar 28 13:41:04 2023 +0000) on "linux_x64"

The unmodified dart executable works on NixOS when explicitly invoked through ld-linux.so. As for finding the uncertain location of the dynamic linker on NixOS, it can be done by reading the program header from /proc/self/exe.

The Radical Solution

Normally, when facing this kind of issues, NixOS users would have to face the challenge of either modifying downloaded programs or building programs from source, which the vast majority of people using NixOS are not familiar with. Even after getting an offical repackaging in Nixpkgs, the compliants from NixOS users would not stop, as many users may still choose to download instead.

A radical solution was born to put a stop:

# Try to execute ELF.
begin
  exec(*COMMAND, *ARGV)
# Catch the "no such file or directory" error.
rescue Errno::ENOENT
  # Locate `ld-linux.so` by parsing `/proc/self/exe`.
  # See: https://github.com/sass-contrib/sass-embedded-host-ruby/blob/main/lib/sass/elf.rb
  require_relative 'elf'

  # Rethrow if `ld-linux.so` cannot be located.
  raise if ELF::INTERPRETER.nil?

  # Invoke `ld-linux.so` to execute ELF.
  exec(ELF::INTERPRETER, *COMMAND, *ARGV)
end

One More Thing

The solution above was shipped as part of the sass-embedded gem. It has worked for a while, until NixOS managed to break it with another innovative non-standard behavior in 24.05 release:

NixOS now installs a stub ELF loader that prints an informative error message when users attempt to run binaries not made for NixOS.

In the past, there was either a properly working ld-linux.so or none at its standard location. Now in NixOS a stub-ld that does nothing but immediately exits with an error message is installed at the standard location of ld-linux.so, so that ENOENT is no more when calling exec. The exec system call will always succeed in running stub-ld, the stub-ld will always fail, and the actual foreign binary will never execute. To avoid stub-ld altogether, the command must be modified to begin with a real ld-linux.so, before even calling exec.

A more radical solution was born:

# Locate `ld-linux.so` by parsing `/proc/self/exe`.
# See: https://github.com/sass-contrib/sass-embedded-host-ruby/blob/main/lib/sass/elf.rb
require_relative '../../lib/sass/elf'

module Sass
  module CLI
    # Preparsed `ld-linux.so` of the foreign binary, different for each platform
    INTERPRETER = '/lib/ld-linux-aarch64.so.1'

    # The last component of the preparsed `ld-linux.so`
    INTERPRETER_SUFFIX = '/ld-linux-aarch64.so.1'

    # Prepend the `ld-linux.so` of `/proc/self/exe` if it differs from the `ld-linux.so` of the foreign binary
    # yet shares the same filename indicating that they are compatible.
    COMMAND = [
      *(ELF::INTERPRETER if ELF::INTERPRETER != INTERPRETER && ELF::INTERPRETER&.end_with?(INTERPRETER_SUFFIX)),
      File.absolute_path('dart-sass/src/dart', __dir__).freeze,
      File.absolute_path('dart-sass/src/sass.snapshot', __dir__).freeze
    ].freeze
  end

  private_constant :CLI
end

It’s unlikely that NixOS will be able to break it again. I hope.