Step 219. 안티디버깅 기법과 우회 — 디버거를 피하는 프로그램과 그 돌파법

Step 219. 안티디버깅 기법과 우회 — 디버거를 피하는 프로그램과 그 돌파법

Level 3 — Reversing 심화 | 난이도 ★★★★☆ | 예상 소요 시간 4시간

전제: Step 178(CTF 맛보기 3: Reversing). WSL 우분투의 gcc와 gdb를 쓸 수 있고, break/x/s 같은 기본 gdb 명령에 익숙해야 합니다.

⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다. 오늘 분석할 샘플은 여러분이 직접 컴파일한 연습용 바이너리입니다.

  • 준비물: WSL 우분투 (실측: Ubuntu 24.04, gcc 13.3.0, gdb 15.1, x86-64). 작업 폴더는 ~/lab219_223를 권장합니다.
  • 주의: 안티디버깅 우회 기법은 악성코드 분석에는 필수지만, 남의 상용 소프트웨어(게임, DRM)에 적용하면 라이선스·법률 문제가 됩니다. 오늘 배우는 대상은 "내가 만든 샘플"뿐입니다.

Step 178에서는 얌전한 crackme를 만났습니다 — 비밀번호를 묻고, 얌전히 비교하고, 결과를 알려 줬죠. 실전의 바이너리는 다릅니다. 악성코드와 상용 보호 프로그램은 "지금 디버거에 잡혀 있는가"를 스스로 검사하고, 감지되면 종료하거나 엉터리로 동작합니다. 이것이 안티디버깅(anti-debugging)입니다. 오늘은 감시하는 프로그램을 직접 만들고, 그 감시망을 gdb로 돌파합니다. 만드는 쪽을 알아야 부수는 쪽이 보입니다.


1. 학습 목표

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

  • 안티디버깅(anti-debugging)이 무엇인지, 왜 쓰는지 설명한다
  • ptrace 자가 검사(PTRACE_TRACEME)의 원리를 설명하고 gdb에서 감지되는 것을 확인한다
  • 타이밍 검사(rdtsc)가 디버거를 잡아내는 원리를 실측으로 보인다
  • gdb의 return (int)0으로 검사 함수의 반환값을 조작해 우회한다
  • Windows 계열 탐지 기법(IsDebuggerPresent 등) 카탈로그를 정리한다

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

오늘의 도구 한눈에 보기

구분 내용
언어·환경 C(샘플 제작용) + WSL 우분투 bash (실측: gcc 13.3.0, gdb 15.1, x86-64)
오늘의 명령어 strings -e S, objdump -d, gdb의 break / return (int)0 / continue / info registers
필요한 개념 ptrace 자가 검사, rdtsc 타이밍 검사, 반환값 조작, 함수 호출의 반환 레지스터(rax)
오늘의 산출물 안티디버깅 샘플 1개 + 감지 로그 + 우회 gdb 스크립트 2개

2-1. 안티디버깅 — 분석가를 쫓아내는 장치

안티디버깅(anti-debugging)은 프로그램이 "나 지금 분석당하고 있나?"를 스스로 검사하고, 감지되면 종료·기만·파괴로 대응하는 기법의 총칭입니다. 악성코드는 분석가의 디버거를 피하려고 쓰고, 게임·DRM은 치트와 크랙을 막으려고 씁니다.

검사 방법은 수십 가지지만 골자는 셋입니다. ① OS나 CPU에게 직접 묻기(디버거가 붙었는지), ② 시간 재기(디버거 아래에서는 실행이 느림), ③ 함정 깔기(예외를 일부러 발생시켜 디버거가 잡는지 보기). 오늘은 ①과 ②를 직접 구현합니다.

2-2. ptrace 자가 검사 — "이미 감시당하고 있습니다"

리눅스에서 디버거는 ptrace라는 시스템 콜로 대상 프로세스에 붙습니다. 그리고 규칙이 하나 있습니다 — 한 프로세스에는 트레이서가 하나만 붙을 수 있습니다.

