
LLVM 패스를 통해 C/C++를 난독화합니다: 문자열 암호화, 제어 흐름 평탄화, MBA 재작성, 그리고 리버스 엔지니어링을 무력화하기 위한 분석 방지 기능.
██╗ ███████╗███████╗████████╗
██║ ██╔════╝██╔════╝╚══██╔══╝
██║ █████╗ █████╗ ██║
██║ ██╔══╝ ██╔══╝ ██║
███████╗███████╗███████╗ ██║
╚══════╝╚══════╝╚══════╝ ╚═╝
██████╗ ██████╗ ███████╗██╗ ██╗███████╗ ██████╗ █████╗ ████████╗ ██████╗ ██████╗
██╔═══██╗██╔══██╗██╔════╝██║ ██║██╔════╝██╔════╝██╔══██╗╚══██╔══╝██╔═══██╗██╔══██╗
██║ ██║██████╔╝█████╗ ██║ ██║███████╗██║ ███████║ ██║ ██║ ██║██████╔╝
██║ ██║██╔══██╗██╔══╝ ██║ ██║╚════██║██║ ██╔══██║ ██║ ██║ ██║██╔══██╗
╚██████╔╝██████╔╝██║ ╚██████╔╝███████║╚██████╗██║ ██║ ██║ ╚██████╔╝██║ ██║
╚═════╝ ╚═════╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝
A very simple obfuscator for C/C++ x64 and x86 code
이것은 LLVM 포크로 제작되었으므로, 난독화하려면 실제 소스 코드가 필요합니다. 임의의 실행 파일용 난독화 도구가 아니라 컴파일러의 수정 버전입니다.
결과를 확인하고 싶다면, 이 도구로 크랙미 2개를 만들어 난독화했습니다. 미리 컴파일된 clang 바이너리와 함께 릴리스에서 받을 수 있습니다.
모든 것은 -O3 플래그로 컴파일되었으며 모든 스크린샷은 IDA Pro 9.4에서 가져왔습니다. Binary Ninja와 Ghidra로도 테스트했으며, 결과는 동일하거나 더 나빴습니다.
컴파일 타임에 문자열을 암호화하고 암호화된 문자열이 사용되는 모든 위치에 복호화 함수를 삽입합니다. 이로 인해 바이너리에서 문자열을 검색하는 기능이 완전히 무력화됩니다. 또한 각 문자열은 복호화 함수에 하드코딩된 고유한 키를 가지므로, 덤프하고 복호화하는 것이 훨씬 어려워집니다.
![]() 패스 적용 전 |
![]() 적용 후 |
산술 연산을 MBA 등가 표현으로 대체합니다. 먼저 MBA 난독화 해제 도구에 통과시키지 않는 한 원래 연산이 무엇을 했는지 확인하는 것은 사실상 불가능합니다. 물론 MBA는 가장 오래된 기법이므로 이 난독화는 다소 약합니다. 이를 처리하는 도구가 많으며, 예를 들어 CoBRA는 이것을 원래 표현식으로 성공적으로 난독화 해제합니다:
./cobra-cli --mba "((x^y) - (((x^y)&0xFF)&0xA) + 10) * ((x^y) - ((x^y)|0xA) + 10) + ((x^y) - (((x^y)&0xFF)|0xFFFFFFF5) - 11) * (~(x^y) - (~(x^y)|0xA) + 10)" --bitwidth 32
10 * (x ^ y)
그래서 AAMBA 패스를 만들었습니다.
![]() 패스 적용 전 |
![]() 적용 후 |
이항 연산의 피연산자를 ADC(X, 255) - 255 - CF 및 SBB(X, 255) + 255 + CF로 대체합니다. 물론 항상 X로 평가되지만, 표현식을 캐리 플래그에 의존하게 만듭니다. 디컴파일러가 CF의 상태를 추적하지 않는 한(때로는 불가능합니다) 매우 혼란스러워지고 이러한 표현식을 접을 수 없게 됩니다. 이전 MBA 패스와 매우 잘 어울려 산술 연산을 더욱 난독화합니다. 아래에서 볼 수 있듯이 디컴파일러는 추가 변수를 생성하고 많은 __PAIR64__ 및 __CFADD__ 호출을 사용하므로, 이를 CoBRA 같은 도구에 붙여넣는 것이 훨씬 어려워집니다(여전히 불가능한 것은 아닙니다). IDA의 gooMBA 플러그인도 이를 단순화하는 데 도움이 되지 않습니다. 또한 IDA의 디컴파일러는 캐리 플래그를 어느 정도 추적하지만, 이를 제어 흐름 난독화와 결합하면 실행 없이는 추적이 사실상 불가능해집니다. 이전 연산이 무엇이었는지 알 수 없기 때문입니다. CF를 1로 설정했을 수도 있고 아닐 수도 있으므로, 이후 패스들이 여기에 더욱 더해질 것입니다.
![]() 패스 적용 전 |
![]() 적용 후 |
함수 내부의 모든 블록을 수집하여 하나의 거대한 상태 머신으로 만듭니다. 함수 시작 부분에 점프 테이블을 만들고 모든 블록 포인터를 그 안에 배치합니다. 그런 다음 각 블록 끝의 일반 점프 대신 모든 것이 간접 점프를 사용하는 디스패처를 통해 라우팅되는데, 이러한 간접 점프는 실행 없이 정적으로 해석하는 것이 거의 불가능합니다. 또한 레지스터를 스택으로 강등시키므로, 블록을 반으로 나누면 이전 블록의 모든 변수가 스택에 있게 되어 모든 표현식에 엄청나게 많은 변수가 생깁니다. 단일 블록의 연산을 CoBRA에 통과시켜도 알 수 없는 변수가 너무 많아 난독화를 많이 해제하지 못합니다.
잘못된 어셈블리를 포함하는 여러 가짜 블록을 생성합니다. 이는 디스어셈블러를 엄청나게 혼란스럽게 만듭니다. 디스어셈블러는 결코 실행되지 않는 기술적으로 유효하지 않은 바이트를 만나도 여전히 그것을 해석하려 시도하기 때문입니다. 바이트가 불완전하면 그 뒤에 오는 바이트들로 명령어를 만들어 본질적으로 그 바이트들을 소비해 버립니다. 이로 인해 이후의 모든 명령어를 파괴하는 역동기화(desync)가 발생합니다. Windows 바이너리에서는 IDA가 이로부터 어느 정도 복구할 수 있습니다. 드물게 그래프를 생성하고 가능한 범위 내에서 디컴파일할 수도 있지만(그래도 깨지고 불완전합니다), Linux 바이너리에서는 그래프 보기가 완전히 깨지고 디컴파일이 비활성화됩니다. 추가로 RDTSC 타이머 검사를 삽입합니다. 시간이 너무 오래 걸리면(예: 디버거가 연결된 경우) 크래시가 발생합니다.
![]() Windows |
![]() Linux |
게다가 이 패스가 0xFF로 시작하는 명령어를 발견하면 그 앞에 단일 0xEB 바이트를 삽입합니다. 이로 인해 JMP RIP+1이 생성되므로 제어 흐름은 변경되지 않지만(RIP가 원래 명령어 속으로 한 바이트 진행될 뿐), 디스어셈블러는 다시 역동기화됩니다. 0xFF로 시작하는 명령어는 대부분 INC/DEC와 간접 JMP/CALL입니다. 아쉽게도 대부분의 일반 호출과 점프는 상대 주소(0xE8/0xE9/0xEB)를 사용하므로 영향을 받지 않습니다. 하지만 이 기법은 모든 곳에서 간접 점프를 사용하는 디스패처 패스와 함께 사용할 때 특히 유용합니다. 호출에는 그다지 유용하지 않습니다. 영향을 받는 호출은 간접 호출뿐이며, 일반적으로 가상 호출, 함수 포인터를 통한 호출, 일부 외부/라이브러리 호출입니다.
함수의 모든 스택 지역 변수를 하나의 큰 공유 스택 버퍼에 던져 넣고, 런타임에 해당 버퍼에 대한 인덱스를 계산합니다. 이렇게 하면 디컴파일러가 변수를 에일리어싱할 수 없으므로, 동일한 변수에 여러 번 접근하는 것이 서로 다른 값에 접근하는 것처럼 보입니다. 이는 레지스터를 스택으로 강등시키는 디스패처와 매우 잘 어울리며, 따라서 이러한 스택 슬롯이 많이 생깁니다.
![]() 패스 적용 전 |
![]() 적용 후 |
예외를 통해 제어 흐름을 난독화합니다. 모든 호출을 int3 트랩으로 대체합니다. 트랩이 트리거되면 제어 흐름이 예외 핸들러로 이동하며, 예외 핸들러가 RIP를 실제 호출로 조정합니다. 또한 트랩 직후에 잘못된 바이트를 삽입하여 디스어셈블러를 더욱 역동기화시킵니다.
![]() 패스 적용 전 |
![]() 적용 후 |
모든 패스를 결합하면, 이를 난독화 해제할 추가 도구 없이는 정적 분석이 매우 어려워집니다. 어떻게든 모든 잘못된 바이트를 NOP 처리하고 일부 의사 코드로 디컴파일하거나 최소한 그래프 보기를 얻을 수 있다 하더라도, 예외와 디스패처를 통한 제어 흐름 난독화가 여전히 남아 있습니다. 그리고 그것마저 뚫는다 해도 실제 연산을 난독화하는 중복된 MBA, 가짜 블록, 스택 변수, 문자열 암호화가 산더미처럼 쌓여 있습니다. 아래는 앞서 언급한 간단한 xor Foo 함수를 포함하는 간단한 프로그램의 메인 진입점을 세 가지 주요 디스어셈블러로 열어본 스크린샷입니다. 보시다시피 이들은 별로 해석해 내지 못합니다.
|
이를 해석하려는 난독화 해제 도구들의 결과도 포함시키고 싶었지만, 안타깝게도 실제 제어 흐름을 확인할 수 있는 기호 실행을 지원하는 작동하는 도구를 찾을 수 없었습니다. 찾을 수 있었던 도구들은 모두 특정 사용 사례에 매우 제한적이거나(예: VMProtect 전용 난독화 해제), 너무 오래되었거나, 유지 관리되지 않아 깨져 있거나(시도해 본 거의 모든 IDA/BN 플러그인), 제가 익숙하지 않은 API를 통한 수동 안내와 설정이 많이 필요한 대형 도구(Angr, Triton, IntelPin)였습니다. 임의의 바이너리에서 무엇이든 추출할 수 있는 난독화 해제 도구를 알고 있다면 알려주세요.
물론 이 모든 것을 바이너리에 삽입하면 실행 속도가 엄청나게 느려집니다. 기본 설정 기준으로 평균 200배 이상 느려집니다:

다만 두 가지 이유로 보이는 것만큼 심각하지는 않습니다. 1. 여기서 성능 비용의 거의 95%는 나노마이트 때문입니다. 왜냐하면, 음, 인터럽트는 그저 느리기 때문입니다. 예외가 커널로 나갔다가 앱으로 돌아와야 하므로 시간이 걸립니다. 나노마이트가 없으면 7.5배 느려지는 것에 그칩니다:

따라서 모든 항목에 적용하는 대신 나노마이트로 난독화하려는 함수와 호출을 수동으로 표시할 것을 강력히 권장합니다. 바이너리 내부의 모든 호출을 난독화하는 것은 무의미하고 비용이 많이 듭니다. 그리고 두 번째 이유는, 대부분의 경우 숨기려는 것들의 성능에 대해 신경 쓸 필요가 없다는 것입니다. 이 난독화 도구는 선택적으로 활성화하거나 비활성화할 수 있는 기능이 있습니다. 따라서 성능에 중요한 코드 섹션에서는 비활성화하고 실제로 필요한 곳에서만 활성화할 수 있습니다. 라이선스 검사가 1ms가 걸리든 0.001ms가 걸리든 아무도 신경 쓰지 않습니다. 인간에게는 여전히 인지할 수 없을 정도이기 때문입니다.
구성 옵션과 전체 가이드는 위키를 참조하세요.
또한 Leet.h 내부의 예외 핸들러와 관련된 모든 함수를 LEET_SKIP 매크로로 표시할 수 있습니다. 그러면 대부분의 패스가 예외 핸들러를 난독화하지 않게 되어 나노마이트 비용이 절반으로 줄어들지만(기본 설정 기준), 난독화되지 않은 예외 핸들러가 남게 됩니다. 이는 약간의 속도 저하보다 더 나쁘다고 생각합니다.
요구 사항:
-DLLVM_USE_LINKER=mold를 삭제하세요. 하지만 mold를 사용하면 더 빠릅니다)git clone https://github.com/Zydak/LeetObfuscator.git --recursive
cd LeetObfuscator
mkdir build
cd build
cmake ../leet-llvm-project/llvm -G Ninja -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DLLVM_USE_LINKER=mold -DLLVM_USE_SPLIT_DWARF=ON -DLLVM_ENABLE_ASSERTIONS=ON -DCMAKE_BUILD_TYPE=RelWithDebInfo -DLLVM_ENABLE_PROJECTS=clang -DLLVM_TARGETS_TO_BUILD=X86 -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
ninja clang
수정된 컴파일러는 build/bin/ 안에 있습니다. 난독화하려는 소스를 컴파일할 때 그냥 그것을 사용하세요.
빌드할 필요도 없습니다. 릴리스에 사전 빌드된 바이너리가 있으니 다운로드하여 압축을 풀기만 하면 됩니다.
이 프로젝트를 Windows용으로 빌드하는 것은 현재 지원되지 않습니다. 하지만 이 프로젝트를 사용하여 Windows용으로 크로스 컴파일하는 것은 지원됩니다. 따라서 정말 원한다면 Linux 바이너리를 가져와 Linux 또는 WSL에서 Windows용 난독화 앱을 크로스 컴파일할 수 있습니다.
이 도구의 정확한 사용법에 대한 전체 가이드는 위키를 참조하세요.
사용 예시:
Leet.h을 프로젝트에 복사한 다음, 하나의 .c/.cpp 파일 안에서 그것을 include하고 LEET_IMPLEMENTATION을 정의하세요.
이것을 여러 모듈에서 정의하지 마세요!
#define LEET_IMPLEMENTATION
#include "Leet.h"
그런 다음 빌드된 컴파일러로 소스를 컴파일하기만 하면 됩니다:
./build/bin/clang++ ./test.cpp -o test -fno-exceptions
![]() Binary Ninja Personal 5.2 |
![]() Ghidra 12.1.2 |