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

Python 개선 제안 한국어 번역

PEP 522 – 보안에 민감한 API에서 BlockingIOError 허용

Author:
Alyssa Coghlan <ncoghlan at gmail.com>, Nathaniel J. Smith <njs at pobox.com>
Status:
Rejected
Type:
Standards Track
Requires:
506
Created:
16-Jun-2016
Python-Version:
3.6
Resolution:
Security-SIG message

Table of Contents

번역·라이선스 안내

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

초록

표준 라이브러리에는 명목상 보안에 민감한 작업에 사용하기에 적합한 무작위 값을 반환하는 API가 여럿 있는데, 이들은 현재 운영 체제에 따라 달라지는 잘 알려지지 않은 실패 모드를 가지고 있으며, 이 실패 모드에서는 실제로는 그러한 작업에 적합하지 않은 반환값을 돌려줄 수 있습니다.

이는 일부 운영 체제 커널(특히 Linux 커널)이 시스템 난수 제너레이터가 완전히 초기화되기 전에도 /dev/urandom에서 읽기를 허용하기 때문입니다. 반면 대부분의 다른 운영 체제는 난수 제너레이터가 준비될 때까지 이러한 읽기를 암묵적으로 블록합니다.

이 PEP는 저수준 API인 os.urandom과 random.SystemRandom에서 발생하는 이러한 실패를 Python 3.6부터 바꿀 것을 제안합니다. 현재는 이 실패가 조용히 일어나서 감지하기도 디버깅하기도 어려운 오류입니다. 제안에 따르면 적절한 오류 메시지와 함께 BlockingIOError를 발생시키므로 쉽게 감지하고 디버깅할 수 있는 오류가 됩니다. 이를 통해 개발자는 이 상황을 처리할 때 선호하는 방식을 명확하게 지정할 수 있습니다.

새로운 고수준 secrets API에 대해서는 두 가지를 제안합니다. 첫째, 해당 모듈이 난수를 생성할 때마다 필요하면 암묵적으로 블록하도록 합니다. 둘째, 새로운 secrets.wait_for_system_rng() 함수를 공개하여, 그 밖에는 저수준 API를 사용하는 코드가 시스템 난수 제너레이터를 사용할 수 있을 때까지 명시적으로 기다릴 수 있도록 합니다.

이 변경은 getrandom() 시스템 호출을 제공하는 모든 운영 체제에 영향을 미칩니다. 시스템 난수 제너레이터가 준비되지 않았을 때 /dev/urandom 장치의 기본 동작이 예측 가능할 수 있는 결과를 반환하는 것(예: Linux, NetBSD)이든 블록하는 것(예: FreeBSD, Solaris, Illumos)이든 관계없습니다. 시스템 난수 제너레이터가 초기화되기 전에 사용자 공간 코드의 실행을 막거나 getrandom() 시스템 호출을 제공하지 않는 운영 체제는 제안된 변경의 영향을 전혀 받지 않습니다(예: Windows, Mac OS X, OpenBSD).

새 예외나 secrets 모듈의 블록 동작은 다음과 같은 상황에서 발생할 수 있습니다.

  • Linux 시스템 초기화 중에 이러한 API를 호출하는 Python 코드
  • 올바르게 초기화되지 않은 Linux 시스템에서 실행되는 Python 코드(예: 시스템 난수 제너레이터에 시드를 제공할 적절한 엔트로피 소스가 없는 임베디드 하드웨어, 또는 VM 호스트로부터 엔트로피를 받도록 구성되지 않은 Linux VM)

다른 PEP와의 관계

이 PEP는 secrets 모듈을 추가하는, 승인된 PEP 506에 의존합니다.

이 PEP는 시스템 RNG가 준비되지 않았을 때 os.urandom 자체가 암묵적으로 블로킹하도록 하자고 제안하는 Victor Stinner의 PEP 524와 경쟁합니다.

PEP 거부

참조 구현과 관련하여 Guido는 이 PEP를 거부하고 PEP 524의 무조건적 암묵적 블록 제안을 채택했습니다. 이 제안은 Linux에서의 CPython 동작을 다른 운영 체제에서의 동작과 일치시킵니다.

따라서 Linux 배포판의 시스템 Python 설치에서 os.urandom()의 적절한 기본 동작을 두고 더 논의하려면, 업스트림 CPython 메일링 리스트가 아니라 각 배포판의 메일링 리스트에서 해야 합니다.

이 PEP와 무관한 변경 사항

CPython 인터프리터 초기화와 random 모듈 초기화는 이미 갱신되었습니다. 이제 시스템 난수 제너레이터가 준비되지 않은 경우 다른 시드 방식으로 문제없이 대체합니다.

이 PEP는 PEP 524의 또 다른 제안인 os.getrandom() API 추가와는 경쟁하지 않습니다. 이 API는 getrandom 시스템 호출을 제공하는 플랫폼에서 해당 호출을 노출합니다. os 모듈은 플랫폼에 따라 달라질 수 있는 운영 체제 기능을 얇게 감싸는 래퍼 역할을 하므로, 이러한 시스템에서 os.urandom()의 기본 동작이 어떻게 되든 그 API를 추가할 충분한 동기가 있습니다.

제안