이 규칙을 역이용한 것이 ptrace(PTRACE_TRACEME, ...) 자가 검사입니다. "나를 추적해 달라"고 스스로 요청하는 것인데, gdb가 이미 붙어 있다면 이 요청은 실패(-1 반환)합니다. 즉 실패가 곧 감지입니다. 정상 실행에서는 성공하고, 디버거 아래에서는 실패하는 — 디버거 탐지기로 이만큼 간단한 것이 없습니다.

Windows에는 같은 일을 하는 IsDebuggerPresent() API가 있습니다 (프로세스 환경 블록 PEB의 플래그를 읽음). 운영체제가 달라도 사고방식은 같습니다: "감시 중인가?"를 운영체제에게 묻는다.

2-3. 타이밍 검사 — 느리면 디버거다

두 번째 기법은 시간을 재는 것입니다. rdtsc는 CPU의 타임스탬프 카운터(Time Stamp Counter)를 읽는 명령으로, 부팅 후 경과한 클럭 틱을 반환합니다. 짧은 코드 조각 앞뒤에서 두 번 읽어 차이를 보면 실행 시간이 나옵니다.

핵심 사실: 디버거에서 브레이크포인트에 멈추거나 한 명령씩 단계 실행하면, 그 사이의 사람 시간이 전부 카운터에 잡힙니다. 평소 수천 클럭이던 구간이 브레이크포인트 한 번에 수십억 클럭으로 불어납니다. "이 구간이 비정상적으로 오래 걸렸다 = 누군가 멈춰서 들여다봤다" — 이것이 타이밍 검사입니다.

2-4. 우회의 기본 — 검사 함수의 반환값을 뒤집는다

검사가 함수로 되어 있다면 우회는 단순합니다. 검사 함수가 "감지됨=1, 안전=0"을 반환할 때, 함수 진입점에 브레이크포인트를 걸고 gdb의 return (int)0으로 함수 몸을 실행하지 않은 채 0을 반환하고 돌아가게 만듭니다. 검사 코드는 영원히 "안전"만 보고하게 됩니다.

더 영구적인 방법은 패치(patch) — 바이너리의 검사 코드 자체를 NOP(아무 일도 안 하는 명령)로 덮거나 조건 분기를 뒤집는 것입니다. x64dbg나 Ghidra에서 자주 쓰는 방법이고, 오늘은 gdb 우회에 집중합니다. 실전에서는 ScyllaHide 같은 "디버거 은폐 플러그인"이 이 짓을 자동으로 해 줍니다 — 화면 예시는 3-7에서 봅니다.


3. 따라 하기

3-1. 실험실 — 감시하는 프로그램 만들기

출제자가 되어 안티디버깅이 걸린 샘플을 만듭니다. ~/lab219_223 폴더에서 진행하세요.

입력 (guard219.c)

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <sys/ptrace.h>
#include <x86intrin.h>

__attribute__((noinline))
static int check_ptrace(void){
    if (ptrace(PTRACE_TRACEME, 0, 1, 0) == -1) return 1;   /* 실패 = 이미 추적 중 */
    return 0;
}

__attribute__((noinline))
static void measured_work(void){
    volatile int s = 0;
    for (int i = 0; i < 1000; i++) s += i;                  /* 재는 대상: 짧은 작업 */
}

__attribute__((noinline))
static int check_timing(void){
    unsigned long long a = __rdtsc();
    measured_work();
    unsigned long long b = __rdtsc();
    printf("[timing] 경과 클럭: %llu\n", b - a);
    return (b - a) > 100000000ULL;                          /* 1억 클럭 초과 = 수상 */
}

int main(void){
    char pw[64];
    if (check_ptrace()) { puts("[!] 디버거 감지: ptrace"); return 1; }
    if (check_timing()) { puts("[!] 디버거 감지: 타이밍"); return 1; }
    printf("password: ");
    if (scanf("%63s", pw) != 1) return 1;
    if (strcmp(pw, "n0-debug-z0ne") == 0)
        puts("correct! FLAG{ant1_d3bug_byp4ss3d}");
    else
        puts("wrong.");
    return 0;
}

__attribute__((noinline))은 "이 함수를 인라인으로 풀어 넣지 마라"는 gcc 지시어입니다. 이게 있어야 gdb에서 함수 이름으로 브레이크포인트를 걸 수 있습니다.

