У нас в «Лаборатории Касперского» есть команда анализа защищенности, занимающаяся поиском уязвимостей в самых разнообразных системах. В ней работают эксперты, способные исследовать практически любое устройство (и публикующие технические заметки о своих находках). Но в жизни практически каждого исследователя безопасности прошивок однажды наступает момент, когда он или она сталкивается с новым или не особо известным микроконтроллером или свежей процессорной архитектурой с кастомными расширениями. В последнее время такие моменты наступают все чаще — за прошедшие несколько лет рынок наполнился огромным количеством новых чипов из Поднебесной, в частности, на базе RISC-V, со своими собственными расширениями и реализациями ядер. И вот не так давно на анализ нашим исследователям попало устройство c таким чипом на базе RISC-V, c базовым набором инструкций RV32I и расширением P (причем еще и не последней версии), добавляющим короткие SIMD-операции (Packed-SIMD Instructions).

То, что наши эксперты видели его впервые — абсолютно нормально. Но, по всей видимости, его впервые видел и IDA Pro — инструмент, которым пользуются наши исследователи. Поэтому им пришлось не только изучить ранний черновик расширения P (оно же Packed-SIMD Extension), но также реализовать поддержку IDA Pro ряда инструкций из него и произвести лифтинг, то есть трансляцию инструкций в промежуточное представление или язык, понятные декомпилятору. Именно этим опытом они и решили поделиться в данной статье.

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

Особенности формата инструкций RISC-V

В базовом наборе инструкций архитектуры RV32I четыре формата: R, I, S, U. Информацию о каждом из них, а также о других можно найти в руководстве The RISC-V Instruction Set Manual Volume I: Unprivileged ISA.

Внимательный читатель сразу заметит особенности кодировки инструкций: опкоды и регистры имеют фиксированное расположение. Это сделано неслучайно, при подобном исполнении устройство декодера инструкций сильно облегчается и упрощается. Кроме того, декодер инструкций RISC-V построен на базе таблиц, что благотворно влияет на процесс разбора инструкций процессором.

Взгляд на декодер

Точкой входа процесса декодирования является поле opcode, которое занимает младшие 7 битов 32-битной инструкции, — inst[6:0]. Декодер — табличный, его схема представлена ниже.

У 32-битных и более широких инструкций младшие 2 бита всегда имеют значение 11, остальные 5 битов кодируют индекс внутри таблицы. В каждой ячейке таблицы может быть либо указатель на другую таблицу, либо декодер конечной инструкции, либо ничего (как водится — Reserved).

P Extension

С основами декода разобрались, осталось понять, где обитают инструкции из P Extension и как их парсить (декодировать). Для этого необходимо обратиться к черновику расширения, который можно найти в репозитории riscv/riscv-p-spec на GitHub. В нашем случае требуется одна из первых публичных версий — 0.5, поэтому запасаемся терпением, пуэром и поворачиваем время вспять — отматываем историю коммитов назад.

Версия 0.5, как и ряд последующих, примечательна тем, что использует значение 0x7F или 0b1111111 для опкода. В этом и был нюанс, который в итоге доставил нам немало хлопот. Дело в том, что данное значение используется для кодирования инструкций 80+ бит. Декодер тупой как пробка. Если не внести в его конструкцию изменения, он так и будет вычитывать по 80+ бит, что конкретно в этом случае не соответствует де��ствительности — инструкции данного расширения имеют длину 32 бита. Ибо в ранней версии, с которой мы столкнулись, использовалось именно это значение для опкода — 0x7F (хотя не должно было, потому что это ломает «базу» декодера, идет вразрез с оригинальной спецификацией ISA).

Более подробно об устройстве кодировки длин инструкций можно прочитать в соответствующем разделе документации. А кратко — в таблице ниже.

Лирическое отступление: знакомство с этим расширением началось с недекодируемых инструкций в ассемблерных листингах IDA Pro, а опкод 0x7F только подливал масла в огонь и сбивал с толку. Определение конечного расширения и его точной версии вылилось в отдельный квест, интуиция подсказывала, что это инструкции формата R-Type, а впрочем, это уже совсем другая история...

Добавление поддержки инструкций

Но вернемся от теории к практике. Для исследования устройства необходимо добавить поддержку инструкций umar64, maddr32, msubr32, mulsr64, mulr64. К счастью, все они находятся в рамках одной таблицы декода, о чем сказано в главе Instruction Encoding Table черновика расширения P Extension. Открываем любимый текстовый редактор или же блокнот, вспоминаем Python и приступаем.

База и абстракции

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

class ITableEntry(metaclass=ABCMeta):
    pass

