
LLVM IR과 어셈블리 파싱을 활용한 최적화를 통한 난독화 해제
Mergen은 어셈블리 코드를 LLVM 중간 표현(IR)으로 변환하도록 설계된 도구입니다. 이 도구는 다음을 위해 설계되었습니다:
프로젝트를 빌드하고 실행하려면 docs/BUILDING.md를 참조하세요.
리라이트 작업은 기준 회귀 게이트를 녹색으로 유지해야 합니다. 게이트는 집중된 PE 샘플을 빌드하고 lifter를 실행하며, 리프트된 IR 출력을 검증합니다.
scripts\\rewrite\\run.cmd우리는 대상을 기호 실행(또는 기호 리프트)합니다. 여기서 아이디어는 개별 명령어를 리프팅하는 것이 아니라 전체 함수를 리프팅하는 것입니다. 우리는 하나의 명령어나 하나의 기본 블록이 매번 동일하게 동작할 것으로 기대하지 않고, 대신 매번 다른 목적을 위해 존재할 수 있고 실제로 그렇다고 취급합니다. 생성된 IR을 가능한 단순하고 최적화 가능하게 유지하려고 노력합니다. 또한 일반 컴파일러와는 다른 요구 사항이 있습니다. 제어 흐름을 평가하기 위해 분석을 사용합니다. 모든 분석을 LLVM에 의존할 수는 없습니다. 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번째 비트인 부호 플래그를 가져오고, 부호 플래그를 사용하여 주소를 계산합니다. 분석을 통해 주소가 두 값 중 하나(5368713257 또는 5368713264)일 수 있다고 결정한 다음, 이를 비교로 변환합니다. 주소가 5368713257이면 한 분기를, 그렇지 않으면 다른 분기를 취합니다. 이 작업을 수행할 때는 조건을 적절한 값으로 표시하는 것도 중요합니다. 나중에 동일한 값으로 다른 점프를 계산해야 할 수도 있기 때문입니다.
간접 점프를 해결했지만, 가능한 위치가 2개보다 많은 점프는 지원되지 않습니다. 이는 해당 분석이 아직 구현되지 않았기 때문입니다. 이를 통해 VM 스타일의 분기는 해결할 수 있지만, 실제 점프테이블에는 문제가 있습니다.
대상 프로그램:

Themida 설정 (현재는 VM만 신경 씁니다):



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 서버에 가입하여 아이디어를 교환하거나 일반적인 대화를 나누세요.