Python

원격 디버깅 연결 프로토콜

이 프로토콜은 외부 도구가 실행 중인 CPython 프로세스에 연결하여 원격으로 파이썬 코드를 실행할 수 있게 합니다.

대부분의 플랫폼에서 다른 파이썬 프로세스에 연결하려면 관리자 권한(elevated privileges)이 필요합니다.

원격 디버깅 비활성화

원격 디버깅 지원을 비활성화하려면 다음 중 하나를 사용하십시오.

권한 요구 사항

원격 디버깅을 위해 실행 중인 파이썬 프로세스에 연결하려면 대부분의 플랫폼에서 관리자 권한이 필요합니다. 구체적인 요구 사항 및 문제 해결 단계는 운영체제에 따라 다릅니다.

Linux

추적 프로세스는 CAP_SYS_PTRACE 기능 또는 그에 상응하는 권한을 가져야 합니다. 본인 소유의이고 시그널을 보낼 수 있는 프로세스만 추적할 수 있습니다. 프로세스가 이미 추적 중이거나, set-user-ID 또는 set-group-ID로 실행 중인 경우 추적에 실패할 수 있습니다. Yama와 같은 보안 모듈은 추적을 추가로 제한할 수 있습니다.

ptrace 제한을 일시적으로 완화하려면(재부팅 전까지) 다음을 실행하십시오:

echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope

참고

ptrace_scope 를 비활성화하면 시스템 보안이 약화되므로 신뢰할 수 있는 환경에서만 수행해야 합니다.

컨테이너 내부에서 실행하는 경우 --cap-add=SYS_PTRACE 또는 --privileged 를 사용하고 필요한 경우 루트 권한으로 실행하십시오.

관리자 권한으로 명령을 다시 실행해 보십시오:

sudo -E !!

macOS

다른 프로세스에 연결하려면 일반적으로 디버깅 도구를 관리자 권한으로 실행해야 합니다. 이는 sudo 를 사용하거나 루트 권한으로 실행하여 수행할 수 있습니다.

본인 소유의 프로세스에 연결하는 경우라도 시스템 보안 제한으로 인해 디버거를 루트 권한으로 실행하지 않으면 macOS에서 디버깅이 차단될 수 있습니다.

Windows

다른 프로세스에 연결하려면 일반적으로 디버깅 도구를 관리자 권한으로 실행해야 합니다. 명령 프롬프트 또는 터미널을 관리자 권한으로 실행하십시오.

SeDebugPrivilege 권한이 활성화되어 있지 않다면 관리자 권한이 있더라도 일부 프로세스에 접근할 수 없을 수 있습니다.

파일 또는 폴더 액세스 문제를 해결하려면 보안 권한을 조정하십시오.

  1. 파일이나 폴더를 마우스 오른쪽 버튼으로 클릭하고 속성 을 선택합니다.

  2. 보안 탭으로 이동하여 액세스 권한이 있는 사용자 및 그룹을 확인합니다.

  3. 편집 을 클릭하여 권한을 수정합니다.

  4. 사용자 계정을 선택합니다.

  5. 권한 에서 필요에 따라 읽기 또는 모든 권한 을 체크합니다.

  6. 적용 을 클릭한 후 확인 을 눌러 확정합니다.

참고

진행하기 전에 모든 권한 요구 사항 를 충족했는지 확인하십시오.

이 섹션은 외부 도구가 실행 중인 CPython 프로세스 내에 파이썬 스크립트를 주입하고 실행할 수 있게 하는 로우 레벨 프로토콜을 설명합니다.

이 메커니즘은 원격 파이썬 프로세스에 .py 파일을 실행하도록 지시하는 sys.remote_exec() 함수의 기반이 됩니다. 하지만 이 섹션은 해당 함수의 사용법을 설명하지 않습니다. 대신 타겟 파이썬 프로세스의 pid 와 실행할 파이썬 소스 파일의 경로를 입력으로 받는 기본 프로토콜에 대한 상세한 설명을 제공합니다. 이 정보는 프로그래밍 언체와 관계없이 프로토콜을 독립적으로 재구현하는 것을 지원합니다.

경고

주입된 스크립트의 실행은 인터프리터가 안전한 평가 지점(safe evaluation point)에 도달해야 가능합니다. 결과적으로 타겟 프로세스의 런타임 상태에 따라 실행이 지연될 수 있습니다.

