Step 221. 키젠 — 시리얼 알고리즘 분석과 생성기 작성

Step 221. 키젠 — 시리얼 알고리즘 분석과 생성기 작성

Level 3 — Reversing 심화 (프로젝트) | 난이도 ★★★★★ | 예상 소요 시간 6시간

전제: Step 219(안티디버깅), Step 178(Reversing 입문). 디스어셈블리에서 xor·shl 같은 연산을 읽을 수 있어야 합니다.

⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다. 키젠(keygen) 기법을 상용 소프트웨어에 적용하는 것은 불법 복제 도구 제작에 해당합니다. 오늘의 대상은 여러분이 직접 만드는 keygenme뿐입니다.

  • 준비물: WSL 우분투 (실측: Ubuntu 24.04, gcc 13.3.0, gdb 15.1)와 파이썬 3. 작업 폴더는 ~/lab219_223를 권장합니다.
  • 주의: 이 챕터는 프로젝트입니다 — 따라 하기를 지나 마지막에 "임의의 이름에 대해 유효한 시리얼을 출력하는 생성기"를 완성해 제출물로 남기는 것이 목표입니다.

패치(Step 219식 우회)가 "검사를 부수는 것"이라면, 키젠(keygen)은 "검사를 통과하는 정답을 만들어 내는 것"입니다. 검증 로직을 파괴하지 않고 완전히 이해했음을 증명하는 방식 — 리버싱 실력의 꽃이라 불립니다. 오늘은 이름을 넣으면 기대 시리얼이 계산되는 keygenme를 직접 만들고, 그다음 소스를 잊은 채 디스어셈블리만 읽어 알고리즘을 역산하고, 파이썬 생성기로 통과시킵니다. 출제와 풀이를 혼자 다 합니다.


1. 학습 목표

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

  • 시리얼 검증 프로그램의 표준 구조(입력 → 변환 → 비교)를 설명한다
  • 디스어셈블리에서 변환 알고리즘(시드, 연산, 순서)을 역산한다
  • 컴파일러가 곱셈을 시프트+덧셈으로 바꾸는 최적화를 읽어 낸다
  • C의 32비트 wrap-around를 파이썬에서 & 0xFFFFFFFF로 재현한다
  • 역산한 알고리즘으로 키 생성기를 작성하고 실제로 통과시킨다

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

오늘의 도구 한눈에 보기

구분 내용
언어·환경 C(keygenme 제작) + 파이썬(키젠) + WSL 우분투 bash (실측: gcc 13.3.0, gdb 15.1)
오늘의 명령어 strings, gdb의 disassemble, break / finish / info registers rax
필요한 개념 검증 알고리즘 역산, 시드 상수, 32비트 정수 wrap-around, 반환 레지스터(rax)
오늘의 산출물 keygenme 바이너리 + 알고리즘 분석 노트 + 파이썬 키젠 + 통과 증거

2-1. 시리얼 검증의 표준 구조

라이선스 검증 프로그램의 전형은 이렇습니다. 이름(name)과 시리얼(serial)을 입력받고, 프로그램이 이름으로부터 기대 시리얼을 계산한 뒤, 입력된 시리얼과 비교합니다.

이름 → [ 변환 함수 f ] → 기대 시리얼
                              ↕ 비교
                        입력된 시리얼

크랙(패치)은 "비교"를 부숩니다. 키젠은 다릅니다 — 변환 함수 f를 통째로 읽어 내 언어로 다시 구현합니다. f를 정확히 재현했다면 임의의 이름에 대해 올바른 시리얼을 무한히 찍어 낼 수 있습니다. 부수는 것보다 이해하는 것이 한 수 위인 이유입니다.

2-2. 변환 함수 읽기 — 상수가 알고리즘의 지문

변환 함수는 보통 작은 해시입니다. 시드(초기값)에서 시작해 입력의 각 바이트를 곱셈·덧셈·XOR로 반죽합니다. 디스어셈블리에서 찾아야 할 것은 세 가지입니다.

  1. 초기 상수(시드) — 함수 시작부의 mov $0x????, %eax
  2. 반복되는 연산 — 루프 몸통의 imul/shl/add/xor 조합
  3. 최종 변환 — 반환 직전의 마스킹이나 형식 변환

