Why should I know this?

LLVM Obfuscator] Call 본문

Knowledge/Compiler

LLVM Obfuscator] Call

die4taoam 2021. 2. 13. 01:38

난독화 기법을 조금이라도 더 쉽게 개발하는 방법은 다음과 같다.

- C로 짠 로직을 통해 IR을 뽑아내기

- IR을 기반으로 LLVM OPT 패스 만들기

 

먼저 순수 C로 예제코드를 만들고, C에서 목적한 난독화 기법을 구현해본다.

이번에 구현하고자 하는 난독화는 CALL 을 난독화 하는 것이며, C 로 구현된 코드는 아래와 같다.

#include <stdio.h>
void foo();

void (*t)() = foo;
void (*tt)() = (void (*)())((long)&foo + 1);

long test = ((long)&foo) + 65536;
long test2 = (long)&foo + 1;


void foo()
{
    puts("TEST\n");
}


int main()
{
    ((int (*)()) (test - 65536))();
    ((int (*)()) (test2-1))();
    foo();
    
    return 0;
}

위의 코드에 대한 설명은 아래와 같다.

1. 선언된 함수 foo 의 주소를 정적 포인터 함수에 저장한다.

2. 1의 과정에서 임의의 값을 더해 난독화(?) 를 수행한다.

3. main() 함수 안에서 호출하는 모든 foo 함수를 정적 포인터 함수 호출로 바꾼다.

 

위의 코드에서 foo() 를 난독화 한 값을 보관하는 정적 포인터 함수 test, test2 를 clang으로 컴파일 해 IR로 확인해보면 다음과 같은 내용을 확인할 수 있다.

@t = dso_local global void (...)* bitcast (void ()* @foo to void (...)*), align 8
@tt = dso_local global void (...)* bitcast (i8* getelementptr (i8, i8* bitcast (void ()* @foo to i8*), i64 1) to void (...)*), align 8
@test = dso_local global i64 ptrtoint (i8* getelementptr (i8, i8* bitcast (void ()* @foo to i8*), i64 65536) to i64), align 8
@test2 = dso_local global i64 ptrtoint (i8* getelementptr (i8, i8* bitcast (void ()* @foo to i8*), i64 1) to i64), align 8
@.str = private unnamed_addr constant [6 x i8] c"TEST\0A\00", align 1

위의 IR에서 주목할 것은 foo()의 주소를 보관한 정적 함수 포인터 변수인 test, test2 의 형태이다. 이들은 i8* 의 형태로 선언되며, 이는 함수포인터의 기본 타입이니 유의해야 한다. 또한 정적 함수 포인터의 경우 보관하는 값이 상수 값이 아니기 때문에 +65536, +1 의 연산이 일반 연산이 아닌 주소 연산으로 들어가게 된다. 이 점 또한 유념해야 한다.

 

별 의미 없을 것 같지만 main() 내용도 확인하면 다음과 같다.

; Function Attrs: noinline nounwind optnone uwtable
define dso_local i32 @main() #0 {
  %1 = alloca i32, align 4
  store i32 0, i32* %1, align 4
  %2 = load i64, i64* @test, align 8
  %3 = sub nsw i64 %2, 65536
  %4 = inttoptr i64 %3 to i32 (...)*
  %5 = call i32 (...) %4()
  %6 = load i64, i64* @test2, align 8
  %7 = sub nsw i64 %6, 1
  %8 = inttoptr i64 %7 to i32 (...)*
  %9 = call i32 (...) %8()
  call void @foo()
  ret i32 0
}

약간 복잡해 보일 수 있으니 다음처럼 분리해서 보자.

((int (*)()) (test - 65536))();
  %2 = load i64, i64* @test, align 8
  %3 = sub nsw i64 %2, 65536
  %4 = inttoptr i64 %3 to i32 (...)*
  %5 = call i32 (...) %4()
((int (*)()) (test2-1))();
  %6 = load i64, i64* @test2, align 8
  %7 = sub nsw i64 %6, 1
  %8 = inttoptr i64 %7 to i32 (...)*
  %9 = call i32 (...) %8()
 

