Hashing

해싱(Hashing)은 임의의 크기를 가진 데이터를 고정된 크기의 값으로 변환하는 과정이다.
보안 관점에서 해싱은 데이터의 무결성 검증, 비밀번호 저장, 디지털 서명 등 다양한 보안 메커니즘의 핵심 기술로 활용된다.

해싱은 다음과 같은 과정으로 이루어진다:

  1. 원본 데이터(예: 비밀번호, 파일, 메시지 등)를 입력으로 받는다.
  2. 해시 함수(Hash Function)라는 알고리즘을 통해 처리한다.
  3. 고정된 길이의 해시값(Hash Value) 또는 해시 다이제스트(Hash Digest)를 출력한다.

이 해시값은 원본 데이터의 디지털 지문(Digital Fingerprint)이라고 볼 수 있으며, 원본 데이터를 유추하기 어렵다는 특성이 있다.

암호학적 해시 함수의 핵심 특성

보안에 사용되는 암호학적 해시 함수(Cryptographic Hash Function)는 다음과 같은 중요한 특성을 갖추어야 한다:

  1. 일방향성(One-way Function)
    해시 함수는 일방향 함수여야 한다.
    즉, 해시값에서 원본 데이터를 계산적으로 복구하는 것이 불가능해야 한다.
    이 특성은 비밀번호 저장과 같은 보안 응용에서 매우 중요하다.

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    
    # 일방향성 예시 - 비밀번호 해싱
    import hashlib
    
    def hash_password(password):
        # SHA-256 해시 함수 사용
        hashed = hashlib.sha256(password.encode()).hexdigest()
        return hashed
    
    # 사용자 비밀번호를 해싱하여 저장
    user_password = "MySecretPassword123"
    stored_hash = hash_password(user_password)
    print(f"저장된 해시값: {stored_hash}")
    
    # 해시값에서 원본 비밀번호를 복구할 수 없음
    # 검증은 항상 동일한 해싱 과정을 통해 이루어짐
    
  2. 충돌 저항성(Collision Resistance)
    충돌 저항성은 서로 다른 두 입력이 동일한 해시값을 생성할 가능성이 매우 낮아야 한다는 것을 의미한다.
    충돌 저항성은 두 가지 유형으로 나뉜다:

    1. 강한 충돌 저항성(Strong Collision Resistance): 동일한 해시값을 생성하는 두 개의 서로 다른, 임의의 입력을 찾는 것이 계산적으로 불가능해야 한다.
    2. 약한 충돌 저항성(Weak Collision Resistance): 주어진 입력 x에 대해, 동일한 해시값을 생성하는 다른 입력 y를 찾는 것이 계산적으로 불가능해야 한다.
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    
    # 충돌 가능성 예시
    import hashlib
    import random
    import string
    
    def generate_random_string(length=10):
        """무작위 문자열 생성"""
        letters = string.ascii_lowercase
        return ''.join(random.choice(letters) for _ in range(length))
    
    # 10,000개의 무작위 문자열에 대한 해시값 생성
    hash_values = set()
    collisions = 0
    total_strings = 10000
    
    for _ in range(total_strings):
        random_string = generate_random_string()
        hash_value = hashlib.md5(random_string.encode()).hexdigest()
    
        if hash_value in hash_values:
            collisions += 1
        else:
            hash_values.add(hash_value)
    
    print(f"생성된 문자열 수: {total_strings}")
    print(f"고유한 해시값 수: {len(hash_values)}")
    print(f"충돌 수: {collisions}")
    print(f"충돌 확률: {collisions/total_strings:f}")
    

    위의 코드는 MD5 해시 함수에서 충돌이 얼마나 발생하는지 보여주는 예시이다.
    현대의 보안 응용에서는 MD5가 안전하지 않다고 간주되므로 사용을 피해야 한다.

  3. 예측 불가능성(Unpredictability)
    해시 함수는 예측 불가능해야 한다.
    즉, 입력값이 조금만 변경되어도 출력 해시값이 완전히 달라져야 한다.
    이러한 특성을 ‘눈사태 효과(Avalanche Effect)‘라고도 한다.

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    
    # 눈사태 효과 예시
    import hashlib
    
    def show_avalanche_effect(text1, text2):
        """두 문자열의 해시값 차이를 비트 단위로 계산"""
        hash1 = hashlib.sha256(text1.encode()).digest()
        hash2 = hashlib.sha256(text2.encode()).digest()
    
        # 바이트 단위로 비교하여 다른 비트 수 계산
        different_bits = 0
        for b1, b2 in zip(hash1, hash2):
            # XOR 연산으로 다른 비트 확인
            xor_result = b1 ^ b2
            # 비트 수 계산
            different_bits += bin(xor_result).count('1')
    
        total_bits = len(hash1) * 8
        difference_percentage = (different_bits / total_bits) * 100
    
        print(f"문자열1: {text1}")
        print(f"해시값1: {hashlib.sha256(text1.encode()).hexdigest()}")
        print(f"문자열2: {text2}")
        print(f"해시값2: {hashlib.sha256(text2.encode()).hexdigest()}")
        print(f"총 비트 수: {total_bits}")
        print(f"다른 비트 수: {different_bits}")
        print(f"차이 비율: {difference_percentage:f}%")
    
    # 한 글자만 다른 두 문자열 비교
    show_avalanche_effect("Hello, World!", "Hello, World.")
    

    위 코드는 입력값이 조금만 변경되어도(마지막 문자가 ‘!‘에서 ‘.‘로) 해시값이 크게 변경됨을 보여준다.

