Step 224. 난독화된 코드 분석 전략 — 정면으로 읽으면 지는 싸움

Step 224. 난독화된 코드 분석 전략 — 정면으로 읽으면 지는 싸움

Level 3 — CTF 실전과 공격 스킬 심화 | 난이도 ★★★☆☆ | 예상 소요 시간 4시간

전제: Step 215(Ghidra로 함수 구조 읽기), Step 220(패킹/언패킹), Step 222(.NET/파이썬 바이너리 리버싱). 파이썬 코드를 읽고 짤 수 있습니다.

⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.

  • 준비물: 파이썬 3(실측: 3.12.14), 메모장. 오늘의 분석 대상은 직접 만든 무해한 시리얼 검증 스크립트입니다.
  • 주의: 실전 난독화 바이너리(팩커·안티디버깅 탑재)는 Step 220의 기법과 함께 씁니다. 오늘은 "전략" 자체에 집중하기 위해 파이썬 소스로 축소 재현합니다.

Step 217~218에서 crackme를 풀다 보면 만납니다 — 변수명이 _0x4a2f이고, 문자열이 숫자 배열이고, while True 안에서 상태 번호로 점프하는 코드. 정면으로 한 줄씩 읽으면 집니다. 난독화는 "읽기를 괴롭게" 만드는 기술이고, 분석가의 대응은 읽기가 아니라 우회입니다. 오늘은 난독화의 세 유형(이름 파괴, 문자열 암호화, 제어 흐름 평탄화)을 직접 만들어 보고, 각각의 우회 전략을 실측합니다.


1. 학습 목표

이 챕터를 끝내면 다음을 할 수 있습니다:

  • 난독화의 세 유형(이름 파괴, 문자열 암호화, 제어 흐름 평탄화)을 구분한다
  • 문자열 암호화에서 "복호화 함수"를 찾아내는 호출 패턴 단서를 설명한다
  • 복호화 루틴을 재사용해 암호화된 문자열을 일괄 복원한다(정적 우회)
  • 상태 머신 형태로 평탄화된 제어 흐름을 블록 단위로 추상화해 원래 순서를 복원한다
  • "완벽한 디난독화가 아니라 내 질문에 대한 답만"이라는 분석 기준을 적용한다

2. 배경 지식 — 오늘의 도구와 개념

오늘의 도구 한눈에 보기

구분 내용
언어·환경 파이썬 3 (실측: 3.12.14) — 분석 대상과 분석 도구 둘 다 파이썬
오늘의 명령어 bytes(b ^ key for b in data), 디버거의 브레이크포인트(개념)
필요한 개념 난독화 3유형, 복호화 루틴 재사용, 제어 흐름 평탄화, 정적 우회 vs 동적 우회
오늘의 산출물 난독화 샘플의 문자열 전체 복원 + 제어 흐름 복원 의사 코드 + 정답 시리얼

2-1. 난독화는 세 가지를 꼰다

난독화(obfuscation)는 코드의 동작은 그대로 두고 읽기만 어렵게 만듭니다. 실전에서 만나는 유형은 크게 셋입니다.

첫째, 이름 파괴check_serial_0x7e1이 되고, key_0x3a가 됩니다. 코드를 읽는 실마리인 "의미 있는 이름"이 사라집니다.

둘째, 문자열 암호화"FAIL: length" 같은 문자열이 [28, 27, 19, ...] 같은 숫자 배열로 저장되고, 실행 시점에 복호화 함수를 거쳐 나타납니다. strings로 훑는 1차 정찰이 무력해집니다.

셋째, 제어 흐름 평탄화(control-flow flattening) — 원래 위에서 아래로 흐르던 코드가 거대한 while True + 상태 변수로 바뀝니다. 각 블록은 "상태 0이면 이걸 하고 상태를 1로 바꿔라" 식이라, 실행 순서를 코드의 배치 순서로 알 수 없습니다.

2-2. 전략 ① — 복호화 함수를 찾아라