스크립트가 주입되면 인터프리터에 의해 다음 안전한 평가 지점에 도달할 때 타겟 프로세스 내에서 실행됩니다. 이 방식은 실행 중인 파이썬 애플리케이션의 동작이나 구조를 변경하지 않고 원격 실행 기능을 제공합니다.

이어지는 섹션에서는 메모리 내의 인터프리터 구조를 찾는 방법, 내부 필드에 안전하게 접근하는 방법, 코드 실행을 트리거하는 방법을 포함하여 프로토콜에 대한 단계별 설명을 제공합니다. 해당되는 경우 플랫폼별 차이점을 기술하며, 각 동작을 명확히 하기 위한 예제 구현이 포함되어 있습니다.

PyRuntime 구조 찾기

CPython은 외부 도구가 런타임에 찾을 수 있도록 PyRuntime 구조를 전용 바이너리 섹션에 배치합니다. 이 섹션의 이름과 형식은 플랫폼마다 다릅니다. 예를 들어, ELF 시스템에서는 .PyRuntime 이 사용되고 macOS에서는 __DATA,__PyRuntime 이 사용됩니다. 도구는 디스크에 있는 바이너리를 조사하여 이 구조의 오프셋을 찾을 수 있습니다.

PyRuntime 구조는 CPython의 전역 인터프리터 상태를 포함하며, 인터프리터 목록, 스레드 상태, 디버거 지원 필드를 포함한 기타 내부 데이터에 대한 접근을 제공합니다.

원격 파이썬 프로세스와 상호 작용하려면 디버거가 먼저 타겟 프로세스 내에 있는 PyRuntime 구조의 메모리 주소를 찾아야 합니다. 이 주소는 운영체제가 바이너리를 로드한 위치에 따라 달라지므로 하드코딩하거나 심볼 이름으로 계산할 수 없습니다.

PyRuntime 을 찾는 방법은 플랫폼에 따라 다르지만 일반적인 단계는 다음과 같습니다.

  1. 타겟 프로세스에서 파이썬 바이너리 또는 공유 라이브러리가 로드된 기본 주소(base address)를 찾습니다.

  2. 디스크에 있는 바이너리를 사용하여 .PyRuntime 섹션의 오프셋을 확인합니다.

  3. 섹션 오프셋을 기본 주소에 더하여 메모리 내의 주소를 계산합니다.

다음 섹션에서는 각 지원 플랫폼에서 이를 수행하는 방법과 예제 코드를 설명합니다.

Linux (ELF)

Linux에서 PyRuntime 구조를 찾는 방법:

  1. 프로세스의 메모리 맵(예: /proc/<pid>/maps)을 읽어 파이썬 실행 파일 또는 libpython 이 로드된 주소를 찾습니다.

  2. 바이너리의 ELF 섹션 헤더를 파싱하여 .PyRuntime 섹션의 오프셋을 구합니다.

  3. 해당 오프셋을 1단계에서 얻은 기본 주소에 더하여 PyRuntime 의 메모리 주소를 구합니다.

다음은 구현 예시입니다:

def find_py_runtime_linux(pid: int) -> int:
    # Step 1: Try to find the Python executable in memory
    binary_path, base_address = find_mapped_binary(
        pid, name_contains="python"
    )

    # Step 2: Fallback to shared library if executable is not found
    if binary_path is None:
        binary_path, base_address = find_mapped_binary(
            pid, name_contains="libpython"
        )

    # Step 3: Parse ELF headers to get .PyRuntime section offset
    section_offset = parse_elf_section_offset(
        binary_path, ".PyRuntime"
    )

    # Step 4: Compute PyRuntime address in memory
    return base_address + section_offset

Linux 시스템에서 다른 프로세스의 메모리를 읽는 데는 두 가지 주요 방식이 있습니다. 첫 번째는 /proc 파일 시스템을 통한 것으로, 특히 프로세스의 메모리에 직접 접근할 수 있는 /proc/[pid]/mem``을 읽는 방식입니다. 방식은 대상 프로세스와 동일한 사용자 권한이거나 루트(root) 권한이 필요합니다. 번째 방식은 ``process_vm_readv() 시스템 호출을 사용하는 것으로, 프로세스 간에 메모리를 복사하는 데 훨씬 더 효율적인 방법을 제공합니다. ptrace의 PTRACE_PEEKTEXT 작업으로도 메모리를 읽을 수 있지만, 한 번에 하나의 워드(word)만 읽고 추적기(tracer)와 추적 대상(tracee) 프로세스 간에 여러 번의 컨텍스트 스위칭이 필요하므로 훨씬 느립니다.