getrandom() 시스템 호출이 있는 플랫폼에서 os.urandom() 변경

이 PEP는 Python 3.6+에서 os.urandom()을 다음과 같이 수정할 것을 제안합니다. 사용할 수 있으면 getrandom() 시스템 호출을 논블로킹 모드로 호출합니다. 커널이 호출이 블록될 것이라고 보고하면 BlockingIOError: system random number generator is not ready; see secrets.token_bytes()를 발생시킵니다.

그러면 이 동작은 기존 random.SystemRandom에도 전파됩니다. random.SystemRandom은 random.Random() API에 맞춰 os.urandom()을 비교적 얇게 감싼 래퍼를 제공합니다.

그러나 PEP 506에서 도입된 새로운 secrets 모듈은 이와 다르게 갱신됩니다. 이 모듈은 새 예외를 포착하고, 예외가 발생하면 시스템 난수 제너레이터를 암묵적으로 기다립니다.

모든 경우에, 이러한 보안에 민감한 API 중 하나에 대한 호출이 한 번 성공하면 그 뒤로는 해당 프로세스에서 이 API들을 호출할 때 모두 블록 없이 성공합니다(시스템 부팅 후 운영 체제 난수 제너레이터가 한 번 준비되면 계속 준비된 상태로 유지됩니다).

Linux와 NetBSD에서는 이 변경이 기존 동작을 대체합니다. 기존에는 /dev/urandom에서 읽은, 예측 가능할 수 있는 결과를 반환했습니다.

FreeBSD, Solaris, Illumos에서는 이 변경이 기존 동작을 대체합니다. 기존에는 시스템 난수 제너레이터가 준비될 때까지 암묵적으로 블록했습니다. 그러나 이러한 운영 체제가 시스템 난수 제너레이터가 준비되기 전에 사용자 공간 코드(따라서 Python)의 실행을 실제로 허용하는지는 분명하지 않습니다.

어떤 경우든 하부의 getrandom() API 호출이 ENOSYS를 보고할 수도 있다는 점에 유의하십시오. 성공적인 응답을 반환하거나 EAGAIN을 보고하는 대신 이렇게 되면 CPython은 지금처럼 /dev/urandom에서 직접 읽는 방식으로 대체합니다.

secrets.wait_for_system_rng() 추가

새 예외를 추가할 때는 그 오류를 마주쳤을 때 어떻게 해결해야 하는지에 대한 간단명료한 권고가 함께 있어야 합니다(실제로 그 새 오류를 마주치는 일이 아무리 드물 것으로 예상되더라도 마찬가지입니다). (새 secrets 모듈 대신) 시스템 난수 제너레이터의 저수준 인터페이스를 실제로 사용해야 하는 보안에 민감한 코드이면서, 이 문제가 이론상의 문제가 아니라 해당 애플리케이션의 사용자층에게 실제로 발생하는 문제임을 보여 주는 버그 보고를 실제로 받는 경우, 이 PEP는 다음 코드 조각을 __main__ 모듈에 (직접 또는 간접적으로) 추가할 것을 권고합니다:

import secrets
secrets.wait_for_system_rng()

또는 Python 3.6 이전 버전과의 호환성이 필요하다면 다음과 같이 합니다:

try:
    import secrets
except ImportError:
    pass
else:
    secrets.wait_for_system_rng()

그리고 secrets 모듈 안에서는 새 예외를 마주쳤을 때 암묵적으로 블록하도록 token_bytes()에서 이것을 사용합니다:

def token_bytes(nbytes=None):
    if nbytes is None:
        nbytes = DEFAULT_ENTROPY
    try:
        result = os.urandom(nbytes)
    except BlockingIOError:
        wait_for_system_rng()
        result = os.urandom(nbytes)
    return result

그런 다음 모듈의 다른 부분들은 os.urandom()을 직접 호출하는 대신 token_bytes()를 기본 난수 생성 구성 요소로 사용하도록 갱신됩니다.

시스템 난수 제너레이터에 대한 접근이 거의 확실히 필요한 사용 사례를 다루는 애플리케이션 프레임워크(예: 웹 프레임워크)는 애플리케이션을 시작하는 명령에 secrets.wait_for_system_rng() 호출을 암묵적으로 포함하도록 선택할 수 있으며, 이렇게 하면 해당 프레임워크를 사용할 때 기존의 os.urandom() 호출이 새 예외를 절대 발생시키지 않도록 보장됩니다.

직접 수정할 수 없는 애플리케이션에서 이 오류가 발생하는 경우에는, 다음 명령을 사용하여 해당 애플리케이션을 시작하기 전에 시스템 난수 제너레이터가 초기화될 때까지 기다릴 수 있습니다:

python3 -c "import secrets; secrets.wait_for_system_rng()"

예를 들어, 이 코드 조각은 셸 스크립트나 systemd ExecStartPre 훅에 추가할 수 있습니다(그리고 이후에 실행되는 명령 자체가 Python 3.6에서 실행되는 애플리케이션이 아니더라도, 시스템 난수 제너레이터가 준비될 때까지 안정적으로 기다리는 데 유용할 수 있습니다)

위에서 제안한 os.urandom() 변경 사항과, 이를 지원하는 시스템에 os.getrandom() API가 포함된다는 점을 고려하면, 이 함수의 구현 제안은 다음과 같습니다:

if hasattr(os, "getrandom"):
    # os.getrandom() always blocks waiting for the system RNG by default
    def wait_for_system_rng():
        """Block waiting for system random number generator to be ready"""
        os.getrandom(1)
        return
else:
   # As far as we know, other platforms will never get BlockingIOError
   # below but the implementation makes pessimistic assumptions
    def wait_for_system_rng():
        """Block waiting for system random number generator to be ready"""
        # If the system RNG is already seeded, don't wait at all
        try:
            os.urandom(1)
            return
        except BlockingIOError:
            pass
        # Avoid the below busy loop if possible
        try:
            block_on_system_rng = open("/dev/random", "rb")
        except FileNotFoundError:
            pass
        else:
            with block_on_system_rng:
                block_on_system_rng.read(1)
        # Busy loop until the system RNG is ready
        while True:
            try:
                os.urandom(1)
                break
            except BlockingIOError:
                # Only check once per millisecond
                time.sleep(0.001)

시스템 RNG가 준비될 때까지 기다릴 수 있는 시스템에서는 os.getrandom()이 정의되어 있거나, os.urandom() 자체가 암묵적으로 블록하거나, /dev/random 장치를 사용할 수 있으면 이 함수가 바쁜 루프 없이 기다립니다. 시스템 난수 제너레이터가 준비되어 있다면, 시스템의 /dev/random 장치가 정상적인 시스템 운영 중에 간헐적으로 블록될 수 있는 설계를 사용하더라도 이 호출은 절대 블록되지 않음이 보장됩니다.

범위의 제한

Windows나 Mac OS X 시스템에 대해서는 어떠한 변경도 제안하지 않습니다. 두 플랫폼 모두 운영 체제의 난수 제너레이터가 초기화되기 전에 Python 코드를 실행할 수 있는 메커니즘을 제공하지 않기 때문입니다. Mac OS X는 난수 제너레이터를 제대로 초기화할 수 없으면 커널 패닉을 일으키고 부팅 과정을 중단하기까지 합니다(다만 지원하는 하드웨어 플랫폼에 대한 Apple의 제한 때문에 실제로 그런 일이 일어날 가능성은 극히 낮습니다).

마찬가지로, getrandom() 시스템 호출을 제공하지 않는 다른 *nix 시스템에 대해서도 변경 사항을 제안하지 않습니다. 이러한 시스템에서 os.urandom()은 계속해서 시스템 난수 제너레이터가 초기화될 때까지 블록하며 기다립니다.

보안에 민감한 애플리케이션에서 사용하기에 적합한 난수를 요청하기 위한 논블로킹 API(getrandom() 이외의 것)를 제공하는 다른 *nix 시스템도 이 PEP에서 getrandom()에 대해 제안한 것과 비슷한 갱신을 받을 수 있지만, 그러한 변경은 이 제안의 범위를 벗어납니다.

새 getrandom() 시스템 호출을 제공하지 않는, 영향을 받는 플랫폼의 이전 버전에서의 Python 동작도 변경되지 않습니다.

근거

필요할 때 secrets 모듈이 암묵적으로 블록하도록 보장하기

이렇게 하는 이유는 보안에 민감한 난수를 생성하는 올바른 방법에 대해 가능한 한 가장 간단한 답을 원하는 사람들 사이에서 퍼지는 통념이, 상용구가 더 많은 “가능하면 항상 secrets.wait_for_system_rng()를 호출하십시오. 그렇지 않으면 애플리케이션이 예기치 않게 중단될 수 있습니다”가 아니라 “가능하면 secrets 모듈을 사용하십시오. 그렇지 않으면 애플리케이션이 예기치 않게 중단될 수 있습니다”가 되도록 장려하기 위해서입니다.

또한 BDFL이 예기치 않은 예외를 발생시킬 수 있는 API보다 예기치 않게 블록할 수 있는 API에 더 관대하기 때문이기도 합니다 [11].

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

수년 전부터 보안 커뮤니티는 Python에서 보안에 민감한 작업을 구현할 때 os.urandom()(또는 random.SystemRandom() 래퍼)을 사용하도록 권고해 왔습니다.

API를 더 쉽게 찾을 수 있게 하고, (둘 다 난수를 다루기는 하지만) 비밀 유지와 시뮬레이션이 같은 문제가 아니라는 점을 더 분명히 하기 위해, PEP 506은 저수준 os.urandom() API를 기반으로 하는 여러 한 줄짜리 레시피를 새 secrets 모듈로 모았습니다.

하지만 이 지침에는 오래전부터 한 가지 단서가 붙어 있었습니다. 적어도 Linux용, 그리고 경우에 따라 일부 다른 *BSD 시스템용으로 보안에 민감한 소프트웨어를 작성하는 개발자는 운영 체제의 난수 제너레이터를 보안에 민감한 작업에 사용하기 전에 그것이 준비될 때까지 기다려야 할 수 있습니다. 이 문제는 일반적으로 시스템 초기화 과정의 아주 이른 시점에 os.urandom()을 읽거나, 사용 가능한 엔트로피 소스가 적은 시스템(예: 일부 종류의 가상화 시스템이나 임베디드 시스템)에서만 발생합니다. 하지만 안타깝게도 이를 유발하는 정확한 조건은 예측하기 어렵고, 발생하더라도 운영 체제별 인터페이스를 조회하지 않고서는 사용자 공간에서 이를 직접 알아낼 방법이 없습니다.

