skip to Main Content

I would like to verify that the dynamic linker used when a program is run is the one mentioned via file, readelf -l, or ldd. My motivation stems from having multiple dynamic linkers that exist in separate spaces on the machine and they should never mix-and-match.

Thus far, the best way I have found to verify the dynamic linker is via gdb. By looking at output of info proc mappings, I can determine which dynamic linker was mapped into the address space and is in use. I am trying to avoid using gdb as it would require me to run test suites and other things through it.

Using the LD_DEBUG environment variable seems like it could be an alternate solution which would allow me to easily save logs for verification after (or during) program execution. However, I am unsure which option would give me the best information. I thought scopes or libs could be good options but libs doesn’t always mention the dynamic linker. For example, this is the output of a simple hello world program:

$ LD_DEBUG=libs ./test0
     24579: find library=libc.so.6 [0]; searching
     24579:  search cache=/etc/ld.so.cache
     24579:   trying file=/lib/x86_64-linux-gnu/libc.so.6
     24579:
     24579:
     24579: calling init: /lib/x86_64-linux-gnu/libc.so.6
     24579:
     24579:
     24579: initialize program: ./test0
     24579:
     24579:
     24579: transferring control: ./test0
     24579:
hello world
     24579:
     24579: calling fini: ./test0 [0]
     24579:
$ LD_DEBUG=libs ./test0-gnu-cross
     24581: find library=libc.so.6 [0]; searching
     24581:  search path=/usr/local/gnu-cross/x86_64-linux-gnu/lib/glibc-hwcaps/x86-64-v4:/usr/local/gnu-cross/x86_64-linux-gnu/lib/glibc-hwcaps/x86-64-v3:/usr/local/gnu-cross/x86_64-linux-gnu/lib/glibc-hwcaps/x86-64-v2:/usr/local/gnu-cross/x86_64-linux-gnu/lib/tls/haswell/avx512_1/x86_64:/usr/local/gnu-cross/x86_64-linux-gnu/lib/tls/haswell/avx512_1:/usr/local/gnu-cross/x86_64-linux-gnu/lib/tls/haswell/x86_64:/usr/local/gnu-cross/x86_64-linux-gnu/lib/tls/haswell:/usr/local/gnu-cross/x86_64-linux-gnu/lib/tls/avx512_1/x86_64:/usr/local/gnu-cross/x86_64-linux-gnu/lib/tls/avx512_1:/usr/local/gnu-cross/x86_64-linux-gnu/lib/tls/x86_64:/usr/local/gnu-cross/x86_64-linux-gnu/lib/tls:/usr/local/gnu-cross/x86_64-linux-gnu/lib/haswell/avx512_1/x86_64:/usr/local/gnu-cross/x86_64-linux-gnu/lib/haswell/avx512_1:/usr/local/gnu-cross/x86_64-linux-gnu/lib/haswell/x86_64:/usr/local/gnu-cross/x86_64-linux-gnu/lib/haswell:/usr/local/gnu-cross/x86_64-linux-gnu/lib/avx512_1/x86_64:/usr/local/gnu-cross/x86_64-linux-gnu/lib/avx512_1:/usr/local/gnu-cross/x86_64-linux-gnu/lib/x86_64:/usr/local/gnu-cross/x86_64-linux-gnu/lib     (RPATH from file ./test0-gnu-cross)
     24581:   trying file=/usr/local/gnu-cross/x86_64-linux-gnu/lib/glibc-hwcaps/x86-64-v4/libc.so.6
     24581:   trying file=/usr/local/gnu-cross/x86_64-linux-gnu/lib/glibc-hwcaps/x86-64-v3/libc.so.6
     24581:   trying file=/usr/local/gnu-cross/x86_64-linux-gnu/lib/glibc-hwcaps/x86-64-v2/libc.so.6
     24581:   trying file=/usr/local/gnu-cross/x86_64-linux-gnu/lib/tls/haswell/avx512_1/x86_64/libc.so.6
     24581:   trying file=/usr/local/gnu-cross/x86_64-linux-gnu/lib/tls/haswell/avx512_1/libc.so.6
     24581:   trying file=/usr/local/gnu-cross/x86_64-linux-gnu/lib/tls/haswell/x86_64/libc.so.6
     24581:   trying file=/usr/local/gnu-cross/x86_64-linux-gnu/lib/tls/haswell/libc.so.6
     24581:   trying file=/usr/local/gnu-cross/x86_64-linux-gnu/lib/tls/avx512_1/x86_64/libc.so.6
     24581:   trying file=/usr/local/gnu-cross/x86_64-linux-gnu/lib/tls/avx512_1/libc.so.6
     24581:   trying file=/usr/local/gnu-cross/x86_64-linux-gnu/lib/tls/x86_64/libc.so.6
     24581:   trying file=/usr/local/gnu-cross/x86_64-linux-gnu/lib/tls/libc.so.6
     24581:   trying file=/usr/local/gnu-cross/x86_64-linux-gnu/lib/haswell/avx512_1/x86_64/libc.so.6
     24581:   trying file=/usr/local/gnu-cross/x86_64-linux-gnu/lib/haswell/avx512_1/libc.so.6
     24581:   trying file=/usr/local/gnu-cross/x86_64-linux-gnu/lib/haswell/x86_64/libc.so.6
     24581:   trying file=/usr/local/gnu-cross/x86_64-linux-gnu/lib/haswell/libc.so.6
     24581:   trying file=/usr/local/gnu-cross/x86_64-linux-gnu/lib/avx512_1/x86_64/libc.so.6
     24581:   trying file=/usr/local/gnu-cross/x86_64-linux-gnu/lib/avx512_1/libc.so.6
     24581:   trying file=/usr/local/gnu-cross/x86_64-linux-gnu/lib/x86_64/libc.so.6
     24581:   trying file=/usr/local/gnu-cross/x86_64-linux-gnu/lib/libc.so.6
     24581:
     24581:
     24581: calling init: /usr/local/gnu-cross/x86_64-linux-gnu/lib/ld-linux-x86-64.so.2
     24581:
     24581:
     24581: calling init: /usr/local/gnu-cross/x86_64-linux-gnu/lib/libc.so.6
     24581:
     24581:
     24581: initialize program: ./test0-gnu-cross
     24581:
     24581:
     24581: transferring control: ./test0-gnu-cross
     24581:
