리버스 엔지니어링을 막기 위해 바이너리에 난독화를 적용하는 과정에서 고민한 점들을 정리하였다.
- 난독화가 적용된 바이너리는 보안 프로그램이 막는 경우가 많다고 한다.
- 왜 보안 프로그램이 막을까? 보안 프로그램이 심볼 정보를 필요로 하기라도 하는걸까…?
- 막는건 알겠고… 그럼 리버스 엔지니어링은 어떻게 막아야할까?
위의 질문들에 대해 대답하기 위해서는 리버스 엔지니어링, 빌드 과정, 난독화 원리, 나아가 보안 프로그램의 원리까지 알아야 한다.
- 대상 언어는 RUST로 조사하였다.
빌드 과정으로 알아보는 난독화와 리버스 엔지니어링
난독화 라는건 결국 바이너리에서 심볼과 같은 특정 정보를 읽지 못하게 숨긴다는 것인데, 이 숨길 정보들이 애초에 바이너리에 포함이 되어 있다는건 어딘가에서 이 정보를 필요로 하기에 들어있다는 것 아닐까?
그렇다면 난독화 라는건 그냥 숨긴다고 되는 것이 아니라 다른 방식이 적용이 되나? 혹은 애초에 숨기려 하는 정보들은 필수 정보가 아닌걸까?
- 실행 파일에는 CPU가 실행하는 코드뿐 아니라, 빌드 도구·운영체제·디버거가 사용하는 여러 정보가 함께 들어갑니다. 각 정보는 필요한 시점과 사용자가 다르므로, 배포할 때 일부 정보를 제거하거나 별도 파일로 분리할 수 있습니다.
- 다만 제거에 따른 손실은 실제로 있습니다. 함수명과 디버그 정보를 제거하면 외부 분석자의 작업이 어려워지는 만큼 개발자의 장애 분석도 어려워집니다. 이를 보완하는 일반적인 방법이 배포 바이너리에는 필요한 정보만 남기고, 내부에는 해당 바이너리와 정확히 대응하는 디버그 정보를 보관하는 것입니다.
좀 더 자세히 보자. 빌드 과정부터…
1. Rust 소스가 실행 파일이 되는 과정
Rust 소스
↓
구문 분석·매크로 확장·이름 및 타입 분석
↓
HIR·THIR·MIR 등의 중간 표현
↓
borrowing 검사·중간 표현 최적화
↓
제네릭 구체화 및 LLVM IR 생성
↓
LLVM 최적화·기계어 생성
↓
오브젝트 파일
↓
링크
↓
실행 파일 + 필요한 경우 별도 디버그 파일
- 여기에서 중요한 점은 CPU가 기계어를 수행할 때 소스코드에 들어 있는 함수 명, 매개변수 명과 같은 심볼 이름에 대한 정보는 필요하지 않다는 점이다.
- 그럼에도 이 심볼 이름에 대한 정보는 바이너리에 포함된다. 그 이유는 코드 연결 과정에서 필요하기 때문인데…. 좀 더 자세히 보자.
함수 이름은 우선 빌드 과정에서 코드를 연결하기 위해 필요합니다.
여러 파일이나 라이브러리를 각각 컴파일하면, 호출하는 쪽은 호출 대상 함수의 최종 배치 주소를 아직 모를 수 있습니다. 오브젝트 파일에는 개념적으로 다음과 같은 정보가 들어갑니다.
호출하는 쪽:
A 함수를 호출해야 함
최종 위치는 아직 모름
함수를 정의한 쪽:
A 함수의 코드는 이 오브젝트 파일의 특정 위치에 있음
링커는 이름을 이용해 두 정보를 연결합니다.
이름으로 함수 정의를 찾음
↓
최종 코드 배치 결정
↓
호출 명령이나 주소 참조를 해결
이때 사용하는 심볼은 함수나 전역 데이터 등의 식별자입니다. 심볼 테이블에는 이름뿐 아니라 위치, 크기, 가시성 등의 정보도 들어갈 수 있습니다. ELF 규격도 심볼 테이블을 프로그램의 정의와 참조를 찾아 연결하기 위한 정보로 설명합니다.
연결이 끝난 내부 함수 호출은 다음처럼 이해할 수 있습니다.
빌드 중: 이름이 A인 함수를 찾아 연결
실행 중: 계산된 위치의 코드를 실행
실제 표현은 아키텍처와 링크 방식에 따라 상대 변위, 주소, 간접 참조 등으로 달라집니다. ASLR 때문에 실행 주소가 바뀔 수도 있습니다. 중요한 점은 일반적인 내부 함수 호출마다 원래 Rust 함수명을 문자열로 검색하지는 않는다는 것입니다.
그래서 빌드에 사용한 이름 정보 중 일부는 최종 실행 파일에서 제거할 수 있습니다.
2. 디버그 정보는 무엇일까
디버그 정보는 “이 기계어가 원래 소스의 무엇에 해당하는가”를 더 자세히 설명합니다.
정보 설명하는 내용 주된 용도
| 함수·전역 심볼 | 이름과 코드·데이터 위치의 대응 | 링크, 함수명 표시, 분석 |
| 소스 줄 정보 | 기계어 주소와 소스 파일·줄의 대응 | 중단점, 오류 위치 표시 |
| 지역 변수 정보 | 변수 이름·타입과 값의 위치 | 디버거에서 변수 확인 |
| 타입 정보 | 구조체 필드, 타입 구성 등 | 값과 메모리를 타입에 맞게 표시 |
| 인라인 정보 | 인라인된 코드와 원래 함수의 관계 | 호출 관계와 소스 위치 복원 |
예를 들어 실행 중 어떤 주소에서 문제가 발생했을 때, 디버그 정보가 있으면 도구는 다음과 같이 설명할 수 있습니다.
이 주소는 limits.rs의 특정 줄에 해당함
이 코드의 원래 함수는 is_within_limit임
이 실행 구간에서 value는 특정 레지스터에 있음
최적화된 프로그램에서는 변수가 사라지거나, 실행 구간에 따라 저장 위치가 달라질 수 있습니다. 디버그 정보가 있어도 모든 변수를 항상 확인할 수 있는 것은 아닙니다.
- Windows의 PDB는 함수·변수·타입·소스 파일·줄 번호 등의 정보를 제공하는 별도 파일입니다. Microsoft는 이러한 정보를 실행 파일과 분리하면 파일 크기를 줄이고 역공학에 도움이 되는 정보의 공개도 제한할 수 있다고 설명합니다.
- 즉, 이 정보는 실행에 필요해서 들어간 것이 아닙니다. 개발·디버깅·프로파일링·장애 분석에 필요해서 생성하는 정보이며, 일반적인 프로그램 실행에는 없어도 되는 부분이 많습니다.
3. 리버스 엔지니어링은 이 정보를 어떻게 사용하는가
바이너리 역공학은 실행 파일에서 프로그램의 구조와 동작을 추론하는 작업입니다. 원래 소스가 완전히 복원되어야만 동작을 이해할 수 있는 것은 아닙니다.
정적 분석의 일반적인 과정은 다음과 같습니다.
실행 파일 구조 파악
↓
기계어를 어셈블리로 해석
↓
함수·분기·호출 관계 분석
↓
의사 코드 생성
↓
이름·문자열·외부 호출 등을 이용해 의미 추론
기계어를 어셈블리 명령으로 해석하는 것이 디스어셈블, 이를 조건문·변수 등이 있는 고수준 의사 코드로 표현하는 것이 디컴파일입니다.
다음은 이름 정보의 효과를 설명하기 위한 의사 코드 예시입니다.
/* 이름 정보가 있는 경우 */
bool is_within_limit(uint64_t value, uint64_t limit) {
return value <= limit;
}
/* 이름 정보가 없는 경우 */
bool FUN_00101230(uint64_t param_1, uint64_t param_2) {
return param_1 <= param_2;
}
두 경우 모두 비교 연산은 읽을 수 있습니다. 다만 첫 번째는 함수의 역할을 곧바로 추정할 수 있고, 두 번째는 호출하는 곳과 입력값 등을 추가로 조사해야 합니다. 실제 디컴파일 결과의 타입·표현·이름은 분석 도구와 보유 정보에 따라 달라집니다.
분석자는 심볼이 없어도 다음 단서를 활용합니다.
단서 추론할 수 있는 내용
| 문자열과 오류 메시지 | 기능의 목적, 오류 조건, 처리 대상 |
| 외부 라이브러리·OS 함수 호출 | 파일·네트워크·암호화 등의 동작 |
| 상수와 데이터 구조 | 알고리즘, 형식, 상태값 |
| 호출·분기 관계 | 처리 순서와 판단 조건 |
| 실행 중 입력·출력 | 실제 동작과 데이터 변화 |
동적 분석에서는 디버거 등을 이용해 값을 관찰하고 실행 경로를 확인합니다. 심볼이 없어도 기계어와 실행 동작은 남으므로 분석 자체가 사라지지는 않습니다.
4. 바이너리 난독화 : 함수명과 디버그 정보를 어떻게 제거하는가
실행 파일은 기계어와 각종 정보가 구분된 구조로 저장됩니다. Linux의 ELF를 예로 들면 다음과 같은 영역이 있습니다. 이름과 구성은 빌드에 따라 달라집니다.
영역의 예 담긴 정보
| .text | 실행할 기계어 |
| .rodata | 읽기 전용 상수와 문자열 등 |
| .symtab, .strtab | 일반 심볼과 심볼 이름 문자열 |
| .dynsym, .dynstr | 동적 연결에 사용하는 심볼과 이름 |
| .debug_info, .debug_line 등 | 타입·변수·소스 줄 등의 디버그 정보 |
| .eh_frame 등 | 스택 되감기 등에 사용하는 정보 |
심볼 제거는 파일 전체에서 함수명처럼 보이는 문자열을 찾아 무작정 지우는 작업이 아닙니다. 파일 형식을 이해하는 링커나 도구가 대상 메타데이터를 제외하거나, 해당 섹션·항목을 제거하고 구조를 정리하는 작업입니다.
Rust의 설정은 다음과 같이 구분됩니다.
설정 의미
| debug | 디버그 정보를 어느 수준으로 생성할지 결정 |
| split-debuginfo | 생성한 디버그 정보를 어디에 배치할지 결정 |
| strip = "debuginfo" | 링크 시 디버그 정보를 제거 |
| strip = "symbols" | 디버그 정보와 추가 심볼 테이블 정보를 플랫폼 지원 범위에서 제거 |
5. 심볼 제거 이후의 더 강한 난독화
심볼 제거는 이름과 소스 대응 정보를 줄입니다. 더 강한 난독화는 남아 있는 기계어와 데이터로 의미를 추론하는 과정에 개입합니다.
분석자가 사용하는 단서 대응하는 보호 기법 바뀌는 부분
| 함수·모듈·타입 이름 | 심볼 정리 | 이름과 위치의 대응 정보 |
| 소스 줄·지역 변수·타입 정보 | 디버그 정보 분리·비공개 | 상세한 소스 대응 정보 |
| 평문 문자열 | 문자열 난독화 | 저장 형태와 실행 중 복원 코드 |
| 읽기 쉬운 분기·반복 구조 | 제어 흐름 난독화 | 기계어의 실행 경로 |
| 알아보기 쉬운 계산식 | 연산 치환 | 동일 결과를 계산하는 명령 구성 |
| 원래 CPU 명령의 구조 | 가상화 | 전용 바이트코드와 해석 실행 구조 |
예를 들어 문자열 난독화는 프로그램에 필요한 메시지를 없애지 않습니다.
기존:
평문 문자열을 저장 → 그대로 사용
변환 후:
변환된 바이트를 저장 → 실행 중 복원 → 같은 문자열 사용
제어 흐름 난독화도 필요한 판단을 제거하지 않습니다. 동일한 결과를 내도록 실행 경로를 복잡하게 재구성합니다.
이 차이 때문에 비용도 달라집니다. 심볼·디버그 정보 제거는 주로 파일 크기와 진단 능력에 영향을 주지만, 복원 코드와 복잡한 분기를 추가하는 난독화는 실행할 명령 자체가 늘어나 성능 비용이 발생할 수 있습니다.
따라서 보호를 설계할 때는 “정보를 전부 지운다”보다 다음 세 가지를 각각 결정해야 합니다.
- 실행과 외부 연동에 필요한 정보는 무엇인지 확인합니다.
- 디버깅과 장애 분석용 정보 중 무엇을 내부에만 보관할지 결정합니다.
- 이름을 감추는 것만으로 부족한 로직에 어떤 변환을 적용할지 결정합니다.
난독화에 대한 이해는 얼추 된 것 같다. 다양한 기법이 있는 것 같은데, 이건 다음에 알아보고 일단 핵심은 “디버깅 정보”를 제거하는 것은 아주 적은 비용으로 분석 단서를 줄이는 효과가 있다는 것. 실제 프로그램의 로직을 숨기려면 실행에 필요한 정보를 숨겨야 한다는 것이다.
- 필요한 정보를 변환하고 다시 실행할 때 복원하려면 결국 키가 있어야 할 것이다.
- 이 키가 프로그램에 들어가게되고 프로그램의 특정 정보들을 모아서 필요한 정보를 다시 복원할 수 있는 구조라면 여전히 리버스 엔지니어링에 취약한 상태이다.
- 이처럼 난독화는 리버스 엔지니어링을 막는다 라는 개념 보다는… 쉽게 읽거나 자동 추출하기 어렵게 한다는 기술에 가깝다.
그건 알겠고… 그래서 왜 난독화가 적용된 바이너리를 보안 프로그램에서 막는걸까? 실제로 막는 사례는 얼마나 있었을까?
- 정상 프로그램이어도 난독화나 패킹을 적용한 뒤 보안 프로그램이 악성으로 탐지하는 사례가 실제로 있습니다. 다만 난독화라는 이유만으로 일괄 차단하는 것은 아니며, 기법에 따라 영향이 다릅니다.
왜 보안 프로그램이 난독화 바이너리를 거부할까?
앞서 설명한 난독화는 분석자가 프로그램을 이해하기 어렵게 만듭니다. 이는 보안 프로그램이 악성코드를 분석할 때도 똑같이 장애물이 됩니다.
특히 악성코드도 다음 구조를 사용합니다.
파일에는 암호화된 코드 저장
↓
실행 시 메모리에 복호화
↓
복호화한 코드를 실행
따라서 보안 프로그램은 숨겨진 내용뿐 아니라 숨기고 복원하는 코드의 특징과 실행 동작도 검사합니다.
관찰하는 특징 의심할 수 있는 이유 정상 프로그램에서도 가능한가
| 알려진 패커·보호 도구의 복원 코드 | 악성코드가 같은 도구를 사용한 전력이 있음 | 가능 |
| 높은 엔트로피의 데이터 영역 | 코드나 데이터를 압축·암호화했을 가능성 | 가능 |
| 메모리에 데이터를 쓴 뒤 그 영역을 실행 | 악성 로더가 숨긴 코드를 실행할 때도 나타남 | 가능 |
| 메모리 실행 권한 변경 | 복원한 코드를 실행하는 과정일 수 있음 | 가능 |
| 디버거·분석 환경 검사 | 보안 분석을 회피하는 동작일 수 있음 | 가능 |
어느 하나만으로 악성이라고 확정할 수는 없습니다. 제품에 따라 정적 특징, 실행 동작, 알려진 탐지 규칙 등을 조합합니다. Microsoft도 파일의 정적 속성, 에뮬레이션, 샌드박스에서 관찰한 동작을 함께 분석한다고 설명합니다.
여기서 중요한 차이가 있습니다.
문자열 복원:
암호화된 데이터 → "설정 파일을 찾을 수 없습니다" → 출력
코드 복원:
암호화된 데이터 → CPU 명령 → 그 메모리로 이동하여 실행
둘 다 복호화를 하지만, 문자열을 복원하는 동작과 새로운 코드를 메모리에서 실행하는 동작은 보안 관점에서 다르게 평가할 수 있습니다. 암호화 라이브러리를 썼거나 문자열을 복원한다는 사실 자체가 곧 차단 사유인 것은 아닙니다.
실제로 확인된 사례
1. Malwarebytes가 정상 DLL의 오탐을 인정한 사례
개발자가 VMProtect로 보호한 DLL이 Trojan.MalPack.PES로 탐지된다고 신고했습니다. Malwarebytes 담당자는 오탐임을 확인했고 다음 데이터베이스에서 탐지되지 않도록 수정했다고 답변했습니다.
따라서 정상 프로그램의 보호 처리와 함께 오탐이 발생한 실제 사례입니다. 다만 정확히 어떤 특징이 탐지를 유발했는지는 공개되지 않았습니다.
2. F-Secure가 보호 도구의 탐지 이유를 공개한 사례
F-Secure는 악성코드가 VMProtect를 사용한 사례 때문에 휴리스틱 탐지를 추가했다고 직접 설명합니다. 제품 설정에 따라 격리하거나 제거한다고 명시합니다.
정상 프로그램까지 걸리는 근본적인 이유
탐지 모델이 학습 과정에서 다음과 같은 관계에 과도하게 의존할 수 있기 때문입니다.
학습 자료에서 특정 패커를 사용한 악성 파일이 많이 관찰됨
↓
그 패커의 특징을 악성 판정에 강하게 사용
↓
같은 패커를 사용한 정상 파일도 악성으로 분류
NDSS 연구에서도 패킹된 파일의 정적 특징에 의존하는 머신러닝 탐지가 상당한 오탐을 일으킬 수 있음을 확인했습니다. NDSS 연구
따라서 현재 이야기한 방식들을 구분하면 다음과 같습니다.
적용 내용 보안 프로그램과의 관계
| 디버그 정보·불필요한 심볼 제거 | 정상 배포에서도 흔한 처리이며, 실행 중 복원 동작을 추가하지 않음 |
| 문자열 난독화 | 탐지 특징에 영향을 줄 수 있지만, 이것만으로 일반적인 차단 확률을 단정할 근거는 없음 |
| 실행 파일 패킹·보호 도구 적용 | 실제 오탐 사례가 있으며, 복원 코드와 실행 동작이 탐지 근거가 될 수 있음 |
| 코드 서명 | 배포자 확인에 도움이 되지만 악성 탐지나 평판 경고를 모두 막아 주지는 않음 |
다음 글은 RUST 바이너리에 대한 리버스 엔지니어링을 막는 방법이다. 곧 적대적 테스트가 예정에 있어서 테스트를 하면서 실제로 어떤 기법이 어떤 효과가 있는지를 정리해보려 한다.
'프로그래밍 언어 > Rust' 카테고리의 다른 글
| Rust가 임베디드에 적합한 이유: 소유권, trait, no_std (0) | 2026.09.28 |
|---|