주요 암호학적 해시 알고리즘

보안에 사용되는 주요 해시 알고리즘을 살펴보면:

MD5 (Message Digest Algorithm 5)

MD5는 128비트(16바이트) 해시값을 생성하는 알고리즘으로, 한때 널리 사용되었지만 현재는 보안상의 취약점이 발견되어 보안 목적으로는 사용하지 않는 것이 좋다.

1
2
3
4
5
import hashlib

data = "안녕하세요, 이것은 MD5 해시 예제입니다."
md5_hash = hashlib.md5(data.encode()).hexdigest()
print(f"MD5 해시: {md5_hash}")

SHA-1 (Secure Hash Algorithm 1)

SHA-1은 160비트(20바이트) 해시값을 생성하는 알고리즘이다.
MD5와 마찬가지로 취약점이 발견되어 보안 목적으로는 권장되지 않는다.

1
2
3
4
5
import hashlib

data = "안녕하세요, 이것은 SHA-1 해시 예제입니다."
sha1_hash = hashlib.sha1(data.encode()).hexdigest()
print(f"SHA-1 해시: {sha1_hash}")

SHA-2 계열 (SHA-256, SHA-384, SHA-512)

SHA-2는 여러 변형이 있으며, 그 중 SHA-256(32바이트), SHA-384(48바이트), SHA-512(64바이트)가 널리 사용된다.
현재 많은 보안 응용 프로그램에서 표준으로 사용되고 있다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import hashlib

data = "안녕하세요, 이것은 SHA-256 해시 예제입니다."

# SHA-256 (32바이트, 64자 16진수)
sha256_hash = hashlib.sha256(data.encode()).hexdigest()
print(f"SHA-256 해시: {sha256_hash}")

# SHA-384 (48바이트, 96자 16진수)
sha384_hash = hashlib.sha384(data.encode()).hexdigest()
print(f"SHA-384 해시: {sha384_hash}")

# SHA-512 (64바이트, 128자 16진수)
sha512_hash = hashlib.sha512(data.encode()).hexdigest()
print(f"SHA-512 해시: {sha512_hash}")

SHA-3 계열

SHA-3는 SHA-2의 후속으로 개발된 해시 알고리즘 가족으로, 이전 SHA 알고리즘과는 완전히 다른 내부 구조를 가지고 있어 같은 취약점의 영향을 받지 않는다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import hashlib

data = "안녕하세요, 이것은 SHA-3 해시 예제입니다."

# SHA3-256
sha3_256_hash = hashlib.sha3_256(data.encode()).hexdigest()
print(f"SHA3-256 해시: {sha3_256_hash}")

# SHA3-512
sha3_512_hash = hashlib.sha3_512(data.encode()).hexdigest()
print(f"SHA3-512 해시: {sha3_512_hash}")

BLAKE2

BLAKE2는 속도와 보안을 모두 고려한 현대적인 해시 알고리즘으로, 다양한 크기의 출력을 지원한다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import hashlib

data = "안녕하세요, 이것은 BLAKE2 해시 예제입니다."

# BLAKE2b (64바이트)
blake2b_hash = hashlib.blake2b(data.encode()).hexdigest()
print(f"BLAKE2b 해시: {blake2b_hash}")

# BLAKE2s (32바이트)
blake2s_hash = hashlib.blake2s(data.encode()).hexdigest()
print(f"BLAKE2s 해시: {blake2s_hash}")

보안 응용에서의 해싱

해싱은 다양한 보안 분야에서 활용된다:

비밀번호 저장

안전한 비밀번호 저장을 위해서는 단순 해싱이 아닌, 솔트(Salt)와 키 스트레칭(Key Stretching)을 조합한 방법을 사용한다.

솔트(Salt)

솔트는 해시 전에 비밀번호에 추가되는 무작위 값으로, 동일한 비밀번호라도 다른 해시값을 갖게 하여 레인보우 테이블 공격 등을 방지한다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import hashlib
import os

def hash_password_with_salt(password):
    # 16바이트(128비트) 무작위 솔트 생성
    salt = os.urandom(16)
    
    # 비밀번호와 솔트 결합 후 해싱
    password_hash = hashlib.pbkdf2_hmac(
        'sha256',
        password.encode(),
        salt,
        100000  # 반복 횟수
    )
    
    # 솔트와 해시를 함께 저장 (나중에 검증 시 필요)
    return salt + password_hash

def verify_password(stored_password_hash, password_to_check):
    # 저장된 해시에서 솔트 추출 (처음 16바이트)
    salt = stored_password_hash[:16]
    stored_hash = stored_password_hash[16:]
    
    # 입력된 비밀번호를 동일한 방식으로 해싱
    password_hash = hashlib.pbkdf2_hmac(
        'sha256',
        password_to_check.encode(),
        salt,
        100000  # 원래 해싱과 동일한 반복 횟수
    )
    
    # 저장된 해시와 계산된 해시 비교
    return stored_hash == password_hash

# 사용 예시
user_password = "MySecurePassword123"
stored_hash = hash_password_with_salt(user_password)
print(f"저장된 해시 (솔트 + 해시): {stored_hash.hex()}")

# 올바른 비밀번호 검증
is_valid = verify_password(stored_hash, user_password)
print(f"비밀번호 검증 결과 (올바른 비밀번호): {is_valid}")

# 잘못된 비밀번호 검증
is_valid = verify_password(stored_hash, "WrongPassword123")
print(f"비밀번호 검증 결과 (잘못된 비밀번호): {is_valid}")
키 스트레칭(Key Stretching)