hello world
     24581:
     24581: calling fini: ./test0-gnu-cross [0]
     24581:

As you can see, the program test0 which is built with a standard Debian/GNU toolchain and uses the system’s dynamic linker doesn’t state that.

The scopes option looks more helpful but I don’t understand what the output is saying:

$ LD_DEBUG=scopes ./test0
     24577:
     24577: Initial object scopes
     24577: object=./test0 [0]
     24577:  scope 0: ./test0 /lib/x86_64-linux-gnu/libc.so.6 /lib64/ld-linux-x86-64.so.2
     24577:
     24577: object=linux-vdso.so.1 [0]
     24577:  scope 0: ./test0 /lib/x86_64-linux-gnu/libc.so.6 /lib64/ld-linux-x86-64.so.2
     24577:  scope 1: linux-vdso.so.1
     24577:
     24577: object=/lib/x86_64-linux-gnu/libc.so.6 [0]
     24577:  scope 0: ./test0 /lib/x86_64-linux-gnu/libc.so.6 /lib64/ld-linux-x86-64.so.2
     24577:
     24577: object=/lib64/ld-linux-x86-64.so.2 [0]
     24577:  no scope
     24577:
hello world
$ LD_DEBUG=scopes ./test0-gnu-cross
     24576:
     24576: Initial object scopes
     24576: object=./test0-gnu-cross [0]
     24576:  scope 0: ./test0-gnu-cross /usr/local/gnu-cross/x86_64-linux-gnu/lib/libc.so.6 /usr/local/gnu-cross/x86_64-linux-gnu/lib/ld-linux-x86-64.so.2
     24576:
     24576: object=linux-vdso.so.1 [0]
     24576:  scope 0: ./test0-gnu-cross /usr/local/gnu-cross/x86_64-linux-gnu/lib/libc.so.6 /usr/local/gnu-cross/x86_64-linux-gnu/lib/ld-linux-x86-64.so.2
     24576:  scope 1: linux-vdso.so.1
     24576:
     24576: object=/usr/local/gnu-cross/x86_64-linux-gnu/lib/libc.so.6 [0]
     24576:  scope 0: ./test0-gnu-cross /usr/local/gnu-cross/x86_64-linux-gnu/lib/libc.so.6 /usr/local/gnu-cross/x86_64-linux-gnu/lib/ld-linux-x86-64.so.2
     24576:
     24576: object=/usr/local/gnu-cross/x86_64-linux-gnu/lib/ld-linux-x86-64.so.2 [0]
     24576:  no scope
     24576:
hello world

In summary, I would like to find a good way to verify the dynamic linker that is being used. Unless you can think of a better option, LD_DEBUG seems like a good bet, but I struggle to understand how to use it effectively in this case.

Thank you for your help 🙂

2