*BSD 시스템(해당 *BSD 변종에서 이 문제가 아예 발생할 수 있는 경우)과, 경우에 따라 Solaris 및 Illumos에서도, 이러한 상황에 부딪히면 os.urandom()은 시스템 난수 제너레이터가 준비될 때까지 블록하며 기다리거나(이 경우 관련 증상은 영향을 받는 스크립트가 os.urandom()을 처음 호출할 때 예기치 않게 멈추는 것입니다), 그렇지 않으면 Linux에서와 같은 방식으로 동작합니다.

Linux에서 Python 3.4 이하 버전과 Python 3.5.2 이후의 Python 3.5 유지보수 버전에서는, 소프트웨어가 Linux 부팅 과정 초기에 실행되거나 운영 체제의 난수 제너레이터를 시드할 만한 좋은 엔트로피 소스가 없는 하드웨어에서 실행될 때 예상대로 동작하지 않을 수 있다는 사실을 개발자에게 명확히 알려 주는 표시가 없습니다. 기반이 되는 /dev/urandom 장치의 동작 때문에 Linux의 os.urandom()은 어느 쪽이든 결과를 반환하며, 보안 취약점이 존재한다는 것을 보이려면 광범위한 통계 분석이 필요합니다.

반면에 이러한 상황에서 BlockingIOError가 발생하면, Python 3.6 이상을 사용하는 개발자는 원하는 동작을 쉽게 선택할 수 있습니다:

  1. 애플리케이션 시작 시점 또는 그 전에 시스템 RNG를 기다립니다(보안에 민감한 경우)
  2. random 모듈을 사용하도록 전환합니다(보안에 민감하지 않은 경우)

secrets.wait_for_system_rng() 공개

이 PEP의 이전 버전에서는 보안에 민감한 사용 사례에 적합하도록 os.urandom()을 감싸는 여러 방법을 제안했습니다.

security-sig 메일링 리스트에서 이 제안을 논의하면서 이 PEP의 API 설계를 이끈 핵심 가정이 무엇이었는지 깨닫게 되었습니다 [9]. 그 가정이란 세 가지 선택지 중 하나를 고르는 일이 애플리케이션과 사용 사례에 따라 달라지는 결정이라는 것입니다. 세 선택지는 예외가 발생해 애플리케이션이 실패하도록 두는 것, 시스템 RNG가 준비될 때까지 블로킹하며 기다리는 것, os.urandom 대신 random 모듈을 사용하도록 전환하는 것입니다. 따라서 이 결정에서는 애플리케이션과 사용 사례의 구체적인 사항을 고려해야 합니다.

인터프리터 런타임이나 지원 라이브러리로서는 특정 사용 사례가 보안에 민감한지 판단할 방법이 없습니다. 또한 애플리케이션 개발자는 특정 API가 던진 예외를 어떻게 처리할지는 쉽게 결정할 수 있지만, 블로킹하지 않으리라 예상한 API가 블로킹하는 경우에는 이를 쉽게 우회할 수 없습니다.

이에 따라 PEP를 개정하여 secrets.wait_for_system_rng()를 추가했습니다. 애플리케이션, 스크립트, 프레임워크는 이 API를 사용해 계속 진행하기 전에 시스템 RNG를 사용할 수 있는지 확인하려 한다는 의사를 나타낼 수 있습니다. 한편 라이브러리 개발자는 os.urandom()이 예기치 않게 시스템 RNG를 사용할 수 있을 때까지 블로킹하며 기다리기 시작할 수 있다는 걱정 없이 계속해서 이를 호출할 수 있습니다.

하위 호환성 영향 평가

PEP 476과 마찬가지로, 이 제안은 이전에는 조용히 넘어가던 보안 실패를 요란한 예외로 바꾸어 애플리케이션 개발자가 원하는 동작을 명시적으로 결정하도록 요구합니다.

getrandom() syscall을 제공하지 않는 운영 체제에 대해서는 아무런 변경도 제안하지 않으므로, os.urandom()은 명목상으로는 블로킹 API이지만 운영 체제의 난수 제너레이터가 준비되기 전에 Python 코드가 실행되도록 스케줄링하기가 어렵기 때문에 실제로는 블로킹하지 않는 기존 동작을 유지합니다. 적어도 일부 *BSD 계열에서는 이 PEP에서 설명한 것과 비슷한 문제를 겪을 수 있으리라 생각하지만, 이를 명시적으로 입증한 사람은 아직 없습니다. Mac OS X와 Windows에서는 부팅 과정의 그렇게 이른 시점에 Python 인터프리터를 실행하려는 시도조차 아예 불가능한 것으로 보입니다.

/dev/urandom이 비슷하게 동작하는 Linux 및 기타 플랫폼에서 os.urandom()은 블로킹하지 않음이 보장되는 API라는 지위를 유지합니다. 하지만 운영 체제의 난수 제너레이터가 아직 보안에 민감한 작업에 사용할 준비가 되지 않은 특정한 경우에는 그 상태를 달성하는 방식이 바뀝니다. 이전에는 예측 가능할 수도 있는 무작위 데이터를 반환했지만, 이 PEP에 따라 BlockingIOError를 발생시키도록 바뀝니다.