컴파일과 일반 실행

mkdir -p ~/lab219_223 && cd ~/lab219_223
gcc -O1 -o guard219 guard219.c
echo n0-debug-z0ne | ./guard219
[timing] 경과 클럭: 5466
password: correct! FLAG{ant1_d3bug_byp4ss3d}

(2026-09-09 실측. 경과 클럭 수는 CPU와 부하에 따라 다릅니다 — 수천~수만 대면 정상입니다.)

출력 읽는 법: 디버거 없이 실행하면 두 검사를 조용히 통과하고 비밀번호까지 맞히면 FLAG가 나옵니다. 측정 구간은 5466클럭 — 임계값 1억의 2만 분의 1입니다. 이 숫자를 기억해 두세요. 곧 엄청나게 불어납니다.

3-2. 정적 분석 — 검사 지점을 파일에서 찾기

이제 소스를 잊고 분석가가 됩니다. 첫 동작은 언제나 strings입니다 (Step 178의 리듬).

strings ./guard219 | grep -iE "timing|password|correct|wrong"
[timing] 
password: 
wrong.
correct! FLAG{ant1_d3bug_byp4ss3d}
check_timing

(2026-09-09 실측.)

주의할 발견: 한글 감지 메시지 [!] 디버거 감지: ptrace보이지 않습니다. strings는 기본적으로 7비트 ASCII만 건지기 때문에 UTF-8 한글이 끊깁니다. 8비트 문자까지 보려면 -e S 옵션을 줍니다:

strings -e S ./guard219 | grep "감지"
[!] 디버거 감지: ptrace
[!] 디버거 감지: 타이밍

(2026-09-09 실측.)

읽는 법: "감지 메시지가 두 종류"라는 것 자체가 단서입니다 — 검사가 최소 두 군데라는 뜻이니까요. 디스어셈블리에서 ptrace 호출을 확인합니다:

objdump -d ./guard219 | grep -B1 -A1 "ptrace@plt" | head -8
00000000000010d0 <ptrace@plt>:
    10d0:	f3 0f 1e fa          	endbr64
--
    1228:	e8 a3 fe ff ff       	call   10d0 <ptrace@plt>
    122d:	48 83 f8 ff          	cmp    $0xffffffffffffffff,%rax

(2026-09-09 실측.)

읽는 법: call ptrace 직후에 cmp $0xffffffffffffffff — 0xFFFF…FF는 -1입니다. "ptrace의 반환값이 -1인가"를 검사하는 코드, 즉 자가 검사의 흔적입니다. main의 호출 순서도 봅니다:

gdb -batch -ex "disassemble main" ./guard219 | grep call | head -4
   0x00000000000012a9 <+26>:	call   0x120b <check_ptrace>
   0x00000000000012b2 <+35>:	call   0x123c <check_timing>
   0x00000000000012ce <+63>:	call   0x10e0 <__printf_chk@plt>
   0x00000000000012e2 <+83>:	call   0x10f0 <__isoc99_scanf@plt>

(2026-09-09 실측.)

읽는 법: main은 시작하자마자 check_ptracecheck_timing 순으로 부릅니다. 비밀번호 입력(scanf)보다 먼저 검문소 두 개를 지나야 하는 구조입니다.

3-3. 감지 실측 — gdb로 그냥 실행해 보기

아무 조치 없이 gdb 아래에서 실행해 봅니다:

echo AAAA > in.txt
gdb -batch -ex "run < in.txt" ./guard219
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
[!] 디버거 감지: ptrace
[Inferior 1 (process 866) exited with code 01]

(2026-09-09 실측.)

읽는 법: 비밀번호 입력조차 받기 전에 종료됐습니다. gdb가 ptrace로 붙어 있는 상태에서 프로그램이 다시 TRACEME를 요청 → 실패(-1) → "감지" 판정. 2-2의 원리가 그대로 실측됐습니다. 분석가의 첫 장벽입니다.

3-4. 우회 1 — ptrace 검사 무력화

check_ptrace 진입점에 브레이크포인트를 걸고, 함수 몸을 실행하지 않은 채 0(안전)을 반환하게 합니다. gdb 명령 파일(bypass1.gdb)을 만듭니다:

break check_ptrace
run < in.txt
return (int)0
continue
gdb -batch -x bypass1.gdb ./guard219
Breakpoint 1 at 0x120b

Breakpoint 1, 0x000055555555520b in check_ptrace ()
[timing] 경과 클럭: 19578
password: wrong.
[Inferior 1 (process 973) exited normally]

(2026-09-09 실측.)

읽는 법: ptrace 감지 메시지가 사라졌습니다. 검사 함수가 0을 반환하도록 속였기 때문입니다. 이번에는 타이밍 검사도 통과했네요 — 19578클럭. return (int)0 이후 브레이크포인트 없이 쭉 달렸기 때문에 측정 구간이 빨랐습니다. 비밀번호 AAAA는 틀려서 wrong. — 하지만 검문소는 통과했습니다. 절반의 성공입니다.

참고: gdb 15에서 return 0처럼 캐스트 없이 쓰면 Return value type not available for selected stack frame. Please use an explicit cast of the value to return. 오류가 납니다 (2026-09-09 실측, 디버그 정보 없는 바이너리 기준). return (int)0처럼 형을 명시하세요. 벽 1에서 다시 다룹니다.

3-5. 타이밍 검사 발동 실측 — 멈추는 순간 잡힌다

타이밍 검사가 진짜로 "멈춤"을 감지하는지 보겠습니다. 측정 구간 한가운데(measured_work)에 브레이크포인트를 걸고, 그 자리에서 2초 정체한 뒤 계속합니다. shell sleep 2는 "멈춰 있는 동안 사람이 코드를 읽는 시간"을 대신합니다 (timing_demo2.gdb):

break check_ptrace
break measured_work
run < in.txt
return (int)0
continue
shell sleep 2
continue
Breakpoint 2 at 0x11e9

Breakpoint 1, 0x000055555555520b in check_ptrace ()

Breakpoint 2, 0x00005555555551e9 in measured_work ()
[timing] 경과 클럭: 7397204434
[!] 디버거 감지: 타이밍
[Inferior 1 (process 1209) exited with code 01]

(2026-09-09 실측.)

읽는 법: 결정적 장면입니다. 평소 5466클럭이던 구간이 2초 정체로 73억 클럭이 됐고, 임계값(1억)을 넘어 "디버거 감지: 타이밍"이 발동했습니다. 실제 디버깅에서는 sleep 없이도 사람이 브레이크포인트에서 멈춰 화면을 읽는 몇 초가 그대로 잡힙니다. 분석가의 사고 시간 자체가 탐지 신호가 되는 것이 타이밍 검사의 질긴 점입니다.

3-6. 우회 2 — 두 검사를 한 스크립트로 돌파

같은 기법을 check_timing에도 적용합니다. 올바른 비밀번호를 넣어 끝까지 가 봅시다 (bypass2.gdb):

break check_ptrace
break check_timing
run < in.txt
return (int)0
continue
return (int)0
continue
echo n0-debug-z0ne > in.txt
gdb -batch -x bypass2.gdb ./guard219
Breakpoint 1 at 0x120b
Breakpoint 2 at 0x123c

Breakpoint 1, 0x000055555555520b in check_ptrace ()

Breakpoint 2, 0x000055555555523c in check_timing ()
password: correct! FLAG{ant1_d3bug_byp4ss3d}
[Inferior 1 (process 1063) exited normally]

(2026-09-09 실측.)

읽는 법: 두 검문소가 차례로 열렸지만 둘 다 return (int)0으로 무력화됐고, 프로그램은 끝까지 달려 FLAG를 냈습니다. 타이밍 측정조차 실행되지 않았습니다 — 함수 몸을 통째로 건너뛰었으니까요. 검사가 함수 단위로 깔끔하게 분리되어 있으면, 우회도 함수 단위로 깔끔합니다.

3-7. 같은 일을 자동으로 — ScyllaHide (화면 예시)

실전 분석에서는 검사마다 수동으로 return 0을 하지 않습니다. x64dbg의 ScyllaHide 플러그인 같은 도구가 "디버거 은폐"를 자동화합니다. 이 환경에는 x64dbg(Windows 도구)가 없으므로 화면 예시로 안내합니다:

# 화면 예시 — ScyllaHide 사용 흐름 (x64dbg, Windows)
1. x64dbg에 대상 exe 로드 → Plugins > ScyllaHide
2. 프로필에서 우회할 기법 체크:
   [x] IsDebuggerPresent      [x] CheckRemoteDebuggerPresent
   [x] NtQueryInformationProcess  [x] Timing (rdtsc/GetTickCount 계열)
3. 적용 후 실행 → 디버거가 붙어 있어도 탐지 API가 "없음"을 반환

읽는 법: 하는 일은 오늘 수동으로 한 것과 같습니다 — 탐지 지점마다 "안전"을 대답하게 만드는 것. 다만 수십 종의 탐지를 일괄 처리하고, 디버거 자체를 숨기는 방식이라는 점이 다릅니다. 도구가 자동화해 줘도 "무엇을 숨기는 것인지"는 오늘 배운 원리 그대로입니다.


4. 미션과 연습문제

미션 — 세 번째 검문소를 달고 뚫기

  1. guard219.c에 세 번째 검사 check_length를 추가합니다 — check_timing 앞에서 호출되며, 인자 없이 정수를 반환하고, 몸은 간단한 계산(예: 두 수의 곱)이 0이 아니면 "감지"로 처리하도록 만듭니다 (검사 로직은 자유 — 중요한 것은 main이 이 함수의 반환으로 분기한다는 구조)
  2. 컴파일 후, disassemble main으로 새 검사의 호출 위치를 확인합니다
  3. bypass3.gdb를 작성해 세 검사 전부return (int)0으로 무력화하고 FLAG까지 도달합니다
  4. 기록을 남깁니다: "검사가 몇 개였나, 어떤 순서로 무력화했나"

연습문제

문제 1. ptrace(PTRACE_TRACEME, ...) 자가 검사가 "실패하면 디버거"가 되는 이유를, 트레이서 규칙 관점에서 설명하세요.

문제 2. 3-5에서 브레이크포인트 2초 정체로 경과 클럭이 5466에서 73억으로 불어났습니다. 이 차이가 생기는 이유를 rdtsc가 재는 것이 무엇인지에 근거해 설명하세요.

문제 3. return (int)0 우회는 함수 몸을 실행하지 않습니다. 그런데도 3-4에서 타이밍 검사가 "통과"한 이유는 무엇인가요? (힌트: 그 시점에 타이밍 검사는 어떤 상태였나)

문제 4. 오늘 샘플의 검사가 check_ptrace()라는 분리된 함수가 아니라 main 안에 인라인으로 섞여 있었다면, 우회는 어떻게 달라졌을까요? 어떤 gdb 기법이 필요했을지 답하세요.


5. 모범 답안과 완료 기준

미션 모범 답안

추가할 검사의 한 예:

__attribute__((noinline))
static int check_length(void){
    volatile int x = 7, y = 6;
    return (x * y) != 42;   /* 참이면 1 = "감지" — 사실은 항상 0(안전) */
}

main에서 check_ptrace 호출 다음에 if (check_length()) { ... }를 끼워 넣습니다. 우회 스크립트는 이렇게 됩니다:

break check_ptrace
break check_length
break check_timing
run < in.txt
return (int)0
continue
return (int)0
continue
return (int)0
continue

검증하는 법: ① disassemble main의 call 목록에 새 함수가 보이는가, ② 스크립트 실행 시 브레이크포인트가 세 번 잡히는가, ③ 최종 출력이 correct! FLAG{...}인가. 셋 다 "예"면 완료입니다. 검사가 3개든 10개든 "진입점에 걸고 0을 반환"의 반복임을 체감하는 것이 이 미션의 목적입니다.

연습문제 해답

문제 1 해답. 리눅스에서는 한 프로세스에 트레이서(추적자)가 하나만 붙을 수 있습니다. gdb는 ptrace로 대상에 이미 붙어 있는 상태이므로, 프로그램이 스스로 TRACEME("나를 추적해 달라")을 요청하면 규칙 위반으로 실패(-1)합니다. 정상 실행에서는 아무도 붙어 있지 않아 이 요청이 성공합니다. 따라서 실패 = 누군가 이미 추적 중 = 디버거 존재가 됩니다.