문자열이 전부 숫자 배열이라면, 그 배열을 문자열로 바꾸는 복호화 함수가 코드 안에 반드시 있습니다. 프로그램이 문자열을 쓰려면 복호화해야 하니까요.

찾는 단서는 호출 패턴입니다 — 어떤 함수가 배열 인자를 받아서, 문자열이 필요한 자리(print, input, return)마다 반복 호출됩니다. 그 함수가 복호화 함수입니다. 찾았으면 두 갈래로 우회합니다.

  • 정적 우회: 복호화 알고리즘이 간단하면(XOR 한 번 등) 파이썬으로 같은 변환을 구현해 모든 배열을 일괄 복원합니다.
  • 동적 우회: 알고리즘이 복잡하면 디버거로 복호화 함수의 "출구"에 브레이크포인트를 걸고, 실행 중 복원된 평문을 메모리에서 낚습니다. 오늘은 정적 우회를 실측하고, 동적 우회는 Step 216(x64dbg)의 연장으로 개념만 짚습니다.

2-3. 전략 ② — 평탄화는 블록 단위로 추상화하라

평탄화된 코드를 정면으로 읽으면 whileif _0x55 == N의 숲에서 길을 잃습니다. 대신 이렇게 봅니다.

  1. 각 상태 번호 블록이 하는 일을 한 줄로 요약한다 ("상태 0: 길이 검사")
  2. 각 블록이 다음으로 가는 상태 번호를 적는다 (0 → 1 → 2 → …)
  3. 상태 번호를 지우고 요약만 순서대로 재배열한다

그러면 원래의 순차 코드가 드러납니다. 핵심은 "입력 → 출력" 관점으로 블록을 블랙박스 취급하는 것입니다.

2-4. 전략 ③ — 완벽을 노리지 마라

가장 중요한 전략입니다. 디난독화의 목표는 코드를 깨끗하게 복원하는 것이 아니라 내 질문에 답하는 것입니다. "시리얼 비교 로직이 어디에 있고 조건이 무엇인가"에 답했으면, 나머지 꼬인 부분은 풀 필요가 없습니다. 완벽한 복원을 노리면 시간이 무한정 들고, 필요한 답만 캐면 수십 분이면 끝납니다.


3. 따라 하기

3-1. 분석 대상 — 난독화된 시리얼 검증기

아래는 오늘의 분석 대상 s224_obf.py입니다. 세 유형이 전부 들어 있습니다 (이 챕터의 모든 파이썬 출력은 2026-09-09 파이썬 3.12.14 실측입니다).

def _0x4a2f(_0x1b):
    return bytes(_0x9c ^ 0x5A for _0x9c in _0x1b).decode()

_0xd1 = [28, 27, 19, 22, 96, 122, 54, 63, 52, 61, 46, 50]
_0xd2 = [28, 27, 19, 22, 96, 122, 41, 47, 55]
_0xd3 = [28, 27, 19, 22, 96, 122, 63, 62, 61, 63]
_0xd4 = [28, 27, 19, 22, 96, 122, 34, 53, 40]
_0xd5 = [21, 17]
_0xd6 = [41, 63, 40, 51, 59, 54, 96, 122]

def _0x7e1(_0x3a):
    _0x55 = 0
    while True:
        if _0x55 == 0:
            if len(_0x3a) != 8:
                return _0x4a2f(_0xd1)
            _0x55 = 1
        elif _0x55 == 1:
            _0x77 = 0
            for _0x12 in _0x3a:
                _0x77 += ord(_0x12)
            if _0x77 != 728:
                return _0x4a2f(_0xd2)
            _0x55 = 2
        elif _0x55 == 2:
            if _0x3a[0] != 'R' or _0x3a[7] != '9':
                return _0x4a2f(_0xd3)
            _0x55 = 3
        elif _0x55 == 3:
            if ord(_0x3a[3]) ^ ord(_0x3a[4]) != 0x1F:
                return _0x4a2f(_0xd4)
            _0x55 = 4
        else:
            return _0x4a2f(_0xd5)

