Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 개선 제안 한국어 번역

PEP 524 – Linux에서 os.urandom()이 블로킹되도록 변경

Author:
Victor Stinner <vstinner at python.org>
Status:
Final
Type:
Standards Track
Created:
20-Jun-2016
Python-Version:
3.6

Table of Contents

번역·라이선스 안내

이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판

초록

보안을 강화하기 위해 Linux 3.17 이상에서 OS의 urandom이 초기화될 때까지 os.urandom()이 블로킹되도록 수정합니다.

또한 Linux에서 os.urandom()이 블로킹되려 할 때 어떻게 처리할지 선택할 수 있도록 새로운 os.getrandom() 함수(Linux 및 Solaris용)를 추가합니다.

버그

원래 버그

Python 3.5.0은 Linux 3.17과 Solaris 11.3에 도입된 새로운 getrandom() 시스템 호출을 사용하도록 개선되었습니다. 문제는 가상 머신과 임베디드 장치의 Linux에서 Python 3.5가 시작 시 블로킹된다는 사용자 불만이 제기되기 시작했다는 것입니다. 이슈 #25420과 #26839를 참조하십시오.

Linux에서 getrandom(0)은 커널이 128비트의 엔트로피로 urandom을 초기화할 때까지 블로킹됩니다. 이슈 #25420은 import random에서 블로킹되는 Linux 빌드 플랫폼을 설명합니다. 이슈 #26839는 MD5 해시를 계산하는 데 사용되는 짧은 Python 스크립트인 systemd-cron을 설명하는데, 이 스크립트는 init 과정의 매우 이른 시점에 호출됩니다. 시스템 초기화는 이 스크립트에서 블로킹되고, 이 스크립트는 Python을 초기화하기 위한 getrandom(0)에서 블로킹됩니다.

Python 초기화에는 해시 서비스 거부(hash DoS)에 대한 대응책을 구현하기 위해 난수 바이트가 필요합니다. 다음을 참조하십시오:

random 모듈을 임포트하면 random.Random의 인스턴스인 random._inst가 생성됩니다. Python 3.5에서 random.Random 생성자는 Mersenne Twister RNG(난수 제너레이터)의 시드를 설정하기 위해 os.urandom()에서 2500바이트를 읽습니다.

다른 플랫폼도 이 버그의 영향을 받을 수 있지만, 실제로 시스템 초기화에 Python 스크립트를 사용하는 것은 Linux 시스템뿐입니다.

Python 3.5.2의 상태

Python 3.5.2는 Python 2.7 및 Python 3.4와 같이 동작합니다. 시스템 urandom이 초기화되지 않았으면 시작 과정은 블로킹되지 않지만, os.urandom()이 품질이 낮은 엔트로피(쉽게 추측할 수는 없더라도)를 반환할 수 있습니다.

사용 사례

다음 사용 사례들은 보안과 실용성 사이에서 올바른 절충안을 선택하는 데 도움을 주기 위한 것입니다.

사용 사례 1: init 스크립트

systemd-cron처럼 Python 3 스크립트를 사용하여 시스템을 초기화합니다. 스크립트가 블로킹되면 시스템 초기화도 멈춥니다. 이슈 #26839가 이 사용 사례의 좋은 예입니다.

사용 사례 1.1: 비밀 값이 필요 없는 경우

init 스크립트가 안전한 비밀 값을 생성할 필요가 없다면, 이 사용 사례는 Python 3.5.2에서 이미 올바르게 처리됩니다. Python 시작 과정이 더 이상 시스템 urandom에서 블로킹되지 않습니다.

사용 사례 1.2: 안전한 비밀 값이 필요한 경우

init 스크립트가 안전한 비밀 값을 생성해야 한다면, 안전한 해결책은 없습니다.

약한 엔트로피로 대체하는 것은 받아들일 수 없습니다. 그렇게 하면 프로그램의 보안이 떨어지기 때문입니다.

Python은 안전한 엔트로피를 스스로 만들어 낼 수 없으며, 시스템 urandom이 초기화될 때까지 기다리는 것밖에 할 수 없습니다. 그러나 이 사용 사례에서는 이 스크립트가 시스템 초기화 전체를 블록하므로 시스템이 부팅되지 않습니다.