ELF 섹션을 파싱하려면 디스크에 있는 바이너리 파일에서 ELF 파일 형식 구조를 읽고 해석하는 과정이 필요합니다. ELF 헤더에는 섹션 헤더 테이블을 가리키는 포인터가 포함되어 있습니다. 각 섹션 헤더는 별도의 문자열 테이블에 저장된 이름, 오프셋, 크기를 포함하여 해당 섹션에 대한 메타데이터를 포함합니다. .PyRuntime과 같은 특정 섹션을 찾으려면 이러한 헤더들을 순회하며 섹션 이름을 매치해야 합니다. 섹션 헤더가 제공하는 위치 정보를 통해 파일 내 해당 섹션의 오프셋을 파악할 수 있으며, 이는 바이너리가 메모리에 로드되었을 때의 런타임 주소를 계산하는 데 사용됩니다.

ELF 파일 형식에 대한 자세한 내용은 ELF specification 에서 확인하십시오.

macOS (Mach-O)

macOS에서 PyRuntime 구조를 찾으려면:

  1. 대상 프로세스의 mach_port_t 태스크 포트를 가져오기 위해 task_for_pid() 를 호출합니다. 이 핸들은 mach_vm_read_overwritemach_vm_region 과 같은 API를 사용하여 메모리를 읽는 데 필요합니다.

  2. 메모리 영역을 스캔하여 파이썬 실행 파일이나 libpython 이 포함된 영역을 찾습니다.

  3. 디스크에서 바이너리 파일을 로드하고 Mach-O 헤더를 파싱하여 __DATA 세그먼트 내의 PyRuntime 이라는 이름의 섹션을 찾습니다. macOS에서는 심볼 이름 앞에 자동으로 언더스코어(_)가 붙기 때문에 심볼 테이블에 PyRuntime 심볼은 _PyRuntime 으로 표시되지만, 섹션 이름은 영향을 받지 않습니다.

다음은 구현 예시입니다:

def find_py_runtime_macos(pid: int) -> int:
    # Step 1: Get access to the process's memory
    handle = get_memory_access_handle(pid)

    # Step 2: Try to find the Python executable in memory
    binary_path, base_address = find_mapped_binary(
        handle, name_contains="python"
    )

    # Step 3: Fallback to libpython if the executable is not found
    if binary_path is None:
        binary_path, base_address = find_mapped_binary(
            handle, name_contains="libpython"
        )

    # Step 4: Parse Mach-O headers to get __DATA,__PyRuntime section offset
    section_offset = parse_macho_section_offset(
        binary_path, "__DATA", "__PyRuntime"
    )

    # Step 5: Compute the PyRuntime address in memory
    return base_address + section_offset

macOS에서 다른 프로세스의 메모리에 액세스하려면 Mach-O 전용 API와 파일 형식을 사용해야 합니다. 첫 번째 단계는 task_for_pid() 를 통해 task_port 핸들을 획득하는 것으로, 이를 통해 대상 프로세스의 메모리 공간에 접근할 수 있습니다. 이 핸들은 mach_vm_read_overwrite() 와 같은 API를 통한 메모리 작업을 가능하게 합니다.

mach_vm_region()``을 사용하여 가상 메모리 공간을 스캔함으로써 프로세스 메모리를 조사할 있으며, ``proc_regionfilename()``은 메모리 영역에 어떤 바이너리 파일이 로드되었는지 식별하는 도움이 됩니다. 파이썬 바이너리나 라이브러리가 발견되면 해당 Mach-O 헤더를 파싱하여 ``PyRuntime 구조의 위치를 찾아야 합니다.

Mach-O 형식은 코드와 데이터를 세그먼트와 섹션으로 구성합니다. PyRuntime 구조는 __DATA 세그먼트 내의 __PyRuntime``이라는 이름의 섹션에 위치합니다. 실제 런타임 주소 계산은 바이너리의 기본 주소 역할을 하는 ``__TEXT 세그먼트를 찾고, 그 후 대상 섹션이 포함된 __DATA 세그먼트를 찾는 과정을 포함합니다. 최종 주소는 기본 주소와 Mach-O 헤더에서 얻은 적절한 섹션 오프셋을 결합하여 계산됩니다.

