Back to course

Interposition

23 minadvancedModule 1: The ScopeContinues from dyn-scope
23 min · Module 1: The Scope · Advanced

Why This Matters

Add one function to the previous concept’s program. That is the entire change:

/* main2.c -- ONE extra line compared with main.c */
extern int lib_value(void);
extern int mid_value(void);
int lib_value(void){ return 100; }   /* <-- the only difference */
int main(void){ printf("  %d %d\n", lib_value(), mid_value()); return 0; }

And here is what it does:

$ ./prog
  lib_value()=1    mid_value()=11
$ ./prog2
  prog's lib_value()=100   libb's view of it=110

mid_value() is 110, not 11. And mid_value is not in the program at all — it lives in libb.so, which knows nothing about main2.c and was compiled and linked without it. A function inside a library, calling another function by name, reached a definition in the executable that neither of them mentions.

This is interposition, and it is the mechanism behind an enormous amount of otherwise-unexplainable behaviour: why a program can replace malloc without asking, why LD_PRELOAD works, and why the same binary behaves differently on two machines.

A Simple Model

There is nothing mysterious here, and that is the point. Walk the algorithm from the previous concept on this exact case.

prog2's scope: 0 prog2 <-- the executable. ALWAYS index 0. 1 libb.so 2 liba.so 3 libc.so.6 libb.so has an undefined reference to `lib_value'. the loader searches the scope in order: index 0 prog2 defines lib_value? YES -> STOP | +-- and prog2 was searched BEFORE liba.so, the library that actually exports it result: libb.so's call is bound to prog2. that is the whole thing. no policy, no special case, no flag. an ordinary search that found an earlier match.

“The executable is index 0” is the entire design decision. Put it anywhere else and this stops happening; put it first and the program can override any library without the library’s cooperation. That is not an accident of glibc — it is what every ELF dynamic linker does, and it is the answer to the question “why can a program replace a library function?”.

Why the design goes that way, which is a design question rather than a fact:

the alternative -- libraries first, executable last -- would make it IMPOSSIBLE for a program to fix a bug in a library, override a slow function, or substitute a stub for one it cannot run. every one of those is something people genuinely need to do. the cost of the choice: a library cannot rely on its own functions. any symbol it exports and calls is a candidate for replacement by whoever is loaded before it. that is a real constraint on library design, and it is why the next concepts in this module are about controlling what a library exports and what its internal calls bind to.

So interposition is a feature with a price, and the price is paid by library authors rather than by program authors. A library that wants a call to be truly its own has to ask for it — and that request is a flag.

Does It Actually Work?

The loader’s own account, which is the evidence that nothing unusual happened:

$ LD_DEBUG=bindings ./prog 2>&1 | grep lib_value
  binding file ./prog    [0] to .../liba.so [0]: normal symbol `lib_value'

$ LD_DEBUG=bindings ./prog2 2>&1 | grep lib_value
  binding file .../libb.so [0] to ./prog2 [0]: normal symbol `lib_value'

Read the two lines as a pair, because the difference is the whole concept. In the first, libb.so bound to liba.so — the natural answer. In the second, libb.so bound to ./prog2, the executable, and the line is otherwise identical. The loader is not reporting an anomaly. It is reporting a search that succeeded earlier than expected.

Now the part that makes the point land. It is not just the program’s own call that was redirected — the library’s was:

$ python3 dynscope.py search ./prog2 lib_value
Every definition of lib_value in scope order
    0  prog2       0x1150  <== WINS
    2  liba.so     0x1100

Two definitions, and the loser is the one that exports it deliberately. The library is not being asked politely; it is simply later in the list. And the loader’s answer and the artifact’s answer agree, computed from completely different inputs.

Now the same thing at scale, because one function is not a mechanism. LD_PRELOAD is interposition with the scope edited rather than the source:

$ cat myprintf.c
#define _GNU_SOURCE
#include <stdio.h>
#include <dlfcn.h>
int printf(const char *fmt, ...){
  static int (*real)(const char *, ...) = NULL;
  if (!real) real = dlsym(RTLD_NEXT, "printf");
  fputs("[mine] ", stderr);
  return real(fmt);
}