키 스트레칭은 해시 함수를 여러 번 반복 적용하여 해싱 과정을 의도적으로 느리게 만드는 기법으로, 무차별 대입 공격(Brute Force Attack)을 어렵게 만든다.
PBKDF2, bcrypt, Argon2와 같은 알고리즘이 대표적이다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import hashlib
import os
import time

def benchmark_key_stretching(password, iterations_list):
    """다양한 반복 횟수에 따른 해싱 시간 측정"""
    salt = os.urandom(16)
    
    for iterations in iterations_list:
        start_time = time.time()
        
        hashlib.pbkdf2_hmac(
            'sha256',
            password.encode(),
            salt,
            iterations
        )
        
        end_time = time.time()
        elapsed = end_time - start_time
        
        print(f"반복 횟수 {iterations:,}: {elapsed:f}초 소요")

# 다양한 반복 횟수로 성능 측정
password = "test_password"
iterations = [1, 1000, 10000, 100000, 500000]
benchmark_key_stretching(password, iterations)

위 코드는 다양한 반복 횟수에 따른 해싱 시간을 측정한다.
반복 횟수를 늘릴수록 해싱 시간이 선형적으로 증가하며, 이는 공격자의 무차별 대입 공격을 그만큼 어렵게 만든다.

현대적인 비밀번호 해싱 알고리즘

현대적인 비밀번호 저장을 위해서는 PBKDF2, bcrypt, scrypt, Argon2와 같은 전문 알고리즘을 사용하는 것이 권장된다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# bcrypt 예시 (설치 필요: pip install bcrypt)
import bcrypt

def hash_password_bcrypt(password):
    # bcrypt는 내부적으로 솔트를 생성하고 관리
    password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))
    return password_hash

def verify_password_bcrypt(stored_hash, password_to_check):
    # bcrypt는 내부적으로 솔트를 추출하고 검증
    return bcrypt.checkpw(password_to_check.encode(), stored_hash)

# 사용 예시
user_password = "MySecurePassword123"
hashed = hash_password_bcrypt(user_password)
print(f"bcrypt 해시: {hashed}")

# 검증
is_valid = verify_password_bcrypt(hashed, user_password)
print(f"비밀번호 검증 결과: {is_valid}")

데이터 무결성 검증

해싱은 파일이나 메시지가 변조되지 않았는지 확인하는 데 사용된다.
원본 데이터의 해시값을 계산하여 저장해두고, 나중에 동일한 해시값이 생성되는지 확인하는 방식이다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import hashlib

def calculate_file_hash(filename, algorithm='sha256'):
    """파일의 해시값 계산"""
    hash_obj = hashlib.new(algorithm)
    
    with open(filename, 'rb') as f:
        # 메모리 효율성을 위해 청크 단위로 읽기
        chunk = f.read(4096)
        while chunk:
            hash_obj.update(chunk)
            chunk = f.read(4096)
    
    return hash_obj.hexdigest()

def verify_file_integrity(filename, expected_hash, algorithm='sha256'):
    """파일의 무결성 검증"""
    calculated_hash = calculate_file_hash(filename, algorithm)
    return calculated_hash == expected_hash

# 사용 예시 (파일이 있다고 가정)
# file_hash = calculate_file_hash('example.txt')
# print(f"파일 해시: {file_hash}")
#
# # 나중에 파일 무결성 검증
# is_valid = verify_file_integrity('example.txt', file_hash)
# print(f"파일 무결성: {is_valid}")

디지털 서명

해시 함수는 디지털 서명 과정에서 중요한 역할을 한다.
전체 메시지가 아닌 메시지의 해시값에 서명함으로써 효율성을 높인다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from cryptography.exceptions import InvalidSignature

def generate_keys():
    """RSA 키 쌍 생성"""
    private_key = rsa.generate_private_key(
        public_exponent=65537,
        key_size=2048
    )
    public_key = private_key.public_key()
    return private_key, public_key

def sign_message(message, private_key):
    """메시지에 디지털 서명"""
    signature = private_key.sign(
        message.encode(),
        padding.PSS(
            mgf=padding.MGF1(hashes.SHA256()),
            salt_length=padding.PSS.MAX_LENGTH
        ),
        hashes.SHA256()
    )
    return signature

def verify_signature(message, signature, public_key):
    """디지털 서명 검증"""
    try:
        public_key.verify(
            signature,
            message.encode(),
            padding.PSS(
                mgf=padding.MGF1(hashes.SHA256()),
                salt_length=padding.PSS.MAX_LENGTH
            ),
            hashes.SHA256()
        )
        return True
    except InvalidSignature:
        return False

# 사용 예시
private_key, public_key = generate_keys()

# 메시지에 서명
message = "중요한 메시지입니다. 이 메시지는 변조되지 않아야 합니다."
signature = sign_message(message, private_key)
print(f"서명: {signature.hex()}")

# 서명 검증
is_valid = verify_signature(message, signature, public_key)
print(f"서명 검증 결과: {is_valid}")

# 변조된 메시지 검증
tampered_message = "변조된 메시지입니다. 이 메시지는 검증에 실패해야 합니다."
is_valid = verify_signature(tampered_message, signature, public_key)
print(f"변조된 메시지 검증 결과: {is_valid}")

4.4. HMAC (Hash-based Message Authentication Code)

HMAC은 메시지 인증에 사용되는 기법으로, 비밀 키와 해시 함수를 조합하여 메시지의 무결성과 출처를 동시에 검증한다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import hmac
import hashlib
import os