macOS에서 다른 프로세스의 메모리에 액세스하려면 일반적으로 권한 상승이 필요합니다. 즉, 루트(root) 접근 권한이나 디버깅 프로세스에 부여된 특별한 보안 권한(entitlements)이 있어야 합니다.

Windows (PE)

Windows에서 PyRuntime 구조를 찾으려면:

  1. ToolHelp API를 사용하여 대상 프로세스에 로드된 모든 모듈을 열거합니다. 이는 CreateToolhelp32Snapshot, Module32FirstModule32Next 와 같은 함수를 사용하여 수행됩니다.

  2. python.exe 또는 pythonXY.dll (여기서 XY 는 파이썬 버전의 메이저 및 마이너 번호입니다)에 해당하는 모듈을 식별하고 해당 기본 주소를 기록합니다.

  3. PyRuntim 섹션을 찾습니다. PE 형식의 섹션 이름에 대한 8자 제한(IMAGE_SIZEOF_SHORT_NAME``으로 정의됨)으로 인해 원래 이름인 ``PyRuntime``이 잘렸습니다. 섹션은 ``PyRuntime 구조를 포함합니다.

  4. 섹션의 상대 가상 주소(RVA)를 가져와 모듈의 기본 주소에 더합니다.

다음은 구현 예시입니다:

def find_py_runtime_windows(pid: int) -> int:
    # Step 1: Try to find the Python executable in memory
    binary_path, base_address = find_loaded_module(
        pid, name_contains="python"
    )

    # Step 2: Fallback to shared pythonXY.dll if the executable is not
    # found
    if binary_path is None:
        binary_path, base_address = find_loaded_module(
            pid, name_contains="python3"
        )

    # Step 3: Parse PE section headers to get the RVA of the PyRuntime
    # section. The section name appears as "PyRuntim" due to the
    # 8-character limit defined by the PE format (IMAGE_SIZEOF_SHORT_NAME).
    section_rva = parse_pe_section_offset(binary_path, "PyRuntim")

    # Step 4: Compute PyRuntime address in memory
    return base_address + section_rva

Windows에서 다른 프로세스의 메모리에 액세스하려면 CreateToolhelp32Snapshot()Module32First()/Module32Next() 와 같은 Windows API 함수를 사용하여 로드된 모듈을 열거해야 합니다. OpenProcess() 함수는 대상 프로세스의 메모리 공간에 액세스할 수 있는 핸들을 제공하며, 이를 통해 ReadProcessMemory() 로 메모리 작업을 수행할 수 있습니다.

로드된 모듈을 열거하여 파이썬 바이너리 또는 DLL을 찾는 방식으로 프로세스 메모리를 조사할 수 있습니다. 찾은 후 해당 PE 헤더를 파싱하여 PyRuntime 구조의 위치를 찾아야 합니다.

PE 형식은 코드와 데이터를 섹션으로 구성합니다. PyRuntime 구조는 “PyRuntim”이라는 이름의 섹션에 위치하며, 이는 PE의 8자 이름 제한으로 인해 “PyRuntime”에서 잘린 것입니다. 실제 런타임 주소 계산은 모듈 엔트리에서 모듈의 기본 주소를 찾고, 그 후 PE 헤더에서 대상 섹션을 찾는 과정을 포함합니다. 최종 주소는 기본 주소와 PE 섹션 헤더에서 얻은 섹션의 가상 주소를 결합하여 계산됩니다.

Windows에서 다른 프로세스의 메모리에 액세스하려면 일반적으로 적절한 권한이 필요합니다. 즉, 관리자 권한 또는 디버깅 프로세스에 부여된 SeDebugPrivilege 권한이 있어야 합니다.

_Py_DebugOffsets 읽기

PyRuntime 구조의 주소가 결정되면, 다음 단계는 PyRuntime 블록의 시작 부분에 위치한 _Py_DebugOffsets 구조를 읽는 것입니다.

이 구조는 인터프리터 및 스레드 상태 메모리를 안전하게 읽기 위해 필요한 버전에 따른 필드 오프셋을 제공합니다. 이 오프셋은 CPython 버전마다 다르므로, 사용 전에 호환성을 확인해야 합니다.