그러면 영향을 받는 애플리케이션의 개발자는 Python 3.6과의 상위 호환성을 확보하기 위해, 개발 중인 애플리케이션의 종류에 따라 다음 변경 사항 중 하나를 적용해야 합니다.

영향을 받지 않는 애플리케이션

다음과 같은 종류의 애플리케이션은 보안에 민감한 작업을 수행하는지와 관계없이 이 변경의 영향을 전혀 받지 않습니다:

  • Linux를 지원하지 않는 애플리케이션
  • 데스크톱이나 일반 서버에서만 실행되는 애플리케이션
  • 시스템 RNG가 준비된 뒤에만 실행되는 애플리케이션(애플리케이션 프레임워크가 대신 secrets.wait_for_system_rng()를 호출해 주는 경우 포함)

이 범주의 애플리케이션은 새 예외를 아예 마주치지 않습니다. 따라서 개발자는 영향을 받는지를 미리 판단하려 애쓰기보다는, 새로운 런타임 동작과 관련된 Python 3.6 호환성 버그가 보고되는지 지켜보는 편이 합리적입니다.

영향을 받는 보안에 민감한 애플리케이션

보안에 민감한 애플리케이션은 운영 체제의 난수 제너레이터가 보안에 민감한 작업을 수행할 준비가 된 뒤에만 애플리케이션이 시작되도록 시스템 설정을 바꾸거나, 애플리케이션 시작 코드에서 secrets.wait_for_system_rng()를 호출하도록 바꾸거나, 새로운 secrets.token_bytes() API를 사용하도록 전환해야 합니다.

예를 들어 systemd 유닛 파일로 시작되는 구성 요소의 경우, 다음 코드 조각을 사용하면 시스템 RNG가 준비될 때까지 활성화가 지연됩니다:

ExecStartPre=python3 -c “import secrets; secrets.wait_for_system_rng()”

또는 다음 코드 조각은 secrets.token_bytes()를 사용할 수 있으면 이를 사용하고, 그렇지 않으면 os.urandom()으로 대체합니다:

try:
import secrets.token_bytes as _get_random_bytes
except ImportError:
import os.urandom as _get_random_bytes

영향을 받는 보안에 민감하지 않은 애플리케이션

보안에 민감하지 않은 애플리케이션은 os.urandom 대신 random 모듈을 사용하도록 수정해야 합니다:

def pseudorandom_bytes(num_bytes):
    return random.getrandbits(num_bytes*8).to_bytes(num_bytes, "little")

애플리케이션의 세부 사항에 따라서는 random 모듈이 직접 사용할 수 있는 다른 API를 제공할 수 있으므로, os.urandom() API가 생성하는 원시 바이트 시퀀스를 흉내 낼 필요가 없을 수도 있습니다.

추가 배경

왜 지금 이것을 제안합니까?

주된 이유는 Python 3.5.0 릴리스가 파일 디스크립터 소비를 피하기 위해 새로운 Linux getrandom() 시스템 호출을 사용할 수 있으면 사용하도록 전환했기 때문이며 [1], 이로 인해 다음 작업들이 시스템 난수 제너레이터가 준비될 때까지 블록되어 기다리는 부작용이 생겼습니다:

  • os.urandom (및 이에 의존하는 API)
  • random 모듈 임포트
  • 일부 내장 타입이 사용하는 무작위화된 해시 알고리즘 초기화

이 중 첫 번째 동작은 바람직하다고 볼 수도 있지만(그리고 다른 운영 체제에서 os.urandom이 보이는 기존 동작과도 일관되지만), 나머지 두 동작은 불필요하고 바람직하지 않습니다. 특히 마지막 동작은 Python 3.5.0 또는 3.5.1로 Linux init 프로세스 중에 Python 스크립트를 실행하려 할 때 시스템 수준의 교착 상태를 일으키는 것으로 현재 알려져 있으며 [2], 두 번째 동작은 견고한 엔트로피 소스가 구성되지 않은 가상 머신을 사용할 때 문제를 일으킬 수 있습니다 [3].

CPython에서 이 동작들을 분리하려면 유지보수 릴리스보다는 기능 릴리스에 더 적합한 여러 구현 변경이 필요하므로, Python 3.5.2에서 적용된 비교적 간단한 해결책은 세 가지 모두를 이전 Python 버전과 유사한 동작으로 되돌리는 것이었습니다: 새 Linux 시스템 호출이 블록될 것이라고 알리면, Python 3.5.2는 암묵적으로 /dev/urandom을 직접 읽는 방식으로 대체합니다 [4].

그러나 이 버그 보고는 또한 os.getrandom() [5], os.urandom_block() [6], os.pseudorandom() 및 os.cryptorandom() [7] 같은 새로운 API를 추가하거나 os.urandom() 자체에 새로운 선택적 매개변수를 추가한 뒤 [8], 그냥 평범한 os.urandom() 호출을 사용하는 대신 언제 그 API들을 호출해야 하는지 사용자에게 교육하려는 다양한 제안으로 이어졌습니다.