여기서 함정 하나: 컴파일러는 곱셈을 더 싼 연산으로 바꿉니다. h * 33shl $0x5(×32) + add(원래 값 더하기)로 컴파일됩니다 — 오늘 실측에서 이 장면을 직접 보게 됩니다. "곱셈이 안 보인다고 곱셈이 아닌 게 아니다"가 오늘의 눈 훈련입니다.

2-3. wrap-around — C와 파이썬의 결정적 차이

C의 uint32_t는 32비트로, 곱셈 결과가 크면 넘치는 윗부분이 버려집니다 (2³²로 나눈 나머지만 남음). 이 wrap-around가 알고리즘의 일부입니다.

반면 파이썬의 정수는 무한 정밀도라 넘치지 않습니다. 그대로 이식하면 값이 달라집니다. 재현 방법은 매 단계마다 & 0xFFFFFFFF로 32비트만 남기는 것 — 키젠 이식의 첫 번째 규칙입니다.

2-4. 동적 대조 — 이식이 맞는지 확인하는 법

정적으로 읽은 알고리즘이 맞는지 확인하는 가장 빠른 방법은 실행 중 진짜 값과 대조하는 것입니다. 변환 함수에 브레이크포인트를 걸고 finish로 빠져나온 직후 rax를 읽으면, 그 이름에 대한 진짜 기대 시리얼이 나옵니다.

내 파이썬 키젠의 출력이 이 값과 일치하면 이식 성공, 다르면 어느 단계가 틀렸는지 추적하면 됩니다. 정적 분석(읽기)과 동적 분석(관찰)의 연계 — Step 178의 리듬이 프로젝트 규모로 돌아왔습니다.


3. 따라 하기

3-1. 1막: 출제자 — keygenme 만들기

먼저 출제자가 됩니다. 이름을 받아 32비트 해시를 계산하고, 시리얼(16진수)과 비교하는 프로그램입니다.

입력 (keygenme.c)

#include <stdio.h>
#include <string.h>
#include <stdint.h>
#include <stdlib.h>

__attribute__((noinline))
static uint32_t name_hash(const char *s){
    uint32_t h = 0x1505;                          /* 시드 상수 */
    for (; *s; s++){
        h = (h * 33) ^ (unsigned char)*s;         /* 곱하고 XOR — 32비트 wrap-around */
    }
    return h;
}

int main(void){
    char name[32], serial[16];
    printf("name: ");
    if (scanf("%31s", name) != 1) return 1;
    printf("serial: ");
    if (scanf("%15s", serial) != 1) return 1;
    uint32_t expect = name_hash(name);
    uint32_t given = (uint32_t)strtoul(serial, NULL, 16);
    if (given == expect)
        puts("correct! 라이선스 등록 완료");
    else
        puts("wrong serial.");
    return 0;
}
cd ~/lab219_223
gcc -O1 -o keygenme keygenme.c
printf "alice\n00000000\n" | ./keygenme
name: serial: wrong serial.

(2026-09-09 실측.)

읽는 법: 틀린 시리얼은 거부됩니다. 여기까지가 출제입니다. 이제 소스를 덮으세요. 다음 막부터 여러분은 이 바이너리만 받은 분석가입니다.

3-2. 2막: 정찰 — strings와 호출 구조

strings ./keygenme | grep -iE "name|serial|wrong|correct"
name: 
serial: 
wrong serial.
correct! 

(2026-09-09 실측. correct! 뒤의 한글은 7비트 ASCII 필터에서 잘립니다 — strings -e S로 보면 correct! 라이선스 등록 완료 전체가 보입니다. Step 219 벽 2의 기억.)

읽는 법: 시리얼 후보가 보이지 않습니다 — 비교 대상이 파일에 없다는 뜻이고, 즉 계산된다는 뜻입니다. 키젠 유형 확정입니다. main의 호출 구조를 봅니다:

gdb -batch -ex "disassemble main" ./keygenme | grep call
   0x000000000000121d <+33>:	call   0x11c9 <name_hash>
   0x000000000000122e <+50>:	call   0x10c0 <__printf_chk@plt>
   ... (scanf, strtoul, puts 등 생략)

(2026-09-09 실측. 순서를 요약한 출력입니다.)

