McSema и декомпиляция в исходный код LLVM: реально ли это?
Представьте себе, что есть некая очень полезная программа, но она, например, существует только в версии Windows и только 64 бита. А вам нужно, например, под ARM64 и под другую ОС, соответственно. Причём исходников у вас нет, и достать их невозможно.
Что делать? Существует проект MCSema (пост на хабре про mcsema: https://habrahabr.ru/post/232871/). Его создатели (а они на финансировании DARPA, между прочим), обещают сказочные вещи: перевод бинарников в LLVM IR, оптимизации, семантический анализ кода и т.д. И конечно же, перекомпиляцию на любые архитектуры, которые поддерживает LLVM. Проект опенсорсный (ссылка на гитхаб: https://github.com/trailofbits/mcsema)
А теперь посмотрим, что происходит на самом деле.
Во-первых, нужно сразу прояснить, что MCSema не умеет сама дизассемблировать бинарники. Для этого нужен IDA Pro, дорогой продукт сам по себе. Но это дело решаемое, в принципе, можно заменить IDA Pro чем-то другим, если сильно захотеть.
Но как происходит волшебство преобразования ассемблера в IR?
(Если кто не в курсе: IR — промежуточный язык LLVM, своего рода обобщённый ассемблер, более высокоуровневый, чем ассемблеры процессоров.)
Очень просто. Весь набор регистров x86–64 можно представить в виде структуры под названием SimulatedCPU, которую, с некоторыми сокращениями, вы можете увидеть здесь:
struct SimulatedCPU
{
enum CPUFlags // See: https://en.wikibooks.org/wiki/X86_Assembly/X86_Architecture
{
CF = (1 << 0),
// 1 is 1
PF = (1 << 2),
// 3 is 0
AF = (1 << 4),
// 5 is 0
ZF = (1 << 6),
SF = (1 << 7),
TF = (1 << 8),
IF = (1 << 9),
DF = (1 << 10),
OF = (1 << 11),
// 12-13 is IOPL
// 14 is NT
// 15 is 0
// 16 is RF
// 17 is VM
// 18 is AC
// 19 is VIF
// 20 is VIP
ID = (1 << 11), // ID flag
// rest is 0
};
enum FPUControl // See: http://www.plantation-productions.com/Webster/www.artofasm.com/Linux/HTML/RealArithmetic.html
{
// Exception masks
FPUC_InvalidOpMask = (1 << 0),
FPUC_DenormalMask = (1 << 1),
FPUC_ZeroDivideMask = (1 << 2),
FPUC_OverflowMask = (1 << 3),
FPUC_UnderflowMask = (1 << 4),
FPUC_PrecisionMask = (1 << 5),
// 6 is 1 (no idea why)
// 7 is 0
FPUC_PrecisionControlMask = (3 << 8), // 2 bits, 00 - 24 bits (float) , 01 - reserved, 10 - 53 bits (double), 11 - 64 bits (FP80), default 11
FPUC_RoundingMask = (3 << 10), // 2 bits, 00 - to nearest or even, 01 - down, 10 - up, 11 - truncate, default 00
FPUC_Infinity = (1 << 12),
// rest is 0
};
enum FPUStatus // 16 bits
{
FPUS_InvalidOpFlag = (1 << 0),
FPUS_DenormalFlag = (1 << 1),
FPUS_ZeroDivideFlag = (1 << 2),
FPUS_OverflowFlag = (1 << 3),
FPUS_UnderflowFlag = (1 << 4),
FPUS_PrecisionFlag = (1 << 5),
FPUS_StackFaultFlag = (1 << 6), // that's for FPU stack (8 regs)
FPUS_ExceptionFlag = (1 << 7), // set if any previous is set
FPUS_C0 = (1 << 8), // Condition bit 0
FPUS_C1 = (1 << 9), // Condition bit 1
FPUS_C2 = (1 << 10), // Condition bit 2
FPUS_TOP_Mask = (7 << 11), // top-of-stack pointer (3 bits)
FPUS_C3 = (1 << 14), // Condition bit 3
FPUS_Busy = (1 << 15), // 1 is FPU is busy. I don't expcet any code to ever use it.
};
enum SSE_MXCSR
{
MXCSR_InvalidOp = (1 << 0),
MXCSR_Denormal = (1 << 1),
MXCSR_DivideByZero = (1 << 2),
MXCSR_Overflow = (1 << 3),
MXCSR_Underflow = (1 << 4),
MXCSR_Precision = (1 << 5),
MXCSR_DenormalsAreZero = (1 << 6),
MXCSR_InvalidOpExceptionMask = (1 << 7),
MXCSR_DenormalExceptionMask = (1 << 8),
MXCSR_DivideByZeroExceptionMask = (1 << 9),
MXCSR_OverflowExceptionMask = (1 << 10),
MXCSR_UnderflowExceptionMask = (1 << 11),
MXCSR_PrecisionExceptionMask = (1 << 12),
MXCSR_RoundingModeMask = (3 << 13), // 2-bit, so it's a mask
MXCSR_FlushToZero = (1 << 15),
};
// General Purpose Registers (32-bit in 32-bit mode, 64-bit in 64-bit mode)
UINT_PTR RegAX; // 0x00 (0)
UINT_PTR RegBX; // 0x04 (4)
UINT_PTR RegCX; // 0x08 (8)
UINT_PTR RegDX; // 0x0C (12)
UINT_PTR RegSI; // 0x10 (16)
UINT_PTR RegDI; // 0x14 (20)
UINT_PTR RegSP; // 0x18 (24)
UINT_PTR RegBP; // 0x1C (28)
#ifdef EON_WIN64
UINT_PTR Reg08;
UINT_PTR Reg09;
UINT_PTR Reg10;
UINT_PTR Reg11;
UINT_PTR Reg12;
UINT_PTR Reg13;
UINT_PTR Reg14;
UINT_PTR Reg15;
UINT_PTR RegRIP; // possibly it won't be needed, if we fold all RIP-relative instructions on conversion. If you remove it, see about padding below.
#endif
// Various often used flags - close to other registers to improve caching if possible
// EFLAGS (some)
BYTE FlagCF; // 0x20 (32)
BYTE FlagPF; // 0x21 (33)
BYTE FlagAF; // 0x22 (34)
BYTE FlagZF; // 0x23 (35)
BYTE FlagSF; // 0x24 (36)
BYTE FlagDF; // 0x25 (37)
BYTE FlagOF; // 0x26 (38)
// FPU Status Word (some)
BYTE FlagFPU_C0; // 0x27 (39)
BYTE FlagFPU_C1; // 0x28 (40)
BYTE FlagFPU_C2; // 0x29 (41)
BYTE FlagFPU_C3; // 0x2A (42)
BYTE FlagFPU_TOP; // 0x2B (43)
// FPU Control Word (some)
BYTE FlagFPU_PC; // 0x2C (44)
BYTE FlagFPU_RC; // 0x2D (45)
// Rarely used flags, serving as paddding
// FPU Control Word (some)
BYTE FlagFPU_IM; // 0x2E (46)
BYTE FlagFPU_DM; // 0x2F (47)
BYTE FlagFPU_ZM; // 0x30 (48)
BYTE FlagFPU_OM; // 0x31 (49)
BYTE FlagFPU_UM; // 0x32 (50)
BYTE FlagFPU_PM; // 0x33 (51)
BYTE FlagFPU_X; // 0x34 (52)
// FPU Status Word (some)
BYTE FlagFPU_ES; // 0x35 (53)
// Tag Word
WORD FPUTagWord; // 0x36 (54)
UINT_PTR ThreadEnvironmentBlock; // 0x38 (56)
UINT_PTR ZeroRegister; // 0x3C (60) zero-filled dummy to give as CS,ES or SS register
// XMM registers (aligned on 64-bit alignment)
#ifdef EON_WIN64
BYTE XMMRegs[16*16]; // 16 128-bit (16-byte) registers
#else
BYTE XMMRegs[8*16]; // 0x40 (64) 8 128-bit (16-byte) registers
#endif
// x87 FPU registers (aligned on 64-bit alignment)
DOUBLE FPURegs[8]; // 0xC0 (192)
// Rarely used flags and other rarely used members
// FPU Status Word (some)
BYTE FlagFPU_IE; // 0x100 (256)
BYTE FlagFPU_DE; // 0x101 (257)
BYTE FlagFPU_ZE; // 0x102 (258)
BYTE FlagFPU_OE; // 0x103 (259)
BYTE FlagFPU_UE; // 0x104 (260)
BYTE FlagFPU_PE; // 0x105 (261)
BYTE FlagFPU_SF; // 0x106 (262)
BYTE FlagFPU_B; // 0x107 (263)
// MXCSR
BYTE SSEFlag_InvalidOperation; // 0x108 (264)
BYTE SSEFlag_Denormal; // 0x109 (265)
BYTE SSEFlag_DivideByZero; // 0x10A (266)
BYTE SSEFlag_Overflow; // 0x10B (267)
BYTE SSEFlag_Underflow; // 0x10C (268)
BYTE SSEFlag_Precision; // 0x10D (269)
BYTE SSEFlag_DenormalsAreZero; // 0x10E (270)
BYTE SSEFlag_InvalidOperationMask; // 0x10F (271)
BYTE SSEFlag_DenormalMask; // 0x110 (272)
BYTE SSEFlag_DivideByZeroMask; // 0x111 (273)
BYTE SSEFlag_OverflowMask; // 0x112 (274)
BYTE SSEFlag_UnderflowMask; // 0x113 (275)
BYTE SSEFlag_PrecisionMask; // 0x114 (276)
BYTE SSE_RoundingMode; // 0x115 (277)
BYTE SSEFlag_FlushToZero; // 0x116 (278)
BYTE FlagID; // 0x117 (279) - EFLAGS' ID flag
// FPU debugging, currently unused but reserved
UINT_PTR LastInstructionPtrOffset; // 0x118 (280)
UINT_PTR LastDataPtrOffset; // 0x11A (281)
WORD FPUFopcode; // 0x11E (282)
WORD LastInstructionPtrSeg; // 0x120 (283)
WORD LastDataPtrSeg; // 0x122 (284)
#ifdef EON_WIN64
UINT_PTR StackValueAtLastNativeCodeEntry; // 0x124 (286), this is used to unwind simulated stack when part of it was used by native code
#endif
//...
};
В ассемблере LLVM эта структура отражается очень просто:
%SimulatedCPU = type <{ i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i3, i2, i2, i1, i1, i1, i1, i1, i1, i1, i1, i16, i64, i64, i128, i128, i128, i128, i128, i128, i128, i128, i128, i128, i128, i128, i128, i128, i128, i128, [8 x double], i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i2, i1, i1, i64, i64, i16, i16, i16 }>
(i64, i1 и т.п. — это целочисленные типы в LLVM IR, имеющие, соответственно, размер 64 бита, 1 бит и т.п.) То есть регистры общего назначения имеют тип i64, флаги — тип i1, далее идут 128-битные регистры XMM, регистры FPU (типа double), флаги FPU и разные служебные регистры.
Пусть у нас есть некоторая функция исходной программы, которая начинается, например, так:
0000000140820d40 mov rax, rsp
0000000140820d43 push rbp
0000000140820d44 push rbx
0000000140820d45 push rsi
0000000140820d46 push rdi
0000000140820d47 push r12
0000000140820d49 push r13
0000000140820d4b push r14
0000000140820d4d push r15
Ничего необычного, в прологе функции мы копируем значение RSP в RAX и сохраняем в стек некоторые регистры, в соответствии с Calling Convention. То есть это даже ещё не сама содержательная часть функции, это только часть пролога.
Во что превращает это код MCSema (с сокращениями):
%0 = type opaque
%SimulatedCPU = type <{ i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i3, i2, i2, i1, i1, i1, i1, i1, i1, i1, i1, i16, i64, i64, i128, i128, i128, i128, i128, i128, i128, i128, i128, i128, i128, i128, i128, i128, i128, i128, [8 x double], i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i2, i1, i1, i64, i64, i16, i16, i16 }>
define hidden fastcc void @Foo(%SimulatedCPU* noalias nocapture align 16 dereferenceable(542))
entry:
%R15_val = alloca i64
%XMM9_val = alloca i128
%XMM8_val = alloca i128
...
%RSP_val = alloca i64
%RBP_val = alloca i64
...
%RAX_val = alloca i64
...
%1 = getelementptr inbounds %SimulatedCPU, %SimulatedCPU* %0, i64 0, i32 0
%2 = load i64, i64* %1
store i64 %2, i64* %RAX_val
...
%13 = getelementptr inbounds %SimulatedCPU, %SimulatedCPU* %0, i64 0, i32 6
%14 = load i64, i64* %13
store i64 %14, i64* %RSP_val
%15 = getelementptr inbounds %SimulatedCPU, %SimulatedCPU* %0, i64 0, i32 7
%16 = load i64, i64* %15
store i64 %16, i64* %RBP_val
...
block_0x10820d40: ; preds = %entry
%165 = load i64, i64* %RSP_val
store i64 %165, i64* %RAX_val
%166 = load i64, i64* %RBP_val
%167 = load i64, i64* %RSP_val
%168 = sub i64 %167, 8
%169 = inttoptr i64 %168 to i64*
store volatile i64 %166, i64* %169, align 4
Для тех, кто не знает LLVM IR, некоторые пояснения:
1.В функцию всегда передаётся один аргумент — указатель на глобальную структуру SimulatedCPU. Функция всегда возвращает void.
2.Выделяем на стеке память для переменной 64 бита:
%RSP_val = alloca i64
3.Получаем адрес 6-го элемента в структуре SimulatedCPU (т.е. регистра RSP)
%13 = getelementptr inbounds %SimulatedCPU, %SimulatedCPU* %0, i64 0, i32 6
4. Получаем значение RSP:
%14 = load i64, i64* %13
5. Сохраняем его в заранее выделенную память под именем RSP_val:
store i64%14, i64* %RSP_val
6. Снова загружаем значение RSP в переменную
%165 = load i64, i64* %RSP_val
7. Копируем в RAX (память под RAX была выделена аналогичным образом):
store i64%165, i64* %RAX_val
8. Получаем значение RBP:
%166 = load i64, i64* %RBP_val
9. Получаем значение RSP
%167 = load i64, i64* %RSP_val
10. Вычитаем 8 из значения RSP (т.е. увеличиваем стек на 8 байт)
%168 = sub i64%167, 8
11. Преобразуем полученное значение в указатель
%169 = inttoptr i64%168 to i64*
12. Сохраняем значение RBP по полученному адресу
store volatile i64%166, i64* %169, align 4
Это команды, в которые MCSema превратил одну команду исходной программы: push rbp.
Но это неоптимизированный код, может быть, оптимизатор opt сделает его эффективным?
Посмотрим:
%0 = type opaque
%SimulatedCPU = type <{ i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i3, i2, i2, i1, i1, i1, i1, i1, i1, i1, i1, i16, i64, i64, i128, i128, i128, i128, i128, i128, i128, i128, i128, i128, i128, i128, i128, i128, i128, i128, [8 x double], i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i1, i2, i1, i1, i64, i64, i16, i16, i16 }>
define hidden fastcc void @Foo(%SimulatedCPU* noalias nocapture align 16 dereferenceable(542)) {
entry:
...
%12 = getelementptr inbounds %SimulatedCPU, %SimulatedCPU* %0, i64 0, i32 6
%13 = load i64, i64* %12, align 16
%14 = getelementptr inbounds %SimulatedCPU, %SimulatedCPU* %0, i64 0, i32 7
%15 = load i64, i64* %14, align 8
...
%56 = add i64 %13, -8
%57 = inttoptr i64 %56 to i64*
store volatile i64 %15, i64* %57, align 4
Итак, оптимизатор убрал размещение на стеке копий регистров симулируемого процессора, заменив их прямыми операциями чтения/записи в глобальную структуру SimulatedCPU. Стала ли от этого программа более эффективной? Однозначно нет. Больше оптимизатор не может сделать ничего: квалификатор volatile является для него непреодолимым препятствием.
Если собрать получившийся код и запустить на ARM64, то он будет приблизительно на два порядка медленнее исходного кода на x86–64.
MCSema не видит никаких переменных на стеке функции: она тупо превращает элементарные операции с регистрами даже не в операции с регистрами целевого процессора (таргета), а в операции со структурой SimulatedCPU, которая расположена в памяти (если программа многопоточная, то каждому потоку соответствует своя копия этой структуры).
Выводы, которые можно сделать из вышеописанного: пока что рано говорить о переводе бинарных файлов с одной архитектуры на другую. Вместо обещанного семантического анализа мы имеем просто очень неэффективную эмуляцию работы процессора. Такой подход в принципе не позволяет добиться эффективной работы программы на процессоре с другой архитектурой.
Ссылки по теме:
1. Andrew Ruef. There and back again. Binary Analysis with mcsema. https://tilde.town/~awruef/binaries.pdf
2. Lifting Windows Driver Binaries into LLVM IR. https://sslab.gtisc.gatech.edu/2017/win-lift.html