John the Ripper 와 키 유도 함수 (KDF)

Kali LinuxBeginner
지금 연습하기

소개

이 실습에서는 암호화 알고리즘인 키 유도 함수 (Key Derivation Functions, KDF) 를 탐구합니다. KDF 는 비밀번호에 대한 무차별 대입 공격 (brute-force attacks) 을 더 어렵게 만들도록 설계되었습니다. 단순 해싱 함수와 달리 KDF 는 의도적으로 계산 비용을 추가하여 많은 비밀번호 추측을 테스트하는 속도를 훨씬 느리게 만듭니다. PBKDF2, bcrypt, scrypt 와 같은 일반적인 KDF 에 대해 배우고, 이들이 고유한 비밀번호 해시를 생성하는 방법을 이해하며, John the Ripper 와 같은 강력한 비밀번호 크래킹 도구가 이러한 함수와 어떻게 상호 작용하는지 관찰합니다. 마지막으로, 안전한 비밀번호 저장을 위해 KDF 를 구현하는 실질적인 경험을 쌓고 사이버 보안의 모범 사례를 강화합니다.

KDF 이해하기 (예: PBKDF2, bcrypt, scrypt)

이 단계에서는 키 유도 함수 (Key Derivation Functions, KDF) 에 대한 기본적인 이해를 쌓게 됩니다. KDF 는 마스터 키 또는 비밀번호와 같은 비밀 값을 사용하여 하나 이상의 비밀 키를 파생시키는 암호화 알고리즘입니다. 비밀번호 저장에서 KDF 의 주요 목적은 공격자가 해시된 비밀번호를 획득하더라도 무차별 대입 및 사전 공격을 계산적으로 비싸게 만드는 것입니다. 이는 반복적인 계산과 "솔트 (salt)"의 사용을 통해 해싱 프로세스를 의도적으로 느리게 함으로써 달성됩니다.

"솔트 (salt)"는 각 비밀번호에 대해 고유한 무작위 데이터 문자열입니다. 비밀번호가 해싱될 때, 솔트는 해싱 전에 비밀번호와 결합됩니다. 이는 공격자가 미리 계산된 레인보우 테이블 (rainbow tables) 을 사용하여 비밀번호를 크랙하는 것을 방지하고, 동일한 비밀번호라도 솔트가 다르면 다른 해시를 생성하도록 보장합니다.

몇 가지 일반적인 KDF 를 살펴보겠습니다.

  • PBKDF2 (Password-Based Key Derivation Function 2): 이 함수는 입력 비밀번호와 솔트를 함께 사용하여 의사 난수 함수 (pseudorandom function, 예: HMAC-SHA256) 를 적용하고, 계산 비용을 늘리기 위해 이 과정을 여러 번 (반복 횟수, iterations) 반복합니다.
  • bcrypt: Blowfish 암호에 기반한 bcrypt 는 적응형 (adaptive) 으로 설계되었습니다. 즉, 프로세서 속도 증가에 맞춰 계산 비용을 시간이 지남에 따라 늘릴 수 있습니다. GPU 기반 공격에 대한 저항성으로 잘 알려져 있습니다.
  • scrypt: 이 KDF 는 계산 능력뿐만 아니라 상당한 양의 메모리를 요구함으로써 하드웨어 공격 (ASICs 및 FPGAs) 에 대한 저항성을 갖도록 특별히 설계되었습니다.

시작하기 위해 나중에 KDF 를 시연하는 데 사용할 John the Ripper 가 설치되어 있는지 확인하겠습니다.

john --version

John the Ripper 가 설치되었음을 나타내는 다음과 유사한 출력이 표시되어야 합니다.

John the Ripper password cracker, version 1.9.0-jumbo-1+bleeding-e7022e5 64-bit
Copyright (c) 1996-2020 by Solar Designer
...

다음으로, PBKDF2 를 사용하여 비밀번호를 해싱하는 방법을 시연할 간단한 Python 스크립트를 만들어 보겠습니다.

kdf_demo.py라는 파일을 생성합니다.

nano kdf_demo.py