읽는 법: 이름 없는 내부 함수 하나(name_hash — 심볼이 남아 있어 다행)가 호출되고, 그 결과가 비교에 쓰입니다. 표적은 이 함수 하나입니다.

3-3. 2막: 알고리즘 역산 — 디스어셈블리 읽기

gdb -batch -ex "disassemble name_hash" ./keygenme
   0x00000000000011c9 <+0>:	movzbl (%rdi),%edx
   0x00000000000011cc <+3>:	test   %dl,%dl
   0x00000000000011ce <+5>:	je     0x11ed <name_hash+36>
   0x00000000000011d0 <+7>:	mov    $0x1505,%eax
   0x00000000000011d5 <+12>:	mov    %eax,%ecx
   0x00000000000011d7 <+14>:	shl    $0x5,%ecx
   0x00000000000011da <+17>:	add    %ecx,%eax
   0x00000000000011dc <+19>:	movzbl %dl,%edx
   0x00000000000011df <+22>:	xor    %edx,%eax
   0x00000000000011e1 <+24>:	add    $0x1,%rdi
   0x00000000000011e5 <+28>:	movzbl (%rdi),%edx
   0x00000000000011e8 <+31>:	test   %dl,%dl
   0x00000000000011ea <+33>:	jne    0x11d5 <name_hash+12>
   0x00000000000011ec <+35>:	ret

(2026-09-09 실측.)

읽는 법 — 한 줄씩 뜯어 봅시다:

  • <+7>: mov $0x1505,%eax시드 발견. h는 0x1505로 시작합니다.
  • <+12>~<+17>: %eax%ecx로 복사 → shl $0x5(×32) → 원래 값을 더함. 즉 h*32 + h = h × 33. 곱셈이 시프트+덧셈으로 번역된 2-2의 최적화입니다.
  • <+22>: xor %edx,%eax — 현재 바이트와 XOR.
  • <+24>~<+33>: 포인터 전진, 다음 바이트 로드, 0이 아니면 <+12>로 회귀 — 문자열 끝까지 반복.

의사 코드로 정리하면: h = 0x1505; 문자열의 각 바이트 c에 대해 h = (h × 33) XOR c; h 반환. 역산 완료입니다.

3-4. 2막: 진짜 값 관찰 — 동적 대조

역산이 맞는지, 실행 중 진짜 값으로 대조합니다. 변환 함수에 브레이크포인트를 걸고 빠져나온 뒤 반환 레지스터(rax)를 읽습니다 (obs.gdb):

break name_hash
run < in2.txt
finish
info registers rax
printf "alice\n0\n" > in2.txt
gdb -batch -ex "break name_hash" -ex "run < in2.txt" -ex "finish" -ex "info registers rax" ./keygenme
Breakpoint 1 at 0x11c9

Breakpoint 1, 0x00005555555551c9 in name_hash ()
0x0000555555555294 in main ()
rax            0xa20fb27           169933607

(2026-09-09 실측.)

읽는 법: 이름 alice에 대한 진짜 기대 시리얼은 0x0A20FB27입니다. 함수의 반환값은 rax에 실립니다 (호출 규약 — Step 178의 인자 규약과 한 세트). 이 숫자가 채점 기준입니다: 내 키젠이 이 값을 찍어 내면 성공입니다.

참고: 스크립트 안에서 print/x $raxThe history is empty. 오류가 날 수 있습니다 (Step 219 벽 3). 레지스터 관찰은 info registers가 안전합니다.

3-5. 3막: 키젠 작성 — 파이썬으로 이식

역산한 알고리즘을 파이썬으로 옮깁니다. 2-3의 규칙 — 매 단계 & 0xFFFFFFFF — 을 잊지 마세요.

입력 (keygen.py)

import sys

def name_hash(name: str) -> int:
    h = 0x1505                                # 디스어셈블리 <+7>의 시드
    for ch in name.encode():                  # 문자열의 각 바이트
        h = ((h * 33) ^ ch) & 0xFFFFFFFF      # ×33, XOR, 32비트 wrap-around
    return h

if __name__ == "__main__":
    name = sys.argv[1] if len(sys.argv) > 1 else input("name: ")
    print(f"{name_hash(name):08X}")
python3 keygen.py alice
python3 keygen.py bob
python3 keygen.py kim-secur1ty
0A20FB27
0B8747AA
DBF01289

