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
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)
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.
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.
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:
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,
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
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)
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
lddbecause 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.
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. ↩︎
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!”. ↩︎
This might help explain my long-standing question on why macOS does not ship with
lddand why I always had to use