While diagnosing a non-determinism Bazel issue at work, I had to compare the dynamic libraries used by two builds of the same binary. To do so, I used ldd(1). The list of libraries printed by the tool was the same between the two builds, but the numbers next to them were different. Which numbers, you ask? Well, take a look at this sample output:

$ ldd /bin/ls
	linux-vdso.so.1 (0x00007fff5bbed000)
	libselinux.so.1 => /lib64/libselinux.so.1 (0x00007f7786f48000)
	libcap.so.2 => /lib64/libcap.so.2 (0x00007f7786f3e000)
	libc.so.6 => /lib64/libc.so.6 (0x00007f7786d60000)
	libpcre2-8.so.0 => /lib64/libpcre2-8.so.0 (0x00007f7786cc6000)
	/lib64/ld-linux-x86-64.so.2 (0x00007f7786fad000)

Without prior knowledge, the numbers next to each library seemed to be the base addresses of the libraries once loaded into memory, and they probably differed across builds because of ASLR. If this was true, it meant that the differences were irrelevant from a build determinism perspective, but I had to confirm this claim because I had never given these numbers a second thought.

To answer the question, I did what one should always do: read the official documentation. I typed man ldd and was greeted by the following on the CentOS 7 machine I was working on:

Screenshot of the beginning of the ldd(1) manual page on CentOS 7 with the following text highlighted by me: "Be aware, however, that in some circumestances, some versions of ldd may attempt to obtain the dependency information by directly executing the program. Thus, you should never employ ldd on an untrusted executable."

Wait, what? Under some circumstances (which ones?) and with some versions of ldd (which ones again?1), ldd may execute the given binary to determine the libraries it uses. Which means that running ldd on an untrusted binary could compromise your system. The manual page goes on to say that you should never run ldd against untrusted binaries.

Needless to say, this was really surprising to see. Why would ldd execute anything to print details about a file? I quickly posted this on Twitter and Mastodon, and the higher-than-usual engagement made me think that many more people than I weren’t aware of this behavior… so I had to investigate a bit more.

This behavior was originally reported as a security vulnerability in CVE-2009-5064 and the report was closed with the rationale: “not a security vulnerability because ldd must not be run on untrusted binaries”. While this may be literally true, I find the answer quite dismissive: how does one know that, say, readelf or objdump can be run against untrusted binaries but ldd cannot? When giving a file to a tool, the common expectation is that the tool will read and parse the file2, not run it. This violates the Principle of least astonishment (POLA), for me at least.

Even though upstream did not agree to the security vulnerability report, various people thought there was a real problem, so some Linux distributions did patch ldd to not execute binaries directly. I do not have access to an old-enough unpatched Linux system to verify this surprising behavior, but there is a trivial repro in the “ldd can execute an app unexpectedly” email thread along with a simple fix.

And if my reading is correct, this surprising behavior was removed from the upstream sources back in 2017 (eight years after the original report) in commit eedca9772e. It’s quite hard to tell if this change was addressing the reported security issue because the commit has no written rationale nor reference to the CVE. Which makes the whole thing feel worse: what I sense is a security-fix-in-disguise begrudgingly accepted after many years of saying this was not a problem… which is never great. But that’s just my read of the whole thing and I’m probably wrong.

In any case, even if this particular issue is fixed, it is still unsafe to run ldd against untrusted binaries. ldd uses the dynamic linker to load the binary and its dependencies into memory, and then relies on the dynamic linker itself to print details to the console. And because of this, this process can be abused in other working-as-intended ways to trigger code injection as explained in CVE-2019-1010023. All of these require social engineering though… but we all know that humans are often the weakest link in security.

So why does ldd look so problematic anyway? Let’s look at the internals of this tool.

My first surprise was to see that, on Linux, ldd is a shell script3—and a very simple one at that. Poking through the contents of /usr/bin/ldd, you’ll notice that all this script does is pass the given binary to a collection of built-in dynamic linkers (stored in RTLDLIST) and, once it finds a dynamic linker that can process the binary, it runs the linker against the binary with the LD_TRACE_LOADED_OBJECTS=1 setting. The linker then lays out the binary inside a new process along all the direct and indirect libraries it requires, dumps information to the console, and exits.

We can do the same by hand:

$ /lib64/ld-linux-x86-64.so.2 --verify /bin/ls && echo 'Supported!'

$ LD_TRACE_LOADED_OBJECTS=1 /lib64/ld-linux-x86-64.so.2 /bin/ls
	linux-vdso.so.1 (0x00007ffd28dc4000)
	libselinux.so.1 => /lib64/libselinux.so.1 (0x00007feb357d8000)
	libcap.so.2 => /lib64/libcap.so.2 (0x00007feb357ce000)
	libc.so.6 => /lib64/libc.so.6 (0x00007feb355f0000)
	libpcre2-8.so.0 => /lib64/libpcre2-8.so.0 (0x00007feb35556000)
	/lib64/ld-linux-x86-64.so.2 (0x00007feb3583d000)

Voila! We get the exact same output that ldd would produce modulo differences in the addresses across different executions.

I’ll probably continue to use ldd because it is very convenient and I rarely face situations where I have to analyze untrusted binaries. But if those situations ever arise, here are some alternatives:

  • libtree the-binary, which displays the direct and indirect libraries required by a binary as a tree.
  • objdump -p the-binary | grep NEEDED, which is the solution provided in the ldd(1) manual page I quoted earlier but is not equivalent to ldd because it can only handle direct dependencies.
  • readelf -d the-binary | grep NEEDED, which is a similar solution to the previous one with the same caveats.

Before parting, the question I have is: why doesn’t ldd walk the library tree by inspecting ELF headers instead of loading the libraries into a process? The answer might be in the name: I’m guessing that ldd stands for “ld dump” and thus it is supposed to run the dynamic linker.

  1. This type of unspecific language is what you get when the manual pages are written by a different set of people than those that write the code and when the manual pages are shipped separately from the tools they document. Thanks Linux. This type of nonsense does not happen on the BSDs. ↩︎

  2. There is always the risk that the parser has bugs that can be exploited by a malicious file, resulting in untrusted code execution, but that’s a different class of issue than “let’s just run the binary!”. ↩︎

  3. This might help explain my long-standing question on why macOS does not ship with ldd and why I always had to use otool↩︎

A blog on operating systems, programming languages, testing, build systems, my own software projects and even personal productivity. Specifics include FreeBSD, Linux, Rust, Bazel and EndBASIC.

Follow @jmmv on Mastodon
Follow @jmmv on Twitter
RSS feed
0 subscribers