디버그 오프셋을 읽고 확인하려면 다음 단계를 따르십시오:

  1. PyRuntime 주소에서 시작하여 _Py_DebugOffsets 구조와 동일한 바이트 수만큼의 대상 프로세스 메모리를 읽습니다. 이 구조는 PyRuntime 메모리 블록의 가장 처음에 위치합니다. 이 구조의 레이아웃은 CPython 내부 헤더에 정의되어 있으며, 특정 마이너 버전 내에서는 동일하게 유지되지만 메이저 버전 간에는 변경될 수 있습니다.

  2. 구조가 유효한 데이터를 포함하는지 확인하십시오:

    • cookie 필드가 예상되는 디버그 마커와 일치해야 합니다.

    • version 필드가 디버거가 사용하는 파이썬 인터프리터 버전과 일치해야 합니다.

    • 디버거 또는 대상 프로세스 중 하나라도 프리릴리스 버전(예: alpha, beta 또는 release candidate)을 사용하는 경우, 버전이 정확히 일치해야 합니다.

    • free_threaded 필드가 디버거와 대상 프로세스 모두에서 동일한 값을 가져야 합니다.

  3. 구조가 유효한 경우, 포함된 오프셋을 사용하여 메모리 내의 필드 위치를 찾을 수 있습니다. 검사가 실패할 경우, 디버거는 잘못된 형식으로 메모리를 읽지 않도록 작업을 중단해야 합니다.

다음은 _Py_DebugOffsets 를 읽고 확인하는 예시 구현입니다:

def read_debug_offsets(pid: int, py_runtime_addr: int) -> DebugOffsets:
    # 단계 1: 대상 프로세스의 PyRuntime 주소에서 메모리 읽기
    data = read_process_memory(
        pid, address=py_runtime_addr, size=DEBUG_OFFSETS_SIZE
    )

    # 단계 2: 원시 바이트를 _Py_DebugOffsets 구조체로 역직렬화
    debug_offsets = parse_debug_offsets(data)

    # 단계 3: 구조체 내용 검증
    if debug_offsets.cookie != EXPECTED_COOKIE:
        raise RuntimeError("Invalid or missing debug cookie")
    if debug_offsets.version != LOCAL_PYTHON_VERSION:
        raise RuntimeError(
            "Mismatch between caller and target Python versions"
        )
    if debug_offsets.free_threaded != LOCAL_FREE_THREADED:
        raise RuntimeError("Mismatch in free-threaded configuration")

    return debug_offsets

경고

프로세스 일시 중단 권장

경합 상태를 방지하고 메모리 일관성을 보장하기 위해, 내부 인터프리터 상태를 읽거나 쓰는 작업을 수행하기 전에 대상 프로세스를 일시 중단할 것을 강력히 권장합니다. 파이썬 런타임은 일반적인 실행 중에 스레드 생성 또는 삭제와 같이 인터프리터 데이터 구조를 동시에 수정할 수 있으며, 이로 인해 잘못된 메모리 읽기나 쓰기가 발생할 수 있습니다.

디버거는 ptrace``로 프로세스에 연결하거나 ``SIGSTOP 시그널을 보내 실행을 일시 중단할 수 있습니다. 실행은 디버거 측의 메모리 작업이 완료된 후에만 재개되어야 합니다.

참고

프로파일러나 샘플링 기반 디버거와 같은 일부 도구는 일시 중단 없이 실행 중인 프로세스에서 작동할 수 있습니다. 이러한 경우, 도구는 부분적으로 업데이트되거나 일관성이 없는 메모리를 처리하도록 명시적으로 설계되어야 합니다. 대부분의 디버거 구현에서는 프로세스를 일시 중단하는 것이 여전히 가장 안전하고 견고한 접근 방식입니다.

인터프리터 및 스레드 상태 위치 파악

원격 파이썬 프로세스에 코드를 주입하고 실행하기 전에, 디버거는 실행을 스케줄링할 스레드를 선택해야 합니다. 이는 원격 코드 주입을 수행하는 데 사용되는 제어 필드가 PyThreadState 객체에 포함된 _PyRemoteDebuggerSupport 구조에 위치하기 때문입니다. 이 필드들은 디버거에 의해 주입된 스크립트의 실행을 요청하기 위해 수정됩니다.