_0x9b = input(_0x4a2f(_0xd6))
print(_0x7e1(_0x9b))

먼저 실행해 동작만 확인합니다 — 분석 전에 "겉으로 무엇을 하는 프로그램인가"를 봅니다.

printf 'short\n' | python s224_obf.py
printf 'RCDe9fF9\n' | python s224_obf.py
serial: FAIL: length
serial: FAIL: sum

출력 읽는 법: 입력을 받고, 길이가 틀리면 "length", 합계가 틀리면 "sum"이라고 거절합니다. 시리얼 검증기라는 것이 동작만으로 드러납니다. 이제 "정확한 검증 조건은 무엇인가"가 우리의 질문입니다.

3-2. 1차 정찰 — 호출 패턴에서 복호화 함수 찾기

코드를 훑으며 세 유형을 체크합니다.

  • 이름 파괴: _0x7e1, _0x3a, _0x55 — 의미가 없습니다 ✅
  • 문자열 암호화: 사람이 읽을 문자열이 코드에 하나도 없고 숫자 배열 여섯 개가 있습니다 ✅
  • 제어 흐름 평탄화: _0x7e1while True + 상태 변수 _0x55로 구성됐습니다 ✅

그리고 복호화 함수를 찾습니다. _0x4a2f를 보세요 — input(...)의 인자로, 네 군데의 return에서, 배열을 받을 때마다 호출됩니다. 본문은 한 줄: bytes(각 바이트 ^ 0x5A). XOR 키 0x5A짜리 복호화 함수입니다. 정적 우회가 가능한 단순한 형태입니다.

3-3. 정적 우회 — 문자열 일괄 복원

복호화 루틴을 그대로 재사용해 여섯 배열을 전부 복원합니다.

tables = {
    "_0xd1": [28, 27, 19, 22, 96, 122, 54, 63, 52, 61, 46, 50],
    "_0xd2": [28, 27, 19, 22, 96, 122, 41, 47, 55],
    "_0xd3": [28, 27, 19, 22, 96, 122, 63, 62, 61, 63],
    "_0xd4": [28, 27, 19, 22, 96, 122, 34, 53, 40],
    "_0xd5": [21, 17],
    "_0xd6": [41, 63, 40, 51, 59, 54, 96, 122],
}
for name, data in tables.items():
    print(name, "->", bytes(b ^ 0x5A for b in data).decode())
_0xd1 -> FAIL: length
_0xd2 -> FAIL: sum
_0xd3 -> FAIL: edge
_0xd4 -> FAIL: xor
_0xd5 -> OK
_0xd6 -> serial: 

출력 읽는 법: 숫자 배열이 전부 문장이 됐습니다. 그리고 문자열이 검증 순서를 알려 줍니다 — length, sum, edge, xor 순으로 검사하고 마지막에 OK입니다. 디버거 없이, 파이썬 여섯 줄로 문자열 암호화 층이 전부 벗겨졌습니다.

3-4. 평탄화 해제 — 블록 추상화와 재배열

이제 _0x7e1의 상태 머신을 풉니다. 각 상태 블록을 한 줄로 요약하고 다음 상태를 따라갑니다.

상태 0: len != 8 이면 "FAIL: length"  → 다음 상태 1
상태 1: 글자 코드 합 != 728 이면 "FAIL: sum"  → 다음 상태 2
상태 2: 첫 글자 'R', 끝 글자 '9' 아니면 "FAIL: edge"  → 다음 상태 3
상태 3: key[3] XOR key[4] != 0x1F 이면 "FAIL: xor"  → 다음 상태 4
상태 4: "OK" 반환

상태 번호를 지우고 순서대로 재배열하면 원래 코드가 복원됩니다.

def check(key):                    # 복원된 의사 코드
    if len(key) != 8:
        return "FAIL: length"
    if sum(ord(c) for c in key) != 728:
        return "FAIL: sum"
    if key[0] != 'R' or key[7] != '9':
        return "FAIL: edge"
    if ord(key[3]) ^ ord(key[4]) != 0x1F:
        return "FAIL: xor"
    return "OK"