class InstructionTable(ITableEntry):
    def __init__(self,
                 rows: int,
                 cols: int,
                 get_row: Callable[[insn_t], int],
                 get_col: Callable[[insn_t], int],
                 *entries):
        if rows * cols != len(entries):
            raise ValueError(f"Rows ({rows}) and cols ({cols}) don't match entries length ({len(entries)})")

        self._rows = rows
        self._cols = cols
        self._get_row = get_row
        self._get_col = get_col
        self._entries = entries

    def _index(self, row: int, col: int) -> int:
        return row * self._cols + col

    def lookup(self, insn: insn_t) -> Optional[ITableEntry]:
        row = self._get_row(insn)
        col = self._get_col(insn)
        idx = self._index(row, col)

        entry = self._entries[idx]
        return entry.lookup(insn) if isinstance(entry, InstructionTable) else entry

class ADecoder(ITableEntry):
    @staticmethod
    def _b2m(bits: int) -> int:
        return (1 << bits) - 1

    @classmethod
    def _bits(cls, value: int, hi: int, lo: int) -> int:
        return (value >> lo) & cls._b2m(hi - lo + 1)

    @classmethod
    def decode(cls, insn: insn_t) -> bool:
        ...

Класс ITableEntry описывает сущность таблицы декода; дочерний класс InstructionTable — саму таблицу, а ADecoder — базовый класс, от которого наследуются другие, в том числе и декодеры конечных инструкций.

Чуть подробнее остановимся на классе InstructionTable: аргументы rows и cols определяют геометрию таблицы декодирования (согласно описанию), get_row и get_col — функции, которые извлекают индексы в рамках таблицы из инструкции, entries — элементы таблицы.

База R-Type

Так как интересующие нас инструкции имеют формат R-Type, создадим для этого отдельный вспомогательный класс.

class RTypeDecoder(ADecoder, ITableEntry):
    _itype: ClassVar[int] = -1
    
    @classmethod
    def decode(cls, insn: insn_t) -> bool:
        opcode = get_bytes(insn.ea, 1)[0]

        # GE80B encoding is used for v0.5.x
        if opcode & 0x7F != 0x7F:
            print(f"Invalid opcode {opcode & 0x7F}")
            return False

        data = int.from_bytes(get_bytes(insn.ea, 4), byteorder="little")

        insn.size = 4
        insn.Op1.type = o_reg
        insn.Op1.reg = cls._bits(data, 11, 7)   # Rd
        insn.Op2.type = o_reg
        insn.Op2.reg = cls._bits(data, 19, 15)  # Rs1
        insn.Op3.type = o_reg
        insn.Op3.reg = cls._bits(data, 24, 20)  # Rs2

        insn.itype = cls._itype

        return True

Необходимо переопределить поведение функции decode, оно будет общим для всех инструкций. Алгоритм следующий:

  1. проверяем, что опкод соответствует нужному значению;

  2. вычитываем инструкцию целиком (32 бита или 4 байта);

  3. заполняем информацию об аргументах — 3 регистра и их номера.

Наш квинтет

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

class PExtension:
    maddr32 = CUSTOM_INSN_ITYPE
    msubr32 = CUSTOM_INSN_ITYPE + 1
    mulr64  = CUSTOM_INSN_ITYPE + 2
    umar64  = CUSTOM_INSN_ITYPE + 3
    mulsr64 = CUSTOM_INSN_ITYPE + 4

    name_mapping = {
        maddr32: "maddr32",
        msubr32: "msubr32",
        mulr64:  "mulr64",
        umar64:  "umar64",
        mulsr64: "mulsr64",
    }
    
    @classmethod
    def values(cls) -> Set[int]:
        return set(list(cls.name_mapping.keys()))


class Maddr32(RTypeDecoder):
    _itype: ClassVar[int] = PExtension.maddr32


class Msubr32(RTypeDecoder):
    _itype: ClassVar[int] = PExtension.msubr32


class Mulr64(RTypeDecoder):
    _itype: ClassVar[int] = PExtension.mulr64
    
    @classmethod
    def decode(cls, insn: insn_t) -> bool:
        res = super().decode(insn)
        if not res:
            return False

        insn.Op1.reg = insn.Op1.reg & ~1  # Rd - pair of registers
        return True


class Umar64(RTypeDecoder):
    _itype: ClassVar[int] = PExtension.umar64


class Mulsr64(RTypeDecoder):
    _itype: ClassVar[int] = PExtension.mulsr64

Таблицы декода

Финальный штрих — таблицы декода, их всего 3 штуки.