문제 2 해답. rdtsc는 프로그램의 실행 시간이 아니라 CPU의 실제 경과 클럭(벽시계 시간에 해당)을 읽습니다. 브레이크포인트에서 프로세스가 정지해 있는 2초 동안에도 CPU 클럭은 계속 흐릅니다. 따라서 측정 구간 앞뒤의 rdtsc 차이에는 사람이 멈춰 있던 시간이 그대로 포함되고, 수천 클럭이던 값이 수십억으로 불어납니다.

문제 3 해답. 3-4에서는 브레이크포인트가 check_ptrace에만 걸려 있었고, 그 지점은 측정 구간(rdtsc ~ rdtsc) 입니다. 우회 후 continue로 멈춤 없이 쭉 실행됐으므로 측정 구간은 평소 속도로 지나갔고(19578클럭), 임계값 미만이라 통과했습니다. 타이밍 검사는 "측정 구간 안에서의 정체"만 잡습니다 — 구간 밖에서 아무리 오래 멈춰도 무관합니다. 이것이 타이밍 검사의 사각지대입니다.

문제 4 해답. 분리된 함수가 없으면 break 함수명 + return 조합을 쓸 수 없습니다. 대신 검사 직후의 조건 분기를 찾아 우회해야 합니다 — 예: 디스어셈블리에서 call ptrace 다음의 cmp/je를 찾고, 그 지점에 브레이크포인트를 걸어 set $rax = 0으로 비교 대상 레지스터를 바꾸거나, 플래그를 조작해 분기 방향을 뒤집습니다. 또는 바이너리를 패치해 분기 자체를 NOP으로 지웁니다. 실전 난도가 올라가는 이유가 바로 이 "검사 코드의 인라인화"입니다.

완료 기준 체크리스트

  • [ ] 안티디버깅이 무엇인지와 쓰이는 이유(악성코드·DRM)를 한 문장으로 말할 수 있다
  • [ ] guard219.c를 컴파일하고 일반 실행에서 FLAG가 나오는 것을 확인했다
  • [ ] gdb로 그냥 실행해 ptrace 감지 메시지를 실측으로 확인했다
  • [ ] return (int)0으로 ptrace 검사를 우회했다
  • [ ] 측정 구간 안에서의 정체로 타이밍 감지가 발동하는 것을 실측했다
  • [ ] 두 검사를 한 스크립트로 무력화해 FLAG까지 도달했다
  • [ ] 미션: 세 번째 검사를 추가하고 세 검사 모두 우회했다

6. 흔한 실수와 해결

벽 1. return 0을 쳤더니 캐스트 오류가 난다

증상: Return value type not available for selected stack frame. Please use an explicit cast of the value to return.
원인: 디버그 정보 없이 컴파일된 바이너리에서는 gdb가 함수의 반환 형을 모릅니다. 이 챕터 실측 중 실제로 겪은 오류입니다 (gdb 15.1).
해결: return (int)0처럼 반환 형을 명시하세요. 포인터를 반환하는 함수라면 return (void*)0 식입니다.

벽 2. strings에 한글 메시지가 안 보인다

증상: 분명 감지 메시지가 있는데 strings에서 검색이 안 됩니다 (2026-09-09 실측: 기본 strings에서 "감지" 0건).
원인: strings는 기본적으로 7비트 ASCII 연속 문자열만 건집니다. UTF-8 한글은 각 바이트가 0x80 이상이라 잘립니다.
해결: strings -e S ./바이너리로 8비트 문자까지 건지세요. 영문 메시지·플래그는 기본 옵션으로도 나오니 둘 다 돌려 보는 습관이 좋습니다.

벽 3. gdb 스크립트에서 print/x $raxThe history is empty. 오류를 낸다