$ clang -shared -fPIC -o myprintf.so myprintf.c -ldl
$ LD_PRELOAD=$PWD/myprintf.so ./prog
  [mine]   lib_value()=1    mid_value()=11

The library’s internal printf call went through the preloaded one. Two things in that program are worth noticing, and both are mechanisms this course covers later:

And the honest measurement of the cost, which is a real number and not a slogan:

$ LD_DEBUG=bindings ./prog 2>&1 | grep -c "binding file"
  99                                  (nothing preloaded)
$ LD_PRELOAD=/lib/x86_64-linux-gnu/libm.so.6 LD_DEBUG=bindings ./prog 2>&1 \
      | grep -c "binding file"
  117                                 (eighteen MORE)

Interposition is not free, and the first draft of this page claimed it was. The measurement says 99 bindings normally and 117 with a library preloaded, and the extra eighteen are the reason: a preloaded library brings its own undefined references with it, and the loader has to resolve those too. You did not just retarget one answer; you added an object to the scope and the object brought work.

What is true, and worth separating from what is not: retargeting a single symbol costs nothing measurable, because a search that succeeds at index 0 is not slower than one that succeeds at index 3. The cost is the whole library, not the interposition.

A Real Example

The case that makes this a security property rather than a curiosity: an attacker-supplied library that wins a name and is never linked against anything.

  a program runs, unprivileged, and somewhere a
  writable directory is on its search path.

  attacker drops libsqlite3.so.6.10.0 there, exporting
  every name the real one exports, and every one of
  them runs attacker's code.

  the program's DT_NEEDED says "libsqlite3.so.6". the
  loader finds the ATTACKER's file first, because of
  where it is looking, and from that moment every
  sqlite call in the program -- including the ones
  inside the real library, if the real library got
  loaded at all -- is attacker's.

  nothing in the program changed. no flag, no
  environment variable the program set, no
  relinking. the same binary, run as the same user,
  with the same arguments.

This is the direct consequence of the two measured facts above, and it is why “search the scope in order, executable first” is a design decision with a security cost. The mitigations are all about narrowing the search rather than changing the rule:

  DT_RUNPATH not DT_RPATH   RUNPATH is searched AFTER
                         LD_LIBRARY_PATH, so a set
                         LD_LIBRARY_PATH overrides it.
                         RPATH is searched BEFORE, so
                         the binary wins -- and a
                         directory the attacker controls
                         earlier in RPATH wins over
                         everything. that ordering
                         difference is the whole reason
                         the deprecation happened.

  -z nodefaultlib    drop /lib and /usr/lib from
                     the search, forcing everything
                     to be named.

  absolute soname    a DT_NEEDED of "/opt/x/libfoo.so"
                     is not searched for at all.

  and the real fix, which is not a linker flag:
  do not have a writable directory on the path.

Which is the honest place to end the security thread: the mechanism is a search order, and the defence is mostly about the directories the search covers. The Relocations course argued that PIE removes address-prediction attacks and does nothing for a stack pointer or a vtable; this is the same shape. Interposition is not address randomisation and is not fixed by it, because there is no address to randomise — there is a name, and the name is resolved by order.

One more thing worth measuring, because it is the boundary of the effect. Only symbols the library actually exports are candidates. A static function, or one marked hidden, is not in .dynsym, so nothing can interpose on it:

$ nm -D --defined-only liba.so | awk '{print "  exported: "$3}'
  exported: lib_value
$ nm liba.so | awk '/ t /{print "  local:    "$3}'
  local:    deregister_tm_clones
  local:    _fini
  local:    static_helper

Three local symbols that the loader will never consider — two of them the C runtime’s, and static_helper is the one this course put in lib.c to make the point checkable. All three are in the full symbol table with a lower-case t and none is in .dynsym.

The loader searches .dynsym and only .dynsym, so a symbol that is not in that list is not a candidate for anything — not for interposition, not for dlsym, not for another library to bind against. And that single fact is the subject of the next module, which is entirely about deciding what lands in that list.