파일에 다음 Python 코드를 추가합니다.

import hashlib
import os

def pbkdf2_hash(password, salt=None, iterations=100000):
    if salt is None:
        salt = os.urandom(16) ## 무작위 16 바이트 솔트 생성

    ## HMAC-SHA256 을 사용한 PBKDF2
    hashed_password = hashlib.pbkdf2_hmac(
        'sha256',
        password.encode('utf-8'),
        salt,
        iterations
    )
    return salt, hashed_password

password = "mysecretpassword"
salt, hashed = pbkdf2_hash(password)

print(f"Password: {password}")
print(f"Salt (hex): {salt.hex()}")
print(f"Hashed Password (hex): {hashed.hex()}")
print(f"Iterations: 100000")

파일을 저장하려면 Ctrl+X, Y, Enter를 누릅니다.

이제 Python 스크립트를 실행합니다.

python3 kdf_demo.py

원본 비밀번호, 생성된 솔트 및 PBKDF2 해시를 보여주는 출력이 표시됩니다. 솔트는 무작위 16 진수 문자열이고 해시도 16 진수 문자열임을 알 수 있습니다. 스크립트를 실행할 때마다 새로운 솔트가 생성되어 동일한 비밀번호에 대해 다른 해시가 생성됩니다.

Password: mysecretpassword
Salt (hex): <random_hex_string>
Hashed Password (hex): <random_hex_string>
Iterations: 100000

이는 KDF 의 핵심 개념을 보여줍니다. 비밀번호와 고유한 솔트를 결합하고 해싱 함수를 여러 번 반복하여 계산 비용이 많이 드는 해시를 생성하는 것입니다.

KDF 로 생성된 해시 식별하기

이 단계에서는 다양한 KDF 로 생성된 해시의 형식을 식별하는 방법을 배우게 됩니다. KDF 의 원시 출력은 이진 문자열일 수 있지만, 시스템에 저장될 때는 종종 인코딩 (예: Base64 또는 16 진수) 되고 사용된 KDF, 솔트, 그리고 때로는 반복 횟수 또는 비용 요인을 나타내는 식별자가 접두사로 붙습니다. 이 표준화된 형식 덕분에 John the Ripper 와 같은 도구는 이를 올바르게 인식하고 처리할 수 있습니다.

PBKDF2, bcrypt, scrypt 의 일반적인 해시 형식을 살펴보겠습니다.

PBKDF2: PBKDF2 해시는 종종 알고리즘, 반복 횟수, 솔트 및 파생된 키를 포함하는 형식으로 나타납니다. 예를 들어, Linux 의 /etc/shadow 파일에서 PBKDF2(특히 SHA512) 해시는 다음과 같이 보일 수 있습니다. $6$rounds=5000$<salt>$<hash> 여기서 $6$은 SHA-512 를 나타내고, rounds=는 반복 횟수를 지정하며, 그 뒤에 솔트와 실제 해시가 옵니다.

bcrypt: bcrypt 해시는 $2a$, $2b$, 또는 $2y$ 접두사로 쉽게 인식할 수 있으며, 그 뒤에 비용 요소 (예: 10), 솔트, 그리고 해시가 옵니다. 예시: $2a$10$<salt><hash>

scrypt: scrypt 해시는 일반적으로 $7$ 또는 $scrypt$로 시작하며, 그 뒤에 ln, r, p(로그 비용, 블록 크기 및 병렬화 계수) 와 같은 매개변수, 솔트, 그리고 해시가 옵니다. 예시: $7$<ln>$<r>$<p>$<salt><hash>

시연을 위해 몇 가지 예제 KDF 해시가 포함된 파일을 생성해 보겠습니다. mkpasswd라는 도구 ( whois 패키지의 일부) 를 사용하여 bcrypt 해시를 생성한 다음, 시연을 위해 PBKDF2 해시를 수동으로 구성할 것입니다.

먼저 mkpasswd를 얻기 위해 whois 패키지를 설치합니다.

sudo apt install -y whois

이제 "password123" 비밀번호에 대해 비용 요소 10 으로 bcrypt 해시를 생성해 보겠습니다.

