tiny-elf

эльфы большие и маленькие

крис касперски ака мыщъх, no-email

даже при программировании на чистом ассемблере, elf файлы обычно получаются очень большими, но существует масса способов уменьшить их размер (с потерей совместимости с другими системами или без). давайте напишем обычную ассемблерную программу и, вырезая все лишнее, постепенно будем оптимизировать ее вплоть до полного экстрима.

Программирование на ассемблере под UNIX'ом многими рассматривается как извращение (http://www.wasm.ru/article.php?article=asmunixlot), граничащее со злостным преступлением, препятствующим переносу программы на другие платформы, даже если никакой перенос не планируется. Являясь колыбелью десятков языков, таких как C, Perl, Haskell, Lisp, Simula и AWK, UNIX поддерживает ассемблер лишь формально. Богатство средств разработки и нищета документации создают проблему даже для опытных программистов, впервые увидевших ассемблер еще на ZX-Spectrum (Электроника BK) и не расстававшихся с ним ни в MS-DOS, ни в Windows.

Классические трансляторы ассемблера (такие, например, как GAS – GNUAssembler) придерживается AT&T синтаксиса, совершенно несовместимого с «официальным» x86 синтаксисом, декларируемым самой фирмой Intel (достаточно сказать, что порядок операндов поменялся местами и всюду торчат эти жуткие суффиксы и префиксы #, $,%, l и проч. дребедень). Ну, тут можно и возразить, что AT&T-синтаксис появился в те «геральдические» времена, когда парни из Intel еще не слезли с деревьев. С другой стороны, в UNIX существуют множество трансляторов типа NASM'а и FASM'а, «переваривающие» привычный нам синтаксис, правда, не совпадающий ни с MASM'ом, ни с TASM'ом, а это значит, что прежде, чем начать программировать нам снова придется учиться!

Процесс обучения погружен в эротический полумрак, в котором, как и в первую ночь с женщиной, приходится действовать наугад. От изобилия сопутствующей литературы буквально рябит в глазах, но в большинстве случаев действие заканчивается там, где у нормальных людей процесс получения удовольствия только начинается. Отчаявшись найти нормальное руководство, мыщъх, махнул хвостом, и обобщив свой опыт ассемблерных похождений, попробовал заточить его сам. И вот что из этого получилось…

В своих экспериментах автор использовал дистрибутив KNOPPIX и BSD 4.5, включающие в себя следующие версии трансляторов:

программный продуктKNOPPIXBSD
as2.152.11.2
ld2.152.11.2
gcc3.3.42.95.3
NASM0.98.38

Таблица 1 версии программных продуктов, используемые в статье

При использовании других версий, размер получаемых файлов может быть другим, что следует учитывать читателям, при сравнении своих результатов с результатами автора.

Почему-то считается, что программировать на ассемблере под UNIX начинается с «прямого» общения с ядром в обход стандартной библиотеки libc. Мотивы этого заблуждения обычно крутятся вокруг чрезмерного увлечения оптимизацией. Дескать, файлы, использующие libc, медленные, неповоротливые и большие как слонопотамы. Согласен, в отношении программ типа «hello, world!» это действительно так, однако, в реальной жизни отказ от libc означает потерю совместимости с другими системами и ведет к необходимости переписывания уже давно написанного и отлаженного кода, в результате чего оптимизация превращается в «пессимизацию».

Никаких убедительных доводов для отказа от высокоуровневых языков еще никто не предложил ### приводил и прибегать к ассемблеру следует лишь тогда, когда компиляторы уже не справляются. На ассемблере обычно пишутся критические к быстродействию вычислительные модули, «перемалывающие» данные и вообще не обращающиеся ни к libc, ни к ядру. Если же все-таки по каким-то мотивам программа должна быть написана на ассемблере целиком, интерфейс libc будет хорошим выбором, уж поверьте мыщъх'у! Короче, первую брачную ночь с ассемблером мы проведем именно с этой библиотекой, а дальше вы сами решайте — оставаться ли с ней и дальше или идти штурмовать ядро.

Ассемблерные файлы имеют традиционное расширение .S, скрывающие сакраментальный мистический смысл, позволяющий нам ассемблировать программы при помощи… компилятора gcc! Кто сказал, что это извращение? Напротив! Распознав по расширению ассемблерную природу транслируемого файла, gcc пропускает его через gas, передавая полученный результат линкеру, благодаря чему процесс сборки существенно упрощается и мы получаем в распоряжение достаточно мощный сишный препроцессор, хоть и не такой мощный как в TASM, но это все-таки лучше, чем совсем ничего.

Естественно, ассемблируя программы «вручную», мы можем назначать им любые расширения, какие только захотим, и .asm в том числе (cuestióndegustos — как говорят в этих случаях испанцы). Но прежде, чем ассемблировать программу, ее нужно создать! Мы будем использовать стандартный для UNIX'а ассемблер 'as', на самом деле представляющий собой целое семейство ассемблеров для платформ различного типа (подробности в «man as»).

Структурно, программа состоит из секции кода, объявленной директивой «.text» и секции данных («.data»), которые могут располагаться в любом порядке, на размер сгенерированного файла это никак не влияет, все равно линкер переставит их по-своему.

Объявлять вызываемые libc-функции «внешними» (директива «.extern»), как это советует целый ряд авторов, совершенно необязательно. Имена функций пишутся как они есть, то есть без всяких там символов прочерка, на которые в частности ссылается Зубков в своей книге «Assembler — язык неограниченных возможностей», дескать иначе под BSD программа ассемблироваться не будет. Ничего подобного! Все работает только так!

Точка входа в программу означается меткой main, которая обязательно должна быть объявлена как global. В действительности, при запуске программы, первым управление получает стартовый код библиотеки libc, который уже и вызывает main. Если же такой метки там не окажется, линкер сообщит о неразрешимой ссылке и все.

Выходить из main можно как по exit(err_code), так и по машинной команде RET, возвращающей нас в стартовый код, корректно завершающий выполнение. Это короче, однако, в последнем случае мы теряем возможность передавать код возврата, который можно «подсмотреть» командой «echo $?» после завершения работы программы.

Согласно Си-соглашению, аргументы функций заносятся в стек справа налево, стек «чистит» вызывающий код. Вот, собственно, и все. С полученным «багажом» знаний уже можно писать программу. В нашем случае она будет выглядеть так (см. листинг 1).

.text

используемые функции объявлять внешними необязательно .externwrite

.extern exit .global main main: pushl$len pushl$msg pushl$1 callwrite addl$12, %esp ret .data msg: .ascii «hello,elf\n» len = . - msg Листинг 1 простейшая ассемблерная программа elf_libc.S Чтобы вдохнуть в ассемблерный файл жизнь, его необходимо прогнать через транслятор, чем мы сейчас и займемся: $gcc -o elf_libc elf_libc.S $ls -l elf_libc -rwxr-xr-x 1 root staff 12.096 2006-04-20 18:32 elf_libc $./elf_libc hello,elf Листинг 2 сборка нашей первой программы На диске образуется файл elf_libc, победоносно выводящий «hello,elf» на экран, но занимающий при этом… целых 12.096 байт (при трансляции под BSD – 4.270). Ну и монстр! Куда это годится?! А все потому, что компилятор самовольно прицепил символьную информацию, которая нам совершенно ни к чему. К счастью, ее очень легко отрезать штатной утилитой strip. $strip elf_libс $ls -l elf_libc -rwxr-xr-x 1 root staff 2.892 2006-04-20 18:36 elf_libc $./elf_libc hello,elf Листинг 3 обрезание символьной информации Файл сразу же похудел до 2.892 байт (под BSD — до 2.744), полностью сохранив свою работоспособность. С таким размером уже можно жить (особенно под BSD, где у мыщъха установлена старая версия компилятора, с годами становящегося все прожорливее и прожорливее). Естественно, сама операционная система тут не причем. А теперь, отказавшись от услуг gcc, попробуем собрать файл вручную. Под BSD это осуществляется так (см. листинг 4): $as -o elf_libc.o elf_libc.S $ld -s -o elf_libc /usr/lib/crt1.o elf_libc.o -lc $ls -l elf_libc -rwxr-xr-x 1 root wheel 2.108 2108 Apr 18:39 elf_libc $./elf_libc hello,elf Листинг 4 «ручная» сборка ассемблерной программы под BSD (подробнее о ключах трансляции см. man as и man ld) На диске образуется файл elf_libc с размером всего 2.108 байт, что на 636 байт короче сборки gcc с последующем стрипаньем символьной информации. То есть, «ручная» сборка намного эффективнее! CLinux'ом и всякими прочими SUN'ми и Solaris'ми в этом плане намного сложнее и стартовый код у них расположен черт знает где, но это еще полбеды. Значительно хуже, что стартовый код содержит дикие зависимости, влекущее за собой дополнительные библиотеки, находящиеся в самых непредсказуемых местах (см. рис. 1). Зубков дает несколько рецептов сборок (http://www.msiu.ru/~law10/index.htm?page=source%2Fhtml%2Fch11_04.htm), но на проверку они оказываются нерабочими. В частности, он пишет, что под Linux, программа должна компоноваться так: «ld -s -m elf_i386 -o helloelf.lnx /usr/lib/crt1.o /usr/lib/crti.o -L/usr/lib/gcc-lib/i586-cubbi-linuxlibc1/2.7.2 helloelf.o -lc -lgcc /usr/lib/crtn.o». Это же умом поехать можно, пока наберешь такую строку, но на моем knoppix'е она не работает, потому что директория /usr/lib/gcc-lib/ не содержит никакого i586-cubbi-linuxlibc1, а опция -lgcc дает ошибку, поскольку предписывает включать библиотеку libgcc.a, которая у меня (то есть у knoppix'а) опять-таки находится совсем не там, где ожидается. Если мыщъх не ошибается, то вариант Зубкова больше для cygwin'а подходит. Рисунок 1 реакция Linux'а на попытку ручкой сборки по типу BSD Что же делать? Приходится обращаться за помощью к gcc — уж он-то наверняка знает, где расположены его библиотеки. Ассемблируем файл транслятором as и передаем полученный elf_libc.o на компоновку компилятору gcc. Стрипаем символьную информацию и… получаем те же самые 2.892 байт, что и при автоматической сборке. $as -o elf_libc.o elf_libc.S $gcc elf_libc.o -o elf_libc $strip elf_libc $ls -l elf_libc -rwxr-xr-x 1 root staff 2.892 2006-04-20 19:06 elf_libc $./elf_libc hello,elf Листинг 5 «полуручная», «полуавтоматическая» сборка Выходит, что «полуавтоматическая» сборка под Linux'ом дает тот же самый результат, что и автоматическая, поэтому, никакого смысла работать руками здесь нет. ===== отладка ассемблерных программ — ночной кошмар ===== Редкая программа начинает работать сразу же после запуска. Практически всегда она содержит ошибки, требующие отладки. Высокоуровневые программисты находятся в более выгодном положении, поскольку значительная часть ошибок отсеивается компилятором еще на стадии трансляции, к тому же сам синтаксис языка делает программу намного более выразительной. Одиночные ассемблерные команды в отрыве от своего окружения — абсолютно бессмысленны и обнаружить ошибку путем визуального просмотра листинга очень тяжело. Отладка ассемблерных программ — это тот вопрос, который большинство составителей tutorial'ов предпочитают обходить стороной. Существует даже мнение, что нормальных отладчиков под UNIX вообще нет, а «великий и могучий» gdb ассемблер не переваривает в принципе (http://www.wasm.ru/comment.php?artcode=asmunixlot). Что ж! Давайте посмотрим, насколько это утверждение близко к истине. Пропустим ассемблерную программу через gcc, но на этот раз не будем удалять символьную информацию, которая, собственно говоря, для отладчика и предназначена. Загружаем elf_libc в gdb («gdb elf_libc»), тут же брякаемся на main («b main»), запускаем программу командой «r» и, дождавшись срабатывания точки останова, пробуем трассировать (команда «s» — трассировка без захода в функции, «n» – с заходом). Отладчик тут же слетает с катушек, ругаясь на отсутствие информации о номерах строк. Оба на! И хотя отладка на ассемблерном уровне (не путать с уровнем исходных текстов!) все-таки доступна (даем команду «display/i $pc» для отображения ассемблерных мнемоник и ведем трассировку командами «si» и «ni» соответственно), но в этом случае мы теряем всю информацию об именах функций, метках, переменных, короче говоря, львиная доля смысла листинга уходит в никуда. Вот тут кто-то говорит, а какая нам, собственно, разница? Ведь ассемблерные команды одни и те же, ну а без имен и меток мы как ни будь переживем. Ага!!! Попробуйте отладить реальную программу, а не учебный пример, сразу же взвоете! Нет, надо действовать не так! Рисунок 2 отладка ассемблерной программы без символьной информации Если отладочной информации нет, это еще не означает, что ее нельзя подключить! В частности, у gcc за это отвечает ключ «-g», а сам процесс сборки выглядит так (см. листинг 6). $gcc -g -o elf_libc elf_libc.S $ls -l elf_libc -rwxr-xr-x 1 root staff 12.268 2006-04-20 19:09 elf_libc $dbg elf_libc Листинг 6 автоматическая сборка ассемблерной программы с отладочной информацией под Linux Ого! Размер файла после подключения отладочной информации возрос до 12.268 байт, что на 172 байта больше, чем у файла, собранного нормальным способом (без отрезания символьной информации, конечно). Грузим программу в отладчик, вновь брякаемся на main, говорим «r» и… чудо! Команды «s» и «n» теперь нормально работают, отображая программу так, как она выглядела в исходном тексте! Рисунок 3 отладка ассемблерной программы на уровне исходных текстов Правда, под BSD этот прием не срабатывает и для подключения отладочной информации приходится собирать программу вручную. Транслятору ассемблера необходимо указать ключ «–gstabs», а у линкера — отобрать ключ «-s», отвечающий за удаление всей отладочной информации. Короче, это выглядит так: $as –gstabs -o elf_libc.o elf_libc.S $ld -o elf_libc /usr/lib/crt1.o elf_libc.o -lc $ls -l elf_libc -rwxr-xr-x 1 root wheel 3.145 2108 Apr 19:09 elf_libc $dbgelf_libc Листинг 7 ручная сборка ассемблерной программы с отладочной информацией под Linux и BSD Размер файла с отладочной информацией составляет… всего 3.145 байта, что намного меньше чем при автоматической сборке с gcc, при этом программа нормально отлаживается! Так что делайте выводы и решайте на чем сидеть и с кем дружить! ===== программирование без libc — штурм ядра ===== Интерфейс системных вызовов (они же syscall'ы) это «задний двор» операционной системы, это ее собственная и к тому же недокументированная кухня. Системные вызовы и native-APIWindows NT стоят на одной степени, причем у Гейтса native-API намного более предсказуемо и документировано. Вот тут кто-то опять порывается крикнуть, что Linux'у никакая документация совсем не нужна, он распространяется в исходных текстах и документирует себя сам. Чушь! Если не сказать провокация. Исходный текст — это не документация! Это программа, в которой нужно очень долго и нужно ковыряться, прежде чем удаться хоть что-то понять. Термин «документация» происходит от слова «документ», а документ предполагает внятное описание материала, изложенного в установленной форме. Более того, «документированные API функции» это стандартизированные API-функции! Различные клоны UNIX'а используют свои собственные способы взаимодействия с ядром, число которых намного больше десятка! Даже разные ядра одной и той же системы могут вносить в syscall'ы непредсказуемые изменения. Возьмите Linux и сравните ядро 2.4 с ядром 2.6. А для большинства коммерческих UNIX'ов исходные тексты вообще недоступны. Что толку с того, что мы знаем как вызвать такой-то syscall на отдельно взятом ядре? Где надежда (я уже не говорю о «гарантиях») что наш файл запуститься на соседней машине? Реально в syscall'ах нуждаются одни лишь черви, распространяющиеся через переполняющиеся буфера и потому очень ограниченные в размерах, чтобы реализовать процедуру поиска libc в памяти. И еще — драйвера. Но драйвера пишутся под конкретные системы и никто не собирается требовать от них переносимости, а мы говорим про прикладные программы! Какой ассемблерный tutor не возьми, там обязательно будут syscall'ы. Что ли мода пошла такая или это просто эпидемия? Ладно, неважно! Рассмотрим и syscall'ы, если народу так будет угодно. Лучшее руководство по интерфейсам системных вызовов можно найти на сайте LastStageofDeliriumResearchGroup или сокращенно LSD. Оно так и называется «UNIXAssemblyCodesDevelopmentforVulnerabilitiesIllustrationPurposes» (http://www.blackhat.com/presentations/bh-usa-01/LSD/bh-usa-01-lsd.pdf), так же хочется порекомендовать неплохой сайт http://www.lxhp.in-berlin.de/lhpsyscal.html — настоящую энциклопедию системных вызовов. Если отбросить всякие редкоземельные UNIX'ы, то интерфейсов системных вызов всего два — Linux и BSD. Рассмотрим их поближе. Linux использует fastcall-соглашение о передаче параметров, это значит, что номер системного вызова помещается в регистр eax, параметры передаются слева направо через регистры ebx, ecx, edx, esi, edi, ebp. Если системный вызов принимает больше шести параметров, они передаются со структурой, указатель на которую заносится в ebx. Передача управления происходит путем вызова прерывания INT 80h. Разумеется, это только общая схема и на практике постоянно приходится сталкиваться с отступлением от правил. Общение с системными вызовами напоминает хождение по минному полю — один шаг в сторону и ты покойник. Вот как это приблизительно выглядит. Допустим, мы хотим вызвать системный вызов write. Для начала необходимо узнать его номер. Системные вызовы перечислены в файле /usr/include/sys/syscall.h, в BSD-системах номера присутствуют сразу, а вот Linux нас отсылает к файлу /usr/include/bits/syscall.h, в котором номеров нет, зато есть нисходящие определения. Короче, чтобы не парится, номер нужного syscall'а проще выяснить с помощью следующей программы (см. листинг 8). Определения syscall'ов обычно имеют префикс SYS_, в частности, системный вызов write определяется как SYS_write, а номер его — #4. #include <stdio.h> #include <sys/syscall.h> main() { printf(«%x\n»,SYS_write); } Листинг 8 макет программы, определяющей номера системных вызовов в Linux и BSD Теперь лезем в man («man 2 write») и смотрим какие параметры этот вызов принимает. Ага: write(int d, const void *buf, size_t n_bytes). То есть, мы должны занести #4 в eax, файловый дескриптор – в ebx, указатель на выводимую строку – в ecx и количество выводимых байт – в edx, после чего вызвать прерывание INT 80h. BSD-системы используют гибридный механизм: прерывание INT 80h и FAR CALL 0007h:00000000h. Номера системных вызовов так же как и в Linux помещаются в регистр eax, а вот параметры передаются через стек по Си-подобному соглашению (то есть, первым заносится крайний правый параметр, последним в стек ложится фиктивный dword, стек чистит за собой вызывающий код). Поскольку, номера базовых системных вызовов в обоих системах совпадают, можно исхитриться и написать программу, работающую под обоими операционными системами: Linux не обращает внимание на стек, а BSD — на регистры, что позволяет нам продублировать параметры и там, и там. Естественно, это увеличивает размер программы, но, к нашему счастью, BSD позволяет эмулировать Linux-интерфейс, достаточно дать команду «brandelf ‑t Linux имя_файла», после чего нам останется только запустить его! А Linux в свою очередь умеет эмулировать BSD, SunOS и еще много чего! Но довольно слов, переходим к делу! Перепишем нашу программу, чтобы она выводила приветствие через системный вызов write без использования libc. Стартовый код в этом случае исчезает и точкой входа в программу становится метка _start, объявленная как global. Ну а сама программа выглядит так: .text .globl_start _start: movl$4,%eax; системный вызов #4 «write»

movl$1,%ebx; 1 – stdout (xorl %ebx,%ebx/incl %ebx) movl$msg,%ecx; смещение выводимой строки

movl$len,%edx; длина строки int$0x80; write(1, msg, len);

movl$1, %eax; системный вызов #1 «exit» xorl%ebx,%ebx; код возврата

int$0x80; exit(0); .data msg: .ascii «hello,elf\n» len = . - msg Листинг 9 ассемблерная программа elf_80h.S, работающая через системные вызовы системы Linux и работающая в BSD только в режиме эмуляции Пара замечаний к программе. Инструкция movl $1,%ebx занимает пять байт, но при желании ее можно ужать до трех: xorl %ebx,%ebx/incl %ebx, однако, учитывая размер служебных полей elf файла, выигрыш не составит и доли процента, так что над оптимизацией кода можно не напрягаться. Сборка для всех систем осуществляется ручным путем и осуществляется она так: $as -о elf_80h.o elf_80h.S $ld -s -o elf_80h elf_80h.o $ls -l elf_80h -rwxr-xr-x 1 root staff 388 2006-04-20 19:27 elf_80h Листинг 10 ручная сборка файла elf_80h.S под Linux и BSD Под Linux'ом размер файла составляет всего 388 байт, BSD слегка отстает — 452 байта (сказываются разные версии трансляторов и линкеров). Под Linux файл запускается сразу же и без вопросов, а вот под BSD требует предварительной эмуляции: $brandelf -t Linux elf_80h $./elf_80h hello,elf Листинг 11 запуск файла elf_80h под BSD в режиме эмуляции Кстати, говоря, под Linux'ом существует альтернативный вариант автоматической сборки при помощи все того же gcc, запущенного с ключом -nostartfiles, но в этом случае размер полученного файла (даже после стрипа) будет составлять 928 байт, что не есть хорошо, тем не менее все равно меньше, чем с использованием libc. ===== конструирование elf'а своими руками ===== Программирование без libc значительно сокращает размер программ, однако, полученные файлы все равно остаются большими и толстыми. Самый крошечный эльф, который нам только удалось получить, весит целых 388 байт и это при том, что он не насчитывает и десятка ассемблерных команд. Что же такое содержится в нем? Возьмем любой hex-редактор и посмотрим (см. рис. 4). Рисунок 4 внутри elf-файла находится просто море пустоты Нашему взору представиться одна вода, то есть нули, «заботливо» вставленные тупым линкером. А что если… отказаться от услуг линкера и попробовать соорудить elf-файл голыми руками? Для этого, нам во-первых, потребуется подробное описание всех служебных структур elf'а (последний draft лежит здесь http://www.caldera.com/developers/gabi/), а, во-вторых, транслятор, умеющий генерировать двоичные файлы, например, NASM, входящий в большинство Linux-дистрибутивов, но к, сожалению, не в BSD. Во всяком случае, его всегда можно скачать с «родной» страницы проекта: http://nasm.sourceforge.net/. Исполняемый elf-файл нуждается в двух структурах: elf-header'e, описывающим основные параметры файла (платформа, адрес точки входа и т. д.) и programheadertable, перечисляющего все сегменты. Как минимум должен быть один сегмент с правами на чтение, запись и исполнение. Наконец, чтобы elf заработал, требуется добавить «боевую начинку», то есть непосредственно сам ассемблерный код. Минимальный адрес, с которого в UNIX-системах может загружаться elf, равен 8048000h, поэтому нам понадобится директива ORG, задающая начальное смещение в файле. Остается только изучить документацию и заполнить все служебные структуры соответствующим образом: BITS 32 org8048000h ehdr:; Elf32_Ehdr

db7Fh, «ELF», 1, 1, 1; e_ident times 9 db0 dw2; e_type

dw3; e_machine dd1; e_version

dd_start; e_entry ddphdr - $$; e_phoff

dd0; e_shoff dd0; e_flags

dwehdrsize; e_ehsize dwphdrsize; e_phentsize

dw1; e_phnum dw0; e_shentsize

dw0; e_shnum dw0; e_shstrndx

ehdrsizeequ $ - ehdr

phdr:; Elf32_Phdr dd1; p_type

dd0; p_offset dd$$; p_vaddr

dd$$; p_paddr ddfilesize; p_filesz

ddfilesize; p_memsz dd5; p_flags

dd1000h; p_align phdrsizeequ $ - phdr _start: moveax,4; системный вызов #4 «write»

xorebx,ebx

incebx; 1 - stdout (xorl %ebx,%ebx/incl %ebx) pushebx movecx,msg; смещение выводимой строки

movedx,msg_end-msg; длина строки int80h; write(stdout, msg, len);

popeax; системный вызов #1 «exit» int80h; exit(?);

msgdb «hello,elf»,0Ah

msg_end:

filesizeequ $ - $$

Листинг 12 ассемблерный файл elf_tiny.asm, сконструированный голыми руками

Теперь, когда борьба идет за каждый байт, воспользуется ассемблерными трюками, оптимизирующими размер ассемблерного кода. Во-первых, заменим MOV EBX, 1 на XOR EBX,EBX/INC EBX (напоминаю, NASM использует INTEL'й синтаксис), во вторых, сохраним это значение в стеке однобайтовой командой PUSH EBX — позднее оно нам понадобиться для системного вызова exit. В-третьих, не будет явно инициализировать код возврата — он ведь нам все равно не нужен.

$nasm -f bin -o elf_tiny elf_tiny.asm

$chmod +x elf_tiny

$ls -l elf_tiny

-rwxr-xr-x 1 root staff 118 2006-04-20 19:29 elf_tiny

./elf_tiny

hello, elf

Листинг 13 ручная сборка и запуск файла elf_tiny.asm под Linux

После сборки образуется двоичный elf-файл размеров всего в… 118 байт, что в три с лишним раза короче аналогично файла, собранного стандартным линкером. Но это еще не предел!

Держитесь! Мы вошли в раж и не оторвемся от клавиатуры, пока не сократим файл хотя бы на десяток байт. Больше всего нас раздражают e_ident байты, оставленные для выравнивания в количестве целых девяти штук. Плюс один байт версии elf-файла, которую все равно никто не проверяет! А что если… разместить строку «hello,elf» именно здесь?! Сказано — сделано! Ведь elf-заголовок отображается на память и вполне пригоден для хранения переменных.

Но это еще не все! Даже поверхностный взгляд показывает, что 8 последних байт elf-заголовка совпадать с 8 первыми байтами programheadertable, следующего непосредственно за ним. вот они, красавчики: 01h 00h 00h 00h 00h 00h 00h 00h 01h 00h 00h 00h 00h 00h 00h 00h. А почему бы не сдвинуть начало programheadertable так, чтобы оба заголовка перекрывались? Для этого будет достаточно всего лишь скорректировать поле e_phoff, переместив метку phdr вглубь elf заголовка.

Оптимизировав служебные структуры насколько это возможно, займемся «несущим» кодом. Команда MOV EAX, 4 отъедает целых 5 байт, но если немного подумать, можно «отвоевать» 1 байт, заменив ее эквивалентной конструкцией: XOR EAX,EAX/MOV AL, 4. Тоже самое относится и MOV EDX,MSG_END-MSG.

Проделав все эти операции, мы получим следующий файл:

BITS 32

org8048000h

ehdr:; Elf32_Ehdr ;db7Fh, «ELF», 1, 1, 1; e_ident db7Fh, «ELF», 1, 1; e_ident ; размещаем выводимую строку в поле e_ident ; в EI_PAD байтах, оставленных для выравнивания ; «захватывая» и байт EI_VERSION msg db «hello,elf»,0Ah msg_end:** dw2; e_type dw3; e_machine dd1; e_version dd_start; e_entry dd phdr - $$; e_phoff dd0; e_shoff dd0; e_flags dwehdrsize; e_ehsize dwphdrsize; e_phentsize phdr: ; используемналожение program header table на elf header ; заголовки как бы проникают друг в друга и это работает! ; потому что конец elf header'а совпадает с prg header'ом dd1; e_phnum ;dw0; e_shentsize dd0; e_shnum ;dw0; e_shstrndx ehdrsizeequ $ - ehdr ;phdr:; Elf32_Phdr ;dd1; p_type ;dd0; p_offset dd$$; p_vaddr dd$$; p_paddr ddfilesize; p_filesz ddfilesize; p_memsz dd5; p_flags dd1000h; p_align phdrsizeequ $ - phdr _start: xoreax,eax; получаем ноль movebx,eax; копируем ноль в ebx movedx,eax; копируем ноль в edx moval,4; системный вызов #4 «write» incebx; 1 - stdout (xorl %ebx,%ebx/incl %ebx) pushebx; сохраняем ebx == 1 для syscall'a #1 exit movecx, msg; смещение выводимой строки movdl, msg_end-msg; длина строки int80h; write(1, msg, len); popeax; системный вызов #1 «exit» int80h; exit(0); filesizeequ $ - $$ Листинг 14 оптимизированный файл elf_tinix.asm с перекрывающимися заголовками Транслируем его тем же путем, что и раньше и получаем… 98 байт! Самое интересное, что под Linux'ом этот файл еще и работает, а вот BSD, увы, — шуток с перекрытием заголовков не понимает. Рисунок 5 BSD 4.5 не поддерживает elf-файлы с перекрывающимися заголовками Но 98 байт это еще не предел! Переписав «несущий» код легендарный хакер Юрий Харон с ходу сократил его еще на 2 байта, сказав при этом «…а вот дальше уже думать надо, но лень ;-)». dw016Ah; push 01 popebx; ebx := 1 leaeax,[ebx+4-1]; eax := #4 (сист. вызов «write») leaedx,[ebx+(msg_end-msg)-1]; хитрый трюк, но так короче pushebx; сохраняем ebx для syscall'а «exit» movecx, msg; смещение выводимой строки int80h; write(stdout, msg, len); popeax; системный вызов #1 «exit» int80h; exit(0); Листинг 15 фрагмент файла elf_tinyh.asm, оптимизированного Юрием Хароном Как видно, Харон использовал прямую засылку константы в стек командой PUSH 1, занимающий всего два байта — 6Ah 01h, которую коварный NASM растянул до целых 5 байт 68h 01h 00h 00h 00h, поэтому пришлось прибегнуть к прямой машиннокодовой вставке директивой dw. Еще Харон использовал могучую инструкцию LEA, о существовании которой никогда нельзя забывать (а вот мыщъх забыл и проиграл). ===== »> врезка график похудания elf-файла ===== |стадия оптимизации|размер, байт|| | ::: |Linux|BSD| |elf_libc.S, автоматически собранный gcc|12.096|4.270| |elf_libc.S, автоматически собранный gcc после стрипа|2.892|2.744| |eflf_libc.S, собранный вручную as-ld|(2.892)|2.108| |eflf_libc.S, собранный с отладочной информацией|12.268|3145| |elf_80h.S, собранный вручную/автоматически|388/928|452/—| |elf_tiny.asm, сконструированный голыми руками|118|118| |elf_tinyx.asm оптимизированныймыщъх'ем|98|—| |elf_tinyh.asm оптимизированный Юрием Хароном|96|—| ===== заключение ===== Мы прошли длинный путь и добились впечатляющих результатов. 96 байт для программы «hello,elf» – это успех, которым можно гордиться. Если убрать перекрытие заголовков, мы получим 100 байт, но тогда файл будет работать как под Linux, так и под BSD. Но цепная реакция оптимизации на этом еще не заканчивается. Кто из читателей примет вызов и сократит файл хотя бы еще на один байт?