Linux.Lacrimae 0.30 (x) 2007-2008 by herm1t
В начале я написал довольно большую доку к этому вирусу, по шагам
описывающую его работу. Сейчас я думаю, что подробно разбирать здесь
отдельные функции вируса и приемы работы с заголовками ELF-файлов
просто не стоит. Код получился довольно большой и неряшливый, поэтому
попытаюсь просто рассказать, что делает вирус, и почему отдельные фичи
реализованы именно так. Возможно я еще напишу руководство о линуксовых
вирусах. А может и нет, в любом случае, если что-то непонятно, то
лучше лезьть за объяснениями не в журналы, и не на форумы, а в
исходники и стандарты. Всё ведь есть: System V ABI/386, TIS ELF,
исходники libc и ядра, man в конце концов.
Основной принцип изложен Z0mbie в "Методологии недетектируемого
вируса". Если совсем кратко, то файл разбирается на части: заголовки,
инструкции, куски данных, код вируса смешивается с кодом жертвы, и все
собирается обратно. В Линуксе это стало возможным только с появлением
PIC-экзешников, или просто PIE. Фича была добавлена году в 2003, и
должна по идее увеличивать безопасность системы. Как PIE собраны самые
"опасные" программы. Но, как известно, если в одном месте прибыло, то
в другом непременно убудет. Падает производительность, с тормозами
борется prelink(8), который в свою очередь затрудняет проверку
целостности. И что самое главное - в исполняемых файлах появились
релоки для данных, а код стал позиционно-независимым, что собственно и
позволяет вирусу двигать код жертвы, как ему заблагорассудиться.
Дизассемблирование
Итак. Вирус нашел файл (search) загрузил его в память (load_elf),
провел перед этим необходимые проверки (elf_check, тип файла ET_DYN,
наличие таблицы секций, определенный набор секций). Подходит. Будем
заражать. Для начала дизассемблируем код . Просто скормим загруженную
секцию дизассемблеру (в начале я использовал XDE, но затем сделал
собственную и сильно отличающуюся версию этого движка - YAD), а
результат поместим в список структур (code_t). Для быстрого доступа к
элементам списка заведем массив указателей с количеством элементов
равным размеру секции. Если, к примеру, понадобиться проверить, есть
ли по заданному смещению инструкция, достаточно написать:
if (fast[offset] != NULL)
... вместо
for (c = code; c != NULL; c = c->next)
if (c->src - map == offset) {
...
break;
}
Чтобы впоследствии можно было востановить выравнивание отдельных
инструкций, вирус сравнивает код со стандартными заливками
генерируемыми binutils. Жертву дизассемблируем сверху вниз без учета
переходов. Если наткнемя на данные в коде - так нам и надо, вылетим с
ошибкой. Хотя стандартных способов создавать _такие_ PIE нет, и весь
софт, который использует ассемблерные вставки и данные в коде, как PIE
просто не собирается. Так что различение кода и данных - это
последнее, о чем стоило бы беспокоиться. Кроме того, используемые
структуры данных не очень для этого подходят. Вирус работает с
секциями, а код содержиться, как минимум в четырех секциях (.init,
.fini, .plt, .text), связанных между собой переходами.
Многочисленные заголовки и инициализированные указатели в сегменте
данных мы исправим, вся необходимая информация имеется в таблице
релоков (.rel.dyn). Необходимо найти те места, в коде, где вычисляются
адреса. Так что, некоторые пояснения о внутреннем устройстве ELF-ов
просто необходимы.
Position Independent Executables
PIE - это ELF-файл с типом ET_DYN и нулевым базовым адресом.
Естественно, что в память он будет загружаться совсем по другому
адресу, и для того, чтобы программа заработала, загрузчику и самой
программе придется постараться. Код имеет атрибут read-only и /не
исправляется/ загрузчиком. Если функция не является листовой (не
вызывает других функций) или обращается к глобальным переменным, то
используется такая вот конструкция:
130b: e8 23 00 00 00 call 1333
# 1310+405c=536c (.got.plt)
1310: 81 c3 5c 40 00 00 add $0x405c,%ebx
.......................................................
# eax = .got.plt - 16236 = 0x1400 (main)
1326: 8d 83 94 c0 ff ff lea 0xffffc094(%ebx),%eax
132c: 50 push %eax
132d: e8 b2 fd ff ff call 10e4 <__libc_start_main@plt>
.......................................................
1333: 8b 1c 24 mov (%esp),%ebx # (1)
1336: c3 ret
.......................................................
133c: e8 00 00 00 00 call 1341 # (2)
1341: 5b pop %ebx
1342: 81 c3 2b 40 00 00 add $0x402b,%ebx
Все вычисления адресов идут относительно начала секции .got.plt. Если
функция не вызывает других функций, то вместо ebx может использоваться
другой регистр. Для вычисления значения PIC-регистра используются две
конструкции (1) и (2). Первая в функциях CRT, добавляемых
компилятором, вторая собственно в программе.
Анализ
Вирус проходит по спискам инструкций (mark) на этот раз в порядке
исполнения (чтобы не получилось так, что PIC-регистр используется _до_
инициализации; вряд ли gcc сгенерирует такой код, но эта фишка нам еще
пригодится); заполняет поля "link" для команд перехода (указатель на
структуру с инструкцией, на которую осуществляется переход), ищет
обращения ко внешним функциям, ищет инициализацию PIC-регистра и
команды, которые используют этот регистр. Помечает найденные команды
флагами:
* FL_GOTPTR1 - call 0f / 0: pop reg / add reg, imm32 (флаг стоит на
add)
* FL_GOTPTR2 - call 0f / add reg, imm32 / ... / 0: mov reg, esp /
ret
* FL_GOTOFF - mod==2 && (rm==pic_reg|base == pic_reg|index ==
pic_reg)
* FL_EXTERN - обращение ко внешней функции (поле symbol - имя
функции)
* FL_SEEN - код без этого флага управления никогда не получит
Если команда с FL_GOTOFF ссылается на код, то этот адрес тоже
проверяется.
JMP-таблицы
Вот собственно и найдены все инструкции в коде требующие исправления.
И всё было бы замечательно и просто, если бы не jmp-таблицы. Массив с
адресами переходов лежит в .rodata, адрес массива вычисляется так же,
как и любой другой адрес, но необходимо узнать не только адрес
массива, но и его размер. Выглядит jmp-таблица (самый простой случай)
вот так:
145e: 83 f8 34 cmp $0x34,%eax
1461: 77 35 ja 1498
1463: 8b 84 83 90 ea ff ff mov 0xffffea90(%ebx,%eax,4),%eax
146a: 01 d8 add %ebx,%eax
146c: ff e0 jmp *%eax
Вирусу нужен аргумент "cmp" - это и есть размер таблицы. Наткнувшись
на команду "jmp reg", вирус просматривает код в обратном направлении в
поисках адреса таблицы (в данном случае это команда "mov"), найдя
адрес и запомнив индексный регистр, просматривает код дальше в поисках
"cmp индекс, imm". Если такая команда найдена, то у вируса есть все
необходимое - адреса выгребаются из таблицы и запоминаются для
последующего исправления. Если, что-либо найти не удалось, или
найденные адреса указывают не на код - значит фатальная ошибка, этот
файл останется не зараженным.
На этом злоключения с jmp-таблицами не только не заканчиваются, но
только начинаются. Индекс таблицы может находиться не в регистре, а в
локальной переменной:
3d60: 83 7d e8 04 cmpl $0x4,0xffffffe8(%ebp)
3d64: 0f 87 8b 03 00 00 ja 40f5
3d6a: 8b 55 e8 mov 0xffffffe8(%ebp),%edx
3d6d: 8b 84 93 30 de ff ff mov 0xffffde30(%ebx,%edx,4),%eax
3d74: 01 d8 add %ebx,%eax
3d76: ff e0 jmp *%eax
Для работы с такими таблицами вирус пытается отслеживать пересылки
между регистрами и локальными переменными. Причем, как для кода со
стек-фреймом, так и без него (обращение к переменным идет через esp, а
не ebp) - в последнем случае у вируса, на самом деле, практически нет
шансов. Вообще весь код для разбора сложных jmp-таблиц довольно
уязвим, но работает, а сломается, ну что же - поищем другой файл,
попроще. Есть и другие способы, но они требуют более детального
анализа кода и используются в декомпиляторах.
Для дизассемблирования тела вируса применяются те же процедуры. Код
вируса, чтобы его можно было потом отличить от кода жертвы, помечается
флагом FL_ME. Вирус не должен содержать неинициализированных данных в
.bss, jmp-таблиц, релоков, и указателей на код. Только указатели на
данные. Ничего принципиального в этих ограничениях нет, при желании
можно добавить недостающие возможности, но это потребовало бы
дополнительного кода, которого и так не мало.
Импорты
Теперь позаботимся о том, чтобы у вируса на новом месте были все
необходимые для работы функции. Ищем в вирусе все call-ы с флагом
FL_EXTERN, ищем в импортах жертвы функции, которые они вызывают. Если
чего-то не хватает - расширяем соответствующие секции (extend_section)
и добавляем (add_symbol): имя функции в .dynstr, символ в .dynsym,
хэлпер-код в .plt, релок в .rel.plt, адрес хэлпера в .got.plt и
наконец версию в .gnu.version. Пересчитываем заново .hash
(build_hash). Все.
Интеграция, мутация, оптимизация
У вируса теперь есть два списка полностью готовых к перемешиванию
(insert_code). Списки разрезаются на командах ret/jmp, чтобы не
нарушился порядок исполнения, и вирус не "провалился" в жертву или
наоборот, и соединяются в один (code_mixer). Кусок вируса, кусок
жертвы, кусок вируса... Ну и так далее.
Раз уж есть единый список, то можно позаботиться о такой мелочи, как
выравнивание. Вирус выкидывает "заливки", подсчитывает на какую
границу выровнена инструкция, следит за тем, чтобы на выкидываемой
заливке не стояла метка. Граница выравнивания запоминается (поле al).
Теперь можем делать с этим списком, всё что душа пожелает.
Дополнительно перемешаем блоки кода (M_BRO в config.h). Случайным
образом инвертируем условные переходы (M_IJCC), мутируем некоторые
инструкции (MUTATE) - это только набросок, код взят из RPME и BI_PERM,
и еще один кусочек написан по мотивам Lexotan.
Пора начинать думать о том, как все это собирать в работающую
программу. Выясним длину команд перехода (short/near jmp) (OJMP в
config.h). Расширим секцию данных, чтобы добавить впоследствии туда
собственные данные. Добавим call на вирус в .init или точку входа в
вирус в .ctors.
Сборка и линковка
Соберем файл (build_elf). Скопируем заголовок ELF, таблицу сегментов
(PHT) и начнем копировать секции из загружаемых сегментов, не забывая
запоминать новые адреса и смещения. Если нужно, выравниваем секции.
Если секция кодовая - ассемблируем ее, копируем каждую команду из
списка, не забывая про выравнивание, запоминаем ее новый адрес. Если
секция с данными - просто копируем ее. Исправляем остальные записи (не
PT_LOAD) в PHT. Копируем секции, которые не загружаются в память.
Копируем и исправляем таблицу секций (SHT). Копируем все, что было
просто дописано к файлу и не принадлежит ни одной из секций.
А сейчас - линковать и фиксить, то что получилось (fix_headers).
Исправим .rel.dyn и создадим заново .rel.plt и .got.plt, когда вирус
добавлял импорты, сделать это было невозможно, так как не были
известны адреса секций.
Пора заняться кодом. У вируса теперь есть новый адрес .got.plt
(относительно которого идут вычисления адресов), так что самое время.
Для каждой команды: вычислим адреса переходов на plt-хэлперы для
вызовов внешних функций; вычислим новые смещения относительно
.got.plt, для команд обращающихся к данным и ассемблируем их заново;
исправим адреса условных и безусловных переходов; исправим смещения от
текущего адреса до .got.plt (FL_GOTPTR?). Исправим все адреса из
jmp-таблиц, которые вирус запомнил. Исправим таблицы символов (.dynsym
и опционально - .symtab). На исправление отладочной информации, меня
уже не хватило :-) Установим точку входа. Скопируем данные вируса, в
предварительно расширенную секцию .data. Заполним, некоторые
переменные в копии данных. Зашифруем данные. Поставим маркер
заражения. Исправим значения в .dynamic. Все готово. Запишем файл,
освободим память (но хитро, так чтобы список с кодом вируса остался и
его не пришлось бы дизасмить заново (freec)), и продолжим поиск.
Немного деталей
Теперь о том, же самом только подробнее. "Фиксим", "исправляем", а как
"фиксить"? Предположим, что у нас есть некий абсолютный адрес, который
нужно пересчитать, и старая и новая таблица секций, плюс код в
списках. Для начала преобразуем адрес в пару <индекс_секции, смещение>
(section_io_by_addr). Если секция с данными, то новый адрес = новый
адрес секции + смещение, а если с кодом, то: fast[смещение]->адрес
(fix_addr). Если нужен не абсолютный, а относительный адрес, то: новый
отн. адрес = fix_addr(old_gotoff + старый отн. адр.) - new_gotoff, где
old_gotoff и new_gotoff - старый и новый адреса .got.plt
соответственно.
Prelink
Когда загружается, к примеру, KDE-ная аппликуха, она тянет за собой
еще кучу библиотек. Настройка программы и библиотек, даже если
разрешение адресов происходит "лениво" не быстрый процесс. С этим и
борется prelink: назначает всем программам и библиотекам базовые
адреса, связывает внешние вызовы, сохраняет информацию необходимую для
отката изменений. Что делать вирусу? Просто запомнить базовый адрес
(адрес сегмента со смещением 0, переменная prelink в вирусе) и
преобразовать все абсолютные адреса в относительные, то есть RVA. Все
что нужно сделать - это добавить или вычесть базовый адрес. Таких мест
в вирусе не очень много.
Нужно также исправить undo информацию prelink'а (это просто EHDR, PHT
и SHT, какими они были до прелинковки, то есть базовый адрес равен
нулю, и нет секций добавленных prelink'ом), и исправить контрольную
сумму. Есть одно, а может быть даже и не одно "но". Если проверить
зараженный файл с помощью "prelink -y", то prelink сообщит о том, что
"файлы отличаются". Что произошло? Когда вирус добавлял импорты, то в
.got.plt, он записывал адреса хэлперов в .plt, а prelink записывает
настоящие адреса функций. Можно смириться, а можно включить
PRELINK_PERFECT в config.h.
Это муторная операция. Чтобы выяснить путь к библиотеке, вирус
запускает интерпретатор. Загружает библиотеку в память и ищет все
необходимые адреса функций. Делает он это совсем не так, как это делал
бы RTLD, поэтому на некоторых файлах, prelink все равно будет
ругаться. Это поправимо, но копировать функции RTLD в вирусе - это
явный overkill. Работать будет в любом случае.
Ограничения на код вируса
Теперь об ограничениях. Чтобы не добавлять собственных релоков (на
адреса точки входа в вирус, .dynsym, .dynstr, .rel.plt), вирус
независимо от наличия или отстутствия прелинка, записывает RVA, а
затем во время исполнения вычисляет базовый адрес (get_base) и
добавляет его к значениям переменных. Начальные значения переменных
записываются утилитой patch, для следующего поколения вируса значения
переменных, записываются предыдущим.
Вирус работает только с одной секцией - .data. Поэтому нельзя
использовать ни адреса функций (в .text), ни неинициализированные
переменные (в .bss), ни jmp-таблицы (.rodata). Иначе пришлось бы
запоминать на какую именно секцию ссылается команда вируса, и какие
секции нужно расширить (иначе перестанет работать section_io_by_addr).
На самом деле одно такое значение всё-таки имеется (если включен
EXTEND_CTOR).
Отличия от версии 0.25.2 (Описанной в "Crimea river")
* Вместо XDE используется его форк - YAD
* Частично поддерживаются индексы jmp-таблиц в локальных переменных
* Поддержка prelink
* Разбор .gnu_version_r
* Инвертирование условных переходов
* Оптимизация jmp-ов
Заключение
Пожалуй все. Вирус работает не очень быстро, скорее всего есть и баги,
и местами - утечки памяти. Данный способ заражения предоставляет
гораздо больше возможностей, чем реализовано в текущей версии. Но это
только первая демонстрация интеграции в Линукс, а все остальное
приложиться. Ж-)
herm1t@vx.netlux.org, may, 2008