이러한 제안들은 과잉 반응이라고 볼 수 있습니다. Linux에서 보안에 민감한 작업에 적합한 난수를 안정적으로 얻는 문제는 주로 운영 체제 개발자와 임베디드 시스템 프로그래머가 관심을 갖는 비교적 잘 알려지지 않은 문제로, 이를 위해 Python 표준 라이브러리의 크로스 플랫폼 API에 새로운 Linux 전용 고려 사항을 더해 확장하는 것은 정당화되지 않을 수 있기 때문입니다. 특히 secrets 모듈이, 어떤 이유로든 (웹 프레임워크 같은) 더 높은 수준의 도메인 특화 API에조차 의존할 수 없고 Python 3.6 이전 버전을 신경 쓸 필요도 없는, 보안에 민감한 소프트웨어를 작성하는 개발자를 위한 “이것을 사용하고 저수준 세부 사항은 걱정하지 마십시오” 선택지로 이미 추가되었다는 점을 고려하면 더욱 그렇습니다.

그렇긴 하지만, 저가형 ARM 장치가 점점 더 보편화되고 있고, 그중 상당수가 Linux를 실행하며, 그 장치에서 실행되는 Python 애플리케이션을 작성하는 사람도 많다는 것 또한 사실입니다. 이는 현재 진단하고 해결하려면 Linux 부팅 과정과 증명 가능하게 예측 불가능한 난수 생성에 대한 많은 지식이 필요한 모호한 보안 문제를, 비교적 평범하고 인터넷 검색으로 쉽게 찾을 수 있는 런타임 예외로 바꿀 기회를 만들어 줍니다.

os.urandom()의 크로스 플랫폼 동작

Linux와 NetBSD 이외의 운영 체제에서는 os.urandom()이 이미 운영 체제의 난수 제너레이터가 준비될 때까지 블록되어 기다릴 수 있습니다. 이는 프로세스 수명 동안 최대 한 번만 발생하며, 이후의 호출은 블록되지 않음이 보장됩니다.

Linux와 NetBSD는 예외적인 경우로, 운영 체제의 난수 제너레이터가 스스로 보안에 민감한 작업에 사용할 준비가 되지 않았다고 판단하는 경우에도 /dev/urandom 장치에서 읽으면 현재 사용 가능한 엔트로피를 바탕으로 한 무작위 값을 반환합니다.

이 동작은 잠재적으로 문제가 될 수 있으므로, Linux 3.17은 새로운 getrandom() 시스템 호출을 추가했습니다. 이 시스템 호출은 (여러 이점 중에서도) 호출자가 난수 제너레이터가 준비될 때까지 블록되어 기다리거나, 난수 제너레이터가 준비되지 않은 경우 오류 반환을 요청할 수 있도록 합니다. 특히 새 API는 보안에 민감한 사용 사례에 적합하지 않은 데이터를 반환하는 이전 동작을 지원하지않습니다.

Python 3.4 및 그 이전의 Python 버전은 Linux /dev/urandom 장치에 직접 접근합니다.

Python 3.5.0과 3.5.1은 (새 시스템 호출을 제공하는 시스템에서 빌드된 경우) /dev/urandom에 접근하기 위한 파일 디스크립터 사용을 피하려고 getrandom()을 블로킹 모드로 호출했습니다. 사용자 코드에서 os.urandom()이 블록되어 보고된 구체적인 문제는 없었지만, 인터프리터 시작 중과 random 모듈을 임포트할 때 CPython이 암묵적으로 블로킹 동작을 일으켜 발생한 문제는 있었습니다.

Python 3.5.2는 SipHash 초기화를 os.urandom() 구현에서 분리하려 하는 대신, getrandom()을 논블로킹 모드로 호출하고 시스템 호출이 블록될 것이라고 알리면 /dev/urandom에서 읽는 방식으로 대체하도록 전환했습니다.

위와 같은 결과로, Python 3.5 및 그 이전의 모든 Python 버전에서 os.urandom()은 기반이 되는 /dev/urandom 장치의 동작을 Python 코드에 그대로 전달합니다.

Linux에서의 /dev/urandom 동작의 문제점

Python os 모듈은 대체로 Linux API와 함께 발전해 왔으므로, Linux에서 실행될 때 os 모듈 함수가 대응하는 Linux 운영 체제 수준 함수의 동작을 밀접하게 따르는 것은 일반적으로 바람직한 특성으로 여겨집니다.

그러나 /dev/urandom은 현재 동작이 문제가 있다고 인정되지만, 이를 커널 수준에서 일방적으로 수정하면 일부 Linux 배포판이 부팅되지 않는 것으로 드러난 경우입니다(적어도 부분적으로는 Python 같은 구성 요소가 현재 시스템 초기화 과정 초기에 보안과 무관한 목적으로 이를 사용하기 때문입니다).

비유로, 다음 두 함수를 생각해 보십시오:

def generate_example_password():
    """Generates passwords solely for use in code examples"""
    return generate_unpredictable_password()

def generate_actual_password():
    """Generates actual passwords for use in real applications"""
    return generate_unpredictable_password()

운영 체제의 난수 제너레이터를 예측할 수 없는 비밀 암호를 생성하는 방법으로 생각한다면, Linux의 /dev/urandom은 다음과 같이 구현되었다고 생각할 수 있습니다:

# Oversimplified artist's conception of the kernel code
# implementing /dev/urandom
def generate_unpredictable_password():
    if system_rng_is_ready:
        return use_system_rng_to_generate_password()
    else:
        # we can't make an unpredictable password; silently return a
        # potentially predictable one instead:
        return "p4ssw0rd"

이 시나리오에서 generate_example_password의 작성자는 문제가 없습니다 - "p4ssw0rd"가 예상보다 조금 더 자주 나오더라도, 어차피 예제에서만 사용되기 때문입니다. 그러나 generate_actual_password의 작성자에게는 문제가 있습니다 - generate_unpredictable_password 호출이 예측 가능한 답을 반환하는 경로를 절대 따르지 않는다는 것을 어떻게 증명할 수 있겠습니까?

실제로는 이보다 조금 더 복잡합니다. 어느 정도의 시스템 엔트로피를 사용할 수 있을지도 모르기 때문입니다. 따라서 대체 동작은 return random.choice(["p4ssword", "passw0rd", "p4ssw0rd"])에 더 가깝거나, 그보다 훨씬 더 가변적인 무언가일 수 있습니다. 그런 경우에도 결과는 통계적으로만 예측할 수 있을 뿐이지만, 그 확률은 generate_actual_password 작성자가 예상한 것보다 높습니다. 하지만 이것이 입증 가능한 수준에서 더 안전해진다는 뜻은 아닙니다. 대부분은 단지 문제를 뻔한 방식으로 – if returned_password == "p4ssw0rd": raise UhOh – 잡으려 하면 제대로 동작하지 않는다는 뜻일 뿐입니다. returned_password가 대신 p4ssword이거나 심지어 pa55word일 수도 있고, 2**64개보다 적은 가능성 중에서 선택된 임의의 64비트 시퀀스일 수도 있기 때문입니다. 따라서 이 대략적인 스케치는 하위 호환성을 깨뜨리지 않고 이 문제의 실질적인 결과를 완화하려는 Linux 커널 팀의 노력에는 전혀 공정하지 않지만, “예상보다 더 예측 가능한” 폴백 동작이 초래하는 결과에 대해서는 대체로 올바른 개념을 전달합니다.

이 설계는 나쁜 아이디어라는 데 대체로 의견이 일치합니다. 우리가 아는 한, 이것이 실제로 원하는 동작인 사용 사례는 전혀 없습니다. 이 설계로 인해 실제 시스템에서 안전하지 않은 ssh 키가 사용되었으며, 많은 *nix 계열 시스템(최소한 Mac OS X, OpenBSD, FreeBSD 포함)은 이 경우 읽기를 블록하게 하거나, 시스템 RNG가 초기화될 때까지 어떤 사용자 공간 프로그램도 실행하지 않는 방식으로 /dev/urandom 구현을 수정하여 예측 가능한 출력을 절대 반환하지 않도록 했습니다. 안타깝게도 Linux는 지금까지 이를 따르지 못했습니다. 블로킹 동작을 활성화하면 현재 존재하는 일부 배포판이 부팅에 실패한다는 사실이 경험적으로 확인되었기 때문입니다.

그 대신 새로운 getrandom() 시스템 호출이 도입되었습니다. 이로써 사용자 공간 애플리케이션은 기존 Linux 배포판의 시스템 초기화 과정에 디버깅하기 어려운 교착 상태 문제를 일으키지 않고도 시스템 난수 제너레이터에 안전하게 접근할 수 있게 되었습니다.

getrandom() 사용 가능성이 Python에 미치는 영향

getrandom() 시스템 호출이 도입되기 전에는 Linux 시스템 난수 제너레이터에 증명 가능하게 안전한 방식으로 접근하는 것이 사실상 불가능했습니다. 그래서 /dev/urandom에서 읽는 방식을 최선의 선택지로 받아들일 수밖에 없었습니다. 그러나 getrandom()은 예측 가능한 데이터를 반환하는 대신 반드시 오류를 발생시키거나 블록하며 그 밖의 장점도 있으므로, 이제 Linux에서 커널 RNG에 접근하는 권장 방법이 되었고 /dev/urandom을 직접 읽는 방식은 “레거시” 지위로 밀려났습니다. 이로써 Linux는 /dev/urandom 장치를 아예 제공하지 않는 Windows 같은 다른 운영 체제와 같은 범주에 들어가게 됩니다. 즉, os.urandom()을 구현하는 최선의 선택지는 더 이상 단순히 /dev/urandom 장치에서 바이트를 읽는 것이 아닙니다.

이는 예전에는 다른 누군가(Linux 커널 개발 팀)의 문제였던 것이 이제는 Python의 문제가 되었다는 뜻입니다. 시스템 RNG가 초기화되지 않았음을 감지할 방법이 주어졌으므로, 우리는 시스템 RNG를 사용하려 할 때마다 이 상황을 어떻게 처리할지 선택해야 합니다.

3.5.0에서 다소 의도치 않게 구현되었고 Victor Stinner의 경쟁 PEP에서 제안된 것처럼, 단순히 블록할 수 있습니다:

# artist's impression of the CPython 3.5.0-3.5.1 behavior
def generate_unpredictable_bytes_or_block(num_bytes):
    while not system_rng_is_ready:
        wait
    return unpredictable_bytes(num_bytes)

또는 이 PEP가 (일부 경우에) 제안하는 것처럼 오류를 발생시킬 수 있습니다:

# artist's impression of the behavior proposed in this PEP
def generate_unpredictable_bytes_or_raise(num_bytes):
    if system_rng_is_ready:
        return unpredictable_bytes(num_bytes)
    else:
        raise BlockingIOError

또는 3.5.2rc1에서 구현되었고 3.5.x 주기의 나머지 기간 동안 유지될 것으로 예상되는 것처럼, /dev/urandom 대체 동작을 명시적으로 흉내 낼 수도 있습니다:

# artist's impression of the CPython 3.5.2rc1+ behavior
def generate_unpredictable_bytes_or_maybe_not(num_bytes):
    if system_rng_is_ready:
        return unpredictable_bytes(num_bytes)
    else:
        return (b"p4ssw0rd" * (num_bytes // 8 + 1))[:num_bytes]

(그리고 위에서 살펴본 /dev/urandom의 generate_unpredictable_password 스케치에 적용된 것과 같은 주의 사항이 이 스케치에도 적용됩니다.)

CPython과 표준 라이브러리가 운영 체제의 난수 제너레이터를 사용하려고 하는 곳은 다섯 군데입니다. 따라서 이 결정을 내려야 하는 곳도 다섯 군데입니다.

  • str.__hash__ 및 관련 함수들을 DoS 공격으로부터 보호하는 데 사용되는 SipHash 초기화(시작 시 무조건 호출됨)
  • random 모듈 초기화(random을 임포트할 때 호출됨)
  • os.urandom 공개 API에 대한 사용자 호출 처리
  • 더 상위 수준의 random.SystemRandom 공개 API
  • PEP 506에서 추가된 새로운 secrets 모듈 공개 API

이전에는 이 다섯 곳 모두 동일한 기반 코드를 사용했으므로, 이 결정도 동일한 방식으로 내렸습니다.

이 문제 전체는 3.5.0에서 해당 기반 코드를 generate_unpredictable_bytes_or_block 동작으로 전환하면서 처음 발견되었습니다. Linux 부트 스크립트가 시스템 초기화의 일부로 Python 프로그램을 실행하려 하는 드문 경우가 있었는데, Python 시작 시퀀스가 SipHash를 초기화하려다 블록되었고, 시스템이 외부 타이머에 의해 Python 스크립트가 강제 종료될 때까지 새로운 엔트로피 수집을 포함한 모든 작업을 멈추면서 교착 상태가 발생한 것으로 밝혀졌습니다. 해당 스크립트들은 신뢰할 수 없는 입력을 전혀 처리하지 않았으므로 애초에 SipHash를 예측 불가능성이 입증된 난수 데이터로 초기화할 필요가 없었다는 점에서, 이는 특히 안타까운 일입니다. 이것이 계기가 되어 3.5.2rc1에서 모든 경우에 예전의 /dev/urandom 동작을 에뮬레이트하도록 변경되었습니다(getrandom()을 논블로킹 모드로 호출한 다음, 시스템 호출이 /dev/urandom 풀이 아직 완전히 초기화되지 않았음을 알리면 /dev/urandom 읽기로 폴백하는 방식입니다.)

Fedora/RHEL/CentOS 생태계에도 이러한 문제가 존재하는지는 알 수 없습니다. 해당 배포판의 빌드 시스템은 getrandom() 시스템 호출을 제공하지 않는 구버전 운영 체제 커널을 실행하는 서버에서 chroot를 사용하므로, CPython의 현재 빌드 구성에서는 해당 시스템 호출에 대한 런타임 검사가 컴파일 과정에서 제외되기 때문입니다 [10].

random 모듈이 기본 전역 random.Random() 인스턴스의 시드를 설정하기 위해 임포트의 부수 효과로 os.urandom을 호출하는 탓에 비슷한 문제가 발견되었습니다.

3.5.0 또는 3.5.1에서 os.urandom()이나 random.SystemRandom()을 직접 호출할 때 블록되는 것에 관한 구체적인 불만은 접수되지 않았습니다. 인터프리터 시작 시와 random 모듈 임포트의 부수 효과로 발생하는 암묵적인 블로킹으로 인한 문제 보고만 있었습니다.

이 PEP와는 별개로, 처음 두 경우는 os.urandom()의 동작과 관계없이 절대 블록하지 않도록 이미 수정되었습니다.

PEP 524는 뒤의 세 경우 모두를 암묵적으로 블로킹하도록 만들자고 제안하는 반면, 이 PEP는 그 접근 방식을 마지막 경우(secrets 모듈)에만 적용할 것을 제안하며, os.urandom()과 random.SystemRandom()은 그 대신 기반 운영 체제의 시스템 호출이 블로킹될 것임을 감지하면 예외를 발생시킵니다.

참고 문헌

이 PEP와 이와 경쟁하는 Victor의 PEP에 담긴 내용 외의 추가 배경 정보는 Victor가 이전에 정리한 관련 정보 및 링크 모음 https://haypo-notes.readthedocs.io/summary_python_random_issue.html 도 참고하십시오.