def create_hmac(message, key=None):
    """메시지에 대한 HMAC 생성"""
    if key is None:
        # 키가 제공되지 않은 경우 무작위 키 생성
        key = os.urandom(32)
    
    # HMAC 생성
    message_hmac = hmac.new(key, message.encode(), hashlib.sha256).digest()
    
    return message_hmac, key

def verify_hmac(message, message_hmac, key):
    """HMAC 검증"""
    # 동일한 키와 해시 함수로 HMAC 재계산
    calculated_hmac = hmac.new(key, message.encode(), hashlib.sha256).digest()
    
    # 상수 시간 비교로 타이밍 공격 방지
    return hmac.compare_digest(message_hmac, calculated_hmac)

# 사용 예시
message = "API 요청 데이터: user_id=123&action=update_profile"

# HMAC 생성
message_hmac, key = create_hmac(message)
print(f"메시지: {message}")
print(f"HMAC: {message_hmac.hex()}")
print(f"키: {key.hex()}")

# HMAC 검증
is_valid = verify_hmac(message, message_hmac, key)
print(f"HMAC 검증 결과: {is_valid}")

# 변조된 메시지 검증
tampered_message = "API 요청 데이터: user_id=456&action=delete_account"
is_valid = verify_hmac(tampered_message, message_hmac, key)
print(f"변조된 메시지 HMAC 검증 결과: {is_valid}")

HMAC은 API 인증, 토큰 검증, 세션 무결성 보장 등에 널리 사용된다.

해싱 관련 보안 위협 및 대응

레인보우 테이블 공격(Rainbow Table Attack)

미리 계산된 해시값과 원본 값을 테이블로 저장해 놓고, 이를 통해 해시값으로부터 원본을 찾아내는 공격 방식이다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 솔트를 사용한 방어 예시
import hashlib
import os

# 솔트 없이 단순 해싱 - 취약함
def simple_hash(password):
    return hashlib.sha256(password.encode()).hexdigest()

# 솔트를 사용한 해싱 - 레인보우 테이블 공격에 강함
def salted_hash(password):
    salt = os.urandom(16).hex()
    hash_value = hashlib.sha256((password + salt).encode()).hexdigest()
    return f"{salt}${hash_value}"

# 동일한 비밀번호에 대한 해시값 비교
password = "password123"

# 솔트 없는 해시 - 항상 동일한 값 생성
simple_hash1 = simple_hash(password)
simple_hash2 = simple_hash(password)
print(f"솔트 없는 해시 #1: {simple_hash1}")
print(f"솔트 없는 해시 #2: {simple_hash2}")
print(f"해시값 동일?: {simple_hash1 == simple_hash2}")

# 솔트 있는 해시 - 매번 다른 값 생성
salted_hash1 = salted_hash(password)
salted_hash2 = salted_hash(password)
print(f"솔트 있는 해시 #1: {salted_hash1}")
print(f"솔트 있는 해시 #2: {salted_hash2}")
print(f"해시값 동일?: {salted_hash1 == salted_hash2}")

무차별 대입 공격(Brute Force Attack)

가능한 모든 입력값을 시도하여 해시값이 일치하는 원본을 찾아내는 공격 방식.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# 키 스트레칭을 사용한 방어 예시
import hashlib
import os
import time

def measure_attack_time(hash_function, iterations):
    """해시 함수의 계산 시간 측정으로 무차별 대입 공격 시간 추정"""
    password = "test123"
    
    # 해싱 시간 측정
    start_time = time.time()
    for _ in range(iterations):
        hash_function(password)
    end_time = time.time()
    
    single_hash_time = (end_time - start_time) / iterations
    
    # 가능한 모든 6자리 숫자 비밀번호를 시도하는 데 걸리는 시간 추정
    possible_passwords = 10 ** 6  # 000000 ~ 999999
    total_time_seconds = single_hash_time * possible_passwords
    
    # 시간 포맷팅
    days = total_time_seconds // (24 * 3600)
    remaining = total_time_seconds % (24 * 3600)
    hours = remaining // 3600
    remaining %= 3600
    minutes = remaining // 60
    seconds = remaining % 60
    
    print(f"단일 해시 시간: {single_hash_time:f}초")
    print(f"100만 개 비밀번호 시도 예상 시간: {days:f}{hours:f}시간 {minutes:f}{seconds:f}초")

# 단순 SHA-256 해싱 (빠름)
def simple_hash(password):
    return hashlib.sha256(password.encode()).hexdigest()

# PBKDF2를 사용한 키 스트레칭 (느림)
def stretched_hash(password, iterations=100000):
    salt = b'fixed_salt_for_demo'  # 실제로는 무작위 솔트 사용
    return hashlib.pbkdf2_hmac('sha256', password.encode(), salt, iterations).hex()

# 각 함수의 무차별 대입 공격 시간 측정
print("단순 SHA-256 해싱:")
measure_attack_time(simple_hash, 10000)

print("\nPBKDF2 키 스트레칭 (10만 번 반복):")
# 실행 시간이 오래 걸리므로 적은 수의 반복으로 측정
iterations = 10
stretched_hash_with_fixed_iterations = lambda p: stretched_hash(p, 100000)
measure_attack_time(stretched_hash_with_fixed_iterations, iterations)