올바른 해법은 이러한 스크립트가 시스템 초기화를 블록해서는 안 된다는 것입니다. 시스템 초기화 과정의 아주 이른 시점에 스크립트를 시작하는 것은 괜찮지만, 스크립트는 비밀 값을 생성할 수 있을 때까지 몇 초 동안 블록될 수 있습니다.

참고: 경우에 따라 시스템 urandom이 끝내 초기화되지 않아서, 시스템 urandom을 기다리는 프로그램이 영원히 블록되기도 합니다.

사용 사례 2: 웹 서버

HTTP 및 HTTPS 프로토콜로 웹 페이지를 제공하는 Python 3 웹 서버를 실행합니다. 서버는 가능한 한 빨리 시작됩니다.

해시 DoS 공격의 첫 번째 표적은 웹 서버였습니다. 따라서 공격자가 해시 비밀 값을 쉽게 추측할 수 없도록 하는 것이 중요합니다.

웹 페이지를 제공할 때 쿠키 생성, 암호화 키 생성 등을 위해 비밀 값이 필요하다면, 그 비밀 값은 좋은 엔트로피로 생성해야 합니다. 다시 말하지만, 비밀 값은 추측하기 어려워야 합니다.

웹 서버에는 보안이 필요합니다. 보안과 약한 엔트로피로 서버를 실행하는 것 중 하나를 선택해야 한다면 보안이 더 중요합니다. 좋은 엔트로피가 없다면 서버는 블록되거나 오류를 내며 실패해야 합니다.

문제는 시스템 urandom이 초기화되기 전에 호스트에서 웹 서버를 시작하는 것이 합리적인가 하는 점입니다.

이슈 #25420과 #26839는 Python 시작에 국한된 것이며, 시스템 urandom이 초기화되기 전에 비밀 값을 생성하는 것에 관한 것이 아닙니다.

시스템 urandom 고치기

부팅 시 디스크에서 엔트로피 불러오기

엔트로피를 수집하는 데는 최대 몇 분이 걸릴 수 있습니다. 시스템 초기화를 빠르게 하기 위해 운영 체제는 종료 시 엔트로피를 디스크에 저장했다가 부팅 시 디스크에서 엔트로피를 다시 불러옵니다.

시스템이 한 번이라도 충분한 엔트로피를 수집했다면, 디스크에서 엔트로피를 다시 불러오는 즉시 시스템 urandom이 빠르게 초기화됩니다.

가상 머신

가상 머신은 하드웨어에 직접 접근할 수 없으므로 베어 메탈보다 엔트로피 소스가 적습니다. 한 가지 해결책은 호스트에서 가상 머신으로 엔트로피를 전달하도록 virtio-rng 장치를 추가하는 것입니다.

임베디드 장치

임베디드 장치의 경우 하드웨어 RNG를 연결하는 것이 한 가지 해결책입니다.

예를 들어 Raspberry Pi에는 하드웨어 RNG가 있지만 기본적으로 사용되지 않습니다. Raspberry Pi의 하드웨어 RNG를 참조하십시오.

난수를 읽을 때 발생하는 서비스 거부

/dev/random이 아니라 /dev/urandom을 사용하십시오

/dev/random 장치는 매우 특정한 사용 사례에만 사용해야 합니다. Linux에서 /dev/random을 읽으면 블로킹될 가능성이 높습니다. 사용자는 애플리케이션이 비밀값을 생성하느라 5초 넘게 블로킹되는 것을 좋아하지 않습니다. 이는 암호화 키를 명시적으로 생성하는 경우처럼 특정한 경우에만 예상되는 동작입니다.

시스템에 사용 가능한 엔트로피가 없을 때, 엔트로피를 사용할 수 있을 때까지 블로킹할지 아니면 품질이 낮은 엔트로피로 대체할지 선택하는 것은 보안과 실용성 사이의 절충 문제입니다. 어느 쪽을 선택할지는 사용 사례에 따라 달라집니다.

Linux에서 /dev/urandom은 안전하므로 /dev/random 대신 사용해야 합니다. Thomas Hühn의 Myths about /dev/urandom을 참고하십시오: “사실: /dev/urandom은 UNIX 계열 시스템에서 선호되는 암호학적 난수 소스입니다”

Linux에서 getrandom(size, 0)은 영원히 블로킹될 수 있습니다

Python 이슈 #26839는 Debian 버그 보고 #822431에서 비롯되었습니다: 실제로 가상 머신에서 getrandom(size, 0)이 영원히 블록됩니다. systemd가 90초 후에 블로킹된 프로세스를 종료했기 때문에 시스템은 부팅에 성공했습니다.