def b2m(bits: int) -> int:
    return (1 << bits) - 1


def bits(value: int, hi: int, lo: int) -> int:
    return (value >> lo) & b2m(hi - lo + 1)


def insn_data(insn: insn_t, length: int) -> int:
    return int.from_bytes(get_bytes(insn.ea, length), byteorder="little")


p_funct3_001 = InstructionTable(
    16, 8,
    lambda insn: bits(insn_data(insn, 4), 31, 28),
    lambda insn: bits(insn_data(insn, 4), 27, 25),
    None, None, None, None, None, None, None, None,
    None, None, None, None, None, None, None, None,
    None, None, None, None, None, None, None, None,
    None, None, None, None, None, None, None, None,
    None, None, None, None, None, None, None, None,
    None, None, None, None, None, None, None, None,
    None, None, None, None, None, None, None, None,
    None, None, None, None, None, None, None, None,
    None, None, None, None, None, None, None, None,
    None, None, None, None, None, None, None, None,
    None, None, Umar64, None, None, None, None, None,
    None, None, None, None, None, None, None, None,
    None, None, Maddr32, Msubr32, None, None, None, None,
    None, None, None, None, None, None, None, None,
    Mulsr64, None, None, None, None, None, None, None,
    Mulr64, None, None, None, None, None, None, None,
)


ge80b_table = InstructionTable(
    1, 8,
    lambda insn: 0,
    lambda insn: bits(insn_data(insn, 4), 14, 12),
    None, p_funct3_001, None, None, None, None, None, None,
)


rv_table = InstructionTable(
    4, 8,
    lambda insn: bits(insn_data(insn, 4), 6, 5),
    lambda insn: bits(insn_data(insn, 4), 4, 2),
    None, None, None, None, None, None, None, None,
    None, None, None, None, None, None, None, None,
    None, None, None, None, None, None, None, None,
    None, None, None, None, None, None, None, ge80b_table,
)

Пробежимся по таблицам: rv_table — базовая таблица RISC-V, ge80b_table — таблица декода инструкций с опкодом 0x7F или 0b1111111, p_funct3_001 — таблица декода P Extension с funct3 = 001 в соответствии с описанием, из этой таблицы выход на декодеры конечных инструкций.

Интегрируем в IDA Pro

На этом описание логики декода заканчивается, остается лишь «подружить» наше творчество с IDA Pro. Для этого наследуем класс от IDP_Hooks и переопределяем в нем поведение функций анализа инструкций (ev_ana_insn) и отображения мнемоник и операндов (ev_out_mnem, ev_out_operand).

class PExtensionIdpHook(IDP_Hooks):
    def __init__(self):
        IDP_Hooks.__init__(self)
    
    @staticmethod
    def _b2m(bits: int) -> int:
        return (1 << bits) - 1
    
    @classmethod
    def _bits(cls, value: int, hi: int, lo: int) -> int:
        return (value >> lo) & cls._b2m(hi - lo + 1)
    
    def _decode(self, insn: insn_t) -> "bool":
        opcode = get_bytes(insn.ea, 1)[0]
        if opcode & 0x7F != 0x7F:
            return False

        entry: ADecoder = rv_table.lookup(insn)
        if entry is None:
            return False

        return entry.decode(insn)

    def ev_ana_insn(self, out: "insn_t *") -> "bool":
        return self._decode(out)
    
    def ev_out_mnem(self, outctx: "outctx_t *") -> "int":
        typ = outctx.insn.itype
        
        if typ >= CUSTOM_INSN_ITYPE and typ in PExtension.name_mapping:
            mnem = PExtension.name_mapping[typ]
            outctx.out_tagon(COLOR_INSN)
            outctx.out_line(mnem)
            outctx.out_tagoff(COLOR_INSN)
            
            width = max(1, 16 - len(mnem))
            outctx.out_line(" " * width)
            
            return 1
        
        return 0
    
    def ev_out_operand(self, outctx: "outctx_t *", op: "op_t const *") -> "bool":
        insn = outctx.insn
        
        if insn.itype in PExtension.values():
            if op.type == o_displ:
                outctx.out_value(op, OOF_ADDR)
                outctx.out_register(ph_get_regnames()[op.reg])
                return True
            
        return False

Сравнение до и после применения нашего расширения представлено ниже.

Добавление поддержки декомпилятора

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

Проблема разрешается за счет разработки лифтера (Lifter): вынесем поведение в отдельный класс (наследуя от microcode_filter_t), в функциях которого опишем процедуру лифтинга с использованием микрокода декомпилятора Hex-Rays. Информацию по микрокоду можно почерпнуть здесь, а также из SDK декомпилятора. Для отладки подобных плагинов рекомендуется использовать Lucid — интерактивный плагин для просмотра микрокода.

