Технологии безопасности

Сайт посвященный вопросам безопасности

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

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

Я часто сталкивался с необходимостью отлаживать 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);