해시 충돌 공격(Hash Collision Attack)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# 다양한 해시 알고리즘의 충돌 저항성 비교 (이론적)
def collision_resistance_comparison():
    algorithms = [
        ("MD5", 128, "약함 - 충돌 발견됨"),
        ("SHA-1", 160, "약함 - 충돌 발견됨"),
        ("SHA-256", 256, "강함 - 실용적인 충돌 공격 없음"),
        ("SHA-3-256", 256, "강함 - 구조적으로 다른 설계로 더 안전"),
        ("BLAKE2b", 512, "강함 - 고성능 및 높은 보안성")
    ]
    
    print("해시 알고리즘 충돌 저항성 비교:")
    print("| 알고리즘 | 비트 길이 | 충돌 저항성 | 이론적 공격 복잡도 |")
    print("|---------|----------|------------|-------------------|")
    
    for algo, bits, strength in algorithms:
        # 생일 역설에 따른 충돌 공격 복잡도: 2^(n/2)
        complexity = 2 ** (bits / 2)
        print(f"| {algo} | {bits} | {strength} | 2^{bits/2}{complexity:e} |")

collision_resistance_comparison()

길이 확장 공격(Length Extension Attack)

일부 해시 함수(SHA-1, SHA-2 등)는 메르켈-담가드(Merkle-Damgård) 구조를 사용하여, 원본 메시지와 해시값을 알면 추가 데이터를 덧붙인 새로운 메시지의 해시값을 계산할 수 있다. 이를 이용한 공격이 길이 확장 공격.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import hmac
import hashlib

def compare_hash_vs_hmac():
    """단순 해싱과 HMAC의 길이 확장 공격 취약성 비교"""
    secret_key = b"very_secret_key"
    original_message = b"original_data"
    
    # 단순 해싱 (길이 확장 공격에 취약)
    simple_hash = hashlib.sha256(secret_key + original_message).hexdigest()
    
    # HMAC (길이 확장 공격에 안전)
    message_hmac = hmac.new(secret_key, original_message, hashlib.sha256).hexdigest()
    
    print("단순 해싱 vs HMAC 비교:")
    print(f"단순 해싱: {simple_hash}")
    print(f"HMAC: {message_hmac}")
    print("\n단순 해싱은 키를 메시지 앞에 연결하여 길이 확장 공격에 취약합니다.")
    print("HMAC은 키를 특별한 방식으로 처리하여 길이 확장 공격에 안전합니다.")

compare_hash_vs_hmac()

길이 확장 공격은 웹 애플리케이션의 인증 토큰, 서명된 데이터 등에 영향을 줄 수 있다.

실제 개발에서의 해싱 모범 사례

개발자가 보안 관련 해싱을 구현할 때 따라야 할 모범 사례:

비밀번호 저장 모범 사례

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import hashlib
import os
import hmac
import base64

class PasswordManager:
    """안전한 비밀번호 관리를 위한 클래스"""
    
    def __init__(self, pepper=None):
        """
        초기화
        pepper: 모든 비밀번호에 추가되는 비밀 값 (서버측 비밀)
        """
        # 페퍼가 제공되지 않으면 무작위로 생성 (실제로는 환경 변수나 설정에서 가져와야 함)
        self.pepper = pepper or os.urandom(16)
    
    def hash_password(self, password, iterations=100000):
        """
        안전한 비밀번호 해싱
        
        password: 해싱할 비밀번호
        iterations: 반복 횟수 (키 스트레칭)
        
        반환: 솔트와 해시를 포함한 문자열 (형식: {알고리즘}${반복횟수}${솔트}${해시})
        """
        # 무작위 솔트 생성
        salt = os.urandom(16)
        
        # PBKDF2 해싱 수행
        password_hash = hashlib.pbkdf2_hmac(
            'sha256',
            password.encode() + self.pepper,  # 페퍼 추가
            salt,
            iterations
        )
        
        # Base64로 인코딩
        salt_b64 = base64.b64encode(salt).decode('utf-8')
        hash_b64 = base64.b64encode(password_hash).decode('utf-8')
        
        # 포맷: {알고리즘}${반복횟수}${솔트}${해시}
        return f"pbkdf2_sha256${iterations}${salt_b64}${hash_b64}"
    
    def verify_password(self, stored_password_hash, password_to_check):
        """
        비밀번호 검증
        
        stored_password_hash: hash_password로 생성된 해시 문자열
        password_to_check: 검증할 비밀번호
        
        반환: 비밀번호가 일치하면 True, 그렇지 않으면 False
        """
        # 저장된 해시에서 정보 추출
        try:
            algorithm, iterations, salt_b64, hash_b64 = stored_password_hash.split('$')
            
            # Base64 디코딩
            salt = base64.b64decode(salt_b64)
            stored_hash = base64.b64decode(hash_b64)
            
            # 반복 횟수를 정수로 변환
            iterations = int(iterations)
            
            # 동일한 방식으로 해싱
            calculated_hash = hashlib.pbkdf2_hmac(
                'sha256',
                password_to_check.encode() + self.pepper,
                salt,
                iterations
            )
            
            # 해시 비교 (상수 시간 비교로 타이밍 공격 방지)
            return hmac.compare_digest(stored_hash, calculated_hash)
        
        except (ValueError, IndexError):
            # 잘못된 형식의 해시
            return False

# 사용 예시
password_manager = PasswordManager()

# 비밀번호 해싱
user_password = "Secure_Password123!"
hashed_password = password_manager.hash_password(user_password)
print(f"해시된 비밀번호: {hashed_password}")

# 비밀번호 검증
is_valid = password_manager.verify_password(hashed_password, user_password)
print(f"올바른 비밀번호 검증 결과: {is_valid}")