위처럼,  call void @foo() 라는 한 줄의 코드가 

A. 미리 연산된 주소 값을 로드하고

B. 주소 값에 더해줬던 값을 빼고난 뒤

C. 호출

하는 형식으로 다소 복잡하게 바뀐 것을 확인할 수 있을 것이다.

 

이를 디스어셈블 된 코드로 확인해보면 아래와 같다.

(gdb) disas main
Dump of assembler code for function main:
   0x0000000000400520 <+0>:     push   %rbp
   0x0000000000400521 <+1>:     mov    %rsp,%rbp
   0x0000000000400524 <+4>:     sub    $0x10,%rsp
   0x0000000000400528 <+8>:     mov    0x601040,%rax
   0x0000000000400530 <+16>:    mov    %rax,-0x8(%rbp)
   0x0000000000400534 <+20>:    mov    $0x0,%al
   0x0000000000400536 <+22>:    mov    -0x8(%rbp),%rcx
   0x000000000040053a <+26>:    callq  *(%rcx)
   0x000000000040053c <+28>:    mov    0x601048,%rcx
   0x0000000000400544 <+36>:    sub    $0x1,%rcx
   0x000000000040054b <+43>:    mov    %eax,-0xc(%rbp)
   0x000000000040054e <+46>:    mov    $0x0,%al
   0x0000000000400550 <+48>:    callq  *%rcx
   0x0000000000400552 <+50>:    xor    %edx,%edx
   0x0000000000400554 <+52>:    mov    %eax,-0x10(%rbp)
   0x0000000000400557 <+55>:    mov    %edx,%eax
   0x0000000000400559 <+57>:    add    $0x10,%rsp
   0x000000000040055d <+61>:    pop    %rbp
   0x000000000040055e <+62>:    retq

 

여기까지 내용을 정리하자면 함수 호출을 난독화 하기 위해서는,

1. 일반적인 함수 호출 구문 foo() 를 함수 주소를 보관한 함수 포인터에 난독화 용 값을 더해 보관하고

2. foo()를 호출하는 구문을 해당 함수 포인터에 더해줬던 값을 빼서 호출한다.

두 단계가 필요하다.

 

이를 LLVM 의 optmize path 를 통해 구현한 내용을 소개할 것이며, 소스코드는 다음 링크를 통해 확인할 수 있다.

https://github.com/ParkHanbum/mystudy.git 

 

ParkHanbum/mystudy

Contribute to ParkHanbum/mystudy development by creating an account on GitHub.

github.com

여기서는 주요 로직만 소개하도록 하겠다.

 

1. 일반적인 함수 호출 구문 foo() 를 함수 주소를 보관한 함수 포인터에 난독화 용 값을 더해 보관하고

 

  for (auto &Func : M)
  {
    if (Func.isDeclaration())
      continue;

    Constant *var = CreateGlobalFunctionPtr(M, Func);
  }

위의 코드는 module 의 모든 함수를 함수포인터로 보관하는 구문이다.

구현은 다음처럼 되어 있다.

 

Constant *CreateGlobalFunctionPtr(Module &M, Function &func)
{
  int offset = getRandomV();
  std::string name;
  name.append("GlobalFunctionPtr_");
  name.append(func.getName());
  GlobalVariable *fnPtr = new GlobalVariable(/*Module=*/M,
                                             /*Type=*/Type::getInt64Ty(func.getContext()),
                                             /*isConstant=*/true,
                                             /*Linkage=*/GlobalValue::ExternalLinkage,
                                             /*Initializer=*/nullptr, // has initializer, specified below
                                             /*Name=*/StringRef(name));

  IntegerType *I64 = Type::getInt64Ty(func.getContext());

  // i8* bitcast (void ()* @fez to i8*)
  Constant *const_ptr_5 = ConstantExpr::getBitCast(&func, Type::getInt8PtrTy(func.getContext()));

  // (i8* getelementptr (i8, i8* bitcast (i32 ()* @foo to i8*), i64 1)
  Constant *One = ConstantInt::get(I64, offset);
  Constant *const_ptr_6 = ConstantExpr::getGetElementPtr(Type::getInt8Ty(func.getContext()), const_ptr_5, One);

  // constant i64 ptrtoint (void ()* @fez to i64)
  Constant *const_ptr_7 = ConstantExpr::getPtrToInt(const_ptr_6, I64);

  fnPtr->setInitializer(const_ptr_7);

  FuncNameMap[func.getName()] = fnPtr;
  FuncOffsetMap[func.getName()] = offset;
  return fnPtr;
}