mkpasswd -m bcrypt -S $(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 16) -s 10 password123

이 명령은 bcrypt 해시를 생성합니다. -S 옵션은 무작위 솔트를 제공하고, -s 10은 비용 요소를 10 으로 설정합니다. 출력은 bcrypt 해시 문자열이 됩니다.

$2a$10$<random_salt_string><hash_string>

이제 John the Ripper 가 읽을 수 있는 kdf_hashes.txt라는 파일을 생성해 보겠습니다. bcrypt 해시와 수동으로 만든 PBKDF2 해시를 포함할 것입니다.

nano kdf_hashes.txt

파일에 다음 내용을 추가합니다. 이전 단계에서 생성한 실제 bcrypt 해시로 <YOUR_GENERATED_BCRYPT_HASH>를 바꾸십시오.

user1:$2a$10$<YOUR_GENERATED_BCRYPT_HASH>
user2:$pbkdf2-sha256$100000$c0ffee$a1b2c3d4e5f678901234567890abcdef1234567890abcdef1234567890abcdef

참고: user2에 대한 PBKDF2 해시는 시연 목적으로 사용된 자리 표시자입니다. 알려진 비밀번호의 실제 해시는 아니지만, John the Ripper 가 PBKDF2-SHA256 에 대해 예상하는 형식을 따릅니다. 형식은 $pbkdf2-sha256$<iterations>$<salt_hex>$<hash_hex>입니다.

파일을 저장하려면 Ctrl+X, Y, Enter를 누릅니다.

이제 John the Ripper 를 사용하여 kdf_hashes.txt의 해시 유형을 식별해 보겠습니다.

john --format=raw-md5 --show kdf_hashes.txt

참고: --show에 대해 형식을 지정해야 하므로 더미 형식으로 --format=raw-md5를 사용합니다. John 은 실제 KDF 형식을 자동으로 감지합니다.

출력은 John 이 해시 유형을 식별하는 것을 보여줍니다.

0 password hashes cracked, 2 left

John 은 두 개의 해시가 있음을 올바르게 식별하며, 이를 크랙하려고 할 때 해당 KDF 유형을 인식할 것입니다. 이 단계는 주로 해시 형식을 인식하는 데 중점을 둡니다.

John the Ripper 의 KDF 지원 관찰하기

이 단계에서는 John the Ripper 가 KDF 로 생성된 해시를 크랙하는 방법을 지원하는지 관찰하게 됩니다. John the Ripper 는 PBKDF2, bcrypt, scrypt 와 같은 다양한 KDF 를 포함하여 광범위한 해시 유형에 대한 내장 지원을 제공합니다. 이러한 해시가 포함된 파일을 John 에게 제공하면, 해당 해시 유형을 자동으로 감지하고 적절한 크랙 알고리즘을 적용합니다.

이전 단계에서 생성한 kdf_hashes.txt 파일을 사용하겠습니다. 간단한 단어 목록 (wordlist) 을 사용하여 user1 해시 (bcrypt) 를 크랙해 보겠습니다.

먼저 "password123"을 포함한 일반적인 비밀번호가 들어 있는 작은 단어 목록 파일인 wordlist.txt를 생성합니다.

nano wordlist.txt

wordlist.txt에 다음 내용을 추가합니다.

test
123456
password
password123
qwerty

파일을 저장하려면 Ctrl+X, Y, Enter를 누릅니다.

이제 John the Ripper 를 사용하여 wordlist.txt를 사용하여 kdf_hashes.txt의 해시를 크랙해 보겠습니다.

john kdf_hashes.txt --wordlist=wordlist.txt

John 은 크랙 프로세스를 시작합니다. "password123"이 단어 목록에 있고 user1의 bcrypt 해시가 이를 기반으로 생성되었으므로, John 은 이를 빠르게 크랙해야 합니다.

Using default input encoding: UTF-8
Loaded 2 password hashes with 2 different salts to test (bcrypt [Blowfish])
Will run 4 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
password123      (user1)
1g 0:00:00:00 DONE (2023-10-27 08:30) 100% <random_speed>c/s <random_speed>p/s <random_speed>L/s <random_speed>PC/s user1
Session completed.