(2026-09-09 실측.)

읽는 법: 첫 줄이 결정적입니다 — alice의 키젠 출력 0A20FB27이 3-4에서 관찰한 진짜 값 0xa20fb27과 완전히 일치합니다. 정적 역산 + 동적 대조 + 이식, 세 조각이 맞물렸습니다.

3-6. 3막: 통과 — 생성기의 증명

만든 키로 실제 프로그램을 통과시킵니다:

printf "alice\n0A20FB27\n" | ./keygenme
printf "kim-secur1ty\nDBF01289\n" | ./keygenme
printf "bob\nDEADBEEF\n" | ./keygenme
name: serial: correct! 라이선스 등록 완료
name: serial: correct! 라이선스 등록 완료
name: serial: wrong serial.

(2026-09-09 실측.)

읽는 법: 생성한 키 두 개는 통과, 임의로 지어낸 DEADBEEF는 거부. "우연이 아니라 알고리즘으로 통과했다"는 것이 증명됐습니다. 임의의 이름에 대해 유효한 시리얼을 출력하는 생성기 — 이것이 오늘 프로젝트의 완성형입니다.

3-7. 제출물 정리 (프로젝트 완료 기준)

프로젝트의 제출물은 세 가지입니다:

제출물 내용 오늘의 결과
분석 노트 디스어셈블리 → 의사 코드 역산 과정 3-3의 "읽는 법" (시드 0x1505, ×33+XOR)
생성기 keygen.py 3-5의 코드
통과 증거 생성 키로 correct 출력 3-6의 실측 출력

Write-up을 쓸 때는 "어떤 단서에서 무엇을 추론했는가"의 순서로 적으세요. 정답 코드보다 추론의 사슬이 리버싱 실력의 본체입니다.


4. 미션과 연습문제

미션 — 변형 keygenme를 만들고, 키젠까지

  1. keygenme.c를 변형한 keygenme2.c를 만듭니다 — 시드를 바꾸고(예: 0xC0DE), 연산을 하나 더 얹습니다 (예: 루프 안에서 h = (h + c) ^ (h >> 3) 같은 식으로 자신만의 반죽을 설계)
  2. 컴파일 후 소스를 잊고, 오늘의 절차(strings → disassemble → 동적 대조)로 알고리즘을 역산합니다
  3. keygen2.py를 작성하고, 임의의 이름 3개로 생성 → 통과를 실측합니다
  4. 제출물: 분석 노트 + keygen2.py + 통과 출력

연습문제

문제 1. 3-3의 <+12>~<+17> 구간(movshl $0x5add)이 왜 ×33인지 설명하세요.

문제 2. keygen.py에서 & 0xFFFFFFFF를 빼면 어떤 일이 일어나나요? 짧은 이름에서는 문제가 없는 경우도 있는 이유와 함께 답하세요.

문제 3. 키젠이 크랙(검사 패치)보다 "알고리즘을 완전히 이해한 증명"이라고 불리는 이유를, 3-6의 검증 방식에 근거해 설명하세요.

문제 4. 3-2에서 strings에 시리얼이 안 보인 것이 왜 "비교 대상이 계산된다"는 추론으로 이어지나요?


5. 모범 답안과 완료 기준

미션 모범 답안

변형의 한 예 (시드 0xC0DE, 연산 h = (h + c) ^ (h >> 3)):

[역산] disassemble name_hash:
  - 시작부 mov $0xc0de,%eax → 시드 0xC0DE
  - 루프 몸통: add %edx,%eax (현재 바이트 더하기)
              mov %eax,%ecx; shr $0x3,%ecx (h >> 3)
              xor %ecx,%eax  (XOR)
  → 의사 코드: h = 0xC0DE; 각 바이트 c에 대해 h = (h + c) ^ (h >> 3)

[keygen2.py]
def name_hash2(name):
    h = 0xC0DE
    for ch in name.encode():
        h = ((h + ch) ^ (h >> 3)) & 0xFFFFFFFF
    return h

[대조] gdb로 관찰한 rax와 keygen2.py 출력 일치 확인 → 임의 이름 3개 통과