증상: 스크립트(-x 파일) 안에서 finish 다음에 print 계열 명령이 오류를 냅니다. 실측 중 실제로 겪은 문제입니다.
원인: 배치 스크립트의 실행 순서와 gdb 값 이력 처리가 얽혀 생기는 문제로, 같은 명령도 -ex 인자로 넘기면 동작합니다.
해결: 레지스터를 볼 때는 print 대신 info registers rax(줄여서 i r rax)를 쓰세요. 이 명령은 값 이력을 쓰지 않아 스크립트에서도 안정적입니다.

벽 4. 멈췄는데 타이밍 감지가 안 뜬다

증상: 브레이크포인트에 잠깐 멈췄는데 감지 메시지 없이 지나갑니다.
원인: 배치 모드의 브레이크포인트 처리는 수 밀리초라 임계값을 못 넘을 수 있습니다 (2026-09-09 실측: 배치 모드 정체만으로는 226만 클럭, 임계값 1억 미만). 또는 정체 위치가 측정 구간 밖일 수 있습니다.
해결: 사람이 직접 대화형 gdb에서 몇 초 멈추면 반드시 걸립니다. 시연·자동화가 목적이라면 스크립트에 shell sleep 2를 넣어 정체를 명시하세요.

벽 5. 우회했는데 또 감지된다 — 검사가 여러 곳이다

증상: 한 곳을 무력화했는데 다른 감지 메시지가 뜹니다.
원인: 실전 바이너리는 같은 검사를 여러 곳에 심거나 서로 다른 기법을 겹칩니다. 오늘 샘플도 두 개였죠.
해결: 3-2처럼 감지 메시지 문자열을 strings -e S로 전부 수집하고, 디스어셈블리에서 각 메시지로 가는 참조를 역추적해 검사 지점 목록을 먼저 만드세요. 목록 없이 하나씩 우회하다 보면 끝이 없습니다. 분석은 지도부터 그리는 것입니다.


7. 정리

오늘의 개념

개념 한 줄 설명
안티디버깅 프로그램이 스스로 "분석당하는 중인가"를 검사해 대응하는 기법
ptrace 자가 검사 TRACEME 요청의 실패(-1) = 이미 트레이서가 붙어 있다 = 디버거
타이밍 검사 rdtsc 전후 차이가 비정상적으로 크면 멈춰서 본 것 = 디버거
rdtsc CPU 타임스탬프 카운터를 읽는 명령 — 정지 시간도 그대로 잡힌다
반환값 조작 return (int)0으로 검사 함수를 "항상 안전"으로 만들기
패치 바이너리의 검사 코드 자체를 NOP/분기 뒤집기로 제거하는 영구 우회
ScyllaHide 수십 종 탐지를 일괄 우회하는 디버거 은폐 플러그인 (x64dbg)

오늘의 명령어

명령 하는 일
strings -e S ./바이너리 UTF-8(한글) 문자열까지 건지기 — 감지 메시지 목록 만들기
objdump -d ./바이너리 | grep -B1 -A1 "ptrace@plt" 자가 검사 호출과 판정(cmp -1) 위치 찾기
gdb -batch -ex "disassemble main" ./바이너리 main의 호출 순서에서 검문소 배치 파악
break 함수return (int)0continue 검사 함수 무력화의 표준 패턴
shell sleep 2 (gdb 스크립트 안) 정체 시간을 명시해 타이밍 감지를 재현
info registers rax 반환값 확인 — 스크립트에서도 안정적인 명령

명령어보다 중요한 감각

오늘의 핵심은 공방의 대칭입니다. 검사하는 쪽은 "감시의 흔적"을 찾고, 우회하는 쪽은 "검사의 위치"를 찾습니다. 감지 메시지 문자열이 곧 검사 지점의 지도가 되고, 검사가 함수로 분리되어 있으면 우회는 return (int)0 한 줄이었습니다.

거꾸로 방어자 입장에서 보면 왜 실전 보호 기법이 검사를 인라인으로 섞고, 여러 기법을 겹치고, 감지 시 바로 죽지 않고 조용히 엉터리로 동작하는지 이해가 됩니다 — 오늘 만든 샘플의 약점을 고치는 방향이 곧 실전의 방향이니까요. 만들어 본 자만이 부술 수 있고, 부숴 본 자만이 제대로 만들 수 있습니다.


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