← 목록으로

코드 난독화 해제: Control Flow Flattening 분석하기

[Rev] 리버싱 · 작성: 2026-07-20 03:10:34 · 수정: 2026-07-21 23:32:17 · 조회 16

Control Flow Flattening(CFF)을 "코드를 복잡하게 꼬아놓는 난독화"라고만 알면, 정확히 어떤 구조로 원래의 분기 흐름을 숨기는지, 그리고 왜 이게 정적 분석의 CFG(Control Flow Graph, 제어 흐름 그래프) 기반 분석을 무력화하는지를 놓치게 된다.

원본 코드의 자연스러운 제어 흐름

void check(int x) {
    if (x > 0) {
        step_a();
    } else {
        step_b();
    }
    step_c();
}

이 코드를 디컴파일하면 if/else가 그대로 보이고, CFG를 그리면 check → step_a/step_b → step_c로 이어지는 직관적인 분기 구조가 나온다. 리버서는 이 그래프 모양만 봐도 로직을 대략 짐작할 수 있다.

CFF가 하는 일: 모든 블록을 하나의 "디스패처 루프"로 평탄화

CFF는 이 자연스러운 분기 구조를, "다음에 어떤 블록을 실행할지"를 결정하는 하나의 중앙 스위치문(디스패처)과, 그 스위치문을 반복하는 하나의 루프로 완전히 재구성한다.

void check(int x) {
    int state = 0;              // 다음에 실행할 블록을 가리키는 "상태 변수"
    while (1) {
        switch (state) {
            case 0:
                if (x > 0) state = 1; else state = 2;
                break;
            case 1:
                step_a();
                state = 3;
                break;
            case 2:
                step_b();
                state = 3;
                break;
            case 3:
                step_c();
                return;
        }
    }
}

모든 원래 코드 블록이 switchcase 하나씩으로 흩어지고, 블록 사이의 실제 실행 순서는 오직 state 변수의 값 변화로만 결정된다. CFG를 그려보면 원래는 check → step_a → step_c처럼 의미 있는 모양이었던 그래프가, **디스패처 루프 하나에서 모든 case로 뻗어나가는 별 모양(스타 토폴로지)**으로 완전히 뭉개진다 — 정적으로 그래프 모양만 봐서는 "어떤 case 다음에 어떤 case가 실행되는지"가 전혀 드러나지 않는다는 게 핵심이다.

왜 CFG 기반 자동 분석이 무력화되는가

Ghidra/IDA 같은 디컴파일러는 기본적으로 분기 명령어(jmp, jcc)가 가리키는 대상 주소를 정적으로 추적해서 CFG를 구성한다. CFF에서는 이 분기 대상이 switch문의 점프 테이블 인덱스, 즉 런타임에 계산되는 state 변수 값에 의해 결정되므로, 정적 분석기는 "이 case 다음에 실제로 어느 case로 가는지"를 코드만 봐서는 알아낼 수 없다 — 이론적으로는 state가 취할 수 있는 모든 값의 흐름을 다 추적해야만(경로 폭발) 원본 순서를 복원할 수 있다는 게 CFF가 노리는 지점이다.

디플래트닝(Deflattening): 어떻게 원래 순서를 되돌리는가

1) 동적 분석으로 실제 실행 경로만 관찰

동적 분석 글에서 다룬 방식으로, 실제 입력값 하나를 넣고 실행하면서 state 변수가 실제로 어떤 순서(0→1→3)로 바뀌는지를 그대로 기록하면, 그 실행 경로에 한해서는 원본 순서가 바로 드러난다. 다만 이건 "그 입력값에 대한 경로"일 뿐, 다른 입력에서 갈라지는 분기(위 예의 else)까지 한 번에 다 보여주진 않는다 — 여러 대표 입력으로 반복 실행하며 경로를 누적해야 전체 그림이 나온다.

2) 심볼릭 실행/테인트 분석으로 state 변수 추적

state 변수에 영향을 주는 모든 명령어를 역추적(backward slicing)해서, "이 case에서 state가 어떤 조건에서 어떤 값이 되는가"를 심볼릭하게 계산하면, 실행하지 않고도 모든 case 사이의 실제 전이 조건을 복원할 수 있다 — angr 같은 바이너리 심볼릭 실행 프레임워크가 이 방식의 자동화에 쓰인다.

3) 패턴 인식 기반 자동 디플래트닝

CFF는 "디스패처 루프 + state 변수 + switch"라는 구조적으로 매우 특징적인 패턴을 남기기 때문에, 이 패턴 자체를 자동으로 탐지해서 원본 CFG로 재구성해주는 Ghidra/IDA 플러그인들이 여러 개 공개되어 있다.

여기서 끝나지 않는다

CFF를 "코드를 어지럽히는 트릭"이 아니라 **"모든 분기 결정을 런타임 변수 하나로 몰아넣어서, 정적 분석이 의존하는 CFG 자체를 무의미하게 만드는 구조적 변환"**으로 이해하면, 왜 이를 되돌리는 작업(디플래트닝)이 결국 동적 실행이나 심볼릭 실행처럼 "런타임 값을 실제로 추적하는" 방법으로 수렴하는지가 같은 논리로 설명된다.

리버싱 카테고리의 글 (11/11)

  1. 리버싱이란 무엇인가: 정적 분석 기초
  2. 어셈블리어 읽기 기초 (x86-64)
  3. 동적 분석: 디버거로 실행 흐름 추적하기
  4. 안티 디버깅 / 안티 리버싱 기법과 우회
  5. 패킹과 언패킹: 실행 파일을 압축/암호화해서 숨기기
  6. Frida로 동적 계측/후킹하기
  7. 안드로이드 앱 리버싱: APK 분석 기초
  8. iOS 앱 리버싱 기초
  9. 크랙미(Crackme) 실습 방법론
  10. PE 포맷 내부 구조: Windows 실행 파일 뜯어보기
  11. 코드 난독화 해제: Control Flow Flattening 분석하기
← PE 포맷 내부 구조: Windows 실행 파일 뜯어보기