오크(ORK) – 난독화 컴파일러 도구 2편

안녕하세요. LINE에서 클라이언트 보호 솔루션인 AIR ARMOR 개발을 담당하고 있는 정상민입니다. 지난 1편에선 예제 소스 코드의 컴파일 과정을 살펴보며 난독화가 실행되는 단계를 확인했습니다. 이번 글에선 오크의 난독화가 어떻게 동작하는지 역시 예제 실행 파일을 통해서 살펴보겠습니다.

 

실행 파일 변조 방법

먼저 예제 실행 파일을 리버싱(reversing)해서 구현된 내용과 반대로 동작하도록 변조해보겠습니다.

리버싱을 수행하는 가장 빠른 방법은 출력에 사용되는 심볼(symbol) 정보를 찾는 것입니다. 디스어셈블러(disassembler)를 사용하면 실행 파일의 구현 내용을 확인할 수 있는데요. 이렇게 확인하면 결과 출력에 사용한 문자열의 참조 위치를 쉽게 파악할 수 있습니다. 파악하고 나면, 그 근처에서 해당 문자열의 출력을 구분하는 분기문의 위치만 찾으면 됩니다. 아래 예시에선 회색으로 선택된 줄의 jnz 어셈블리 언어 명령문으로 출력이 분기되는 것을 쉽게 알 수 있습니다. 이제 해당 분기를 수행하는 코드를 변조하면 리버싱이 마무리됩니다. 

바이너리 편집기로도 해당 위치를 간단하게 수정할 수 있습니다. 수정할 jnz(jump if not zero) 어셈블리 언어의 Opcode는 0F85이며, 반대로 동작하는 jz(jump if zero) 어셈블리 언어의 Opcode는 0F84입니다. 실행 파일에서 0F95를 0F94로 수정한 뒤 저장하면 간단하게 변조 작업이 완료됩니다.

이렇게 변조된 예제 파일을 실행하면, 아래와 같이 결과가 이전과는 반대로 출력됩니다.

 

오크 난독화

위와 같이 실행 파일이 쉽게 변조되는 것을 막기 위해 오크로 난독화 작업을 해보겠습니다.

오크에선 모든 난독화 작업을 최적화 패스로 등록하는데요. 각 기능을 세분화해 개별적인 패스로 구현했습니다. 각각의 난독화 기능에 따로 명칭을 부여하지 않았기 때문에 대략적인 설명으로 기능을 나열해 보겠습니다. 소스 코드의 문자열이 노출되지 않도록 변경하는 문자열 난독화, 상수 접근을 연산으로 수행하는 난독화, 분기문이나 반복문 등의 제어 흐름을 변경하는 난독화, 구성 요소를 매번 다른 순서로 재배치하는 난독화, 이진 연산을 다른 형태의 연산으로 치환하는 난독화, 연속된 명령어가 불연속된 것처럼 보이게 분리하는 난독화 등과 같은 다양한 난독화를 구현했고, 새로운 난독화 기능을 계속 추가하고 있습니다.

예제 실행 파일을 보호하기 위해 문자열 난독화와 재배치 난독화, 제어 흐름 난독화를 각각 적용해보고 결과를 살펴보겠습니다.

 

문자열 난독화

오크의 문자열 난독화 기능은 소스 코드에서 사용된 문자열을 인지할 수 없는 형태로 변경한 뒤 참조되는 시점에 복원하는 방식으로 정적 분석에 노출될 수 있는 문자열을 보호하는 난독화 기능인데요. 실행 코드에 저장된 문자열을 LLVM IR에서 찾아내 암호화된 문자열로 대체하고, 동적으로 힙(heap) 메모리를 할당한 뒤, 복원된 문자열을 저장하는 LLVM IR을 해당 문자열 참조 위치에 추가하는 방식으로 난독화합니다.

아래 그림은 문자열 난독화 기능을 적용하기 전과 후의 제어 흐름을 간략하게 나타낸 다이어그램입니다.

문자열을 치환하는 기본 기능에 더해, 성능에 끼치는 영향을 최소화하기 위한 지연 로딩(lazy loading) 기능과, 힙 메모리 재사용 및 동기화 처리 등의 기능을 추가로 구현했습니다. 또한 복원 코드 예측과 후킹(hooking), 또는 코드 리프팅(lifting) 등의 공격을 방지하기 위한 기능도 함께 추가했습니다.

아래와 같이 난독화된 예제 실행 파일에선 더 이상 문자열을 찾을 수 없습니다.

참고로 리버싱 관점에서는, 실행 파일에 존재하는 모든 심볼은 소스 코드만큼 중요한 정보입니다. 그런데 소스 코드에서 사용한 문자열 외에도 다양한 심볼 정보가 개발자가 인지하지 못한 상태에서 실행 파일에 포함될 수 있는데요. 정적으로 참조되는 라이브러리 파일명과 함수명은 물론, C++의 RTTI(Runtime Type Information)로 클래스명까지 노출될 수 있습니다. 추후 이런 다양한 심볼 정보까지 함께 제거될 수 있도록 기능을 확장할 예정입니다.

 

재배치 난독화