검증하는 법: ① 역산 노트의 의사 코드가 디스어셈블리의 각 명령과 1:1로 대응하는가, ② gdb 관찰값과 생성기 출력이 일치하는가(한 글자라도 다르면 이식 오류 — 시드, 연산 순서, wrap-around를 의심), ③ 생성한 키가 실제 바이너리를 통과하는가. ③이 최종 심판입니다.

연습문제 해답

문제 1 해답. shl $0x5는 왼쪽으로 5비트 시프트, 즉 ×32입니다. 그런데 그 전에 원래 값을 %ecx가 아니라 복사해 뒀다가(mov %eax,%ecx), 시프트된 값에 원래 값을 더합니다(add %ecx,%eax — 정확히는 복사본을 시프트해 원본에 더함). ×32 + ×1 = ×33입니다. 컴파일러가 곱셈 명령(imul)보다 빠른 시프트+덧셈 조합으로 번역한 것이며, 디스어셈블리에서 곱셈 상수를 찾을 때는 이 패턴을 읽을 줄 알아야 합니다.

문제 2 해답. 파이썬 정수는 무한 정밀도라 32비트를 넘어도 버려지지 않습니다. h * 33을 반복하면 h가 32비트를 넘는 순간부터 C의 결과(넘친 부분이 버려진 값)와 파이썬의 결과(그대로 커진 값)가 갈라집니다. 다만 입력이 짧고 시드가 작으면 곱셈 결과가 32비트 안에 머물 수 있어 우연히 일치하는 경우가 있습니다 — 그래서 "짧은 이름에서는 되는데 긴 이름에서 틀리는" 키젠은 wrap-around 누락이 첫 용의자입니다.

문제 3 해답. 패치는 비교 분기 하나를 뒤집는 것이라 알고리즘을 몰라도 됩니다. 반면 키젠은 임의의 입력에 대해 정답을 "계산해 내야" 하므로 변환의 시드·연산·순서·비트 폭을 전부 정확히 재현해야 합니다. 3-6에서 본 것처럼 생성 키는 통과하고 임의 키(DEADBEEF)는 거부되는 대조가 성립하려면 부분적 이해로는 불가능합니다. 그래서 키젠의 성공은 곧 완전한 이해의 증명입니다.

문제 4 해답. strings는 파일 안의 평문 문자열을 건집니다. 비교 대상이 상수였다면(Step 178의 crackme1처럼) 반드시 걸립니다. 걸리지 않았다는 것은 비교 대상이 파일에 존재하지 않고 실행 중에 만들어진다는 뜻이며, 입력(이름)으로부터 계산되는 구조 — 즉 키젠 유형이라는 추론으로 이어집니다. "보이지 않음"도 정보입니다.

완료 기준 체크리스트

  • [ ] 시리얼 검증의 표준 구조(입력 → 변환 → 비교)를 그릴 수 있다
  • [ ] keygenme를 컴파일하고 틀린 시리얼이 거부되는 것을 확인했다
  • [ ] 디스어셈블리에서 시드(0x1505)와 ×33(shl+add) 패턴을 찾아 읽었다
  • [ ] gdb로 변환 함수의 실제 반환값(rax)을 관찰했다
  • [ ] 파이썬 키젠을 작성하고 출력이 관찰값과 일치함을 확인했다
  • [ ] 생성한 키로 keygenme를 통과시켰다
  • [ ] 미션: 변형 keygenme2를 만들고 역산 → keygen2.py → 통과까지 완주했다

6. 흔한 실수와 해결

벽 1. 곱셈이 디스어셈블리에 안 보인다

증상: 분명 곱셈인데 imul이 없고 shladd만 있습니다.
원인: 컴파일러 최적화입니다. h * 33shl $0x5 + add로, h * 5shl $0x2 + add(lea 1가지)로 번역됩니다 (2026-09-09 실측: gcc -O1).
해결: "시프트량 + 더해지는 것"을 곱셈 상수로 환산하는 연습을 하세요. shl $n이면 ×2ⁿ, 거기에 원본이 더해지면 ×(2ⁿ+1)입니다.

벽 2. 키젠 값이 짧은 이름에서는 맞고 긴 이름에서 틀린다

