PEP 768 – CPython을 위한 안전한 외부 디버거 인터페이스
- Author:
- Pablo Galindo Salgado <pablogsal at python.org>, Matt Wozniski <godlygeek at gmail.com>, Ivona Stojanovic <stojanovic.i at hotmail.com>
- Discussions-To:
- Discourse thread
- Status:
- Final
- Type:
- Standards Track
- Created:
- 25-Nov-2024
- Python-Version:
- 3.14
- Post-History:
- 11-Dec-2024
- Resolution:
- 17-Mar-2025
Table of Contents
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
이 PEP는 디버거와 프로파일러가 실행 중인 파이썬 프로세스에 안전하게 연결할 수 있게 하는, 오버헤드가 전혀 없는 디버깅 인터페이스를 CPython에 추가할 것을 제안합니다. 이 인터페이스는 인터프리터의 정상 실행 경로를 수정하거나 런타임 오버헤드를 추가하지 않고도 디버거 코드를 연결할 수 있는 안전한 실행 지점을 제공합니다.
이 인터페이스의 핵심 활용 사례는 gdb -p와 유사하게 pdb가 프로세스 ID로 실행 중인 프로세스에 연결할 수 있도록 하는 것입니다. 이를 통해 개발자는 Python 애플리케이션을 중지하거나 재시작하지 않고도 실시간으로 대화형으로 검사하고 디버깅할 수 있습니다.
동기
프로덕션 환경과 실행 중인 환경에서 Python 프로세스를 디버깅하는 일에는 고유한 어려움이 있습니다. 개발자는 서비스를 중지하거나 재시작하지 않고 애플리케이션의 동작을 분석해야 하는 경우가 많으며, 이는 고가용성 시스템에서 특히 중요합니다. 흔한 시나리오로는 교착 상태 진단, 메모리 사용량 검사, 예기치 않은 동작의 실시간 조사 등이 있습니다.
실행 중인 프로세스에 연결할 수 있는 파이썬 도구는 매우 적습니다. 주된 이유는 그렇게 하려면 운영 체제 디버깅 인터페이스와 CPython 내부 구조 양쪽에 대한 깊은 전문 지식이 필요하기 때문입니다. GDB나 LLDB 같은 C/C++ 디버거는 잘 알려진 기법을 사용하여 프로세스에 연결할 수 있지만, Python 도구는 이러한 저수준 메커니즘을 모두 구현해야 할 뿐만 아니라 추가적인 복잡성까지 처리해야 합니다. 예를 들어, GDB가 대상 프로세스에서 코드를 실행해야 할 때 GDB는 다음과 같이 동작합니다:
- ptrace를 사용하여 실행 가능한 메모리를 작게 한 덩어리 할당합니다(말처럼 쉽지는 않습니다)
- 짧은 기계어 코드 시퀀스를 작성합니다 - 일반적으로 함수 프롤로그, 원하는 명령어, 레지스터를 복원하는 코드로 구성됩니다
- 대상 스레드의 레지스터를 모두 저장합니다
- 명령어 포인터를 주입된 코드로 변경합니다
- 주입된 코드 끝의 중단점에 도달할 때까지 프로세스를 실행시킵니다
- 원래 레지스터를 복원하고 실행을 계속합니다
Python 도구도 코드 주입이라는 동일한 과제에 직면하지만, 여기에는 복잡성이 한 겹 더해집니다. 이러한 도구는 위의 메커니즘을 구현해야 할 뿐만 아니라, 인터프리터 루프, 가비지 컬렉터, 스레드 상태, 참조 카운팅 시스템을 포함한 CPython의 런타임 상태를 이해하고 이와 안전하게 상호 작용해야 합니다. 이처럼 저수준 시스템 조작과 인터프리터에 대한 깊은 도메인 지식이 함께 요구되기 때문에 Python 디버깅 도구를 구현하기가 매우 어렵습니다.
이를 시도하는 소수의 도구들(예를 들어 DebugPy와 Memray를 참조하십시오)은 GDB나 LLDB 같은 시스템 디버거를 사용하여 코드를 강제로 주입하는, 최적이 아니고 안전하지 않은 방법에 의존합니다. 이 접근 방식은 근본적으로 안전하지 않습니다. 주입된 코드가 인터프리터 실행 주기의 어느 시점에서든 실행될 수 있기 때문입니다 - 심지어 메모리 할당, 가비지 컬렉션, 스레드 상태 관리 같은 중요한 작업 도중에도 실행될 수 있습니다. 이런 일이 발생하면 결과는 치명적입니다: 이미 malloc() 내부에 있는 상태에서 메모리를 할당하려고 하면 크래시가 발생하고, 가비지 컬렉션 도중 객체를 수정하면 인터프리터의 상태가 손상되며, 잘못된 시점에 스레드 상태를 건드리면 교착 상태로 이어집니다.
여러 도구는 주입된 코드를 위한 별도의 스레드를 생성하거나, 작업 시점을 신중하게 조정하거나, 프로세스를 중지하기에 좋은 지점을 고르려고 시도하는 등 복잡한 우회 방법을 통해 이러한 위험을 최소화하려고 합니다. 그러나 이러한 완화책으로는 근본적인 문제를 완전히 해결할 수 없습니다: 인터프리터의 협조 없이는 특정 순간에 코드를 실행해도 안전한지 알 방법이 없습니다. 신중하게 구현된 도구조차 인터프리터를 크래시시킬 수 있습니다. 근본적으로 인터프리터와 협력하는 것이 아니라 인터프리터에 맞서 동작하기 때문입니다.
근거
도구가 안전하지 않은 코드 주입으로 인터프리터의 제약을 우회하도록 강요하는 대신, 안전한 실행을 보장하는 적절한 디버깅 인터페이스로 CPython을 확장할 수 있습니다. 몇 개의 스레드 상태 필드를 추가하고 인터프리터의 기존 평가 루프와 통합하면, 디버깅 작업이 잘 정의된 안전한 지점에서만 수행되도록 보장할 수 있습니다. 이를 통해 정상 실행 중에는 오버헤드를 전혀 발생시키지 않으면서도 크래시와 상태 손상의 가능성을 제거합니다.
핵심은 임의의 지점에 코드를 주입할 필요가 없다는 것입니다. 다음 안전한 기회에 코드를 실행하기를 원한다는 신호를 인터프리터에 보내기만 하면 됩니다. 이 접근 방식은 인터프리터의 자연스러운 실행 흐름에 맞서지 않고 그 흐름에 맞춰 동작합니다.
이 아이디어를 PyPy 개발 팀에 설명한 뒤, 이 제안은 이미 PyPy에 구현되어 실현 가능성과 효과를 모두 입증했습니다. 이 구현은 정상 실행 중에 런타임 오버헤드 없이 안전한 디버깅 기능을 제공할 수 있음을 보여 줍니다. 제안된 메커니즘은 현재 디버깅 방식에 따르는 위험을 줄일 뿐만 아니라 향후 개선을 위한 기반도 마련합니다. 예를 들어, 이 프레임워크를 통해 널리 쓰이는 관측 가능성(observability) 도구와 통합하여 인터프리터 성능이나 메모리 사용량에 대한 실시간 정보를 제공할 수 있습니다. 이 인터페이스의 설득력 있는 사용 사례 중 하나는, gdb가 프로세스 ID로 프로그램에 연결할 수 있게 해 주는 것(gdb -p <pid>)과 비슷하게 pdb가 실행 중인 Python 프로세스에 연결할 수 있게 하는 것입니다. 이 기능을 사용하면 개발자는 실행 중인 애플리케이션의 상태를 검사하고, 표현식을 평가하고, 코드를 동적으로 한 단계씩 실행할 수 있습니다. 이 접근 방식은 Python의 디버깅 기능을 이러한 모드를 지원하는 다른 주요 프로그래밍 언어 및 디버깅 도구의 수준에 맞출 것입니다.
명세
이 제안은 외부 프로세스가 잘 정의된 안전 지점에서 Python 인터프리터의 코드 실행을 촉발할 수 있게 하는 안전한 디버깅 메커니즘을 도입합니다. 핵심은 시스템 디버거를 통해 코드를 직접 주입하는 대신, 인터프리터의 기존 평가 루프와 스레드 상태를 활용하여 디버깅 작업을 조율할 수 있다는 것입니다.
이 메커니즘은 디버거가 대상 프로세스의 특정 메모리 위치에 값을 쓰면, 인터프리터가 정상 실행 주기 중에 그 위치를 확인하는 방식으로 동작합니다. 인터프리터는 디버거가 연결하려 한다는 것을 감지하면, 안전할 때에만, 즉 내부 잠금이 하나도 잡혀 있지 않고 모든 자료 구조가 일관된 상태일 때에만 요청된 작업을 실행합니다.
런타임 상태 확장
원격 디버깅을 지원하기 위해 PyThreadState에 새 구조체가 추가됩니다:
typedef struct {
int debugger_pending_call;
char debugger_script_path[...];
} _PyRemoteDebuggerSupport;
이 구조체는 PyThreadState에 덧붙여지며, 정상 실행 중에는 절대 접근되지 않는 몇 개의 필드만 추가합니다. debugger_pending_call 필드는 디버거가 실행을 요청했음을 나타내고, debugger_script_path는 인터프리터가 안전 지점에 도달했을 때 실행될 Python 소스 파일(.py)의 파일 시스템 경로를 제공합니다. 이 경로는 컴파일된 Python 코드(.pyc)나 다른 형식이 아닌 Python 소스 파일을 가리켜야 합니다.
debugger_script_path의 크기는 바이너리 크기와 디버깅 스크립트 경로의 최대 길이 사이의 절충이 될 것입니다. 스레드당 메모리 오버헤드를 제한하기 위해 이 크기를 512바이트로 제한할 것입니다. 이 크기는 디버거 지원 구조체의 일부로도 제공되므로, 디버거는 얼마만큼 쓸 수 있는지 알 수 있습니다. 이 값은 필요하다면 향후 확장할 수 있습니다.
디버그 오프셋 테이블
Python 3.12는 PyRuntime 구조체의 시작 부분에 위치하는 디버그 오프셋 테이블을 도입했습니다. 이 섹션에는 ASLR이나 Python의 컴파일 방식과 관계없이 외부 도구가 핵심 런타임 구조체를 안정적으로 찾을 수 있게 해 주는 _Py_DebugOffsets 구조체가 들어 있습니다.
이 제안은 기존 디버그 오프셋 테이블에 디버거 지원을 위한 새 필드를 추가하여 확장합니다:
struct _debugger_support {
uint64_t eval_breaker; // Location of the eval breaker flag
uint64_t remote_debugger_support; // Offset to our support structure
uint64_t debugger_pending_call; // Where to write the pending flag
uint64_t debugger_script_path; // Where to write the script path
uint64_t debugger_script_path_size; // Size of the script path buffer
} debugger_support;
이 오프셋들을 사용하면 디버거가 대상 프로세스의 메모리 공간에서 핵심 디버깅 제어 구조체를 찾을 수 있습니다. eval_breaker와 remote_debugger_support 오프셋은 각 PyThreadState를 기준으로 하는 상대 오프셋이고, debugger_pending_call과 debugger_script_path 오프셋은 각 _PyRemoteDebuggerSupport 구조체를 기준으로 하는 상대 오프셋이므로, 새 구조체와 그 필드가 메모리의 어디에 있든 찾을 수 있습니다. debugger_script_path_size는 연결하는 도구에 버퍼의 크기를 알려 줍니다.
연결 프로토콜
디버거가 Python 프로세스에 연결하려 할 때는 다음 단계를 따릅니다:
- 프로세스에서
PyRuntime구조체를 찾습니다:- 프로세스 메모리에서 Python 바이너리(실행 파일 또는 libpython)를 찾습니다(과정은 OS에 따라 다릅니다).
- 바이너리 형식(ELF/Mach-O/PE)에서
.PyRuntime섹션 오프셋을 추출합니다. - 오프셋을 바이너리의 로드 주소로 재배치하여 실행 중인 프로세스에서 실제
PyRuntime주소를 계산합니다.
PyRuntime구조체 시작 부분에 있는_Py_DebugOffsets를 읽어 디버그 오프셋 정보에 접근합니다.- 오프셋을 사용하여 원하는 스레드 상태를 찾습니다.
- 오프셋을 사용하여 해당 스레드 상태 안의 디버거 인터페이스 필드를 찾습니다.
- 제어 정보를 기록합니다:
- 대부분의 디버거는 프로세스의 메모리에 쓰기 전에 프로세스를 일시 중지합니다. 이는 SIGSTOP이나 ptrace를 사용하여 프로세스를 일시 중지하는 GDB 같은 도구의 표준 관행입니다. 이 방식은 프로세스 메모리에 쓸 때 발생하는 경쟁 상태를 방지합니다. 프로세스를 중지하고 싶지 않은 프로파일러나 기타 도구도 이 인터페이스를 사용할 수 있지만, 발생할 수 있는 경쟁 상태를 처리해야 합니다. 이는 프로파일러라면 일반적으로 고려하는 사항입니다.
_PyRemoteDebuggerSupport의debugger_script_path필드에 Python 소스 파일(.py)의 파일 경로를 기록합니다._PyRemoteDebuggerSupport의debugger_pending_call플래그를 1로 설정합니다.eval_breaker필드에_PY_EVAL_PLEASE_STOP_BIT를 설정합니다.
인터프리터가 다음 안전 지점에 도달하면 디버거가 지정한 파일에 담긴 Python 코드를 실행합니다.
인터프리터 통합
인터프리터의 일반 평가 루프에는 이미 시그널, 주기적 작업, 기타 인터럽트를 처리하기 위한 eval_breaker 플래그 검사가 포함되어 있습니다. eval_breaker가 설정된 경우에만 디버거 보류 호출을 검사하는 방식으로 이 기존 메커니즘을 활용하므로, 일반 실행 중에는 오버헤드가 전혀 없습니다. 이 검사에는 오버헤드가 없습니다. 실제로 Linux perf로 프로파일링해 보면 이 브랜치는 예측 가능성이 매우 높습니다. 일반 실행 중에는 debugger_pending_call 검사가 결코 참이 되지 않으므로, 최신 CPU는 이 검사를 효과적으로 투기 실행하여 넘어갈 수 있습니다.
디버거가 eval_breaker 플래그와 debugger_pending_call을 모두 설정하면, 인터프리터는 다음 안전 지점에서 제공된 디버깅 코드를 실행합니다. eval breaker를 검사할 때마다 인터프리터가 일관된 상태에 있음이 보장되므로, 이 모든 과정은 완전히 안전한 컨텍스트에서 이루어집니다.
처음에는 debugger_pending_call에 유효한 값이 0과 1뿐이며, 다른 값은 향후 사용을 위해 예약되어 있습니다.
코드가 실행되기 전에 감사 이벤트가 발생하므로, 시스템 관리자가 원하는 경우 이 메커니즘을 감사하거나 비활성화할 수 있습니다.
// In ceval.c
if (tstate->eval_breaker) {
if (tstate->remote_debugger_support.debugger_pending_call) {
tstate->remote_debugger_support.debugger_pending_call = 0;
const char *path = tstate->remote_debugger_support.debugger_script_path;
if (*path) {
if (0 != PySys_Audit("debugger_script", "%s", path)) {
PyErr_Clear();
} else {
FILE* f = fopen(path, "r");
if (!f) {
PyErr_SetFromErrno(OSError);
} else {
PyRun_AnyFile(f, path);
fclose(f);
}
if (PyErr_Occurred()) {
PyErr_WriteUnraisable(...);
}
}
}
}
}
실행 중인 코드가 Python 예외를 발생시키면, 해당 예외는 코드가 실행된 스레드에서 발생시킬 수 없는 예외로 처리됩니다.
Python API
모든 도구에서 이 단계들을 전부 다시 구현하지 않고도 원격 프로세스에서 Python 코드를 안전하게 실행할 수 있도록, 이 제안은 sys 모듈에 새로운 함수를 추가합니다. 이 함수를 사용하면 디버거나 외부 도구가 지정된 Python 프로세스의 컨텍스트 안에서 임의의 Python 코드를 실행할 수 있습니다:
def remote_exec(pid: int, script: str|bytes|PathLike) -> None:
"""
Executes a file containing Python code in a given remote Python process.
This function returns immediately, and the code will be executed by the
target process's main thread at the next available opportunity, similarly
to how signals are handled. There is no interface to determine when the
code has been executed. The caller is responsible for making sure that
the file still exists whenever the remote process tries to read it and that
it hasn't been overwritten.
Args:
pid (int): The process ID of the target Python process.
script (str|bytes|PathLike): The path to a file containing
the Python code to be executed.
"""
이 API의 사용 예는 다음과 같습니다:
import sys
import uuid
# Execute a print statement in a remote Python process with PID 12345
script = f"/tmp/{uuid.uuid4()}.py"
with open(script, "w") as f:
f.write("print('Hello from remote execution!')")
try:
sys.remote_exec(12345, script)
except Exception as e:
print(f"Failed to execute code: {e}")
구성 API
재배포자, 시스템 관리자 또는 사용자가 이 메커니즘을 비활성화할 수 있도록, 인터프리터의 동작을 제어하는 여러 방법이 제공됩니다:
런타임에 동작을 제어할 수 있도록 새로운 PYTHON_DISABLE_REMOTE_DEBUG 환경 변수가 제공됩니다. 이 변수가 어떤 값으로든(빈 문자열 포함) 설정되면, 인터프리터는 이 메커니즘으로 디버거를 연결하려는 모든 시도를 무시합니다.
사용자가 런타임에 이 기능을 비활성화할 수 있도록, 이 환경 변수와 함께 Python 인터프리터에 새로운 -X disable-remote-debug 플래그가 추가됩니다.
또한 재배포자가 원하는 경우 원격 디버깅 지원 없이 Python을 빌드할 수 있도록 configure 스크립트에 새로운 --without-remote-debug 플래그가 추가됩니다.
원격 디버깅 상태를 나타내는 새로운 플래그가 디버그 오프셋을 통해 제공됩니다. 따라서 도구는 원격 프로세스가 이 기능을 비활성화했는지 조회할 수 있습니다. 이렇게 하면 도구는 연결에 성공했다고 믿었는데 스크립트가 끝내 실행되지 않는 상황을 겪는 대신, 왜 동작하지 않는지 설명하는 유용한 오류 메시지를 제공할 수 있습니다.
멀티스레딩 고려 사항
전반적인 실행 패턴은 Python이 내부적으로 시그널을 처리하는 방식과 유사합니다. 인터프리터는 주입된 코드가 안전 지점에서만 실행되도록 보장하며, 이 코드가 인터프리터 내부의 원자적 연산을 중단시키는 일은 결코 없습니다. 이 방식은 대부분의 실제 상황에서 적시에 실행되도록 하면서도 디버깅 작업이 인터프리터 상태를 손상시킬 수 없도록 보장합니다.
하지만 이 인터페이스를 통해 주입된 디버깅 코드는 어느 스레드에서든 실행될 수 있습니다. 시그널 핸들러는 메인 스레드에서만 실행될 수 있으므로, 이 동작은 Python이 시그널을 처리하는 방식과 다릅니다. 디버거가 실행 중인 모든 스레드에 코드를 주입하려면 모든 PyThreadState에 코드를 주입해야 합니다. 디버거가 처음으로 사용 가능한 스레드에서 코드를 실행하려면 모든 PyThreadState에 코드를 주입해야 하며, 주입된 코드는 다른 스레드에서 이미 실행되었는지 확인해야 합니다(아마도 어떤 모듈의 전역 변수에 특정 플래그를 설정하는 방식이 될 것입니다).
주입된 코드가 실행될 때에도 전역 인터프리터 잠금(GIL)이 평소와 같이 실행을 통제한다는 점에 유의하십시오. 즉, 대상 스레드가 GIL을 계속 점유하는 C 확장을 현재 실행 중이라면, 해당 작업이 완료되어 GIL을 사용할 수 있게 될 때까지 주입된 코드는 실행될 수 없습니다. 하지만 이 인터페이스는 주입된 코드 자체가 필요로 하는 것 이상의 추가적인 GIL 경합을 유발하지 않습니다. 중요한 점은 이 인터페이스가 Python의 자유 스레드(free-threaded) 모드와 완전히 호환된다는 것입니다.
실행할 코드를 주입한 디버거가 이어서 미리 등록된 시그널을 프로세스에 보내는 것이 유용할 수 있습니다. 이 시그널은 외부 자원을 기다리는 블로킹 I/O나 대기(sleep) 상태를 중단시키고, 주입된 코드를 실행할 안전한 기회를 제공할 수 있습니다.
하위 호환성
이 변경은 기존 Python 코드나 인터프리터 성능에 아무런 영향을 주지 않습니다. 추가된 필드는 디버거 연결 중에만 접근되며, 확인 메커니즘은 기존 인터프리터 안전 지점에 편승합니다.
보안 영향
이 인터페이스는 주어진 프로세스 내의 임의 메모리에 이미 쓸 수 있고 (실행할 Python 코드가 담긴 파일을 만들기 위해) 해당 머신에서 임의 코드를 실행할 수 있는 프로세스만 사용할 수 있으므로, 새로운 보안 문제를 유발하지 않습니다.
또한 코드 실행은 인터프리터의 감사 훅(audit hook)의 통제를 받으며, 민감한 환경에서는 감사 훅을 사용하여 코드 실행을 감시하거나 차단할 수 있습니다.
기존 운영 체제 보안 메커니즘은 공격자가 임의 메모리 쓰기 권한을 얻는 것을 막는 데 효과적입니다. 이 PEP는 대상 프로세스에 메모리를 어떻게 써야 하는지 명시하지 않지만, 실제로는 다른 디버거와 도구가 이미 사용하고 있는 표준 시스템 호출을 사용하게 됩니다. 몇 가지 예는 다음과 같습니다:
- Linux에서는 process_vm_readv() 및 process_vm_writev() 시스템 호출을 사용하여 다른 프로세스의 메모리를 읽고 씁니다. 이러한 작업은 ptrace 접근 모드 검사로 제어되며, 이는 디버거 연결을 통제하는 것과 동일한 검사입니다. 프로세스는 적절한 권한이 있는 경우에만 다른 프로세스의 메모리를 읽거나 쓸 수 있습니다(일반적으로 root 권한이나 CAP_SYS_PTRACE 역량(capability)이 필요하지만, 보안을 덜 중시하는 배포판에서는 같은 uid로 실행되는 모든 프로세스의 연결을 허용할 수도 있습니다).
- macOS에서는 이 인터페이스가 Mach 태스크 시스템을 통해 mach_vm_read_overwrite()와 mach_vm_write()를 활용하게 됩니다. 이러한 작업에는
task_for_pid()접근 권한이 필요하며, 이 권한은 운영 체제가 엄격하게 통제합니다. 기본적으로 root로 실행되는 프로세스나 Apple의 보안 프레임워크가 부여한 특정 인타이틀먼트(entitlement)를 가진 프로세스만 접근할 수 있습니다. - Windows에서는 ReadProcessMemory() 및 WriteProcessMemory() 함수가 유사한 기능을 제공합니다. 접근은 Windows 보안 모델을 통해 통제됩니다. 프로세스에는 PROCESS_VM_READ 및 PROCESS_VM_WRITE 권한이 필요하며, 이 권한을 얻으려면 일반적으로 같은 사용자 컨텍스트에서 실행되거나 적절한 권한이 있어야 합니다. 이는 디버거에 필요한 것과 같은 권한이므로, 플랫폼 전반에 걸쳐 일관된 보안 의미 체계를 보장합니다.
모든 메커니즘은 다음을 보장합니다:
- 승인된 프로세스만 메모리를 읽고 쓸 수 있습니다
- 기존 디버거 연결에 적용되는 것과 같은 보안 모델이 적용됩니다
- 운영 체제가 디버깅을 위해 이미 제공하는 것 이상의 공격 표면은 추가로 노출되지 않습니다
- 공격자가 임의의 메모리에 쓸 수 있더라도, 이미 파일 시스템 접근 권한을 가지고 있지 않은 한 이를 임의 코드 실행으로 확대할 수 없습니다
메모리 작업 자체는 충분히 확립된 기법이며, GDB, LLDB 및 다양한 시스템 프로파일러 같은 도구에서 수십 년 동안 안전하게 사용되어 왔습니다.
이 메커니즘을 통해 Python 프로세스에 연결하려는 모든 시도는 시스템 수준 모니터링 도구뿐만 아니라 Python 감사 훅으로도 탐지할 수 있다는 점에 유의해야 합니다. 이러한 투명성은 책임 추적성을 한 겹 더해 주어, 관리자가 민감한 환경에서 디버깅 작업을 감사할 수 있게 합니다.
또한 운영 체제 수준의 보안 통제에 엄격하게 의존하므로 기존 시스템 정책이 계속 유효하게 유지됩니다. 기업 환경에서는 관리자가 추가 설정 없이도 표준 도구와 정책을 사용하여 디버깅 제한을 계속 적용할 수 있다는 의미입니다. 예를 들어 디버거 접근을 제한하기 위해 Linux의 ptrace_scope 또는 macOS의 taskgated를 활용하면, 제안된 인터페이스에도 똑같이 적용됩니다.
이 설계는 기존 보안 프레임워크와의 호환성을 유지함으로써, 새 인터페이스를 도입하더라도 이미 확립된 체계를 변경할 필요가 없도록 보장합니다.
보안 시나리오
- 외부 공격자의 경우, 프로세스의 임의 메모리에 쓸 수 있다는 것 자체가 이미 심각한 보안 문제입니다. 공격자는 이미 프로세스에서 임의 코드를 실행할 수 있는 상태이므로, 이 인터페이스는 새로운 공격 표면을 도입하지 않습니다. 이 인터페이스는 기존 디버거와 정확히 같은 방식으로 동작하며, 새로운 보안 위험을 추가로 도입하지 않습니다.
- 프로세스에 대한 임의 메모리 쓰기 권한은 얻었지만 임의 코드 실행 권한은 얻지 못한 공격자의 경우, 이 인터페이스를 이용해 권한을 확대할 수 없습니다. 특정 메모리 위치를 계산하고 그곳에 쓸 수 있어야 하는데, 이는 Python 프로세스 외부에 있는 다른 머신 자원을 장악하지 않고서는 불가능합니다.
또한 실행될 코드가 인터프리터의 감사 훅을 거쳐야 하므로, 시스템 관리자가 코드 실행을 모니터링하고 통제할 수 있습니다. 즉, 공격자가 애플리케이션 및 파일 시스템을 장악했더라도, 이 인터페이스를 악의적인 목적으로 활용하는 것은 공격자에게 매우 위험한 선택입니다. 공격을 탐지할 뿐만 아니라 이를 막기 위한 조치까지 취할 수 있는 시스템 관리자에게 자신의 행위가 드러날 위험이 있기 때문입니다.
마지막으로, 공격자가 프로세스에 대한 임의 메모리 쓰기 권한을 가지고 있고 파일 시스템까지 장악했다면 이미 존재하는 다른 메커니즘을 사용해 임의 코드 실행으로 확대할 수 있으므로, 이 시나리오에서 이 인터페이스가 새로운 위험을 도입하지 않는다는 점에 유의해야 합니다.
교육 방법
도구 작성자에게 이 인터페이스는 안전하지 않은 시스템 디버거 방식을 대체하여, 디버거 연결을 구현하는 표준 방법이 됩니다. Python 개발자 가이드의 한 절에서 이 메커니즘의 내부 동작을 설명할 수 있으며, 여기에는 debugger_support 오프셋과 시스템 API를 사용해 이 오프셋을 다루는 방법이 포함될 수 있습니다.
최종 사용자는 이 인터페이스를 알 필요가 없으며, 디버깅 도구의 안정성과 신뢰성이 향상되는 이점만 누리면 됩니다.
참조 구현
pdb에 원격 지원을 추가하는 프로토타입이 포함된 참조 구현은 여기에서 확인할 수 있습니다.
기각된 아이디어
버퍼에 Python 코드 쓰기
디버거가 Python 코드를 담은 파일의 경로를 원격 프로세스의 버퍼에 쓰도록 하는 방식을 택했습니다. 이 방식은 실행할 Python 코드 자체를 원격 프로세스의 버퍼에 쓰는 것보다 더 안전하다고 판단되었습니다. 프로세스에서 임의 쓰기 권한은 얻었지만 임의 코드 실행이나 파일 시스템 조작 권한은 얻지 못한 공격자가 이 인터페이스를 통해 임의 코드 실행으로 권한을 상승시킬 수 없기 때문입니다.
다만 이 방식에서는 연결하는 디버거가 실행할 코드를 담은 파일을 만들 때 파일 시스템 권한에 세심한 주의를 기울여야 합니다. 공격자가 그 파일을 덮어쓰거나, 파일 경로에 있는 심볼릭 링크를 공격자가 제어하는 위치를 가리키도록 바꿀 수 있다면, 디버거가 실행하려던 코드 대신 공격자의 악성 코드가 강제로 실행되게 할 수 있습니다.
단일 런타임 버퍼 사용
이 PEP를 검토하는 과정에서 모든 디버거 통신에 런타임 수준의 단일 공유 버퍼를 사용하자는 제안이 있었습니다. 이 방식은 더 단순하고 메모리도 덜 필요한 것처럼 보였지만, 실제로는 여러 디버거가 서로 다른 스레드에 걸쳐 작업을 조율해야 하는 시나리오나 단일 디버거가 복잡한 디버깅 작업을 총괄해야 하는 시나리오를 불가능하게 만든다는 사실을 발견했습니다. 단일 공유 버퍼를 사용하면 모든 디버깅 작업이 직렬화될 수밖에 없으므로, 디버거가 서로 다른 스레드에서 독립적으로 작업할 수 없게 됩니다.
스레드별 버퍼 방식은 스레드가 많은 애플리케이션에서 메모리 오버헤드가 있기는 하지만, 각 디버거가 대상 스레드와 독립적으로 통신할 수 있게 해 주므로 이러한 중요한 디버깅 시나리오를 가능하게 합니다.
감사의 말
이 제안을 논의하는 과정에서 통찰력 있는 의견과 제안을 주신 CF Bolz-Tereick 님께 감사드립니다.
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.