
Деобфускация с помощью оптимизации с использованием LLVM IR и разбора ассемблера.
Mergen — это инструмент, предназначенный для преобразования ассемблерного кода в промежуточное представление LLVM (IR). Этот инструмент разработан для:
Чтобы собрать и запустить проект, посмотрите docs/BUILDING.md.
Работа по переписыванию должна поддерживать зелёный статус базового шлюза регрессии. Шлюз собирает целевые PE-образцы, запускает lifter и проверяет результирующий поднятый IR.
scripts\\rewrite\\run.cmdМы выполняем символьное выполнение (или символьный подъём) цели. Идея заключается не в подъёме отдельных инструкций, а в подъёме целой функции. Мы не ожидаем, что одна инструкция или один базовый блок будут вести себя одинаково каждый раз; вместо этого мы рассматриваем их как способные вести себя по-разному и для разных целей каждый раз. Мы стараемся сделать генерируемый IR как можно более простым и оптимизируемым. У нас также другие потребности, чем у обычного компилятора. Мы используем анализ для оценки потока управления. Мы не можем полагаться на LLVM во всём анализе, потому что он создан для других целей и может быть неоптимальным для нашего случая.

Это практический пример, иллюстрирующий, как Mergen работает с виртуализированными программами.
Это наша целевая программа
struct test {
int a;
int b;
int c;
};
int maths(test a, int b, int c) {
return a.a + b - c;
}


Настройки VMProtect: всё выключено, мы виртуализируем функцию на ультра-настройке. (Проверенные версии 3.4.0-3.6.0 3.8.1)


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

; ModuleID = 'my_lifting_module'
source_filename = "my_lifting_module"
; Function Attrs: mustprogress nofree norecurse nosync nounwind willreturn memory(argmem: read)
define i64 @main(i64 %rax, i64 %rcx, i64 %rdx, i64 %rbx, i64 %0, i64 %rbp, i64 %rsi, i64 %rdi, i64 %r8, i64 %r9, i64 %r10, i64 %r11, i64 %r12, i64 %r13, i64 %r14, i64 %r15, ptr nocapture readonly %memory) local_unnamed_addr #0 {
entry:
%stackmemory = alloca i128, i128 13758960, align 8
%1 = trunc i64 %r8 to i32
%2 = trunc i64 %rdx to i32
%GEPLoadxd-5369456437- = getelementptr i8, ptr %memory, i64 %rcx
%3 = load i32, ptr %GEPLoadxd-5369456437-, align 4
%adc-temp-5370242400- = sub i32 %2, %1
%realnot-5369532059- = add i32 %adc-temp-5370242400-, %3
%stackmemory10243.sroa.55.1375304.insert.ext10255 = zext i32 %realnot-5369532059- to i64
ret i64 %stackmemory10243.sroa.55.1375304.insert.ext10255
}
attributes #0 = { mustprogress nofree norecurse nosync nounwind willreturn memory(argmem: read) }
После компиляции:


Возможно, вы заметили, что регистры немного смещены. Это потому, что мы не следуем соглашениям о вызовах; если бы мы им следовали, сигнатура функции выглядела бы так:
define i64 @main(i64 %rcx, i64 %rdx, i64 %rdx, i64 %r8, i64 %r9 ...)
Поэтому мы просто корректируем сигнатуру функции, чтобы она выглядела нормально. Если у вас есть дополнительные вопросы по этой части, рекомендую изучить соглашения о вызовах и ABI.
Итак, предположим, у нас есть такой код. VM возьмут приведённый ниже код и превратят его в косвенный переход, что немного менее удобно для реверс-инженера.
int maths(int a, int b, int c) {
if (a > b)
return a + b + c;
else
return a - b - c;
}
next_handler = xxx;
if ( a-b > 0 )
next_handler = yyy;
jump next_handler;
Мы стараемся всегда анализировать значения и отслеживать их. Это позволяет нам понимать поток управления. Для ветвлений, похожих на таблицы переходов Оптимизированный вывод будет простым:
define i64 @main(i64 %rax, i64 %rcx, i64 %rdx, i64 %rbx, i64 %rsp, i64 %rbp, i64 %rsi, i64 %rdi, i64 %r8, i64 %r9, i64 %r10, i64 %r11, i64 %r12, i64 %r13, i64 %r14, i64 %r15, ptr nocapture readnone %TEB, ptr nocapture readnone %memory) local_unnamed_addr #0 {
fake_ret:
%0 = lshr i64 %rcx, 62
%common.ret.op = and i64 %0, 2
ret i64 %common.ret.op
}
Неоптимизированный вывод. (DCE для читаемости)
source_filename = "my_lifting_module"
define i64 @main(i64 %rax, i64 %rcx, i64 %rdx, i64 %rbx, i64 %rsp, i64 %rbp, i64 %rsi, i64 %rdi, i64 %r8, i64 %r9, i64 %r10, i64 %r11, i64 %r12, i64 %r13, i64 %r14, i64 %r15, ptr %TEB, ptr %memory) {
%lsb = and i64 %rcx, 255
%pf1 = mul i64 %lsb, 72340172838076673
%pf2 = and i64 %pf1, -9205322385119247871
%pf3 = urem i64 %pf2, 511
%pf4 = and i64 %pf3, 1
%pf5 = icmp eq i64 0, %pf4
%0 = zext i1 %pf5 to i64
%createrflag2 = shl i64 %0, 2
%creatingrflag = or i64 2, %createrflag2
%zeroflag = icmp eq i64 %rcx, 0
%1 = zext i1 %zeroflag to i64
%createrflag21 = shl i64 %1, 6
%creatingrflag2 = or i64 %creatingrflag, %createrflag21
%signflag = icmp slt i64 %rcx, 0
%2 = zext i1 %signflag to i64
%createrflag23 = shl i64 %2, 7
%creatingrflag4 = or i64 %creatingrflag2, %createrflag23
%GEPSTORE-5368713221- = getelementptr i8, ptr %memory, i64 1376032
store i64 %creatingrflag4, ptr %GEPSTORE-5368713221-, align 4
%realand-5368713229- = and i64 %creatingrflag4, 128
%shr-lshr-5368713233- = lshr i64 %realand-5368713229-, 7
%3 = mul i64 %shr-lshr-5368713233-, 4
%bvalue_indexvalue = add i64 5368713249, %3
%4 = icmp eq i64 %bvalue_indexvalue, 5368713253
%lolb- = select i1 %4, i64 5368713264, i64 5368713257
%GEPSTORE-5368713248- = getelementptr i8, ptr %memory, i64 1376032
store i64 %lolb-, ptr %GEPSTORE-5368713248-, align 4
br i1 %4, label %real_ret, label %real_ret41
real_ret: ; preds = %fake_ret
%inc-5368713273- = add i64 %shr-lshr-5368713233-, 1
ret i64 %inc-5368713273-
real_ret41: ; preds = %fake_ret
ret i64 %shr-lshr-5368713233-
}
Обратите внимание на эту часть
%realand-5368713229- = and i64 %creatingrflag4, 128
%shr-lshr-5368713233- = lshr i64 %realand-5368713229-, 7
Мы получаем флаги, затем получаем 7-й бит, который является флагом знака (Sign Flag), и используем его для вычисления адреса. С помощью анализа мы определяем, что адрес может быть одним из двух значений: 5368713257 или 5368713264, и превращаем это в сравнение. Если адрес равен 5368713257, переходим по одной ветке, иначе — по другой. При этом важно пометить условие соответствующим значением, так как позже может потребоваться вычислить другой переход с тем же значением.
Хотя мы решаем косвенные переходы, переходы с более чем двумя возможными адресами не поддерживаются. Это связано с тем, что анализ для них ещё не реализован. Это позволяет нам решать ветвления в стиле VM, но вызывает проблемы с реальными таблицами переходов.
Наша целевая программа:

Настройки Themida (нас пока интересуют только VM):



После виртуализации:

Запуск Mergen:

Вывод кода: нажмите здесь
Итак, почему наш результат не так успешен, как подъём бинарного файла, защищённого VMP?
Themida активно записывает данные в секцию .themida. В отличие от стека, мы не можем игнорировать эти записи, потому что эти значения могут быть прочитаны другими компонентами позже.
Но у нас есть временное решение: удалить все сохранения в секцию .themida. Поскольку наша программа не записывает в память, я просто закомментировал все сохранения. Теперь осталось это:
source_filename = "my_lifting_module"
define i64 @main(i64 %rax, i64 %rcx, i64 %rdx, i64 %rbx, i64 %rsp, i64 %rbp, i64 %rsi, i64 %rdi, i64 %r8, i64 %r9, i64 %r10, i64 %r11, i64 %r12, i64 %r13, i64 %r14, i64 %r15, ptr writeonly %memory) local_unnamed_addr #0 {
%trunc = trunc i64 %r8 to i32
%trunc1 = trunc i64 %rdx to i32
%trunc2 = trunc i64 %rcx to i32
%realadd-5369771371- = add i32 %trunc1, %trunc2
%realadd-5369582686- = add i32 %realadd-5369771371-, %trunc
%trunc457139 = zext i32 %realadd-5369582686- to i64
ret i64 %trunc457139
}
attributes #0 = { mustprogress nofree norecurse nosync nounwind willreturn memory(argmem: write) }
Присоединяйтесь к нашему серверу Mergen в Discord, чтобы обмениваться идеями или просто общаться.