Вступление

Rust как язык программирования только набирает обороты и находит своих почитателей. Он не только предлагает множество надстроек для безопасности кода, но с недавнего времени еще и появился в ядре Linux.

В этой статье мы посмотрим на него с "обратной" стороны, а именно попробуем пореверсить программу, написанную на Rust, и выяснить, что можно сделать, чтобы сделать ее анализ проще. Рассмотрим утилиты, приложения и плагины, а также напишем свой плагин для IDA Pro, Cutter и rizin, чтобы автоматически создать сигнатуры для исполняемого файла без отладочных символов. Поговорим о FLIRT-сигнатурах, их преимуществах и недостатках и о том, можно ли автоматизировать их создание.

В качестве примера будем рассматривать задачу с BSidesSF CTF 2020 под названием rusty2. Создаем две версии исполняемого файла: один с отладочными символами и без strip, а второй без отладочных символов со strip. Второй вариант используется чаще всего в реальных проектах.

Собирать будем с помощью менеджера пакетов cargo 1.63.0:

  • релизную версию —cargo build --release

  • дебаг сборку — cargo build

Использовался компилятор stable версии 1.63.0.

Плагины

В статье мы рассмотрим три плагина, два из который для IDA Pro:

rust-reversing-helper

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

Проверим работу плагина на сборке с отладочными символами. Деманглинг имен функций в IDA автоматический, поэтому проверим вторую функцию.

До работы плагина мы имеем следующий вид программы:

После его выполнения получаем такой вид:

Функции, которые стали Library function, — измененные плагином. Например, функция core::panicking::assert_failed_usize_usize__1 до преобразования выглядела так:

А после преобразования мы получаем следующий вид:

В данном случае скрипт изменил функцию неверно, поскольку она должна принимать 4 агрумента, и к тому же не должна возвращать ничего.

На сборке без отладочных символов скрипт закончил свою работу без каких либо изменений.

В целом, плагин довольно интересный, но сильно помочь в реверсе Rust, на мой взгляд, не может.

idapro-rust

Плагин состоит из Rust-кода и трех скриптов на Python. Основная задача плагина — сбор информации о функциях: начало и конец кода, соглашение о вызовах, имена и т.д.

Выводит информацию в JSON-формате. Показывать его работу особо смысла не имеется.

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

Дизассемблеры / декомпиляторы

Дизассемблеры, которые могут помочь в реверсе программ на Rust:

Это выбор каждого. Просто посмотрим, как одна и та же функция main выглядит в каждом из них.

IDA Pro:

Rizin/Cutter:

Ghidra:

Если начать сравнивать, то можно увидеть, что в декомпиляторе HexRays есть проблема с определением адреса прыжка (JUMPOUT). Это не очень критично, но неприятно. Остальные декомпиляторы справились хорошо.

FLIRT-сигнатуры

FLIRT-сигнатуры — это база с соответствием имен функций и их первых байт кодов. Позволяет при реверсе приложения без отладочных символов определить часть функций, для которых первые байты кода совпадут с одной из сигнатур. Такая возможность существует в IDA Pro и Cutter. Ghidra пока такого не умеет.

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

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

Тем не менее это очень удобный способ с ходу распознать часть функции в бинарном файле.

Собственный плагин

Все способы, описанные выше, имеют свое применение в определенных условиях. Но ведь можно не только пользоваться, но и создавать свое. Мы попробуем написать плагин для IDA Pro, Cutter и rizin, который будет автоматически создавать сигнатуры для исполняемых файлов или библиотек, написанных на языке Rust и собранных без отладочных символов.

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

Для начала посмотрим, какую информацию можно вынести из скомпилированного файла, написанного на Rust. С виду он выглядит как обычный исполняемый файл с кучей функций (около 700 в нашем примере). Самое интересное для нас находится в строках. Замечу, что чаще всего оказывается, что строки в Rust не заканчиваются нулем. Компилятор хранит большую часть строковых типов как ссылку на ее начало и длину. В некоторый случаях (как в типе String) еще ее максимальную вместимость до выделения большего объема памяти.

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

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