PyThreadState 구조는 파이썬 인터프리터 내에서 실행되는 스레드를 나타냅니다. 이는 스레드의 평가 컨텍스트를 유지하며 디버거 조율에 필요한 필드들을 포함합니다. 따라서 유효한 PyThreadState 를 찾는 것은 원격으로 실행을 트리거하기 위한 핵심 전제 조건입니다.

스레드는 일반적으로 역할이나 ID를 기반으로 선택됩니다. 대부분의 경우 메인 스레드가 사용되지만, 일부 도구는 네이티브 스레드 ID를 통해 특정 스레드를 대상으로 할 수 있습니다. 대상 스레드가 선택되면 디버거는 메모리 내에서 인터프리터와 관련된 스레드 상태 구조체 모두를 찾아야 합니다.

관련된 내부 구조는 다음과 같이 정의됩니다:

  • PyInterpreterState 는 격리된 파이썬 인터프리터 인스턴스를 나타냅니다. 각 인터프리터는 자체적인 임포트 모듈 세트, 내장 상태 및 스레드 상태 리스트를 유지합니다. 대부분의 파이썬 애플리케이션은 단일 인터프리터를 사용하지만, CPython은 동일한 프로세스 내에서 여러 개의 인터프리터를 지원합니다.

  • PyThreadState 는 인터프리터 내에서 실행되는 스레드를 나타냅니다. 여기에는 실행 상태와 디버거가 사용하는 제어 필드가 포함되어 있습니다.

스레드를 찾으려면:

  1. 오프셋 runtime_state.interpreters_head``를 사용하여 ``PyRuntime 구조체 내 첫 번째 인터프리터의 주소를 얻습니다. 이는 활성 인터프리터 연결 리스트의 진입점입니다.

  2. 오프셋 interpreter_state.threads_main 을 사용하여 선택된 인터프리터와 연관된 메인 스레드 상태에 접근합니다. 일반적으로 이것이 가장 신뢰할 수 있는 대상 스레드입니다.

  3. 선택적으로 오프셋 interpreter_state.threads_head``를 사용하여 모든 스레드 상태의 연결 리스트를 순회할 있습니다. ``PyThreadState 구조체는 native_thread_id 필드를 포함하며, 이를 대상 스레드 ID와 비교하여 특정 스레드를 찾을 수 있습니다.

  4. 유효한 PyThreadState 를 찾으면 그 주소를 프로토콜의 이후 단계에서 디버거 제어 필드 쓰기 및 실행 예약과 같은 작업에 사용할 수 있습니다.

다음은 메인 스레드 상태를 찾는 예제 구현입니다:

def find_main_thread_state(
    pid: int, py_runtime_addr: int, debug_offsets: DebugOffsets,
) -> int:
    # 단계 1: PyRuntime에서 interpreters_head 읽기
    interp_head_ptr = (
        py_runtime_addr + debug_offsets.runtime_state.interpreters_head
    )
    interp_addr = read_pointer(pid, interp_head_ptr)
    if interp_addr == 0:
        raise RuntimeError("No interpreter found in the target process")

    # 단계 2: 인터프리터에서 threads_main 포인터 읽기
    threads_main_ptr = (
        interp_addr + debug_offsets.interpreter_state.threads_main
    )
    thread_state_addr = read_pointer(pid, threads_main_ptr)
    if thread_state_addr == 0:
        raise RuntimeError("Main thread state is not available")

    return thread_state_addr

다음 예제는 네이티브 스레드 ID를 사용하여 스레드를 찾는 방법을 보여줍니다:

def find_thread_by_id(
    pid: int,
    interp_addr: int,
    debug_offsets: DebugOffsets,
    target_tid: int,
) -> int:
    # threads_head에서 시작하여 연결 리스트를 순회
    thread_ptr = read_pointer(
        pid,
        interp_addr + debug_offsets.interpreter_state.threads_head
    )

    while thread_ptr:
        native_tid_ptr = (
            thread_ptr + debug_offsets.thread_state.native_thread_id
        )
        native_tid = read_int(pid, native_tid_ptr)
        if native_tid == target_tid:
            return thread_ptr
        thread_ptr = read_pointer(
            pid,
            thread_ptr + debug_offsets.thread_state.next
        )

    raise RuntimeError("Thread with the given ID was not found")

