Pull to refresh

Перехват вызовов функций нативных библиотек в Android приложениях

Reading time4 min
Views13K

Для чего это нужно


Я часто сталкивался с необходимостью отлаживать Android приложения, использующие нативный код. Иногда мне было нужно перехватить вызовы к bionic (libc), иногда к .so-шкам, к которым исходного кода у меня не было. Иногда приходилось включать в свои приложения чужие .so, к которым не было исходников и надо было подкорректировать их поведение.

Итак, как сделать LD_PRELOAD в Android?

Как широко известно, в обычном desktop-е Linux эта проблема легко решается использованием переменной окружения LD_PRELOAD. Этот фокус работает следующим образом: динамический линковщик ставит библиотеку из этой переменной в самое начало списка доступных библиотек. В результате, когда код пытается сделать библиотечный вызов в первый раз (lazy binding), линковщик байндит функцию именно на ту, которую мы определили в нашей библиотеке.

Это все замечательно, но в Android этот фокус не пройдет. Приложения, запущенные из UI, уже слинкованы к тому моменту, когда запускается код, написаный автором приложения. Чисто теоритически приложения можно запускать из командной строки и выставить LD_PRELOAD. Но это сложное занятие, да и работает только для дебага.

Немного о динамической компоновке


Для того, чтобы использовать динамические библиотеки, нужна возможность вызова их кода из других библиотек — и наоборот. Как уже скомпилированный код может вызвать код из другой библиотеки? Обычные операции перехода типа jmp/bx требуют адрес, но его мы заранее (в момент сборки .so) знать не можем, так как разные .so в памяти могут попасть в разные (или даже случайные) места. Можно банально в коде пропатчить адреса требуемых функций, когда все .so уже разложены в памяти. Но это не элегантно, медленно, требует записи в область кода, плюс каждому приложению пришлось получать свой собственный экземпляр кода и сбережение памяти не было бы.

Выход очень простой: прыжок происходит по адресу, записанному где-то вне секции исполняемого кода. И если этот адрес сделать не абсолютным, а относительным (например, записав его как смещение самой команды), то получается, что сам код можно размещать где угодно в памяти. А уже за ним уже размещается таблица PLT, procedure linkage table. Она обычно мапится как (r, или rw), а не eXecutable. В эту таблицу помещаются как раз «настоящие» адреса. Таблица может заполнятся как на старте, так и непосредственно во время выполнения, в lazy режиме.

Если собрать всё вместе, то для того, чтобы заставить модуль xxx.so при вызове функции yyy() прыгать в наш перехватчик, нужно:
  • найти PLT секцию в xxx;
  • посчитать/найти смещение для yyy() в PLT;
  • записать адрес нашей функции.


Собственно, перехват


В Android используется bionic и он слегка отличается от glibc, но принципиальных отличий нет. Внутренние данные хранятся в структуре soinfo и это связанный список из всех загруженных в данных момент .so.

В glibc dlopen() возвращает нам сферический void* в вакууме:

void *dlopen(const char *filename, int flag)


Но, посмотрев в исходники bionic, мы увидим, что возвращается заветный soinfo
soinfo* do_dlopen(const char* name, int flags)

Если библиотека уже загружена, нам вернут soinfo для нее. Ура, теперь у нас в руках вся информация об интересующей нас .so.

В ELF строчки с символами хранятся отдельно (strtab), отдельно структуры с описанием символов (symtab). Для самих символов (строковых констант) вычислен хэш, что позволяет быстро найти offset для интересующего нас символа.

подсчета хэша символа ELF
 static unsigned elfhash(const char *_name)  
 {  
   const unsigned char *name = (const unsigned char *) _name;  
   unsigned h = 0, g;  
   while(*name) {  
     h = (h << 4) + *name++;  
     g = h & 0xf0000000;  
     h ^= g;  
     h ^= g >> 24;  
   }  
   return h;  
 } 


Когда хэш посчитан, необходимо найти сивол.
поиск символа по хэшу
static Elf32_Sym *soinfo_elf_lookup(soinfo *si, unsigned hash, const char *name)  
 {  
   Elf32_Sym *s;  
   Elf32_Sym *symtab = si->symtab;  
   const char *strtab = si->strtab;  
   unsigned n;  
   n = hash % si->nbucket;  
   for(n = si->bucket[hash % si->nbucket]; n != 0; n = si->chain[n]){  
     s = symtab + n;  
     if(strcmp(strtab + s->st_name, name)) continue;  
       return s;  
     }  
   return NULL;  
 }


А вот и процедура замены нужного значения:
 int hook_call(char *soname, char *symbol, unsigned newval) {  
  soinfo *si = NULL;  
  Elf32_Rel *rel = NULL;  
  Elf32_Sym *s = NULL;   
  uint32_t sym_offset = 0;  
  uint32_t page_size = 0;

  if (!soname || !symbol || !newval)  
     return 0;  
  si = (soinfo*) dlopen(soname, 0);  
  if (!si)  
   return 0;  
  s = soinfo_elf_lookup(si, elfhash(symbol), symbol);  
  if (!s)  
    return 0;  
  page_size = getpagesize();
  sym_offset = s - si->symtab;  // индекс найденного символа
  rel = si->plt_rel;  
  /* идем по таблице релокаций пока не попадется нужный нам индекс */  
  for (int i = 0; i < si->plt_rel_count; i++, rel++) {  
   unsigned type = ELF32_R_TYPE(rel->r_info);  
   unsigned sym = ELF32_R_SYM(rel->r_info);  
   unsigned reloc = (unsigned)(rel->r_offset + si->base);  
   unsigned oldval = 0;  
   if (sym_offset == sym) {  
    switch(type) {  
      case R_ARM_JUMP_SLOT: 
         // нужно пометить страницу как RW, и адрес должен быть page-aligned
         mprotect((uint32_t *) reloc& (~(page_size - 1), page_size, PROT_READ | PROT_WRITE);
         oldval = *(unsigned*) reloc;  
         *((unsigned*)reloc) = newval;  
         return 1;  
      default:  
         return 0;  
    }  
   }  
  }  
  return 0;  
 }  


Теперь, чтобы перехватить connect() из libandroid_runtime.so, нам надо вызвать:

hook_call("libandroid_runtime.so", "connect", &my_connect);
Tags:
Hubs:
Total votes 31: ↑31 and ↓0+31
Comments2

Articles