Try It Yourself

$ cd courses/dyn/assets/samples
$ ./build_samples.sh 2>&2 | sed -n '/D2/,/D3/p'
$ python3 crosscheck.py 2>&2 | sed -n '/\\[2\\]/,/\\[3\\]/p'
$ python3 dynscope.py search ./prog2 lib_value

Then interpose on something real, which is where it stops being a demonstration:

  1. Interpose on `puts` using LD_PRELOAD, the way
     the concept does. Now interpose on MALLOC. Count
     the allocations before and after: a counting
     malloc is a real tool and this is how it is built.

  2. Remove RTLD_NEXT and replace it with dlsym(h,
     "printf"). What happens? (It recurses until the
     stack runs out. Predict the failure before you run
     it, then run it in a subshell.)

  3. The executable defines lib_value and gets 100. Now
     give liba.so a WEAKER definition of the same name
     and link prog2 against it. Does the executable
     still win? (It should -- weak vs strong is a
     LINK-time rule, and this is a run-time search.
     Working out why is the exercise.)

  4. Build a library with -fvisibility=hidden and try
     to interpose on a function in it. Can you? (You
     cannot, and the reason is one line of the previous
     concept.)

  5. LD_PRELOAD a library that exports a symbol the
     program does not even reference. Does anything
     change? (No, and checking WHY -- the loader only
     binds symbols that are actually undefined somewhere
     -- is the point.)

Exercise 3 is the one that teaches the boundary between the two passes, and it is a question people get wrong with confidence. Weak and strong is a link-time decision, made by ld, about which definition ends up in the output. The run-time scope search has no opinion about weak symbols at all — it takes the first object that defines the name, and by the time it runs the question has already been settled. prog2 wins because it is index 0, not because it is strong.

Exercise 1 is the one that is worth doing for its own sake. A counting allocator built as an LD_PRELOAD is about thirty lines, it needs no cooperation from the program, and it is how profilers, tracers and leak checkers have been built for thirty years. Interposition is not a curiosity you will read about again; it is a tool you will write.

Connect

This is the second of three scope concepts and the one with the most consequences. The Global Scope gave the algorithm; this one walks through its first consequence and shows that it reaches further than anyone expects. Breadth-First takes the second. All three are one idea, and after this module the phrase “resolved at run time” should mean “first match in an ordered list” rather than “looked up somehow”.

The connection to the symbol-resolution course’s visibility concept is the one that makes this a design tension rather than a curiosity. That concept found that visibility("hidden") and visibility("internal")demote the binding to local and remove the symbol from .dynsym entirely, so that for two of the four visibility values the answer is not in st_other at all. That is the mechanism this concept depends on. Hidden is not a weaker kind of visible; it is the mechanism by which a symbol stops being a candidate, and therefore the way a library opts out of being interposed on.

The second connection is to LD_PRELOAD and therefore to the whole family of runtime tricks, and the ELF course already stated that the variable exists without explaining what it does. Now it can: LD_PRELOAD puts libraries at the front of the scope, ahead of the executable, which is the same rule as “executable first” with one more step in front. A reader who has this concept does not need to be told which of two symbols will win; they can derive it.

And the connection that closes the module is the security framing, which links forward rather than back. Does the Executable Actually Move? in the Relocations course listed the attack classes PIE removes and, pointedly, the ones it does not: a return address on the stack, a vtable pointer in read-only data. Interposition is a fifth class, and it is the only one with no address involved at all. There is nothing to randomise: there is a name, and the name is resolved by order. That is why the mitigations in this concept are all about directories and flags rather than about entropy, and why a course that taught only PIE would leave this one wide open.

Forward into the building module, this concept creates the problem the next three solve. If a library’s exported symbols are all interposition candidates, then a library author needs control over two separate things: what it exports, and what its own internal calls bind to. Those are different questions with different answers, and Hiding Things, and the Trap is the first and One Instruction Called -Bsymbolic is the second. Keeping them apart is the point of having both.

render_lesson_js(&mut page)