앞서 살펴본 컴파일 과정에서 원본 소스 코드는 AST에서 LLVM IR을 거쳐 어셈블리 언어, 그리고 기계어에 이르는 일련의 변환 과정을 거칩니다. 각각의 단계는 앞선 자료 형태를 일정한 방법으로 분석하고 변환하기 때문에 원본 소스 코드와 최종 기계어 사이에는 지역적으로 연관 관계가 발생합니다. 예를 들어 소스 코드에서 특정 분기문을 수정하면 실행 파일에서도 해당되는 기계어 부분만 변경되는 지역성이 관찰될 수 있습니다. 즉 실행 파일을 업데이트하더라도, 이전 파일과의 차이점을 찾아내면 수정된 내용을 쉽게 파악할 수 있다는 것인데요. 이를 재배치 난독화로 보완할 수 있습니다.

오크의 재배치 난독화는 매우 간단한 원리로 구현했습니다. 원본 소스 코드에서 일정한 형태로 생성되는 LLVM IR의 구성 요소를 난독화 시점에 임의로 재배치해서 컴파일러 백앤드가 매번 다른 형태의 어셈블리 언어를 생성하도록 만듭니다. 원본 실행 파일과 재배치 난독화를 적용한 실행 파일의 바이너리를 비교한 결과는 다음과 같습니다. 아주 간단한 예제 소스 코드였지만 전혀 다른 실행 코드 구조를 갖게 되었습니다.

  • 원본 실행 파일의 바이너리
  • 재배치 난독화를 적용한 실행 파일의 바이너리

 

제어 흐름 난독화

오크의 제어 흐름 난독화는 코드의 각종 분기문과 반복문을 하나의 거대한 switch 구문으로 만들어 전체 흐름을 알아보기 어렵게 만드는 난독화 방법입니다. 특정 구문의 동작을 확인하기 위해서는 전체 경우의 수를 검증해야 하므로 분석에 많은 시간을 소모하게 됩니다. 이 방법은 ‘제어 흐름 그래프 평탄화(Control Flow Graph Flattening)’라고 부르는데요. 오래전부터 연구되고 있는 난독화 기술입니다.

함수 내부의 모든 베이직 블록(basic block)을 임의의 순서로 나열하고, 각 베이직 블록의 실행 조건을 switch 구문의 조건에 저장한 뒤, 함수의 진입점이 switch 구문의 시작점으로 연결되도록 LLVM IR을 변경하는 방식으로 난독화를 수행합니다.

순서에 따라 배치되어 있던 베이직 블록을 동일한 수준으로 나열하는 것만으로도 강력한 난독화 효과를 얻을 수 있습니다. 또한 switch 구문의 동기화 이슈를 처리하고 자동화된 예측 공격을 방지하기 위한 여러 기능도 함께 적용했습니다.

제어 흐름 난독화는 다른 난독화 기능과 함께 사용하면 더욱 큰 효과를 얻을 수 있는데요. 예를 들어 연속된 명령어 또는 함수 호출과 같은 특정 명령어를 별도의 베이직 블록으로 분리한 후 제어 흐름 난독화를 적용하면 생성된 switch 구문의 복잡도가 더욱 높아집니다. 아래와 같이 원본 실행 파일에서 쉽게 찾을 수 있는 분기 조건문 jnz를 난독화된 실행 파일에선 더 이상 찾을 수 없습니다.

  • 원본 실행 파일
  • 제어 흐름 난독화를 적용한 실행 파일

 

오크로 난독화된 실행 파일 확인

먼저 원본 예제 실행 파일의 제어 흐름 그래프를 다시 살펴보겠습니다. 아래와 같이 동작 구조가 쉽게 파악되는 걸 확인할 수 있습니다. 

이제 예제 실행 코드에 오크를 이용해 앞서 설명드린 세 가지 난독화 기능, 문자열 난독화와 재배치 난독화, 제어 흐름 난독화를 적용한 뒤의 제어 흐름 그래프를 살펴보겠습니다. 아래와 같이 세 가지 난독화를 모두 적용한 실행 파일은 제어 흐름 그래프로 동작 구조를 쉽게 파악할 수 없습니다. 다른 난독화 기능의 코드를 포함한 모든 실행 코드의 제어 흐름이 동일한 수준으로 정렬되기 때문에, 단순한 예제 실행 파일임에도 분석하는 게 쉽지 않습니다.

다른 난독화 패스까지 함께 적용하면, 아래와 같은 수준으로 강력하게 실행 코드를 보호할 수도 있습니다. 

구현된 내용에 따라 최대한 강력하게 난독화를 적용해야 하는 부분도 있고, 성능에 민감하여 최소한으로 적용해야 하는 부분도 있을 수 있습니다. 이를 위해서 소스 파일이나 함수 단위로 난독화를 추가하거나 제외할 수 있게 설정 기능을 제공하고 있습니다.

 

마치며

앱 위변조에 대응하는 기술을 구현하기 위해선 많은 개발 자원이 필요하지만, 현실적인 문제로 단기적인 기술 위주로 대응해 왔습니다. 하지만 이제 난독화 컴파일러를 다른 보호 기술과 함께 사용하여 장기적으로 난독화 생명 주기를 관리할 수 있게 되었습니다.

앞으로 새로운 난독화 아이디어를 구현해서 추가하는 것은 물론, 오크가 서비스 프로젝트 전반에서 사용될 수 있도록 Xcode, Android NDK, Unity, Unreal 등의 개발 환경에 대한 지원을 개선하고, Swift와 MSVC 등으로 지원 플랫폼을 확장할 계획입니다.

Related Post