증상: "bob"은 통과하는데 "kim-secur1ty"는 틀립니다.
원인: & 0xFFFFFFFF 누락 — wrap-around가 발동하는 길이부터 값이 갈라집니다 (문제 2 해답 참조).
해결: 모든 산술 단계(곱셈·덧셈) 뒤에 32비트 마스킹을 넣으세요. 참고로 시프트(>>)와 XOR은 윗비트를 키우지 않아 마스킹이 덜 중요하지만, 습관적으로 루프 끝에서 한 번에 마스킹하는 형태가 안전합니다.

벽 3. finish 다음에 print/x $raxThe history is empty.

증상: 스크립트(-x 파일)에서 값이 안 찍힙니다.
원인: 배치 스크립트와 gdb 값 이력의 궁합 문제입니다 (Step 219 벽 3과 동일, 2026-09-09 재현).
해결: -ex "info registers rax"를 쓰세요. 레지스터 직접 조회는 이력을 거치지 않아 안정적입니다.

벽 4. 시리얼 형식 때문에 통과가 안 된다

증상: 값은 맞게 계산했는데 wrong serial.이 뜹니다.
원인: 형식 불일치 — 오늘 샘플은 16진수 문자열을 strtoul(..., 16)로 읽습니다. 앞에 0x를 붙이거나, 10진수로 넣거나, 자릿수가 달라도 파싱 결과가 달라질 수 있습니다.
해결: 비교 전에 시리얼을 어떤 함수로 파싱하는지 디스어셈블리에서 확인하세요 (strtoul의 두 번째 인자 16 = 16진수). 실전 키젠me는 하이픈 구분, 대소문자 고정 등 형식 함정이 흔합니다.

벽 5. 역산한 의사 코드가 맞는지 확신이 안 선다

증상: 이식했는데 값이 다르고, 어느 단계가 틀렸는지 모르겠습니다.
원인: 한 번에 전체를 이식하고 한 번에 검증했기 때문입니다.
해결: 단계별 대조로 바꾸세요 — 루프 안에 브레이크포인트를 걸고 첫 바이트 처리 후의 eax를 관찰해, 파이썬에서 같은 첫 단계를 계산한 값과 비교합니다. 첫 단계가 맞으면 두 번째로. 키젠 디버깅은 "중간값 대조"가 전부입니다.


7. 정리

오늘의 개념

개념 한 줄 설명
키젠(keygen) 검증 알고리즘을 역산해 정당한 시리얼을 생성하는 도구 — 완전한 이해의 증명
keygenme 키젠 연습용 바이너리 — 이름 → 기대 시리얼 변환 구조
시드(seed) 해시의 초기 상수 — 디스어셈블리 시작부의 mov $0x????
곱셈 최적화 h * 33shl $0x5 + add — 컴파일러의 번역을 거슬러 읽기
wrap-around 32비트 정수의 넘침 — 파이썬 이식 시 & 0xFFFFFFFF 필수
동적 대조 gdb로 관찰한 진짜 값과 생성기 출력을 비교해 이식을 검증하는 기법

오늘의 명령어

명령 하는 일
strings ./바이너리 | grep -iE "패턴" 시리얼 부재 확인 → "계산된다" 추론
gdb -batch -ex "disassemble main" ./바이너리 변환 함수 호출 위치 찾기
gdb -batch -ex "disassemble 함수명" 변환 알고리즘 역산 (시드·연산·루프)
break 함수finishinfo registers rax 진짜 기대값 관찰 — 이식의 채점 기준
python3 keygen.py 이름 역산한 알고리즘으로 시리얼 생성
printf "이름\n시리얼\n" | ./바이너리 생성 키의 통과 여부 최종 검증

명령어보다 중요한 감각

오늘 프로젝트의 뼈대는 한 줄입니다 — 읽고(정적), 대조하고(동적), 다시 쓴다(이식). 디스어셈블리에서 시드와 연산을 읽고, gdb로 진짜 값을 관찰해 채점 기준을 만들고, 파이썬으로 재현해 통과시켰습니다. 세 도구가 서로를 검증하는 구조입니다.

그리고 키젠은 리버싱의 졸업 시험 같은 과제입니다. 부수지 않고 이해로 통과하기 때문입니다. Step 178의 "비교 대상을 찾아라"에서 시작해, 오늘은 "비교 대상을 계산해 내라"까지 왔습니다. 여기까지 오신 여러분은 이제 crackme의 출제자와 플레이어, 두 자리를 모두 앉아 본 것입니다.


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