읽는 법: while True의 숲이 다섯 줄의 평범한 검증 함수가 됐습니다. 2-4의 기준대로입니다 — 우리의 질문("검증 조건이 무엇인가")에 답했으니 디난독화는 여기서 끝입니다. 변수명을 원래대로 예쁘게 고치는 일은 하지 않습니다.

3-5. 완결 — 조건을 만족하는 시리얼 찾기

복원된 조건으로 정답을 만들어 검증합니다. 조건이 네 개라 손으로 맞추기 어려우니 파이썬으로 탐색합니다.

import itertools, string
chars = string.ascii_letters
for c3 in chars:
    c4 = chr(ord(c3) ^ 0x1F)          # 조건: key[3] ^ key[4] == 0x1F
    if c4 not in chars:
        continue
    for combo in itertools.product(chars, repeat=3):
        last = 728 - ord('R') - ord('9') - ord(c3) - ord(c4) - sum(map(ord, combo))
        if 0x61 <= last <= 0x7A:      # 나머지 글자는 소문자로
            key = 'R' + combo[0] + combo[1] + c3 + c4 + combo[2] + chr(last) + '9'
            print('key:', key)
            raise SystemExit
key: RaaezAk9

원본 난독화 스크립트에 넣어 검증합니다.

printf 'RaaezAk9\n' | python s224_obf.py
serial: OK

출력 읽는 법: OK가 떴습니다. 난독화된 코드를 한 줄도 "정면으로" 읽지 않고 — 복호화 함수 재사용과 블록 추상화만으로 — 핵심 로직을 파악하고 정답까지 도출했습니다. 이것이 오늘 배운 전략의 완주입니다.


4. 미션과 연습문제

미션 — 나만의 난독화 샘플 만들고 해부 보고서 쓰기

  1. 오늘의 검증기를 변형해 여러분만의 샘플을 만듭니다: 검증 조건 하나를 바꾸고(예: 합계 728 → 다른 수), XOR 키도 바꿉니다(0x5A → 다른 값)
  2. 그 파일을 분석가 시점으로 다시 열어, ① 세 유형 체크 ② 복호화 함수 식별 근거(호출 패턴) ③ 문자열 일괄 복원 출력 ④ 복원된 의사 코드 ⑤ 정답 시리얼과 OK 출력 — 다섯 항목의 해부 보고서를 씁니다
  3. 보고서 마지막에 "만약 XOR이 아니라 실행 시점에만 풀리는 복잡한 암호였다면 어떤 우회를 썼을까"를 두 문장으로 적습니다

연습문제

문제 1. 난독화와 암호화(Step 169)의 차이를 "복호화 함수/키가 어디 있느냐"로 설명해 보세요.

문제 2. 문자열 암호화된 프로그램에서 복호화 함수를 찾는 두 가지 단서를 말해 보세요.

문제 3. 정적 우회와 동적 우회 중, 복호화 알고리즘이 "실행 중 네트워크에서 받은 키로 AES 복호화"라면 어느 쪽을 선택해야 하나요? 이유는?

문제 4. 3-4의 상태 머신에서 상태 1(합계 검사)을 상태 0보다 먼저 실행하도록 배치를 바꾸면(상태 번호는 유지, _0x55 초기값만 1로) 결과가 달라지나요? 분석가에게는 어떤 변화인가요?


5. 모범 답안과 완료 기준

미션 모범 답안

변형의 예: 합계를 800으로, XOR 키를 0x33으로 바꿨다면, 배열 생성은 bytes(b ^ 0x33 for b in s.encode())의 바이트 목록으로 다시 만들면 됩니다. 분석 보고서의 핵심은 이런 형태입니다:

1. 유형 체크: 이름 파괴 ✅ / 문자열 암호화(배열 6개) ✅ / 평탄화(while+상태변수) ✅
2. 복호화 함수: _0x4a2f — input/return 자리마다 배열 인자로 반복 호출됨. 본문이 XOR 한 줄
3. 일괄 복원: bytes(b ^ 0x33 ...) → "FAIL: length" 등 6개 문자열 확보
4. 의사 코드: 상태 0→4를 재배열해 다섯 줄 검증 함수로 복원
5. 정답: 탐색 스크립트로 생성한 시리얼 → 실행 결과 OK (출력 첨부)

검증하는 법: ① 복호화 함수를 "이름"이 아니라 "호출 패턴"으로 근거를 댔는가. ② 의사 코드가 상태 번호 없이 순차 흐름으로 쓰였는가. ③ 마지막 문항에서 "동적 우회 — 복호화 직후 브레이크포인트로 메모리에서 평문 낚기"를 선택했는가.

연습문제 해답

문제 1 해답. 난독화는 복호화 함수(변환 절차)가 프로그램 안에 들어 있고 별도의 비밀 키가 없습니다 — 절차를 알면 누구나 되돌릴 수 있습니다(오늘 XOR 0x5A가 코드에 박혀 있었습니다). 암호화는 키가 코드 밖에 있어서, 키 없이는 되돌릴 수 없어야 합니다.

문제 2 해답. ① 호출 패턴: 문자열이 필요한 자리(print, input, 비교, return)마다 같은 함수가 배열을 인자로 반복 호출됩니다. ② 본문의 모양: 배열을 받아 변환해서 문자열/바이트를 돌려주는 짧은 함수이며, 거기서 돌려준 값이 바로 쓰입니다.

문제 3 해답. 동적 우회입니다. 키가 실행 시점에 외부에서 오면 코드만으로는 복호화할 수 없으니(정적 우회 불가), 프로그램을 실행해 복호화 함수의 출구에 브레이크포인트를 걸고 메모리에서 복원된 평문을 수집합니다. Step 216의 메모리 브레이크포인트 기법이 여기에 해당합니다.

문제 4 해답. 실행 결과는 같을 수 있지만(합계를 먼저 검사해도 같은 입력은 같은 자리에서 걸림) 엄밀히는 다릅니다 — 길이가 틀린 입력에 대해 "FAIL: length" 대신 "FAIL: sum"이 먼저 나옵니다. 분석가에게는 별일이 아닙니다: 블록 추상화는 "각 블록이 무엇을 하고 어디로 가는가"를 보는 기법이라, 코드의 배치 순서나 시작 상태가 바뀌어도 상태 전이만 따라가면 같은 지도가 나옵니다. 평탄화 대응 전략이 배치 무관하게 통하는 이유입니다.

완료 기준 체크리스트

  • [ ] 난독화 3유형(이름 파괴, 문자열 암호화, 평탄화)을 구분해 설명할 수 있다
  • [ ] 호출 패턴으로 복호화 함수를 식별했다
  • [ ] 복호화 루틴을 재사용해 문자열 6개를 일괄 복원했다
  • [ ] 상태 머신을 블록 추상화 → 재배열로 순차 의사 코드로 복원했다
  • [ ] 조건을 만족하는 시리얼을 만들어 원본 스크립트에서 OK를 확인했다
  • [ ] 정적 우회와 동적 우회의 선택 기준을 한 문장으로 말할 수 있다
  • [ ] 미션: 변형 샘플의 5개 항목 해부 보고서를 작성했다

6. 흔한 실수와 해결

벽 1. 배열을 복원했는데 UnicodeDecodeError가 난다

증상: UnicodeDecodeError: 'utf-8' codec can't decode byte 0x.. in position ..
원인: XOR 키를 잘못 집었거나(예: 0x5A를 0x5a가 아닌 0xA5로), 배열이 XOR이 아닌 다른 변환의 산물입니다.
해결: 복호화 함수 본문의 키와 연산을 한 글자씩 다시 읽으세요. 복원 결과가 "글자처럼 보이는데 일부만 깨지면" 키가 부분적으로 틀린 것이고, 전부 깨지면 변환 자체가 다릅니다. 그때는 복호화 함수를 그대로 import하듯 베껴 쓰는 것이 안전합니다.