Answers



  1. You can use LD_DEBUG=scopes for this

    Sample output from my machine:

    LD_DEBUG=scopes ./hello
         17513:
         17513:     Initial object scopes
         17513:     object=./hello [0]
         17513:      scope 0: ./hello /lib/x86_64-linux-gnu/libc.so.6 /lib64/ld-linux-x86-64.so.2
         17513:
         17513:     object=linux-vdso.so.1 [0]
         17513:      scope 0: ./hello /lib/x86_64-linux-gnu/libc.so.6 /lib64/ld-linux-x86-64.so.2
         17513:      scope 1: linux-vdso.so.1
         17513:
         17513:     object=/lib/x86_64-linux-gnu/libc.so.6 [0]
         17513:      scope 0: ./hello /lib/x86_64-linux-gnu/libc.so.6 /lib64/ld-linux-x86-64.so.2
         17513:
         17513:     object=/lib64/ld-linux-x86-64.so.2 [0]
         17513:      no scope
         17513:
    Hello world
    

    Look for the object with no scope.
    Also, there are only a couple of values for LD_DEBUG, check them here and experiment.

    Login or Signup to reply.
  2. There is no need to actually run the executable to determine the ELF interpreter that it will use.

    We can use static tools and be guaranteed that we can get the full path.

    We can use a combination of readelf and ldd.

    If we use readelf -a, we can parse the output.


    One part of the readelf output:

    Section Headers:
      [Nr] Name              Type             Address           Offset
           Size              EntSize          Flags  Link  Info  Align
      [ 0]                   NULL             0000000000000000  00000000
           0000000000000000  0000000000000000           0     0     0
      [ 1] .interp           PROGBITS         00000000000002e0  000002e0
           000000000000001c  0000000000000000   A       0     0     1
    

    Note the address of the .interp section. It is 0x2e0.


    If we open the executable and do a seek to that offset, we can read the ELF interpreter string. For example, here is [what I’ll call] fileBad:

    000002e0: 2F6C6962 36342F7A 642D6C69 6E75782D  /lib64/zd-linux-
    000002f0: 7838362D 36342E73 6F2E3200 00000000  x86-64.so.2.....
    

    Note that the string seems a little odd … More on that later …


    Under the "Program Headers:" section, we have:

    Program Headers:
      Type           Offset             VirtAddr           PhysAddr
                     FileSiz            MemSiz              Flags  Align
      PHDR           0x0000000000000040 0x0000000000000040 0x0000000000000040
                     0x00000000000002a0 0x00000000000002a0  R      0x8
      INTERP         0x00000000000002e0 0x00000000000002e0 0x00000000000002e0
                     0x000000000000001c 0x000000000000001c  R      0x1
          [Requesting program interpreter: /lib64/zd-linux-x86-64.so.2]
    

    Again, note the 0x2e0 file offset. This may be an easier way to get the path to the ELF interpreter.

    Now we have the full path to the ELF interpreter.


    We can now do ldd /path/to/executable and we’ll get a list of the shared libraries it is/will be using. We’ll do this for fileGood. Normally, this looks like [redacted]:

    linux-vdso.so.1 (0x00007ffc96d43000)
    libpython3.7m.so.1.0 => /lib64/libpython3.7m.so.1.0 (0x00007f36d1ee2000)
    ...
    libc.so.6 => /lib64/libc.so.6 (0x00007f36d1ac7000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f36d23ff000)
    ...
    

    That’s for a normal executable. Here’s the ldd output for fileBad:

    linux-vdso.so.1 (0x00007ffc96d43000)
    libpython3.7m.so.1.0 => /lib64/libpython3.7m.so.1.0 (0x00007f36d1ee2000)
    ...
    libc.so.6 => /lib64/libc.so.6 (0x00007f36d1ac7000)
    /lib64/zd-linux-x86-64.so.2 => /lib64/ld-linux-x86-64.so.2 (0x00007f3f4f821000)
    ...
    

    Okay, to explain …

    fileGood is a standard executable [/bin/vi on my system]. However, fileBad is a copy that I made where I patched the interpreter path to a non-existent file.

    From the readelf data, we know the interpreter path. We can check for existence of that file. If it doesn’t exist things are [obviously] bad.

    With the interpreter path we got from readelf, we can find the output line from ldd for the interpreter.

    For the good file, ldd gave us the simple interpreter resolution:

    /lib64/ld-linux-x86-64.so.2 (0x00007f36d23ff000)
    

    For the bad file, ldd gave us:

    /lib64/zd-linux-x86-64.so.2 => /lib64/ld-linux-x86-64.so.2 (0x00007f3f4f821000)
    

    So, either ldd or the kernel detected the missing interpreter and substituted the default one.

    If we try to exec fileBad from the shell we get:

    fileBad: Command not found
    

    If we try to exec fileBad from a C program we get an ENOENT error:

    No such file or directory
    

    From this we know that the kernel did not try to use a "default" interpreter when we did an exec* syscall.

    So, we now know that the static analysis we did to determine the ELF interpreter path is valid.

    We can be assured that the path we came up with is [will be] the path to the ELF interpreter that the kernel will map into the process address space.


    For further assurance, if you need to, download the kernel source code. Look in the file: fs/binfmt_elf.c


    I think that’s sufficient, but to answer the question in your top comment

    with that solution would I not have to race to read /proc/<pid>/maps before the program terminates?

    There’s no need to race.

    We can control the fork process. We can set up the child to run under [the syscall] ptrace, so we can control its execution (Note that ptrace is what gdb and strace use).

    After we fork, but before we exec, the child can request that the target of the exec sleep until a process attaches to it via ptrace.

    So, the parent can examine /proc/pid/maps [or whatever else] before the target executable has executed a single instruction. It can control execution via ptrace [and, eventually, detach to allow the target to run normally].

    Is there a way to predict what PID will be generated next and then wait on its creation in /proc?

    Given the answer to the first part of your question, this is a bit of a moot point.

    There is no way to [accurately] predict the pid of a process we fork. If we could determine the pid that the system would use next, there is no guarantee that we will win the race against another process doing a fork [before us] and "getting" the pid we "thought" would be ours.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search