Load entropy from disk at boot와 같은 해결책은 이 버그의 위험을 줄여 줍니다.

근거

Linux에서 /dev/urandom을 읽으면 urandom이 완전히 초기화되기 전, 즉 커널이 128비트의 엔트로피를 수집하기 전에 “약한” 엔트로피가 반환될 수 있습니다. Linux 3.17에는 urandom이 초기화될 때까지 블로킹할 수 있게 해 주는 새로운 getrandom() 시스템 호출이 추가되었습니다.

Python 3.5.2에서 os.urandom()은 getrandom(size, GRND_NONBLOCK)을 사용하지만, getrandom(size, GRND_NONBLOCK)이 EAGAIN으로 실패하면 논블로킹 /dev/urandom을 읽는 방식으로 대체합니다.

보안 전문가들은 os.urandom()이 암호학적으로 안전한 의사 난수 생성기(CSPRNG)로 구현되어 있기 때문에 암호 키 생성에 os.urandom()을 권장합니다. 참고로, os.urandom()은 여러 가지 이유로 ssl.RAND_bytes()보다 선호됩니다.

이 PEP는 약한 엔트로피를 반환하지 않도록 os.urandom()이 getrandom()을 블로킹 모드로 사용하게 수정하되, Python이 시작 시 블록되지 않도록 보장할 것을 제안합니다.

변경 사항

Linux에서 os.urandom()을 블로킹 방식으로 만들기

이 절에서 설명하는 모든 변경 사항은 Linux 플랫폼에만 해당합니다.

변경 사항:

  • 시스템 urandom이 초기화될 때까지 블로킹하도록 os.urandom() 수정: os.urandom() (C 함수 _PyOS_URandom())은 Linux와 Solaris에서 항상 getrandom(size, 0) (블로킹 모드)을 호출하도록 수정됩니다.
  • 새로운 비공개 _PyOS_URandom_Nonblocking() 함수 추가: Linux와 Solaris에서 getrandom(size, GRND_NONBLOCK) 호출을 시도하되, EAGAIN으로 실패하면 /dev/urandom을 읽는 방식으로 대체합니다.
  • 블로킹되지 않는 시스템 urandom으로 해시 비밀값 초기화: _PyRandom_Init()이 _PyOS_URandom_Nonblocking()을 호출하도록 수정됩니다.
  • 이제 random.Random 생성자는 블로킹되지 않는 시스템 urandom을 사용합니다: RNG의 시드를 설정할 때 새로운 _PyOS_URandom_Nonblocking() 함수를 내부적으로 사용하도록 수정됩니다.

새로운 os.getrandom() 함수 추가

새로운 os.getrandom(size, flags=0) 함수가 추가됩니다: Linux에서는 getrandom() 시스템 호출을, Solaris에서는 getrandom() C 함수를 사용합니다.

이 함수에는 새로운 플래그 2개가 함께 제공됩니다:

  • os.GRND_RANDOM: /dev/urandom을 읽는 대신 /dev/random에서 바이트를 읽습니다
  • os.GRND_NONBLOCK: os.getrandom()이 블로킹될 경우 BlockingIOError를 발생시킵니다

os.getrandom()은 getrandom() 시스템 호출/C 함수를 얇게 감싼 래퍼이므로 그 동작을 그대로 물려받습니다. 예를 들어 Linux에서는 시스템 호출이 시그널에 의해 중단되면 요청한 것보다 적은 바이트를 반환할 수 있습니다.

os.getrandom()을 사용하는 예제

최선의 노력(best-effort) RNG

이식 가능한 논블로킹 RNG 함수의 예제입니다. OS urandom에서 무작위 바이트를 얻으려고 시도하고, 실패하면 random 모듈로 대체합니다.

def best_effort_rng(size):
    # getrandom() is only available on Linux and Solaris
    if not hasattr(os, 'getrandom'):
        return os.urandom(size)

    result = bytearray()
    try:
        # need a loop because getrandom() can return less bytes than
        # requested for different reasons
        while size:
            data = os.getrandom(size, os.GRND_NONBLOCK)
            result += data
            size -= len(data)
    except BlockingIOError:
        # OS urandom is not initialized yet:
        # fallback on the Python random module
        data = bytes(random.randrange(256) for byte in range(size))
        result += data
    return bytes(result)