Постепенно у нас вырисовывается план, как будет работать наш плагин:

  1. Ищем строки в бинарной файле.

  2. Регулярным выражением выделяем библиотеки и их версии.

  3. В документации находим нужные библиотеки и информацию об их функциях.

  4. Создаем свой Rust-проект и пытаемся добавить в него как можно больше функций.

  5. Компилируем с отладочными символами.

  6. Генерируем сигнатуры для дальнейшего использования.

Пройдемся по каждому пункту. Тут я буду писать про плагин для IDA Pro, но в целом для Cutter и rizin будет примерно такая же ситуация.

Находим строки в бинарной файле

Тут ничего сложного. С помощью средств idapython вытягиваем все строки в файле. Код выглядит так:

import idautils
sc = idautils.Strings()

Регулярным выражением выделяем библиотеки и их версии

С этим тоже трудностей не предвидится. Берем библиотеку **re** и с ее помощью достаем нужную нам информацию и преобразуем в множество (set), чтобы убрать повторяющийся элементы:

import idautils
import re

sc = idautils.Strings()

pattern = re.compile(r'([\w\d\-_]+)-(\d\.\d+\.\d+)')
libs = set(re.findall(pattern, ''.join(map(str, sc))))

print(f'Found {len(libs)} libraries!')

for lib, version in libs:
    print('{} = {}'.format(lib, version))

В документации находим нужные библиотеки и информацию об их функциях

Для запросов к документации будем использовать библиотеку requests. А для парсинга html страниц — BeautifulSoup4. Информация о библиотеке по ее версии можно найти по адресу https://docs.rs/{lib_name}/{lib_version}. А список функций - по адресу https://docs.rs/{lib_name}/{lib_version}/#functions. Потом с помощью html-парсера находим ссылки на сами функции. Эти действия вынесем в функцию:

def get_lib_funcs(lib, ver):
    '''Get all functions html path'''

    url = 'https://docs.rs/{}/{}'

    r = requests.get(url.format(lib, ver) + '/#functions')

    soup = BeautifulSoup(r.text, 'html.parser')

    result = []
    for link in soup.find_all('a', attrs={'class': 'fn'}):
        result.append(link.get('href'))

    return result

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

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

Так будем доставать код функции:

def get_func_code(lib: str, ver: str, func=''):
    '''Get single function code from it's html page'''

    url = f'https://docs.rs/{lib}/{ver}/{lib.replace("-", "_")}/{func}'

    soup, examples = get_example(url)

    check_template(soup, lib)

    if examples:
        return map(lambda x: x.text, examples)


    return None

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

Стоит добавить, какие типы аргументов будет поддерживать плагин, потому что кастомные и generic типы сложно вывести заранее. Поддерживаемые типы (будет mut в скобках если добавлены изменяемые и неизменяемый типы):

(mut) str, (mut) String, bool, u8, u16, u32, u64, i8, i16, i32, i64, 
(mut) char, &(mut) [u8], &(mut) [u16], &(mut) [u32], &(mut) [u64], 
&(mut) [i8], &(mut) [i16], &(mut) [i32], &(mut) [i64], usize, isize, 
&(mut) usize, &(mut) isize, f32, f64, &str

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

При желании, можно добавить свои типы в глобальные переменные VARIABLES, DEFINES.

Создаем свой Rust проект

Проект будем создавать с помощью команды cargo new {project_name} --lib --vcs none. С помощью нее мы получим готовый проект для создания библиотеки.

Теперь наша задача — добавить библиотеки в зависимости в Cargo.toml и добавить их в проект с помощью extern crate {crate_name}.

Код для генерации Cargo.toml:

def gen_cargo_toml(libs):
    '''Generate valid Cargo.toml'''

    cargo_template = textwrap.dedent(
        '''
        [package]
        name = "rust_codes"
        version = "0.1.0"
        edition = "2021"

        [profile.release]
        debug = true
        strip = false
        lto = true

        [lib]
        crate-type = ["staticlib", "cdylib"]

        [dependencies]
        {}
        ''')

    toml_path = f'{RUST_PROJ_PATH}/{RUST_PROJ_NAME}/Cargo.toml'

    deps = ''
    for lib, version in libs:
        deps += '{} = "{}"\n'.format(lib, version)

    for ext in EXTERNS:
        if not f'{ext} =' in deps and not f'{ext.replace("_", "-")} =' in deps:
            version = get_latest_version(ext)

            deps += '{} = "{}"\n'.format(ext, version)

    with open(toml_path, 'w') as toml:
        toml.write(cargo_template.format(deps))

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

Прежде чем продолжить, поговорим немного о возможных оптимизациях, применяемых в Rust. Это необходимо, поскольку может сильно повлиять на вид сигнатур. Для управления настройками компиляции и определения зависимостей cargo использует файл Cargo.toml. Самое интересное для нас из него — это настройки профилей по умолчанию. Основные профили — это dev и release. Профиль dev используется по умолчанию в сборке через cargo: cargo build. Профиль release используется в сборке с флагом --release.

Стандартные настройки для дебаг сборки:

[profile.dev]
opt-level = 0
debug = true
split-debuginfo = '...'  # Platform-specific.
debug-assertions = true
overflow-checks = true
lto = false
panic = 'unwind'
incremental = true
codegen-units = 256
rpath = false

Стандартные настройки для релизной сборки:

[profile.release]
opt-level = 3
debug = false
split-debuginfo = '...'  # Platform-specific.
debug-assertions = false
overflow-checks = false
lto = false
panic = 'unwind'
incremental = false
codegen-units = 16
rpath = false