John 이 user1을 성공적으로 크랙하고 비밀번호를 password123으로 식별했음을 볼 수 있습니다. user2 해시 (PBKDF2) 는 해당 "비밀번호"가 단어 목록에 없었기 때문에 크랙되지 않았습니다 (자리 표시자 해시였습니다).

크랙된 비밀번호를 보려면 --show 옵션을 사용할 수 있습니다.

john --show kdf_hashes.txt

출력은 user1에 대한 크랙된 비밀번호를 표시합니다.

user1:password123

1 password hash cracked, 1 left

이는 John the Ripper 가 KDF 해시를 자동으로 감지하고 크랙하는 능력을 보여주며, KDF 를 사용하더라도 강력하고 고유한 비밀번호를 사용하는 것의 중요성을 강조합니다.

KDF 의 계산 비용 이해하기

이 단계에서는 KDF 와 관련된 계산 비용에 대한 더 깊은 이해를 얻고, 이것이 왜 중요한 보안 기능인지 알아보겠습니다. KDF 의 주요 목표는 비밀번호 크랙을 계산적으로 비싸게 만들어, 공격자가 비밀번호를 추측하는 데 필요한 시간과 리소스를 늘리는 것입니다. 이 비용은 반복 횟수 (PBKDF2 의 경우) 또는 비용 요소 (bcrypt 및 scrypt 의 경우) 와 같은 매개변수로 제어됩니다.

이전 단계에서 생성한 kdf_demo.py 스크립트를 다시 살펴보고, bcrypt 및 scrypt 해싱을 포함하도록 수정하여 각 해싱에 걸리는 시간을 관찰해 보겠습니다.

먼저 다양한 KDF 에 대한 구현을 제공하는 passlib 라이브러리를 설치합니다.

pip install passlib

이제 kdf_demo.py를 수정하여 bcrypt 및 scrypt 해싱을 포함시킵니다.

nano kdf_demo.py

기존 내용을 다음 코드로 바꿉니다.

import time
from passlib.hash import pbkdf2_sha256, bcrypt, scrypt

password = "mysecretpassword"

print("--- PBKDF2-SHA256 ---")
start_time = time.time()
## passlib 에서 pbkdf2_sha256 의 기본 반복 횟수는 29000 입니다.
pbkdf2_hash = pbkdf2_sha256.hash(password)
end_time = time.time()
print(f"Hash: {pbkdf2_hash}")
print(f"Time taken: {end_time - start_time:.4f} seconds")
print(f"Iterations: {pbkdf2_sha256.identify(pbkdf2_hash).get('rounds')}")
print("-" * 20)

print("--- bcrypt ---")
start_time = time.time()
## passlib 에서 bcrypt 의 기본 반복 횟수는 12 입니다.
bcrypt_hash = bcrypt.hash(password)
end_time = time.time()
print(f"Hash: {bcrypt_hash}")
print(f"Time taken: {end_time - start_time:.4f} seconds")
print(f"Cost factor (rounds): {bcrypt.identify(bcrypt_hash).get('rounds')}")
print("-" * 20)

print("--- scrypt ---")
start_time = time.time()
## passlib 에서 scrypt 의 기본 매개변수는 N=2^14, r=8, p=1 입니다.
scrypt_hash = scrypt.hash(password)
end_time = time.time()
print(f"Hash: {scrypt_hash}")
print(f"Time taken: {end_time - start_time:.4f} seconds")
print(f"N (CPU/Memory cost): {scrypt.identify(scrypt_hash).get('N')}")
print(f"r (Block size): {scrypt.identify(scrypt_hash).get('r')}")
print(f"p (Parallelization): {scrypt.identify(scrypt_hash).get('p')}")
print("-" * 20)

파일을 저장하려면 Ctrl+X, Y, Enter를 누릅니다.

이제 업데이트된 Python 스크립트를 실행합니다.

python3 kdf_demo.py

