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는 하위 수준의 os.urandom 및 random.SystemRandom API에서 이러한 실패가 발생할 때, Python 3.6부터 현재의 조용하고 감지 및 디버깅이 어려운 오류 대신 적절한 오류 메시지와 함께 BlockingIOError를 발생시켜 쉽게 감지하고 디버깅할 수 있도록 변경할 것을 제안합니다. 이를 통해 개발자는 해당 상황을 처리하는 데 선호하는 방식을 명확하게 지정할 수 있습니다.
새로운 고수준 secrets API에서는 해당 모듈이 난수를 생성할 때마다 필요한 경우 암묵적으로 차단하고, 저수준 API를 사용하는 코드가 시스템 난수 제너레이터를 명시적으로 사용할 수 있을 때까지 기다릴 수 있도록 새로운 secrets.wait_for_system_rng()함수를 제공할 것을 제안합니다.
이 변경은 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 524의 무조건적 암묵적 블로킹 제안을 지지하여 이 PEP를 거부했습니다. 이 제안은 Linux에서 CPython의 동작을 다른 운영 체제에서의 동작과 일치시킵니다.
이는 Linux 배포판의 시스템 Python 설치에서 os.urandom()의 적절한 기본 동작에 관한 향후 논의가 업스트림 CPython 메일링 리스트가 아니라 각 배포판의 메일링 리스트에서 이루어져야 함을 의미합니다.
이 PEP와 독립적인 변경 사항
시스템 난수 제너레이터가 준비되지 않은 경우 대체 시드 설정 옵션으로 정상적으로 대체하도록 CPython 인터프리터 초기화와 random 모듈 초기화는 이미 업데이트되었습니다.
이 PEP는 해당 기능을 제공하는 플랫폼에서 getrandom 시스템 호출을 공개하는 os.getrandom() API를 추가하자는 PEP 524의 제안과 경쟁하지 않습니다. 잠재적으로 플랫폼에 종속적인 운영 체제 기능을 얇게 래핑하는 os 모듈의 역할상 해당 API를 추가할 충분한 이유가 있으므로, 이러한 시스템에서 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()를 발생시킬 것을 제안합니다.
그러면 이 동작은 os.urandom()을 비교적 얇게 래핑하면서 random.Random() API와 일치하는 기존 random.SystemRandom으로 전파됩니다.
그러나 PEP 506에서 도입된 새로운 secrets 모듈은 새로운 예외를 포착하도록 업데이트되며, 해당 예외가 발생할 경우 시스템 난수 제너레이터를 암묵적으로 사용할 수 있을 때까지 기다립니다.
모든 경우에 이러한 보안 민감 API 중 하나에 대한 호출이 성공하는 즉시 해당 프로세스에서 이러한 API에 대한 이후의 모든 호출은 차단 없이 성공합니다(시스템 부팅 후 운영 체제의 난수 제너레이터가 준비되면 계속 준비된 상태로 유지됩니다).
Linux와 NetBSD에서는 이 동작이 /dev/urandom에서 읽은 잠재적으로 예측 가능한 결과를 반환하던 이전 동작을 대체합니다.
FreeBSD, Solaris 및 Illumos에서는 시스템 난수 제너레이터가 준비될 때까지 암묵적으로 차단하던 이전 동작이 이 동작으로 대체됩니다. 그러나 이러한 운영 체제가 시스템 난수 제너레이터가 준비되기 전에 사용자 공간 코드(따라서 Python)의 실행을 실제로 허용하는지는 명확하지 않습니다.
모든 경우에 기반이 되는 getrandom() API 호출이 성공적인 응답을 반환하거나 EAGAIN을 보고하는 대신 ENOSYS를 보고하면 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 모듈을 사용하십시오. 그렇지 않으면 애플리케이션이 예기치 않게 충돌할 수 있습니다”라는 밈을 따르도록 장려하기 위한 것입니다. 이는 상용구가 훨씬 많은 “사용 가능한 경우 항상 secrets.wait_for_system_rng()를 호출하십시오. 그렇지 않으면 애플리케이션이 예기치 않게 충돌할 수 있습니다”라는 방식보다 간단합니다.
또한 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 이상을 사용하는 개발자는 원하는 동작을 쉽게 선택할 수 있습니다.
- 애플리케이션을 시작할 때 또는 그 전에 시스템 RNG가 준비될 때까지 기다리십시오(보안 민감).
- random 모듈을 사용하도록 전환하십시오(보안과 무관).
secrets.wait_for_system_rng() 공개하기
이 PEP의 이전 버전에서는 보안에 민감한 사용 사례에 적합하게 만들기 위해 os.urandom()을 래핑하는 여러 레시피를 제안했습니다.
security-sig 메일링 리스트에서 이 제안을 논의하면서 [9] 다음과 같은 사실을 깨닫게 되었습니다. 이 PEP의 API 설계를 이끄는 핵심 가정은 예외가 발생하여 애플리케이션이 실패하도록 둘지, 시스템 RNG가 준비될 때까지 차단하여 기다릴지, 아니면 os.urandom 대신 random 모듈을 사용할지 선택하는 것이 애플리케이션 및 사용 사례별 결정이며 애플리케이션과 사용 사례의 구체적인 세부 사항을 고려해야 한다는 것입니다.
특정 사용 사례가 보안에 민감한지 여부를 인터프리터 런타임이나 지원 라이브러리가 판단할 방법은 없으며, 애플리케이션 개발자가 특정 API에서 발생한 예외를 처리할 방법을 결정하는 것은 간단하지만, 비차단 방식일 것으로 예상한 API가 차단되는 상황을 쉽게 우회할 수는 없습니다.
이에 따라 PEP가 업데이트되어, 애플리케이션, 스크립트 및 프레임워크가 계속 진행하기 전에 시스템 RNG를 사용할 수 있도록 보장하려 한다는 것을 나타내기 위한 API로 secrets.wait_for_system_rng()을 추가했습니다. 한편 라이브러리 개발자는 시스템 RNG를 사용할 수 있을 때까지 예기치 않게 차단되기 시작할 수 있다는 걱정 없이 os.urandom()을 계속 호출할 수 있습니다.
하위 호환성 영향 평가
이 제안은 PEP 476과 마찬가지로, 이전에 조용히 발생하던 보안 실패를 애플리케이션 개발자가 원하는 동작을 명시적으로 결정해야 하는 오류를 알리는 예외로 전환하는 제안입니다.
getrandom() 시스템 호출을 제공하지 않는 운영 체제에는 변경을 제안하지 않으므로 os.urandom()은 기존 동작을 유지합니다. 즉, 명목상 차단하는 API이지만 운영 체제의 난수 제너레이터가 준비되기 전에 Python 코드를 실행하도록 예약하기 어렵기 때문에 실제로는 차단하지 않습니다. 적어도 일부 *BSD 변형에서는 이 PEP에 설명된 것과 유사한 문제가 발생할 수 있다고 생각하지만, 이를 명시적으로 입증한 사람은 없습니다. Mac OS X와 Windows에서는 부팅 프로세스의 그처럼 이른 단계에 Python 인터프리터를 실행하려고 시도하는 것조차 사실상 불가능한 것으로 보입니다.
Linux 및 이와 유사한 /dev/urandom동작을 보이는 플랫폼에서는 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")
애플리케이션의 세부 사항에 따라 os.urandom() API가 생성하는 원시 바이트 시퀀스를 에뮬레이트할 필요 없이 직접 사용할 수 있는 다른 API를 random 모듈이 제공할 수도 있습니다.
추가 배경
지금 제안하는 이유는 무엇입니까?
주된 이유는 Python 3.5.0 릴리스가 파일 디스크립터를 소비하지 않기 위해 [1]에서 새로운 Linux getrandom() 시스템 호출을 사용할 수 있으면 사용하도록 전환했기 때문이며, 이로 인해 다음 작업이 시스템 난수 제너레이터가 준비될 때까지 차단되는 부작용이 발생했습니다.
os.urandom(및 이에 의존하는 API)random모듈을 가져오는 작업입니다.- 일부 기본 제공 타입에서 사용하는 무작위화된 해시 알고리즘을 초기화하는 작업입니다.
이 동작들 중 첫 번째는 바람직하다고 볼 수 있고(다른 운영 체제에서의 기존 os.urandom의 동작과도 일관되지만), 뒤의 두 동작은 불필요하고 바람직하지 않습니다. 특히 마지막 동작은 Python 3.5.0 또는 3.5.1에서 Linux 초기화 프로세스 중 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에서 보안에 민감한 작업에 적합한 난수를 안정적으로 얻는 문제는 주로 운영 체제 개발자와 임베디드 시스템 프로그래머가 관심을 두는 비교적 잘 알려지지 않은 문제이며, 새로운 Linux 전용 우려 사항을 추가하여 Python 표준 라이브러리의 플랫폼 간 API를 확장할 만큼 중요하지 않을 수 있기 때문입니다. 이는 특히 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이 차단 동작을 암묵적으로 호출하여 발생한 문제는 있었습니다.
SipHash 초기화를 os.urandom()구현에서 분리하려고 하는 대신, Python 3.5.2는 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 포함)은 /dev/urandom 구현을 수정하여 예측 가능한 출력을 절대 반환하지 않도록 했습니다. 이 경우 읽기 작업을 블록하게 하거나, 시스템 RNG가 초기화될 때까지 사용자 공간 프로그램을 전혀 실행하지 않는 방식입니다. 안타깝게도 Linux는 지금까지 이를 따르지 못했습니다. 블록 동작을 활성화하면 현재 존재하는 일부 배포판이 부팅에 실패한다는 사실이 실증적으로 확인되었기 때문입니다.
대신 새로운 getrandom() 시스템 호출이 도입되어, 사용자 공간 애플리케이션이 기존 Linux 배포판의 시스템 초기화 프로세스에 디버깅하기 어려운 교착 상태 문제를 일으키지 않고 시스템 난수 제너레이터에 안전하게 액세스할 수 있게 했습니다.
Python에서 getrandom()을 사용할 수 있게 됨에 따른 결과
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과 표준 라이브러리가 운영 체제의 난수 제너레이터를 사용하려고 시도하는 곳은 다섯 군데이며, 따라서 이 결정을 내려야 하는 곳도 다섯 군데입니다:
- DoS 공격으로부터
str.__hash__및 관련 항목을 보호하는 데 사용되는 SipHash 초기화(시작 시 무조건 호출됨) random모듈 초기화(random을 가져올 때 호출됨)os.urandom공개 API에 대한 사용자 호출 처리- 상위 수준의
random.SystemRandom공개 API - 새로운
secrets모듈의 공개 API는 PEP 506에 의해 추가되었습니다.
이전에는 이 다섯 지점이 모두 동일한 기반 코드를 사용했으므로, 동일한 방식으로 이 결정을 내렸습니다.
이 문제 전체가 처음 발견된 이유는 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 도 참고하십시오.
Copyright
This document has been placed into the public domain.