[Rev] 리버싱 · 작성: 2026-07-20 03:10:34 · 수정: 2026-07-21 23:32:17 · 조회 88
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는 이 자연스러운 분기 구조를, "다음에 어떤 블록을 실행할지"를 결정하는 하나의 중앙 스위치문(디스패처)과, 그 스위치문을 반복하는 하나의 루프로 완전히 재구성한다.
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;
}
}
}
모든 원래 코드 블록이 switch의 case 하나씩으로 흩어지고, 블록 사이의 실제 실행 순서는 오직 state 변수의 값 변화로만 결정된다. CFG를 그려보면 원래는 check → step_a → step_c처럼 의미 있는 모양이었던 그래프가, **디스패처 루프 하나에서 모든 case로 뻗어나가는 별 모양(스타 토폴로지)**으로 완전히 뭉개진다 — 정적으로 그래프 모양만 봐서는 "어떤 case 다음에 어떤 case가 실행되는지"가 전혀 드러나지 않는다는 게 핵심이다.
Ghidra/IDA 같은 디컴파일러는 기본적으로 분기 명령어(jmp, jcc)가 가리키는 대상 주소를 정적으로 추적해서 CFG를 구성한다. CFF에서는 이 분기 대상이 switch문의 점프 테이블 인덱스, 즉 런타임에 계산되는 state 변수 값에 의해 결정되므로, 정적 분석기는 "이 case 다음에 실제로 어느 case로 가는지"를 코드만 봐서는 알아낼 수 없다 — 이론적으로는 state가 취할 수 있는 모든 값의 흐름을 다 추적해야만(경로 폭발) 원본 순서를 복원할 수 있다는 게 CFF가 노리는 지점이다.
동적 분석 글에서 다룬 방식으로, 실제 입력값 하나를 넣고 실행하면서 state 변수가 실제로 어떤 순서(0→1→3)로 바뀌는지를 그대로 기록하면, 그 실행 경로에 한해서는 원본 순서가 바로 드러난다. 다만 이건 "그 입력값에 대한 경로"일 뿐, 다른 입력에서 갈라지는 분기(위 예의 else)까지 한 번에 다 보여주진 않는다 — 여러 대표 입력으로 반복 실행하며 경로를 누적해야 전체 그림이 나온다.
state 변수 추적#state 변수에 영향을 주는 모든 명령어를 역추적(backward slicing)해서, "이 case에서 state가 어떤 조건에서 어떤 값이 되는가"를 심볼릭하게 계산하면, 실행하지 않고도 모든 case 사이의 실제 전이 조건을 복원할 수 있다 — angr 같은 바이너리 심볼릭 실행 프레임워크가 이 방식의 자동화에 쓰인다.
CFF는 "디스패처 루프 + state 변수 + switch"라는 구조적으로 매우 특징적인 패턴을 남기기 때문에, 이 패턴 자체를 자동으로 탐지해서 원본 CFG로 재구성해주는 Ghidra/IDA 플러그인들이 여러 개 공개되어 있다.
cmp+jcc가 여러 case에 흩어져 있어서, 디플래트닝을 먼저 하지 않으면 어떤 비교가 실제로 어떤 순서로 실행되는지 짜맞추기 어렵다CFF를 "코드를 어지럽히는 트릭"이 아니라 **"모든 분기 결정을 런타임 변수 하나로 몰아넣어서, 정적 분석이 의존하는 CFG 자체를 무의미하게 만드는 구조적 변환"**으로 이해하면, 왜 이를 되돌리는 작업(디플래트닝)이 결국 동적 실행이나 심볼릭 실행처럼 "런타임 값을 실제로 추적하는" 방법으로 수렴하는지가 같은 논리로 설명된다.