is_valid = password_manager.verify_password(hashed_password, "Wrong_Password123!")
print(f"잘못된 비밀번호 검증 결과: {is_valid}")

이 코드는 비밀번호 저장을 위한 여러 보안 기법을 보여준다:

API 인증을 위한 HMAC 사용

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
import hmac
import hashlib
import time
import base64

class ApiAuthenticator:
    """API 요청 인증을 위한 HMAC 기반 클래스"""
    
    def __init__(self, api_key, api_secret):
        """
        초기화
        api_key: API 키 (클라이언트 식별)
        api_secret: API 비밀 키 (서명 생성용)
        """
        self.api_key = api_key
        self.api_secret = api_secret.encode('utf-8')
    
    def generate_signature(self, method, endpoint, params, timestamp=None):
        """
        API 요청에 대한 HMAC 서명 생성
        
        method: HTTP 메서드 (GET, POST 등)
        endpoint: API 엔드포인트 경로
        params: 요청 파라미터 딕셔너리
        timestamp: 타임스탬프 (None인 경우 현재 시간 사용)
        
        반환: Base64로 인코딩된 HMAC 서명
        """
        # 타임스탬프가 제공되지 않으면 현재 시간 사용
        if timestamp is None:
            timestamp = int(time.time())
        
        # 정렬된 파라미터 문자열로 변환
        sorted_params = '&'.join(f"{k}={v}" for k, v in sorted(params.items()))
        
        # 서명 대상 문자열 생성: method + endpoint + params + timestamp
        message = f"{method.upper()}{endpoint}{sorted_params}{timestamp}"
        
        # HMAC-SHA256 서명 생성
        signature = hmac.new(
            self.api_secret,
            message.encode('utf-8'),
            hashlib.sha256
        ).digest()
        
        # Base64로 인코딩
        return base64.b64encode(signature).decode('utf-8')
    
    def verify_signature(self, signature, method, endpoint, params, timestamp):
        """
        API 요청 서명 검증
        
        signature: 검증할 서명
        method, endpoint, params, timestamp: 원본 요청 정보
        
        반환: 서명이 일치하면 True, 그렇지 않으면 False
        """
        expected_signature = self.generate_signature(method, endpoint, params, timestamp)
        
        # 상수 시간 비교로 타이밍 공격 방지
        return hmac.compare_digest(signature, expected_signature)

# 사용 예시
api_key = "user123"
api_secret = "very_secret_api_key"
authenticator = ApiAuthenticator(api_key, api_secret)

# API 요청 정보
method = "POST"
endpoint = "/api/v1/orders"
params = {
    "product_id": "12345",
    "quantity": "2",
    "price": "99.99"
}
timestamp = int(time.time())

# 서명 생성 (클라이언트 측)
signature = authenticator.generate_signature(method, endpoint, params, timestamp)
print(f"생성된 서명: {signature}")

# 서명 검증 (서버 측)
is_valid = authenticator.verify_signature(signature, method, endpoint, params, timestamp)
print(f"서명 검증 결과: {is_valid}")

# 변조된 요청 검증
tampered_params = params.copy()
tampered_params["quantity"] = "10"  # 수량을 2에서 10으로 변경
is_valid = authenticator.verify_signature(signature, method, endpoint, tampered_params, timestamp)
print(f"변조된 요청 검증 결과: {is_valid}")

이 코드는 API 요청의 무결성과 출처를 검증하기 위한 HMAC 기반 인증 시스템을 보여준다.
클라이언트가 API 요청을 보낼 때 서명을 함께 제공하고, 서버는 이 서명을 검증하여 요청이 변조되지 않았는지 확인한다.

파일 무결성 검증을 위한 체크섬(Checksum)

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
import hashlib
import json
import os
from datetime import datetime

