Анализ унаследованного кода, когда исходный код утрачен: делать или не делать?

    Анализ бинарного кода, то есть кода, который выполняется непосредственно машиной, – нетривиальная задача. В большинстве случаев, если надо проанализировать бинарный код, его восстанавливают сначала посредством дизассемблирования, а потом декомпиляции в некоторое высокоуровневое представление, а дальше уже анализируют то, что получилось.

    Здесь надо сказать, что код, который восстановили, по текстовому представлению имеет мало общего с тем кодом, который был изначально написан программистом и скомпилирован в исполняемый файл. Восстановить точно бинарный файл, полученный от компилируемых языков программирования типа C/C++, Fortran, нельзя, так как это алгоритмически неформализованная задача. В процессе преобразования исходного кода, который написал программист, в программу, которую выполняет машина, компилятор выполняет необратимые преобразования.

    В 90-е годы прошлого века было распространено суждение о том, что компилятор как мясорубка перемалывает исходную программу, и задача восстановить ее схожа с задачей восстановления барана из сосиски.


    Однако не так все плохо. В процессе получения сосиски баран утрачивает свою функциональность, тогда как бинарная программа ее сохраняет. Если бы полученная в результате сосиска могла бегать и прыгать, то задачи были бы схожие.

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

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

    Задача дизассемблирования обычно решается в полуавтоматическом режиме, то есть специалист делает восстановление вручную при помощи интерактивных инструментов, например, интерактивным дизассемблером IdaPro, radare или другим инструментом. Дальше также в полуавтоматическом режиме выполняется декомпиляция. В качестве инструментального средства декомпиляции в помощь специалисту используют HexRays, SmartDecompiler или другой декомпилятор, который подходит для решения данной задачи декомпиляции.

    Восстановление исходного текстового представления программы из byte-кода можно сделать достаточно точным. Для интерпретируемых языков типа Java или языков семейства .NET, трансляция которых выполняется в byte-код, задача декомпиляции решается по-другому. Этот вопрос мы в данной статье не рассматриваем.

    Итак, анализировать бинарные программы можно посредством декомпиляции. Обычно такой анализ выполняют, чтобы разобраться в поведении программы с целью ее замены или модификации.

    Из практики работы с унаследованными программами


    Некоторое программное обеспечение, написанное 40 лет назад на семействе низкоуровневых языков С и Fortran, управляет оборудованием по добыче нефти. Сбой этого оборудования может быть критичным для производства, поэтому менять ПО крайне нежелательно. Однако за давностью лет исходные коды были утрачены.

    Новый сотрудник отдела информационной безопасности, в обязанности которого входило понимать, как что работает, обнаружил, что программа контроля датчиков с некоторой регулярностью что-то пишет на диск, а что пишет и как эту информацию можно использовать, непонятно. Также у него возникла идея, что мониторинг работы оборудования можно вывести на один большой экран. Для этого надо было разобраться, как работает программа, что и в каком формате она пишет на диск, как эту информацию можно интерпретировать.

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

    Если требуется анализ бинарной программы с целью восстановления логики ее работы, частичного или полного восстановления логики преобразования входных данных в выходные и т.д., удобно делать это с помощью декомпилятора.

    Помимо таких задач, на практике встречаются задачи анализа бинарных программ по требованиям информационной безопасности. При этом у заказчика не всегда есть понимание, что этот анализ очень трудоемкий. Казалось бы, сделай декомпиляцию и прогони получившийся код статическим анализатором. Но в результате качественного анализа практически никогда не получится.

    Во-первых, найденные уязвимости надо уметь не только находить, но и объяснять. Если уязвимость была найдена в программе на языке высокого уровня, аналитик или инструментальное средство анализа кода показывают в ней, какие фрагменты кода содержат те или иные недостатки, наличие которых стало причиной появления уязвимости. Что делать, если исходного кода нет? Как показать, какой код стал причиной появления уязвимости?

    Декомпилятор восстанавливает код, который «замусорен» артефактами восстановления, и делать отображение выявленной уязвимости на такой код бесполезно, все равно ничего не понятно. Более того, восстановленный код плохо структурирован и поэтому плохо поддается инструментальным средствам анализа кода. Объяснять уязвимость в терминах бинарной программы тоже сложно, ведь тот, для кого делается объяснение, должен хорошо разбираться в бинарном представлении программ.

    Во-вторых, бинарный анализ по требованиям ИБ надо проводить с пониманием, что делать с получившимся результатом, так как поправить уязвимость в бинарном коде очень сложно, а исходного кода нет.

    Несмотря на все особенности и сложности проведения статического анализа бинарных программ по требованиям ИБ, ситуаций, когда такой анализ выполнять нужно, много. Если исходного кода по каким-то причинам нет, а бинарная программа выполняет функционал, критичный по требованиям ИБ, ее надо проверять. Если уязвимости обнаружены, такое приложение надо оправлять на доработку, если это возможно, либо делать для него дополнительную «оболочку», которая позволит контролировать движение чувствительной информации.

    Когда уязвимость спряталась в бинарном файле


    Если код, который выполняет программа, имеет высокий уровень критичности, даже при наличии исходного текста программы на языке высокого уровня, полезно сделать аудит бинарного файла. Это поможет исключить особенности, которые может привнести компилятор, выполняя оптимизирующие преобразования. Так, в сентябре 2017 года широко обсуждали оптимизационное преобразование, выполненное компилятором Clang. Его результатом стал вызов функции, которая никогда не должна вызываться.

    #include <stdlib.h>
    typedef int (*Function)();
    static Function Do;
    static int EraseAll() {
      return system("rm -rf /");
    }
    void NeverCalled() {
      Do = EraseAll;  
    }
    int main() {
      return Do();
    }

    В результате оптимизационных преобразований компилятором будет получен вот такой ассемблерный код. Пример был скомпилирован под ОС Linux X86 c флагом -O2.

    	.text
    	.globl	NeverCalled
    	.align	16, 0x90
    	.type	NeverCalled,@function
    NeverCalled:                            # @NeverCalled
    	retl
    .Lfunc_end0:
    	.size	NeverCalled, .Lfunc_end0-NeverCalled
    	.globl	main
    	.align	16, 0x90
    	.type	main,@function
    main:                                   # @main
    	subl	$12, %esp
    	movl	$.L.str, (%esp)
    	calll	system
    	addl	$12, %esp
    	retl
    .Lfunc_end1:
    	.size	main, .Lfunc_end1-main
    	.type	.L.str,@object          # @.str
    	.section	.rodata.str1.1,"aMS",@progbits,1
    .L.str:
    	.asciz	"rm -rf /"
    	.size	.L.str, 9
    

    В исходном коде есть undefined behavior. Функция NeverCalled() вызывается из-за оптимизационных преобразований, которые выполняет компилятор. В процессе оптимизации он скорее всего выполняет анализ аллиасов, и в результате функция Do() получает адрес функции NeverCalled(). А так как в методе main() вызывается функция Do(), которая не определена, что и есть неопределенное стандартом поведение (undefined behavior), получается такой результат: вызывается функция EraseAll(), которая выполняет команду «rm -rf /».

    Следующий пример: в результате оптимизационных преобразований компилятора мы лишились проверки указателя на NULL перед его разыменованием.

    #include <cstdlib>
    void Checker(int *P) {
       int deadVar = *P;
       if (P == 0)
    	   return;
       *P = 8;
    }
    

    Так как в строке 3 выполняется разыменование указателя, компилятор предполагает, что указатель ненулевой. Дальше строка 4 была удалена в результате выполнения оптимизации «удаление недостижимого кода», так как сравнение считается избыточным, а после и строка 3 была удалена компилятором в результате оптимизации «удаление мертвого кода» (dead code elimination). Остается только строка 5. Ассемблерный код, полученный в результате компиляции gcc 7.3 под ОС Linux x86 с флагом -O2, приведен ниже.

    	.text
    	.p2align 4,,15
    	.globl	_Z7CheckerPi
    	.type	_Z7CheckerPi, @function
    _Z7CheckerPi:
    	movl	4(%esp), %eax
    	movl	$8, (%eax)
    	ret
    

    Приведенные выше примеры работы оптимизации компилятора – результат наличия в коде undefined behavior UB. Однако это вполне нормальный код, который большинство программистов примут за безопасный. Сегодня программисты уделяют время исключению неопределенного поведения в программе, тогда как еще 10 лет назад не обращали на это внимания. В результате унаследованный код может содержать уязвимости, связанные с наличием UB.

    Большинство современных статических анализаторов исходного кода не обнаруживают ошибки, связанные с UB. Следовательно, если код выполняет критичный по требованиям информационной безопасности функционал, надо проверять и его исходники, и непосредственно тот код, который будет выполняться.

    Ростелеком-Solar

    215,00

    Безопасность по имени Солнце

    Поделиться публикацией
    Комментарии 16
      +4
      Картинка не правильная. В мясорубку должна входить сосиска, а выходить барашек.
        0
        Спасибо за замечание :) Там так и нарисовано, только стрелки в обратном порядке поставлены с идей, что процесс в две стороны рассматривается.
          +1
          Заменила картинку :) Спасибо.
            0
            Я как-то неловко отклонила Ваше предложение поменять барана и сосиску местами. Но в этот раз все верно: ДЕкомпилятор — это «мясорубка наооборот», которая именно из фарша (бинарника) восстанавливает подобие исходника, то есть барана :)
          • НЛО прилетело и опубликовало эту надпись здесь
              0

              Все оптимизации компилятора должны сохранять наблюдаемое поведение.
              До оптимизации функция либо падала при доступе к нулевому указателю либо записывала значение в память. После оптимизаций она делает то же самое.
              Более эпичный случай: компилятор может иногда выкинуть бесконечный цикл:


              int infinte(){
              int counter = 0;
              while(true){
                counter++;
                if(PureVeryComplicatedCondition(counter))
                  return 1;
              }
              return 0;
              }
                0
                Про оптимизационные преобразования Вы говорите верно, однако примеры, приведенные в публикации, рабочие. Там написано, как получен представленный ассемблер, чтобы каждый мог повторить и убедиться сам. Первый пример действительно на выполнение вызовет функцию, которая никогда не должна вызываться.
                +2
                Низкоуровневое программирование — оно такое, чем дальше двигаешься, тем больше сюрпризов. «Я знаю, что ничего не знаю» — Сократ сказал очень верно :)
                0
                Мы делаем вручную с помощью инструментальных средств, помогающих анализировать низкоуровневые программы. Undefined behavior можно находить в исходнике на С/C++, но в бинарнике искать надежнее. Я сама использую IdaPro для низкоуровневого анализа. Для анализа C/C++ и других программ по исходникам использую InCode. Конечно, большинство работы для анализа бинарников делается руками.
                  0
                  О каком undefined behavior в бинарниках вы говорите?
                  Компилятор переводит C++ программу с UB в опкоды x64, поведение которых специфицировано производителем CPU. UB исчезает.

                  Или вы ищете недокументированные опкоды процессора? Откуда они после компилятора?
                    0
                    Я говорю о том, что UB в исходной C/C++ программе может привести к различным непредсказуемым defined behavior в низкоуровневой программе. Именно поэтому проверить исходник может быть недостаточно, а для более точного анализа надо проверять и исходник, и бинарник.
                      0
                      Ага, то есть нужно скомилировать C++ в код, затем декомпилировать обратно в C++, проанализировать результат (вручную?). Если никакой странной лажи не появилось, то, наверное, в исходной программе нет UB. Не слишком ли дорого выходит?
                        0
                        Нет, совсем не так. В статье же написано, что декомпиляцию стоит выполнять, если надо понять содержательный аспект бинарника. В декомпилированном коде уязвимости искать несравненно сложнее, нежели в исходнике, так как «артефакты» восстановления мешают работе статических анализаторов.
                        Анализировать и исходник, и бинарник, но именно бинарник в бинарном виде (по ассемблеру, например) нужно, если у вас критичный по надежности фрагмент кода и надо точно понимать, что будет выполняться.
                        В статье приведены 2 примера, когда именно по ассемблеру отлавливается уязвимость, которая «спряталась» в исходнике.
                  0
                  Интересная статья. Скажите, у вас используются какие-то специальные инструменты для обнаружения undefined behavior или это выполняется «врукопашную»? Не пробовали ли учесть опыт обнаружения подобных косяков в каком-нибудь средстве автоматизации/плагине и т.д.?
                    0
                    Мы делаем вручную с помощью инструментальных средств, помогающих анализировать низкоуровневые программы. Undefined behavior можно находить в исходнике на С/C++, но в бинарнике искать надежнее. Я сама использую IdaPro для низкоуровневого анализа. Для анализа C/C++ и других программ по исходникам использую InCode. Конечно, большинство работы для анализа бинарников делается руками.
                      0
                      ясно, спасибо

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                  Самое читаемое