Лифтинг: великий и ужасный

Базовая идея следующая: необходимо описать работу каждой добавленной инструкции, используя микроинструкции и маппинг (отображения) регистров в микрокод. Иными словами, надо рассказать декомпилятору, что за зверь такой перед ним и с чем его едят. Для этого нам также потребуется обратиться к черновику P Extension, в котором описывается поведение интересующих нас инструкций, и перевести это на язык, понятный конечному инструменту, — тот самый процесс лифтинга.

Вот фрагмент кода класса лифтера с примером самой «зубодробительной» реализации — для инструкций maddr32 и msubr32. Если интересно, то весь код с пояснениями можно найти на гитхабе.

class PExtensiionLifter(microcode_filter_t):
    def __init__(self):
        super(PExtensiionLifter, self).__init__()
        self._p_ext_handlers = {
            PExtension.maddr32: self._maddr32,
            PExtension.msubr32: self._msubr32,
            PExtension.mulr64:  self._mulr64,
            PExtension.umar64:  self._umar64,
            PExtension.mulsr64:  self._mulsr64,
        }

        self._NO_MOP = mop_t()

    def install(self):
        install_microcode_filter(self, True)
        print(f"Installed P-Extension lifter... ({len(self._p_ext_handlers)} instruction(s) supported)")

    def remove(self):
        install_microcode_filter(self, False)
        print("Removed P-Extension lifter...")

    def match(self, cdg):
        return cdg.insn.itype in self._p_ext_handlers

    def apply(self, cdg):
        return self._p_ext_handlers[cdg.insn.itype](cdg, cdg.insn)

    def _mac_common(self, cdg, insn, op: m_add | m_sub):
        rd = reg2mreg(insn.Op1.reg)
        rs1 = reg2mreg(insn.Op2.reg)
        rs2 = reg2mreg(insn.Op3.reg)

        # Временный регистр для промежуточного итога умножения
        tmp64 = cdg.mba.alloc_kreg(8)  # 64 bits
        tmp64_mop = mop_t(tmp64, 8)

        # Временный регистр для маскированного результата умножения
        tmp32 = cdg.mba.alloc_kreg(8)  # 32 bits
        tmp32_mop = mop_t(tmp32, 8)

        imm_mop = mop_t()
        imm_mop.make_number(0xFFFF_FFFF, 8)

        # Hex-Rays не поддерживает операнды разного размера, поэтому мы используем расширение до 8 байтов
        # Надеемся, что старшие биты регистров rs1 и rs2 нулевые :)
        cdg.emit(m_mul, 8, rs1, rs2, tmp64, 0)
        # Как и раньше, маскируем младшую часть умножения 64-битной маской 0x0000_0000_FFFF_FFFF
        cdg.emit(m_and, imm_mop, tmp64_mop, tmp32_mop)
        # На данном этапе нужно уменьшить разрядность данных до 32 бит. Это задается вторым аргументом (4).
        cdg.emit(op, 4, rd, tmp32, rd, 0)

        # Удаляем временные регистры и освобождаем ресурсы.
        cdg.mba.free_kreg(tmp64, 8)
        cdg.mba.free_kreg(tmp32, 4)

        return MERR_OK

    def _maddr32(self, cdg, insn):
        return self._mac_common(cdg, insn, m_add)

    def _msubr32(self, cdg, insn):
        return self._mac_common(cdg, insn, m_sub)

Каждое действие в рамках микроопераций подписано в коде, нюансы умножения в функции maccommon двух 32-битных чисел с 64-битным результатом с точки зрения лифтера и его API также описаны в соответствующих комментариях.

Теперь можем насладиться плодами наших трудов, результат декомпиляции без потерь и ассемблерных вставок представлен ниже :)

Заключение

Вот таким получилось приключение по улучшению и прокачиванию инструментария :) (отдельный привет опкоду для P Extension ранней редакции чер��овика). Уважаемому читателю желаем не пугаться страшных документаций и куце описанных API, нередко за ними стоят весьма стройные концепции, понять и прочувствовать которые удается лишь при непосредственном использовании, а их разработчиков — разве что простить...

Кстати, если вам тоже по душе аккуратно ковыряться в разных системах и безжалостно это документировать — вливайтесь в наши ряды в команды тестирования на проникновение и анализа защищенности АСУ ТП.

Ну а если у вас был классный (или не очень) опыт столкновения с необычными расширениями и с их анализом — пишите свои истории в комментариях!