class IntegrityVerifier:
    """파일 무결성 검증을 위한 클래스"""
    
    def __init__(self, hash_algorithm='sha256'):
        """
        초기화
        hash_algorithm: 사용할 해시 알고리즘
        """
        self.hash_algorithm = hash_algorithm
    
    def calculate_file_hash(self, file_path):
        """
        파일의 해시값 계산
        
        file_path: 해시를 계산할 파일 경로
        
        반환: 파일의 해시값 (16진수 문자열)
        """
        hash_obj = hashlib.new(self.hash_algorithm)
        
        with open(file_path, 'rb') as f:
            # 메모리 효율성을 위해 청크 단위로 읽기
            chunk = f.read(8192)
            while chunk:
                hash_obj.update(chunk)
                chunk = f.read(8192)
        
        return hash_obj.hexdigest()
    
    def generate_manifest(self, directory, output_file='integrity_manifest.json'):
        """
        디렉토리 내 모든 파일의 해시값이 포함된 무결성 매니페스트 생성
        
        directory: 파일이 있는 디렉토리 경로
        output_file: 매니페스트를 저장할 파일 경로
        
        반환: 매니페스트 데이터
        """
        manifest = {
            'created_at': datetime.now().isoformat(),
            'hash_algorithm': self.hash_algorithm,
            'files': {}
        }
        
        # 디렉토리 내 모든 파일 처리
        for root, _, files in os.walk(directory):
            for filename in files:
                # 매니페스트 파일 자체는 제외
                if filename == os.path.basename(output_file):
                    continue
                
                file_path = os.path.join(root, filename)
                # 디렉토리 경로에 상대적인 파일 경로
                relative_path = os.path.relpath(file_path, directory)
                
                try:
                    file_hash = self.calculate_file_hash(file_path)
                    file_size = os.path.getsize(file_path)
                    manifest['files'][relative_path] = {
                        'hash': file_hash,
                        'size': file_size
                    }
                except (IOError, OSError) as e:
                    print(f"파일 처리 중 오류 발생: {file_path}, {e}")
        
        # 매니페스트 파일 저장
        with open(output_file, 'w') as f:
            json.dump(manifest, f, indent=2)
        
        return manifest
    
    def verify_integrity(self, directory, manifest_file='integrity_manifest.json'):
        """
        매니페스트를 기반으로 파일 무결성 검증
        
        directory: 검증할 파일이 있는 디렉토리
        manifest_file: 매니페스트 파일 경로
        
        반환: 검증 결과 딕셔너리
        """
        # 매니페스트 파일 로드
        try:
            with open(manifest_file, 'r') as f:
                manifest = json.load(f)
        except (IOError, json.JSONDecodeError) as e:
            return {'status': 'error', 'message': f"매니페스트 파일 로드 오류: {e}"}
        
        results = {
            'status': 'success',
            'algorithm': manifest.get('hash_algorithm', 'unknown'),
            'verified_at': datetime.now().isoformat(),
            'total_files': len(manifest.get('files', {})),
            'verified_files': 0,
            'modified_files': [],
            'missing_files': [],
            'details': {}
        }
        
        # 각 파일 검증
        for relative_path, file_info in manifest.get('files', {}).items():
            file_path = os.path.join(directory, relative_path)
            expected_hash = file_info.get('hash')
            
            if not os.path.exists(file_path):
                results['missing_files'].append(relative_path)
                results['details'][relative_path] = {
                    'status': 'missing',
                    'expected_hash': expected_hash
                }
                continue
            
            try:
                actual_hash = self.calculate_file_hash(file_path)
                if actual_hash == expected_hash:
                    results['verified_files'] += 1
                    results['details'][relative_path] = {
                        'status': 'verified',
                        'hash': actual_hash
                    }
                else:
                    results['modified_files'].append(relative_path)
                    results['details'][relative_path] = {
                        'status': 'modified',
                        'expected_hash': expected_hash,
                        'actual_hash': actual_hash
                    }
            except (IOError, OSError) as e:
                results['details'][relative_path] = {
                    'status': 'error',
                    'message': str(e)
                }
        
        # 검증 요약 계산
        if results['missing_files'] or results['modified_files']:
            results['status'] = 'failed'
        
        return results

# 사용 예시 (실제 파일이 있다고 가정)
'''
verifier = IntegrityVerifier()

# 무결성 매니페스트 생성
print("무결성 매니페스트 생성 중…")
manifest = verifier.generate_manifest("./my_important_files")

# 나중에 무결성 검증
print("\n파일 무결성 검증 중…")
results = verifier.verify_integrity("./my_important_files")

if results['status'] == 'success':
    print("모든 파일이 검증되었습니다.")
else:
    print("무결성 검증 실패!")
    if results['modified_files']:
        print(f"변경된 파일: {results['modified_files']}")
    if results['missing_files']:
        print(f"누락된 파일: {results['missing_files']}")
'''

이 코드는 디렉토리 내 파일들의 무결성을 검증하기 위한 시스템을 보여준다.
초기에 모든 파일의 해시값이 포함된 매니페스트를 생성하고, 나중에 이 매니페스트를 사용하여 파일이 변경되거나 삭제되었는지 확인할 수 있다.

개발자를 위한 해싱 보안 지침

보안 관점에서 해싱을 효과적으로 활용하기 위한 핵심 지침

  1. 항상 최신 알고리즘 사용: 최신 보안 권장사항에 따라 강력한 해시 알고리즘(SHA-256, SHA-3, BLAKE2)을 사용한다.
  2. 비밀번호는 특수 함수로 해싱: 일반 해시 함수 대신 비밀번호에 특화된 함수(bcrypt, Argon2, PBKDF2)를 사용한다.
  3. 무작위 솔트 사용: 모든 해싱 작업에 충분히 길고 무작위적인 솔트를 사용한다.
  4. 키 스트레칭 적용: 충분한 반복 횟수로 해싱 과정을 느리게 만들어 무차별 대입 공격을 어렵게 만든다.
  5. 상수 시간 비교: 해시 비교 시 항상 상수 시간 비교 함수를 사용하여 타이밍 공격을 방지한다.
  6. 보안 모범 사례 준수: 암호화 키와 솔트를 안전하게 관리하고, 정기적으로 보안 업데이트를 적용한다.
  7. 라이브러리 활용: 가능하면 직접 구현하기보다 검증된 보안 라이브러리를 활용한다.
  8. 데이터 무결성 확인: 중요한 파일이나 메시지에 해시 기반 무결성 검증을 적용한다.
  9. 표준 포맷 사용: 해시값, 솔트, 알고리즘 정보 등을 표준화된 형식으로 저장한다.
  10. 정기적인 보안 감사: 해싱 관련 코드와 구현을 정기적으로 감사하고 개선한다.

개발자는 이러한 지침을 따름으로써 애플리케이션의 보안을 크게 향상시킬 수 있다.
해싱은 적절하게 구현될 때 데이터 보호와 인증의 강력한 도구가 된다.

해싱의 미래와 발전 방향