유효한 스레드 상태가 파악되면, 다음 섹션에서 설명하는 대로 제어 필드를 수정하고 실행을 예약하는 단계를 진행할 수 있습니다.

제어 정보 쓰기

유효한 PyThreadState 구조가 확인되면, 디버거는 해당 내의 제어 필드를 수정하여 특정 파이썬 스크립트의 실행을 예약할 수 있습니다. 이 제어 필드들은 인터프리터에 의해 주기적으로 확인되며, 올바르게 설정된 경우 평가 루프의 안전한 지점에서 원격 코드의 실행을 트리거합니다.

PyThreadState``는 디버거와 인터프리터 간의 통신에 사용되는 ``_PyRemoteDebuggerSupport 구조를 포함합니다. 이 필드들의 위치는 _Py_DebugOffsets 구조에 의해 정의되며, 다음을 포함합니다.

  • debugger_script_path: 파이썬 소스 파일(.py)의 전체 경로를 저장하는 고정 크기 버퍼입니다. 이 파일은 실행이 트리거될 때 대상 프로세스에서 접근 가능하고 읽을 수 있어야 합니다.

  • debugger_pending_call: 정수 플래그입니다. 이를 1 로 설정하면 인터프리터에 스크립트가 실행될 준비가 되었음을 알립니다.

  • eval_breaker: 실행 중에 인터프리터가 확인하는 필드입니다. 이 필드의 비트 5(_PY_EVAL_PLEASE_STOP_BIT, 값 1U << 5)를 설정하면 인터프리터가 일시 중지되고 디버거 활동을 확인합니다.

주입을 완료하려면 디버거는 다음 단계들을 수행해야 합니다.

  1. debugger_script_path 버퍼에 전체 스크립트 경로를 기록합니다.

  2. debugger_pending_call1 로 설정합니다.

  3. eval_breaker``의 현재 값을 읽고 비트 5(``_PY_EVAL_PLEASE_STOP_BIT)를 설정한 후 업데이트된 값을 다시 씁니다. 이는 인터프리터에 디버거 활동을 확인하도록 신호를 보냅니다.

다음은 구현 예시입니다:

def inject_script(
    pid: int,
    thread_state_addr: int,
    debug_offsets: DebugOffsets,
    script_path: str
) -> None:
    # Compute the base offset of _PyRemoteDebuggerSupport
    support_base = (
        thread_state_addr +
        debug_offsets.debugger_support.remote_debugger_support
    )

    # Step 1: Write the script path into debugger_script_path
    script_path_ptr = (
        support_base +
        debug_offsets.debugger_support.debugger_script_path
    )
    write_string(pid, script_path_ptr, script_path)

    # Step 2: Set debugger_pending_call to 1
    pending_ptr = (
        support_base +
        debug_offsets.debugger_support.debugger_pending_call
    )
    write_int(pid, pending_ptr, 1)

    # Step 3: Set _PY_EVAL_PLEASE_STOP_BIT (bit 5, value 1 << 5) in
    # eval_breaker
    eval_breaker_ptr = (
        thread_state_addr +
        debug_offsets.debugger_support.eval_breaker
    )
    breaker = read_int(pid, eval_breaker_ptr)
    breaker |= (1 << 5)
    write_int(pid, eval_breaker_ptr, breaker)

이 필드들이 설정되면 디버거는 프로세스를 재개할 수 있습니다(일시 중지된 경우). 인터프리터는 다음 안전한 평가 지점에서 요청을 처리하고, 디스크에서 스크립트를 로드하여 실행합니다.

실행 중에 스크립트 파일이 대상 프로세스에서 존재하고 접근 가능하도록 유지하는 것은 디버거의 책임입니다.

참고

스크립트 실행은 비동기적입니다. 스크립트 주입 직후에 파일이 삭제되어서는 안 됩니다. 디버거는 주입된 스크립트가 관찰 가능한 효과를 생성할 때까지 기다린 후에 파일을 제거해야 합니다. 이 효과는 스크립트의 설계 목적에 따라 달라집니다. 예를 들어, 디버거는 원격 프로세스가 소켓에 다시 연결될 때까지 기다린 후 스크립트를 제거할 수 있습니다. 이러한 효과가 확인되면 파일이 더 이상 필요하지 않다고 간주해도 안전합니다.

요약