위에서 C로 작성했던 코드로 생성됐던 IR을 역으로 만드는 구문을 작성했다.

추가로 함수의 이름을 key로 함수포인터의 값과 +한 offset 값을 보관하는 map을 전역으로 선언해 보관한다.

 

 

2. foo()를 호출하는 구문을 해당 함수 포인터에 더해줬던 값을 빼서 호출한다.

  for (auto &Func : M)
  {
    for (auto &BB : Func)
    {
      for (auto &Ins : BB)
      {
        if (isa<CallInst>(Ins) || isa<InvokeInst>(Ins))
        {
          IRBuilder<> builder(&Ins);
          debugInst(&Ins);

          CallInst *cinst = dyn_cast<CallInst>(&Ins);

          Function *fn = cinst->getCalledFunction();
          if (fn)
          {
            Constant *gFnAddr = FuncNameMap[fn->getName()];
            LoadInst *load = builder.CreateLoad(gFnAddr);
            int offset = FuncOffsetMap[fn->getName()];
            Value* orig = builder.CreateSub(load, ConstantInt::get(I64, offset));
            Value *tofn = new IntToPtrInst(orig, fn->getType(), "tofncast", &Ins);
            builder.CreateCall(tofn);
          }
        }
      }
    }
  }

Instruction이 Call 혹은 Invoke일 경우

Call instruction => Call 되는 함수 이름으로 저장된 fnPtr 값을 로드하여 Call 되는 함수 이름으로 저장된 offset 값을 뺀 뒤 호출하는 IR을 생성한다.

 

 

위의 과정으로 생성되는 코드는 다음과 같다.

 

대상 예제 코드)

#include <stdio.h>

int a() {
  return 1+1;
}

int fez() {
  return a()+1;
}

int main() {
  fez();
  return 0;
}
~

 

Call 난독화 IR 코드)

@GlobalFunctionPtr_a = constant i64 ptrtoint (i8* getelementptr (i8, i8* bitcast (i32 ()* @a to i8*), i64 1655182250) to i64)
@GlobalFunctionPtr_fez = constant i64 ptrtoint (i8* getelementptr (i8, i8* bitcast (i32 ()* @fez to i8*), i64 1462869916) to i64)
@GlobalFunctionPtr_main = constant i64 ptrtoint (i8* getelementptr (i8, i8* bitcast (i32 ()* @main to i8*), i64 1605830790) to i64)

; Function Attrs: noinline nounwind optnone uwtable
define dso_local i32 @a() #0 {
  ret i32 2
}

; Function Attrs: noinline nounwind optnone uwtable
define dso_local i32 @fez() #0 {
  %1 = load i64, i64* @GlobalFunctionPtr_a
  %2 = sub i64 %1, 1655182250
  %tofncast = inttoptr i64 %2 to i32 ()*
  %3 = call i32 %tofncast()
  %4 = call i32 @a()
  %5 = add nsw i32 %4, 1
  ret i32 %5
}

; Function Attrs: noinline nounwind optnone uwtable
define dso_local i32 @main() #0 {
  %1 = alloca i32, align 4
  store i32 0, i32* %1, align 4
  %2 = load i64, i64* @GlobalFunctionPtr_fez
  %3 = sub i64 %2, 1462869916
  %tofncast = inttoptr i64 %3 to i32 ()*
  %4 = call i32 %tofncast()
  %5 = call i32 @fez()
  ret i32 0
}

 

- 끗 -

 

Comments