벽 2. printf '...' | python에서 입력이 안 먹힌다

증상: EOFError: EOF when reading a line (2026-09-09 실측, 입력 없이 실행한 경우).
원인: input()은 표준 입력이 필요한데 파이프로 넘기지 않았습니다.
해결: printf '시리얼\n' | python s224_obf.py처럼 개행 포함해 파이프로 넘기거나, 대화형으로 실행하세요.

벽 3. 상태 머신에서 "다음 상태"를 놓쳐 무한 루프로 읽는다

증상: 블록을 요약하다가 어디로 가는지 모르는 블록이 생깁니다.
원인: _0x55 = N 대입문을 놓쳤거나, else 분기(기본 다음 상태)를 안 본 것입니다.
해결: 각 블록 끝의 대입문을 먼저 밑줄 그으세요. 다음 상태 번호를 모으고 나서야 블록 내용을 읽습니다 — 순서가 정해진 후에야 내용이 의미를 갖습니다.

벽 4. 정답 탐색이 아무것도 못 찾는다

증상: 3-5 탐색 스크립트가 출력 없이 끝납니다.
원인: 허용 문자 범위가 좁아 합계 조건을 못 맞춥니다. 예를 들어 대문자만 쓰면 6글자 합계가 728에 못 미칩니다(실측에서 확인 — 소문자까지 허용해야 성립).
해결: 허용 문자 집합(대문자/소문자/숫자)을 넓히고, 마지막 글자의 허용 범위(예: 0x61~0x7A)도 조정하세요.

벽 5. "완벽하게" 복원하려다 지친다

증상: 변수명까지 전부 원래대로 추측해 고치고, 주석을 달고, 몇 시간을 씁니다.
원인: 2-4의 기준을 잊은 것입니다.
해결: 질문을 먼저 적으세요 — "검증 조건이 뭐냐". 그 답(의사 코드 다섯 줄)이 나오면 멈춥니다. 예쁜 복원은 보고서용이지 분석용이 아닙니다.


7. 정리

오늘의 개념

개념 한 줄 설명
이름 파괴 의미 있는 식별자를 _0x..로 바꿔 읽기 실마리 제거
문자열 암호화 문자열을 배열로 저장하고 실행 시 복호화 — 복호화 함수가 코드 안에 있음
제어 흐름 평탄화 순차 코드를 while + 상태 변수 머신으로 변형
정적 우회 복호화 알고리즘이 간단하면 코드만으로 일괄 복원
동적 우회 복잡하면 복호화 직후 시점에 BP를 걸어 평문을 메모리에서 수집
블록 추상화 각 상태 블록을 "입력→출력" 한 줄로 요약해 재배열하는 기법

오늘의 명령어·코드

명령 하는 일
bytes(b ^ key for b in data).decode() XOR 문자열 복원 — 복호화 루틴 재사용
printf '입력\n' | python 스크립트 input()이 있는 스크립트에 파이프로 입력
itertools.product(chars, repeat=n) 정답 후보 조합 탐색
상태 전이 표(수기) 평탄화 해제의 핵심 산출물

명령어보다 중요한 감각

난독화는 "못 읽게" 하는 것이지 "못 풀게" 하는 것이 아닙니다. 복호화 함수는 반드시 프로그램 안에 있고, 평탄화는 순서를 숨길 뿐 로직을 바꾸지 못합니다. 그래서 분석가의 무기는 꼼꼼함이 아니라 우회 루트 선택입니다 — 정면으로 읽지 않고, 재사용하고, 추상화하고, 필요한 만큼만 풉니다. 이 감각은 다음 챕터의 악성코드 분석에서 그대로 씁니다 — 악성코드는 오늘의 샘플보다 훨씬 세게 꼬여 있지만, 대응의 순서(정찰 → 복호화 루틴 → 우회 선택)는 같습니다.


전부 체크되면 Step 224 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.