가상화 난독화 된 코드를 LLM을 이용하여 분석을 하기 위해 학습 데이터에 Feature를 생성해 보았다.
Feature 생성을 위해 LLVM Pass를 이용하여 가상화 난독화의 핵심 구조에 dummy_function call을 삽입할 계획이다. 삽입할 위치는 다음과 같이 정하였다.
main 함수 시작과 끝 위치를 지정한 이유는 Tigress로 가상화 난독화 할때, main 함수를 지정하여 가상화 난독화 하였기 때문에 가상화 영역을 LLM이 알 수 있도록 하기 위함이다.
이렇게 LLVM 단에서 데이터에 Feature를 생성해 주고 이후 어셈블리로 컴파일한 후, 각 데이터에 태깅을 하여 원본인지, 가상화 인지, dispatcher 시작인지, handler인지 알 수 있도록 할 예정이다.
이제 Pass 코드를 살펴보면, 이전에 dispatcher를 찾은 방법을 활용하여 코드를 제작하였다. dispatcher 시작 블럭에서는 각 핸들러 블럭으로 분기하기 때문에 handler는 이를 이용하여 찾으면 아주 간단하다. 다음 main 함수 시작과 끝 또한 아주 간단하게 구할 수 있다.
// 함수가 main일 때
if (F.getName() == "main") {
errs() << "--- Main Function Modification ---\n";
// dummy_function_VM_start 삽입 (시작 블럭의 첫 번째 명령어 직전)
BasicBlock &entryBlock = F.getEntryBlock();
IRBuilder<> startBuilder(&entryBlock, entryBlock.begin());
FunctionType *startFuncType = FunctionType::get(Type::getVoidTy(Ctx), false);
FunctionCallee startFunc = M->getOrInsertFunction("dummy_function_VM_start", startFuncType);
startBuilder.CreateCall(startFunc);
errs() << "[+] Inserted dummy_function_VM_start call at the start of 'main'.\n";
irModified = true;
// dummy_function_VM_end 삽입 (종료 블럭의 Terminator 직전)
for (BasicBlock &BB : F) {
Instruction *terminator = BB.getTerminator();
if (isa<ReturnInst>(terminator)) {
IRBuilder<> endBuilder(terminator);
FunctionType *endFuncType = FunctionType::get(Type::getVoidTy(Ctx), false);
FunctionCallee endFunc = M->getOrInsertFunction("dummy_function_VM_end", endFuncType);
endBuilder.CreateCall(endFunc);
errs() << "[+] Inserted dummy_function_VM_end call before return in BB: ";
BB.printAsOperand(errs(), false);
errs() << "\n";
irModified = true;
}
}
코드가 매우 길기 때문에 기능별로 나눠서 보도록 하자. 먼저 main 함수의 시작과 끝에 dummy_function call을 삽입하는 코드다.
F.getName()을 하면 간단히 함수명을 구할 수 있다.
IRBuilder<> startBuilder(&entryBlock, entryBlock.begin()); 이 줄을 보면 IRBuilder는 IR을 삽입할 때 사용하는 유틸리티 클래스로 인자는 각 삽입할 블럭과 블럭내 위치를 인자로 받는다. 즉 main 함수 시작블럭의 시작 명령어 바로 위에 삽입을 하게 된다.
삽입할 함수의 type은
FunctionType *startFuncType = FunctionType::get(Type::getVoidTy(Ctx), false); void type의 가변인자는 미포함으로 하였다.
이제 아래 반복문을 보면 함수 안에 있는 베이직블럭을 순회하며 베이직 블럭의 종료 명령어를 가져온다. 만약 종료 명령어가 return이면 main함수의 종료를 의미하므로 해당 위치에 dummy_function call을 삽입한다.
여기서는 endFunc위치에 삽입하는 것을 볼 수 있는데 사실 main의 시작 이외에는 모두 베이직 블럭의 종료 명령어 직전에 명령어를 삽입한다.
그 이유는 LLVM IR 규칙에 있는데 베이직 블럭의 가장 위에는 반드시 PHI 노드가 위치해야 하는 규칙이 있다. 하지만 main 함수는 처음으로 시작되는 함수이기 때문에 PHI노드가 존재하지 않아 시작 위치에 삽입하였다.
// 후행자 개수 찾기
std::map<const BasicBlock*, unsigned> successorCounts;
for (BasicBlock &BB : F) {
const Instruction *terminator = BB.getTerminator();
unsigned numSuccessors = terminator ? terminator->getNumSuccessors() : 0;
successorCounts[&BB] = numSuccessors;
}
// 후행자가 가장 많은 블록 찾기
const BasicBlock *succCandidate = nullptr;
unsigned maxSuccs = 0;
for (auto const& [block, count] : successorCounts) {
if (count > maxSuccs) {
maxSuccs = count;
succCandidate = block;
}
}
이 코드는 이전에도 다뤘던 코드인데 아주 간단하다. main안에 있는 각 BB에 대하여 종료 명령어를 찾고 Successors(후행자)의 개수를 찾아 map에 저장한다.
개수를 구한뒤 가장 많은 후행자를 가진 베이직 블럭을 찾으면 끝이다.
앞서 찾은 후행자를 가장 많이 가진 블럭(dispatcher 시작)을 찾은 경우 handler를 찾게 된다.
if (succCandidate) {
errs() << "--- Dispatcher/Handler Tagging ---\n";
errs() << "Max Successors Found in main!\n";
errs() << "-> BB: ";
succCandidate->printAsOperand(errs(), false);
errs() << "\n";
errs() << "-> Count: " << maxSuccs << "\n";
// Dispatcher 블록에 dummy_function_dispatch_start 삽입
FunctionType *dispatchFuncType = FunctionType::get(Type::getVoidTy(Ctx), false);
FunctionCallee dispatchFunc = M->getOrInsertFunction("dummy_function_dispatch_start", dispatchFuncType);
BasicBlock *targetBlock = const_cast<BasicBlock*>(succCandidate);
IRBuilder<> dispatchBuilder(targetBlock->getTerminator());
dispatchBuilder.CreateCall(dispatchFunc);
errs() << "[+] Inserted dummy_function_dispatch_start call.\n";
irModified = true;
// 후행 블럭들에 dummy_function_handler 삽입
Instruction *terminator = targetBlock->getTerminator();
if (terminator) {
FunctionType *handlerFuncType = FunctionType::get(Type::getVoidTy(Ctx), false);
FunctionCallee handlerFunc = M->getOrInsertFunction("dummy_function_handler", handlerFuncType);
for (unsigned i = 0; i < terminator->getNumSuccessors(); ++i) {
BasicBlock *successor = terminator->getSuccessor(i);
// 후행 블럭의 Terminator 직전에 삽입
IRBuilder<> handlerBuilder(successor->getTerminator());
handlerBuilder.CreateCall(handlerFunc);
errs() << "[+] Inserted dummy_function_handler call into successor BB: ";
successor->printAsOperand(errs(), false);
errs() << "\n";
irModified = true;
}
}
} else {
errs() << "-> No candidate blocks found for dispatcher tagging in main.\n";
}
먼저 dispatch 시작 블럭에 dummy_function call을 삽입해 준다.
그 다음 constant type인 succCandidate(dispatch 시작 블럭)을 수정 가능하도록 type casting을 해준다.
그다음 dispatch 시작 블럭의 종료 명령어의 후행자들을 순회하며 후행자 블럭의 종료 명령어 직전에 dummy_function call을 삽입해 준다.
C 코드를 Tirgress로 난독화하여 진행하였다.
#include <stdio.h>
#include <stdbool.h>
#include <stdlib.h>
void initialize_and_print() {
int version = 101;
printf("--- Initialization ---\n");
printf("System Version: v%d\n", version);
}
bool perform_check(int value) {
if (value > 50) {
printf("Check: Value is high.\n");
return true;
} else {
printf("Check: Value is low or normal.\n");
return false;
}
}
int calculate(int op_code, int a, int b) {
int result = 0;
switch (op_code) {
case 10:
result = a + b;
printf("Operation: Add\n");
break;
case 20:
result = a - b;
printf("Operation: Subtract\n");
break;
case 30:
result = a * b;
printf("Operation: Multiply\n");
break;
case 40:
result = (b != 0) ? (a / b) : 0;
printf("Operation: Divide\n");
break;
default:
printf("Operation: Invalid or Default Case\n");
result = 0;
break;
}
return result;
}
int main() {
int x = 200;
int y = 75;
int opcode;
int final_result;
bool status;
printf("Starting VM Tagging Test Program.\n");
initialize_and_print();
status = perform_check(x);
if (status) {
opcode = 30;
} else {
opcode = 10;
}
final_result = calculate(opcode, x, y);
printf("Final result: %d\n", final_result);
printf("Program finished successfully.\n");
return 0;
}
간단한 C 코드로 main 함수 외에 여러 함수를 사용하여 작성하였다.
Tigress로 main 함수만 가상화 난독화를 적용하였다. dispatch option은 switch를 사용하였다.

실행 결과를 보면 main 함수에서 가장 많은 후행자를 가진 블럭을 찾고 각 handler에 잘 삽입했다고 출력이 뜬 것을 볼 수 있다.
이제 LLVM IR에 올바르게 dummy_function call이 삽입됐는지 확인해 보자

main 시작에 아주 잘 삽입 된것을 볼 수 있다.

그 다음 dispatch start 인데 잘 찾은 것을 볼 수 있다.


확인해 보면 handler도 전부 잘 찾은 것을 볼 수 있다.

main end인데 보면 handler도 같이 있는 것을 볼 수 있다. 이렇게 된 이유는 main 함수의 종료 또한 가상화로 인해 handler에서 수행하기 대문에 이렇게 중복이 발생하는 것이다.
현재 블로그에는 dispatch option을 대상으로 한 결과를 보였지만, dispatch option을 direct, indirect로 한 결과 같은 핸들러에 dummy_function call을 중복적으로 삽입하는 것을 확인하였다.
이러한 문제가 발생한 원인은 direct, indirect 옵션의 경우 (예상)코드 분석을 어렵게 하기 위하여 분기할 핸들러를 저장하는 배열에 핸들러들의 주소가 중복적으로 저장돼 있고, 컴파일 하여도 중복적으로 남아있게 된다. 그리하여 pass를 수정해야 한다.