출력을 관찰합니다. 각 KDF 에서 생성된 해시와 이를 생성하는 데 걸린 시간을 볼 수 있습니다.

--- PBKDF2-SHA256 ---
Hash: $pbkdf2-sha256$<iterations>$<salt>$<hash>
Time taken: <time> seconds
Iterations: <iterations_value>
--------------------
--- bcrypt ---
Hash: $2a$<cost_factor>$<salt_and_hash>
Time taken: <time> seconds
Cost factor (rounds): <cost_factor_value>
--------------------
--- scrypt ---
Hash: $scrypt$<N>$<r>$<p>$<salt_and_hash>
Time taken: <time> seconds
N (CPU/Memory cost): <N_value>
r (Block size): <r_value>
p (Parallelization): <p_value>
--------------------

단일 해시의 경우에도 측정 가능한 시간 (예: 밀리초) 이 걸린다는 것을 알 수 있습니다. 이것이 의도된 계산 비용입니다. 공격자가 수백만 또는 수십억 개의 비밀번호를 추측하여 비밀번호를 크랙하려고 하면, 해시당 이 작은 지연이 크게 누적되어 무차별 대입 공격을 비실용적으로 만듭니다.

예를 들어, KDF 가 비밀번호를 해싱하는 데 0.1 초가 걸린다면, 초당 1000 번의 추측을 시도하는 공격자는 초당 10 개의 비밀번호만 테스트할 수 있습니다. 이는 간단하고 빠른 해싱 알고리즘에 비해 크랙 프로세스를 극적으로 늦춥니다.

매개변수 (반복 횟수, 비용 요소, N, r, p) 는 이 계산 비용을 늘리거나 줄이도록 조정할 수 있습니다. 시간이 지남에 따라 컴퓨팅 성능이 향상됨에 따라 동일한 수준의 보안을 유지하기 위해 이러한 매개변수를 늘려야 합니다.

안전한 비밀번호 저장을 위한 KDF 구현하기

이 단계에서는 실제 시나리오에서 안전한 비밀번호 저장을 위해 KDF 를 구현하는 방법을 배우게 됩니다. 목표는 비밀번호 자체가 아닌 비밀번호 해시를 저장하고, KDF 를 사용하여 이러한 해시가 크랙에 저항하도록 만드는 것입니다. 여기에는 각 비밀번호에 대한 고유한 솔트 (salt) 생성, 선택한 KDF 및 솔트를 사용한 비밀번호 해싱, 그리고 결과 해시 (솔트 및 KDF 매개변수 포함) 를 데이터베이스 또는 파일에 저장하는 과정이 포함됩니다.

Python 과 passlib 라이브러리를 계속 사용하여 간단한 사용자 등록 및 로그인 시스템을 시뮬레이션할 것입니다.

먼저 ~/project 디렉토리에 있는지 확인합니다.

cd ~/project

이제 secure_auth.py라는 새 Python 스크립트를 생성합니다.

nano secure_auth.py

파일에 다음 Python 코드를 추가합니다. 이 스크립트는 사용자가 비밀번호로 새 사용자를 등록하고 (bcrypt 를 사용하여 해싱됨), 로그인 시도를 검증할 수 있도록 합니다.

from passlib.hash import bcrypt

## 사용자 데이터베이스 시뮬레이션
## 실제 애플리케이션에서는 데이터베이스 (예: SQLite, PostgreSQL) 가 될 것입니다.
user_db = {}

def register_user(username, password):
    """bcrypt 를 사용하여 비밀번호를 해싱하고 저장합니다."""
    if username in user_db:
        print(f"Error: User '{username}' already exists.")
        return False

    ## bcrypt 를 사용하여 비밀번호를 해싱합니다. Passlib 는 솔트 생성 및 비용 요소를 처리합니다.
    hashed_password = bcrypt.hash(password)
    user_db[username] = hashed_password
    print(f"User '{username}' registered successfully.")
    print(f"Stored hash: {hashed_password}")
    return True