В целом, различия очевидны. В дебаг сборке включены макросы вроде debug_assert!, добавляются отладочные символы. А в релизной сборке это лишнее — там включены оптимизации. Рассмотрим основные флаги оптимизации, используемые в Cargo.toml:

  • opt-level — определяет уровень оптимизации. Наиболее продвинутая оптимизация достигается при уровне 3 и отключается на уровне 0

  • lto (LLVM's link time optimizations) — отвечает за оптимизацию кода во время линковки. Для этого приходится проводить дополнительный анализ, из-за чего время сборки проекта может сильно возрасти. Как правило, в реальных проектах, эта опция будет включена.

Компилируем с отладочными символами

Шаблон у нас есть, код функций тоже. Теперь осталось грамотно вызвать все функции и правильно сгенерировать код библиотеки. Этот шаг довольно непростой. Как создать библиотеку и быть уверенным, что код в ней соберется как нужно? Для начала определимся со "скелетом", в который мы будем добавлять остальной код:

#![allow(unused_imports)]
#![allow(dead_code)]

pub mod smth {
    #[no_mangle]
    pub extern "C" fn main() {
    }
}

Определяем модуль smth, в котором будет экспортируемая функция main. Внутри нее и будет основная логика библиотеки - определение переменных и вызовы функций. Еще тут не хватает добавления внешних библиотек.

В наш "скелет" сначала добавим внешние крейты. Это и есть наши зависимости, описанные в Cargo.toml. Теперь хотелось бы убедиться, что наши вызовы или примеры кода, вынесенные в отдельные модули, могут компилироваться. Для этого будем добавлять по одному вызову функций в шаблон выше и проверять с помощью cargo check --release. Если команда выполняется успешно, мы оставляем этот вызов в конечный результат. Если нет, то мы игнорируем этот вызов и переходим к следующему. Код, выполняющий проверку и записывающий конечный результат в lib.rs приведен ниже.

def check_compile():
    '''Check every function for compilation. If succeed, add it to lib.rs'''

    head = '#![allow(unused_imports)]\n#![allow(dead_code)]\n\n'
    head += '\n'.join(map(lambda x: f'#[macro_use]\nextern crate {x.replace("-", "_")};\n',
                          set(EXTERNS)))
    head += '\n'

    mods = ''
    candidates = ''
    usings = ''
    variables = ''

    for define in DEFINES:
        variables += f'        {define}\n'

    for i in range(len(LIB_FUNCS)):
        candidate = f'        {LIB_FUNCS[i]}\n'

        full_code = head + '\npub mod smth {\n' + \
            '\n    #[no_mangle]\n    pub extern "C" fn main() {\n' + \
            variables + candidate + '    }\n' + '}'

        if cargo_check(full_code, LIB_FUNCS[i]):
            candidates += candidate

    for i in range(len(EXAMPLE_FUNCS)):
        mod = f'mod func{i};\n'
        candidate = f'        {EXAMPLE_FUNCS[i]}();\n'
        use = f'    use crate::func{i}::{EXAMPLE_FUNCS[i]};\n'

        full_code = head + mod + '\npub mod smth {\n' + use + \
            '\n    #[no_mangle]\n    pub extern "C" fn main() {\n' + \
            variables + candidate + '    }\n' + '}'

        if cargo_check(full_code, EXAMPLE_FUNCS[i]):
            candidates += candidate
            mods += mod
            usings += use

    lib_code = head + mods + '\npub mod smth {\n' + usings + \
        '\n    #[no_mangle]\n    pub extern "C" fn main() {\n' + \
        variables + candidates + '    }\n' + '}'

    with open(f'{RUST_PROJ_PATH}/{RUST_PROJ_NAME}/src/lib.rs', 'w') as rust_lib:
        rust_lib.write(lib_code)

После этого мы вызываем cargo build --release и получаем библиотеку с отладочными символами.

Генерируем сигнатуры

Дело за малым — создать из готовой библиотеки сигнатуры. В случае с IDA Pro 8.0 мы будем использовать ее инструментарий flair80. А именно pelf/sigmake для Linux или pld/sigmake для Windows. Сначала мы создаем .pat-файл, и с него с помощью sigmake создаем .sig-файл. При этом чаще всего с первого раза мы получим предупреждение о коллизиях. Для этого нам нужно удалить строки, начинающиеся с ";" с .exc-файла. После чего повторяем действия с sigmake и получаем flirt-сигнатуры для IDA Pro.

В случае с Rizin / Cutter все намного проще. Нам нужно иметь установленный rizin и выполнить команду rizin -A -qc "zfc {target}/libname}.sig" {target}/{libname}, где libname — это скомпилированная разделяемая библиотека с отладочными символами.

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

А вот что мы получаем после:

158 функций из 772 распознались с помощью сигнатур. Вполне неплохой результат. Часть оставшихся функций — это аналоги распознанных функций для другого типа принимаемого значения. Правда, часть функций получит имя unknown_libname, и с этим пока не очень понятно что делать. К тому же мы отбрасываем все коллизии вместо выбора. Эту проблему можно решить руками, если их не особо много.

Вывод

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

В планах добавить парсинг документаций к библиотекам с других источников вроде github или сгенерированной с помощью rustdoc-gen.

Комментарии (2)


  1. IkaR49
    27.10.2022 09:39
    +8

    Замечу, что важно ещё брать ту же версию rustc и тот же тулчейн (stable/beta/nightly), что и у анализируемого бинаря. Ибо никаких гарантий о стабильности ABI нет и вряд ли они будут.


  1. Blacklynx
    27.10.2022 09:50
    +1

    Отличная статья. К сожалению ключи оптимизации и даже минорное изменение версии раста - сильно меняют сигнатуры,не говоря уже о тулчейнах, но это все же лучше чем ничего. А вот по поводу гидры вы ошиблись, все она умеет в сигнатуры прекрасно - function ID пробуйте. Мало того - генерит из бинаря прямо из интерфейса программы, что очень удобно!