보안 해싱 기술은 계속 발전하고 있다.

  1. 양자 저항성 해시 함수
    양자 컴퓨터가 발전함에 따라 현재의 암호화 알고리즘이 취약해질 가능성이 있다. 이에 대비하여 양자 컴퓨터의 공격에도 안전한 양자 저항성(Quantum-Resistant) 해시 함수를 개발하는 연구가 진행되고 있다.
    양자 컴퓨터는 Grover의 알고리즘을 통해 현재 해시 함수의 보안 강도를 약화시킬 수 있다. n비트 해시의 경우, 기존에는 2^n의 계산 복잡도가 필요했지만, 양자 컴퓨터에서는 2^(n/2)로 감소할 수 있다. 이에 대응하기 위해 더 긴 출력을 제공하는 해시 함수나 새로운 구조의 해시 함수가 필요하다.

  2. 메모리 하드(Memory-Hard) 해시 함수
    많은 메모리를 필요로 하는 메모리 하드 해시 함수는 특수 하드웨어(ASIC, GPU 등)를 사용한 공격에 강한 저항성을 제공한다. 이러한 함수는 비밀번호 해싱에 특히 유용하다.
    Argon2는 이러한 접근 방식의 대표적인 예로, 2015년 Password Hashing Competition에서 우승한 알고리즘이다.

  3. 블록체인과 해싱
    블록체인 기술은 해싱에 크게 의존하며, 이로 인해 새로운 해싱 알고리즘과 응용 방법이 개발되고 있다.
    블록체인에서는 해시 함수가 다음과 같은 역할을 한다:

    1. 블록 해싱: 각 블록의 고유 식별자를 생성
    2. 작업 증명(Proof of Work): 채굴 과정에서 특정 패턴을 가진 해시를 찾는 작업
    3. 머클 트리(Merkle Tree): 트랜잭션의 효율적인 무결성 검증

해싱 관련 실수와 취약점 방지

해싱을 구현할 때 흔히 발생하는 실수와 그 방지법:

  1. 일반적인 실수

    1. 취약한 해시 알고리즘 사용: MD5, SHA-1과 같은 취약한 알고리즘 대신 SHA-256, SHA-3, BLAKE2와 같은 안전한 알고리즘을 사용해야 한다.
    2. 솔트 없이 해싱: 모든 비밀번호 해싱에는 무작위 솔트를 사용해야 한다.
    3. 키 스트레칭 생략: 비밀번호 해싱에는 반드시 키 스트레칭(PBKDF2, bcrypt, Argon2 등)을 적용해야 한다.
    4. 고정된 솔트 사용: 솔트는 항상 무작위로 생성해야 하며, 고정된 솔트는 레인보우 테이블 공격에 취약한다.
    5. 평문 비교를 통한 타이밍 공격 취약성: 해시 비교 시 항상 상수 시간 비교 함수를 사용해야 한다.
  2. 안전한 해싱을 위한 체크리스트

    1. 최신 해시 알고리즘 사용: SHA-256 이상의 안전한 알고리즘
    2. 무작위 솔트 적용: 각 비밀번호마다 고유한 솔트
    3. 키 스트레칭 적용: 충분한 반복 횟수(최소 10,000회 이상)
    4. 페퍼(추가 비밀값) 고려: 더 높은 보안이 필요한 경우
    5. 상수 시간 비교: 타이밍 공격 방지
    6. 안전한 난수 생성기: 예측 불가능한 솔트 생성
    7. 솔트 길이: 최소 16바이트(128비트) 이상
    8. 정기적인 업데이트: 발견된 취약점에 대응하여 알고리즘 업데이트
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    
    def secure_hashing_checklist(password, use_pepper=True):
        """안전한 해싱 구현을 위한 체크리스트 예시"""
        # 1. 최신 해시 알고리즘 사용
        hash_algorithm = 'sha256'
    
        # 2. 무작위 솔트 적용 (16바이트)
        # 6. 안전한 난수 생성기 사용
        salt = os.urandom(16)
    
        # 4. 선택적 페퍼 적용
        pepper = os.environ.get('SECRET_PEPPER', b'').encode() if use_pepper else b''
    
        # 3. 키 스트레칭 적용 (100,000회 반복)
        iterations = 100000
    
        # 해싱 수행
        password_hash = hashlib.pbkdf2_hmac(
            hash_algorithm,
            password.encode() + pepper,
            salt,
            iterations
        )
    
        # 저장 형식: 알고리즘$반복횟수$솔트$해시
        salt_b64 = base64.b64encode(salt).decode()
        hash_b64 = base64.b64encode(password_hash).decode()
        stored_hash = f"{hash_algorithm}${iterations}${salt_b64}${hash_b64}"
    
        return stored_hash
    
    def verify_with_checklist(stored_hash, password, use_pepper=True):
        """안전한 검증 구현을 위한 체크리스트 예시"""
        # 저장된 해시에서 정보 추출
        try:
            algorithm, iterations, salt_b64, hash_b64 = stored_hash.split('$')
            iterations = int(iterations)
            salt = base64.b64decode(salt_b64)
            stored_password_hash = base64.b64decode(hash_b64)
    
            # 페퍼 적용 (저장하지 않고 환경 변수에서 가져옴)
            pepper = os.environ.get('SECRET_PEPPER', b'').encode() if use_pepper else b''
    
            # 동일한 방식으로 해싱
            password_hash = hashlib.pbkdf2_hmac(
                algorithm,
                password.encode() + pepper,
                salt,
                iterations
            )
    
            # 5. 상수 시간 비교로 타이밍 공격 방지
            return hmac.compare_digest(stored_password_hash, password_hash)
    
        except (ValueError, IndexError, base64.binascii.Error):
            # 잘못된 형식의 해시
            return False
    

참고 및 출처