def verify_login(username, password):
    """저장된 해시와 비밀번호를 비교하여 검증합니다."""
    if username not in user_db:
        print(f"Login failed: User '{username}' not found.")
        return False

    stored_hash = user_db[username]

    ## bcrypt.verify() 를 사용하여 비밀번호를 검증합니다.
    ## 이 함수는 해시에서 솔트와 비용을 자동으로 추출합니다.
    if bcrypt.verify(password, stored_hash):
        print(f"Login successful for user '{username}'.")
        return True
    else:
        print(f"Login failed: Incorrect password for user '{username}'.")
        return False

## --- 시연 ---
print("--- Registering Users ---")
register_user("alice", "securepassword123")
register_user("bob", "anothersecret")
register_user("alice", "duplicateuser") ## 기존 사용자 등록 시도

print("\n--- Attempting Logins ---")
verify_login("alice", "securepassword123") ## 올바른 비밀번호
verify_login("bob", "wrongpassword")      ## 잘못된 비밀번호
verify_login("charlie", "anypassword")    ## 존재하지 않는 사용자
verify_login("bob", "anothersecret")     ## 올바른 비밀번호

파일을 저장하려면 Ctrl+X, Y, Enter를 누릅니다.

이제 secure_auth.py 스크립트를 실행합니다.

python3 secure_auth.py

출력을 관찰합니다. 등록 프로세스, 생성된 bcrypt 해시 및 로그인 시도 결과를 볼 수 있습니다.

--- Registering Users ---
User 'alice' registered successfully.
Stored hash: $2a$<cost_factor>$<salt_and_hash>
User 'bob' registered successfully.
Stored hash: $2a$<cost_factor>$<salt_and_hash>
Error: User 'alice' already exists.

--- Attempting Logins ---
Login successful for user 'alice'.
Login failed: Incorrect password for user 'bob'.
Login failed: User 'charlie' not found.
Login successful for user 'bob'.

이 스크립트는 KDF 를 사용한 안전한 비밀번호 저장의 기본 원칙을 보여줍니다.

  1. 등록 시 해싱: 사용자가 등록할 때, 평문 비밀번호는 절대 저장되지 않습니다. 대신, 즉시 KDF(이 경우 bcrypt) 를 사용하여 해싱되고, 결과 해시가 저장됩니다. bcrypt.hash() 함수는 자동으로 솔트 생성을 처리하고 기본 비용 요소를 적용합니다.
  2. 로그인 시 검증: 사용자가 로그인하려고 할 때, 제공된 비밀번호는 저장된 해시에서 추출된 *동일한 KDF 및 매개변수 (솔트 및 비용 요소)*를 사용하여 다시 해싱됩니다. 새로 생성된 해시는 저장된 해시와 비교됩니다. 일치하면 로그인이 성공합니다. bcrypt.verify() 함수는 이 프로세스를 단순화합니다.

KDF 를 사용함으로써, 공격자가 user_db(또는 실제 데이터베이스) 에 접근하더라도, 계산 비용이 많이 드는 해시만 접근하게 되므로 원래 비밀번호를 복구하는 것이 훨씬 더 어렵고 느려집니다.

요약

이 실습에서는 키 유도 함수 (KDF) 와 안전한 비밀번호 저장에서 KDF 의 중요한 역할에 대한 포괄적인 이해를 얻었습니다. PBKDF2, bcrypt, scrypt 와 같은 인기 있는 KDF 에 대해 배우고, 고유한 해시 형식과 계산 비용을 제어하는 매개변수를 인식했습니다. 강력한 비밀번호 크래킹 도구인 John the Ripper 가 이러한 KDF 를 지원하는 것을 관찰하여 KDF 를 사용할 때에도 강력한 비밀번호의 중요성을 강조했습니다. 마지막으로 Python 과 passlib 라이브러리를 사용하여 기본적인 안전한 비밀번호 저장 시스템을 구현하여, 비밀번호 해싱 및 검증을 위한 KDF 의 실제 적용을 시연했습니다. 이 실습은 KDF 가 무차별 대입 및 사전 공격을 계산적으로 불가능하게 만드는 데 필수적이며, 사용자 자격 증명의 보안을 크게 향상시킨다는 원칙을 강화했습니다.