이 함수는 os.getrandom()을 사용할 수 없지만 os.urandom()이 블록될 수 있는 플랫폼에서는 이론적으로 블록될 수 있습니다.

wait_for_system_rng()

Linux 또는 Solaris에서 OS urandom이 초기화될 때까지 타임아웃 값인 timeout 초 동안 기다리는 함수의 예:

def wait_for_system_rng(timeout, interval=1.0):
    if not hasattr(os, 'getrandom'):
        return

    deadline = time.monotonic() + timeout
    while True:
        try:
            os.getrandom(1, os.GRND_NONBLOCK)
        except BlockingIOError:
            pass
        else:
            return

        if time.monotonic() > deadline:
            raise Exception('OS urandom not initialized after %s seconds'
                            % timeout)

        time.sleep(interval)

이 함수는 이식 가능하지 않습니다. 예를 들어 FreeBSD에서는 시스템 초기화의 초기 단계에서 os.urandom()이 이론적으로 블록될 수 있습니다.

최선의 노력(best-effort) RNG 만들기

Linux에서 논블로킹 RNG를 만드는 더 간단한 예제입니다. getrandom(size)가 블록될지에 따라 Random.SystemRandom과 Random.Random 중 하나를 선택합니다.

def create_nonblocking_random():
    if not hasattr(os, 'getrandom'):
        return random.Random()

    try:
        os.getrandom(1, os.GRND_NONBLOCK)
    except BlockingIOError:
        return random.Random()
    else:
        return random.SystemRandom()

이 함수는 이식 가능하지 않습니다. 예를 들어 FreeBSD에서는 시스템 초기화의 초기 단계에서 random.SystemRandom이 이론적으로 블록될 수 있습니다.

대안

os.urandom()은 그대로 두고 os.getrandom()을 추가하기

os.urandom()은 변경되지 않습니다. 절대 블록되지 않지만, 시스템 urandom이 아직 초기화되지 않았다면 약한 엔트로피를 반환할 수 있습니다.

새로운 os.getrandom() 함수(getrandom() 시스템 호출/C 함수의 래퍼)만 추가합니다.

이식 가능한 코드를 작성하려면 secrets.token_bytes() 함수를 사용해야 합니다.

이 변경의 문제점은 사용자가 보안을 잘 이해하고 각 플랫폼을 잘 알고 있다고 전제한다는 것입니다. Python에는 “구현 세부 사항”을 숨기는 전통이 있습니다. 예를 들어 os.urandom()은 /dev/urandom 장치를 얇게 감싼 래퍼가 아닙니다. Windows에서는 CryptGenRandom()을, OpenBSD에서는 getentropy()를 사용하고, Linux와 Solaris에서는 getrandom()을 시도하거나 /dev/urandom을 읽는 방식으로 대체합니다. Python은 이미 플랫폼에 따라 사용 가능한 최선의 시스템 RNG를 사용합니다.

이 PEP는 API를 변경하지 않습니다.

  • 보안 용도로는 os.urandom(), random.SystemRandom, secrets
  • 그 밖의 모든 용도로는 random 모듈(random.SystemRandom 제외)

os.urandom()에서 BlockingIOError 발생시키기

제안

PEP 522: Linux의 보안에 민감한 API에서 BlockingIOError 허용하기.

Python이 The bug를 어떻게 처리할지 개발자 대신 결정해서는 안 됩니다. os.urandom()이 블록될 상황에서 즉시 BlockingIOError를 발생시키면 개발자가 이 경우를 어떻게 처리할지 선택할 수 있습니다.

  • 예외를 잡아서 안전하지 않은 엔트로피 소스로 대체합니다. Linux에서 /dev/urandom을 읽거나, Python random 모듈(전혀 안전하지 않음)을 사용하거나, 시간을 사용하거나, 프로세스 식별자를 사용하는 등의 방법이 있습니다.
  • 오류를 잡지 않으면 이 치명적인 예외로 인해 프로그램 전체가 실패합니다

더 일반적으로, 이 예외는 무언가 잘못되었을 때 이를 알리는 데 도움이 됩니다. 애플리케이션은 os.urandom()을 기다리기 시작할 때 경고를 낼 수 있습니다.

비판

사용 사례 2(웹 서버)에서는 안전하지 않은 엔트로피로 대체하는 것을 받아들일 수 없습니다. 애플리케이션은 BlockingIOError를 처리해야 합니다. 즉, os.urandom()이 완료될 때까지 폴링해야 합니다. 예시:

def secret(n=16):
    try:
        return os.urandom(n)
    except BlockingIOError:
        pass

    print("Wait for system urandom initialization: move your "
          "mouse, use your keyboard, use your disk, ...")
    while 1:
        # Avoid busy-loop: sleep 1 ms
        time.sleep(0.001)
        try:
            return os.urandom(n)
        except BlockingIOError:
            pass

정확성을 위해, 안전한 비밀 값을 생성해야 하는 모든 애플리케이션은 The bug가 발생할 가능성이 낮더라도 BlockingIOError를 처리하도록 수정되어야 합니다.

os.urandom()을 사용하지만 실제로는 보안이 필요하지 않은 애플리케이션의 경우는 명확하게 정의되어 있지 않습니다. 어쩌면 이러한 애플리케이션은 애초에 os.urandom()을 사용하지 말고, 항상 블로킹하지 않는 random 모듈을 사용해야 할 것입니다. os.urandom()을 보안 목적으로 사용한다면, 위에서 설명한 사용 사례 2로 돌아가게 됩니다: Use Case 2: Web server. 개발자가 os.urandom()을 버리고 싶지 않다면 코드를 수정해야 합니다. 예시:

def almost_secret(n=16):
    try:
        return os.urandom(n)
    except BlockingIOError:
        return bytes(random.randrange(256) for byte in range(n))

문제는 The bug가 그렇게 많은 애플리케이션을 수정하도록 요구할 만큼 흔한가 하는 것입니다.

또 다른 더 간단한 선택지는 시스템 urandom이 초기화되기 전에는 시작을 거부하는 것입니다.:

def secret(n=16):
    try:
        return os.urandom(n)
    except BlockingIOError:
        print("Fatal error: the system urandom is not initialized")
        print("Wait a bit, and rerun the program later.")
        sys.exit(1)

Linux에서 os.urandom()이 절대 블로킹하지도 않고 예외를 발생시키지도 않는 Python 2.7, Python 3.4, Python 3.5.2와 비교하면, 이러한 동작 변경은 심각한 회귀로 여겨질 수 있습니다.

os.urandom()에 선택적 block 매개변수 추가

issue #27250: Add os.urandom_block()을 참고하십시오.

os.urandom()에 선택적 block 매개변수를 추가합니다. 기본값은 True(기본적으로 블로킹) 또는 False(블로킹하지 않음)가 될 수 있습니다.

첫 번째 기술적 문제는 모든 플랫폼에서 os.urandom(block=False)를 구현하는 것입니다. 명확하게 정의된 논블로킹 API(getrandom(size, GRND_NONBLOCK))를 가진 것은 Linux 3.17(및 그 이후 버전)과 Solaris 11.3(및 그 이후 버전)뿐입니다.

Raise BlockingIOError in os.urandom()과 마찬가지로, 이론적인(또는 적어도 매우 드문) 사용 사례를 위해 API를 더 복잡하게 만들 가치는 없어 보입니다.

Leave os.urandom() unchanged, add os.getrandom()과 마찬가지로, 문제는 API가 더 복잡해지고 그로 인해 오류가 발생하기 더 쉬워진다는 것입니다.

수락

이 PEP는 2016-08-08에 Guido van Rossum이 승인했습니다.

부록

운영 체제의 난수 함수

os.urandom()은 다음 함수를 사용합니다:

Linux에서 /dev/random의 상태를 확인하는 명령어입니다(결과는 바이트 수입니다).:

$ cat /proc/sys/kernel/random/entropy_avail
2850
$ cat /proc/sys/kernel/random/poolsize
4096

왜 os.urandom()을 사용합니까?

os.urandom()은 커널에 구현되어 있으므로 사용자 공간 RNG의 문제가 없습니다. 예를 들어, 그 상태를 알아내기가 훨씬 더 어렵습니다. 이는 보통 CSPRNG를 기반으로 만들어지므로, 설령 그 상태가 “도난”당하더라도 이전에 생성된 숫자를 계산하기는 어렵습니다. 커널은 엔트로피 소스를 잘 파악하고 있으며, 엔트로피 풀에 정기적으로 엔트로피를 공급합니다.

ssl.RAND_bytes()보다 os.urandom()이 선호되는 이유도 바로 이것입니다.