원격 프로세스에서 파이썬 스크립트를 주입하고 실행하려면:

  1. 대상 프로세스의 메모리에서 PyRuntime 구조를 찾습니다.

  2. PyRuntime 시작 부분에 있는 _Py_DebugOffsets 구조를 읽고 검증합니다.

  3. 오프셋을 사용하여 유효한 PyThreadState 를 찾습니다.

  4. 파이썬 스크립트 경로를 debugger_script_path 에 기록합니다.

  5. debugger_pending_call 플래그를 1 로 설정합니다.

  6. eval_breaker 필드에 _PY_EVAL_PLEASE_STOP_BIT 을 설정합니다.

  7. 프로세스를 재개합니다(중단된 경우). 스크립트는 다음 안전한 평가 지점에서 실행됩니다.

보안 및 위협 모델

원격 디버깅 프로토콜은 GDB 및 LLDB와 같은 네이티브 디버거에서 사용하는 것과 동일한 운영체제 기본 기능을 기반으로 합니다. 프로세스에 연결하려면 이러한 디버거가 요구하는 것과 동일한 권한 이 필요하며, 예를 들어 Linux의 ptrace / Yama LSM, macOS의 task_for_pid, Windows의 SeDebugPrivilege 등이 있습니다. 파이썬은 새로운 권한 상승 경로를 제공하지 않습니다. 공격자가 이미 프로세스에 연결하는 데 필요한 권한을 보유하고 있다면, 동일하게 GDB를 사용하여 메모리를 읽거나 코드를 주입할 수 있습니다.

다음 원칙은 이 기능에서 보안 취약점으로 간주되는 것과 그렇지 않은 것을 정의합니다:

연결에는 OS 수준의 권한이 필요합니다.

모든 지원되는 플랫폼에서 운영체제는 권한 확인(CAP_SYS_PTRACE, root 또는 관리자 권한)을 통해 프로세스 간 메모리 접근을 제한합니다. 이러한 권한이 이미 확보된 후에 발생하는 문제에 대한 보고는 OS 보안 경계가 이미 넘어진 상태이므로 CPython의 취약점으로 간주되지 않습니다.

침해된 프로세스를 읽을 때 발생하는 충돌이나 메모리 오류는 취약점이 아닙니다.

대상 프로세스에서 내부 인터프리터 상태를 읽는 도구는 해당 메모리가 올바르게 형성되어 있음을 신뢰해야 합니다. 만약 대상 프로세스가 손상되었거나 공격자에 의해 제어되는 경우, 디버거 또는 프로파일러가 충돌하거나 잘못된 출력을 생성하거나 예측할 수 없는 동작을 할 수 있습니다. 이는 모든 ptr_trace 기반 디버거에서 수용되는 것과 동일한 위험입니다. 이 범주의 버그(손상된 상태를 읽음으로써 발생하는 버퍼 오버플로, 세그먼트 오류 또는 정의되지 않은 동작)는 보안 문제로 취급되지 않으나, 안정성을 향상시키는 수정 사항은 환영합니다.

대상 프로세스의 취약점은 범위에 포함되지 않습니다.

디버깅 중인 파이썬 프로세스가 이미 침해되었다면, 공격자는 이미 해당 프로세스의 실행을 제어하고 있는 것입니다. 그러한 시점에서 추가적인 영향력을 입증하는 것은 원격 디버깅 프로토콜의 취약성으로 간주되지 않습니다.

PYTHON_DISABLE_REMOTE_DEBUG 를 사용하는 경우

환경 변수 PYTHON_DISABLE_REMOTE_DEBUG (및 동일한 기능의 -X disable_remote_debug 플래그)는 운영자가 심층 방어(defence-in-depth) 조치로 프로토콜의 내부 프로세스 측을 비활성화할 수 있게 해줍니다. 이는 OS 수준의 권한 확인이 이미 권한 없는 접근을 차단함에도 불구하고, 프로세스의 디버깅이나 프로파일링이 필요하지 않고 공격 표면을 줄이는 것이 우선인 강화된 환경이나 샌드박스 배포 환경에서 유용할 수 있습니다.

이 변수를 설정하는 것은 다른 OS 수준의 디버깅 인터페이스(ptrace, /proc, task_for_pid 등)에는 영향을 주지 않으며, 해당 인터페이스들은 고유의 권한 모델에 따라 사용 가능합니다.

분실물 보관소