광고
~ / posts / developer
smith@lab:~/posts$ cat developer/python-pth-sitecustomize-startup-hook-check-guide.md

파이썬 .pth 파일 실행 원리, sitecustomize 시작 훅 점검법

패키지를 import 하지도 않았는데 그 패키지의 코드가 돌 수 있을까요? 돌 수 있어요. site-packages 에 놓인 .pth 파일의 import 로 시작하는 줄과 sitecustomize.py 는 파이썬이 시작할 때마다, 내 스크립트보다 먼저 실행돼요. python -c pass 한 줄이나 pip --version 도 예외가 아니에요. 그래서 이 자리는 정상 패키지가 초기화용으로 쓰기도 하고, 악성 릴리스가 자격 증명을 훔치는 코드를 심는 자리가 되기도 해요.

이 글은 이 "시작 훅"이 어디서 어떤 순서로 실행되는지, 어떤 실행 옵션이 무엇을 막는지, 그리고 내 환경과 설치할 휠에 수상한 훅이 있는지 그 환경의 파이썬을 켜지 않고 확인하는 방법을 다뤄요. 숫자는 맥의 Homebrew Python 3.13.7 가상 환경에서 직접 잰 값이고, 규칙은 Python 공식 문서와 PEP 829 를 따랐어요.

광고

파이썬 시작 순서, 내 코드보다 먼저 도는 것

파이썬은 시작할 때 site 모듈을 자동으로 불러와요. Python site 모듈 문서에 적힌 순서를 정리하면 이래요.

  1. site-packages 디렉터리(사용자 디렉터리 포함)를 찾아 sys.path 에 붙여요.
  2. 각 디렉터리 안의 *.pth 파일을 파일 이름 알파벳순으로 읽어요. 빈 줄과 # 줄은 건너뛰고, import 뒤에 공백·탭이 오는 줄은 exec() 로 실행하고, 나머지 줄은 존재하는 경로일 때만 sys.path 에 추가해요.
  3. 경로 처리가 끝나면 sitecustomize 모듈을 import 해요. 없어서 생긴 ImportError 만 조용히 넘기고, 다른 예외는 프로세스를 "조용하고 알 수 없게" 실패시킬 수 있다고 문서가 적고 있어요.
  4. 사용자 site 가 켜져 있으면 usercustomize 모듈도 import 해요.

이 단계가 끝나야 내 스크립트, pip, pytest 가 실행돼요. 문서는 .pth 의 실행 줄을 "하위 호환을 위한" 기능이라고 부르는데, 실제로는 이 동작 덕분에 패키지 하나가 설치만으로 모든 파이썬 프로세스에 코드를 끼워 넣을 수 있어요.

파이썬 시작 순서: site 모듈, 사용자 site-packages 의 .pth, site-packages 의 .pth, sitecustomize, usercustomize, 내 스크립트 순서이고 -s·-I 는 사용자 쪽만, -S 는 전부 건너뛴다는 비교도
주황 칸이 시작 훅이에요. -I 로도 ③·④는 그대로 돌아요

import 판정은 줄 맨 앞에서 해요. 직접 해 보면 앞에 공백이 하나 붙은 ␣import sys 줄은 실행되지 않고 경로로 취급돼 버려지고, import 뒤에 탭을 둔 줄은 실행돼요. 한 줄에 세미콜론으로 여러 문장을 이어 쓸 수 있어서, 정상 패키지의 실행 줄도 대부분 한 줄짜리 긴 문장이에요.

실행 옵션별로 막히는 것: -I, -s, -S 차이

시작 훅을 줄이려고 격리 모드 -I 를 쓰는 경우가 많은데, -I 가 끄는 범위는 생각보다 좁아요. Python 명령행 옵션 문서는 -I 가 -E, -P, -s 를 함께 켠다고만 적어요. -S 는 포함되지 않아요.

옵션하는 일사용자 site 의 .pth·usercustomizesite-packages 의 .pth·sitecustomize
(없음)기본실행실행
-s 또는 PYTHONNOUSERSITE사용자 site-packages 를 sys.path 에 안 붙임건너뜀실행
-EPYTHON* 환경 변수 무시실행실행
-P (3.11+)스크립트 폴더·현재 폴더를 sys.path 앞에 안 붙임실행실행
-I (3.4+)-E + -P + -s건너뜀실행
-Ssite 모듈 자동 import 안 함건너뜀건너뜀

-S 는 강력한 만큼 부작용도 커요. site-packages 자체가 sys.path 에 붙지 않으니 설치한 서드파티 패키지를 import 할 수 없어요. 그래서 -S 는 표준 라이브러리만 쓰는 점검 스크립트를 돌릴 때 쓰는 옵션이지, 평소 실행 옵션으로 쓰기는 어려워요.

직접 확인하려면 가상 환경의 site-packages 에 메시지를 찍는 .pth 를 하나 넣고 옵션을 바꿔 가며 실행해 보면 돼요.

python3 -m venv /tmp/hooklab
SP=$(/tmp/hooklab/bin/python -c 'import site; print(site.getsitepackages()[0])')
printf 'import sys; print("pth 실행됨", file=sys.stderr)\n' > "$SP/zz_demo.pth"

/tmp/hooklab/bin/python -c pass        # pth 실행됨
/tmp/hooklab/bin/python -I -c pass     # pth 실행됨  ← 격리 모드여도 돌아요
/tmp/hooklab/bin/python -S -c pass     # (출력 없음)
/tmp/hooklab/bin/python -m pip --version   # pth 실행됨, 그다음 pip 버전

마지막 줄이 중요해요. pip 도 그 환경의 파이썬으로 돌기 때문에, 의심스러운 환경에서 pip list 나 pip uninstall 로 상태를 보려는 순간 시작 훅이 한 번 더 실행돼요.

.pth 시작 시간 실측, import 한 줄이 늘리는 지연

시작 훅은 보안 문제 이전에 성능 문제이기도 해요. .pth 의 실행 줄은 모든 파이썬 프로세스에서 돌기 때문에, 무거운 모듈을 import 하면 CLI 도구·테스트 워커·서브프로세스마다 그만큼 느려져요. 맥(Homebrew Python 3.13.7, 가상 환경)에서 python -c pass 를 80번 실행하고 앞 10번을 뺀 중앙값을 두 번 재서 평균했어요.

조건기본-I-S
.pth 없음116ms113ms83ms
.pth 에 import json124ms——
.pth 에 import asyncio317ms317ms80ms
.pth 한 줄(import asyncio)이 있을 때와 없을 때 파이썬 시작 시간
.pth 없음.pth 에 import asyncio
0100200300400기본 실행 · .pth 없음 116ms기본 실행 · .pth 에 import asyncio 317ms기본 실행-I 격리 모드 · .pth 없음 113ms-I 격리 모드 · .pth 에 import asyncio 317ms-I 격리 모드-S · .pth 없음 83ms-S · .pth 에 import asyncio 80ms-S

-I 는 site-packages 의 .pth 를 막지 못해 차이가 그대로 남아요

단위: ms · 자료: 맥 Homebrew Python 3.13.7 가상 환경, python -c pass 80회 중앙값 2회 평균 (직접 측정)
표로 보기
구분.pth 없음.pth 에 import asyncio
기본 실행116317
-I 격리 모드113317
-S8380
.pth 실행 줄이 import 하는 모듈별 시작 시간
.pth 없음.pth 없음 · 116ms116msimport jsonimport json · 124ms124msimport asyncioimport asyncio · 317ms317ms

측정 당시 시스템 부하가 있어 절대값보다 차이를 보세요

단위: ms · 자료: 맥 Homebrew Python 3.13.7 가상 환경 직접 측정
표로 보기
구분시작 시간
.pth 없음116
import json124
import asyncio317

절대값은 기계와 부하에 따라 달라요. 이 측정은 다른 작업이 돌던 맥에서 잰 것이라 빈 파이썬이 100ms 를 넘었어요. 봐야 할 것은 차이예요. import json 한 줄은 8ms 정도, import asyncio 한 줄은 약 200ms 를 더했어요. 테스트를 프로세스 100개로 나눠 돌리면 200ms × 100 = 20초가 시작에만 쓰이는 셈이에요.

느려진 원인이 시작 훅인지 의심되면 python -X importtime -c pass 2>&1 | sort -t'|' -k2 -n | tail 로 시작할 때 import 되는 모듈과 누적 시간을 볼 수 있어요. 내가 import 한 적 없는 모듈이 맨 아래에 크게 찍히면 .pth 나 sitecustomize 를 확인해 보세요.

정상 .pth 와 수상한 .pth 구별하는 표

.pth 가 있다는 것만으로 이상한 건 아니에요. 예를 들어 setuptools 휠에는 distutils-precedence.pth 가 들어 있고, 그 안의 한 줄은 import os; var = 'SETUPTOOLS_USE_DISTUTILS'; ... 로 시작하는 실행 줄이에요. setuptools 84.2.1 을 내려받아 직접 열어 확인한 내용이에요. 반대로 2026년 10월 LiteLLM 1.107.0 악성 릴리스는 cook.pth 에 import cook 한 줄을 넣어 같은 자리를 썼어요. 마이크로소프트 보안 블로그의 LiteLLM 1.107.0 분석에 따르면 휠의 litellm-1.107.0.data/purelib/ 아래에 cook.pth, cook.py, sitecustomize.py 를 넣어 설치 때 패키지 폴더 밖, site-packages 바로 아래로 옮겨지게 했어요.

이 경로가 가능한 이유는 휠 형식 자체에 있어요. PyPA 휠 형식 명세는 {배포명}-{버전}.data/purelib/ 아래 내용을 설치 때 purelib 위치, 즉 site-packages 로 옮기라고 정해요. 패키지 폴더 안만 보고 지우면 이 파일들이 남는다는 뜻이에요.

확인할 점대체로 정상의심
어느 패키지 소유인가RECORD 에 소유 패키지가 있고 이름이 그 패키지와 관련 있음소유 패키지가 없음, 또는 이름이 패키지와 무관(cook.pth 가 LLM 프록시 소유)
내용경로 줄만 있음, 또는 그 패키지 모듈을 부르는 짧은 실행 줄실행 줄이 처음 보는 모듈을 import, 인코딩된 긴 문자열, exec·compile·base64
sitecustomize.py배포판·Homebrew 같은 인터프리터 설치본이 둔 것일반 PyPI 패키지가 설치한 것
설치 경로패키지 폴더 안휠의 .data/purelib/ 로 site-packages 루트에 놓임

sitecustomize.py 는 정상 환경에도 있을 수 있어요. 이 글을 쓴 맥의 Homebrew 파이썬도 인터프리터 폴더에 sitecustomize.py 를 두고 sys.path 순서를 정리해요. 그래서 "있다/없다"보다 누가 설치했는지가 판단 기준이에요.

설치된 환경 점검: 그 파이썬을 켜지 않고 확인하기

마이크로소프트 분석은 의심 환경 안에서 python·pip 를 실행하지 말고, 깨끗한 시스템의 신뢰할 수 있는 도구로 조사하라고 권해요. 위에서 본 것처럼 그 환경의 파이썬을 켜는 순간 훅이 돌기 때문이에요. 아래 스크립트는 다른 파이썬으로 -I -S 를 붙여 실행하고, 대상 site-packages 경로를 인자로 받아 파일만 읽어요. 각 .dist-info/RECORD 를 읽어 파일마다 소유 패키지도 붙여 줘요.

# scan_startup_hooks.py — 실행: python3 -I -S scan_startup_hooks.py <site-packages 경로>
import csv, pathlib, sys

HOOKS = {"sitecustomize.py", "usercustomize.py"}

def owners(site_dir):
    table = {}
    for record in site_dir.glob("*.dist-info/RECORD"):
        dist = record.parent.name.split("-")[0]
        with record.open(newline="", encoding="utf-8") as f:
            for row in csv.reader(f):
                if row:
                    table[(site_dir / row[0]).resolve()] = dist
    return table

for arg in sys.argv[1:]:
    site_dir = pathlib.Path(arg).resolve()
    who = owners(site_dir)
    for f in sorted(site_dir.iterdir()):
        if not f.is_file():
            continue
        owner = who.get(f.resolve(), "RECORD 없음")
        if f.suffix == ".pth":
            text = f.read_text(encoding="utf-8", errors="replace")
            runs = [l for l in text.splitlines() if l.startswith(("import ", "import\t"))]
            print(f"[.pth] {f.name}  ({owner}, {'실행 줄 있음' if runs else '경로만'})")
            for line in runs:
                print("        ", line[:100])
        elif f.suffix == ".start":
            print(f"[.start] {f.name}  ({owner})")
        elif f.name in HOOKS:
            print(f"[훅] {f.name}  ({owner})")

setuptools 를 설치한 가상 환경에 가짜 cook.pth·sitecustomize.py 를 손으로 넣고 돌린 결과예요.

[.pth] cook.pth  (RECORD 없음, 실행 줄 있음)
         import cook
[.pth] distutils-precedence.pth  (setuptools, 실행 줄 있음)
         import os; var = 'SETUPTOOLS_USE_DISTUTILS'; enabled = os.environ.get(var, 'local') == 'local'; enab
[훅] sitecustomize.py  (RECORD 없음)

손으로 넣은 파일은 "RECORD 없음"으로 나오고, 악성 휠로 설치된 파일은 RECORD 에 그 패키지 이름이 찍혀요. 어느 쪽이든 실행 줄 내용과 소유 패키지를 함께 보면 판단이 빨라져요. 컨테이너 이미지라면 실행하지 말고 파일 시스템을 풀어서 같은 경로에 돌리면 돼요. 사용자 site(~/.local/lib/python3.x/site-packages)와 인터프리터 폴더의 sitecustomize.py 도 같이 보세요.

광고
광고

설치 전 휠 검사와 해시 고정

설치한 뒤에 찾는 것보다 설치 전에 막는 편이 싸요. 휠은 zip 파일이라 열어 보기만 하면 시작 훅이 들었는지 알 수 있어요. 아래 함수는 설치 뒤 site-packages 바로 아래에 놓일 .pth·.start·sitecustomize.py·usercustomize.py 를 찾아요. .data/purelib/·.data/platlib/ 아래 파일은 루트로 옮겨진다는 휠 규칙을 반영했어요.

# wheel_hooks.py — 실행: python3 -I -S wheel_hooks.py 패키지.whl
import sys, zipfile

def startup_files(wheel_path):
    found = []
    with zipfile.ZipFile(wheel_path) as whl:
        for name in whl.namelist():
            parts = name.split("/")
            if len(parts) >= 3 and parts[0].endswith(".data") and parts[1] in ("purelib", "platlib"):
                parts = parts[2:]
            if len(parts) == 1 and (parts[0].endswith((".pth", ".start"))
                                    or parts[0] in ("sitecustomize.py", "usercustomize.py")):
                found.append(name)
    return found

for path in sys.argv[1:]:
    print(path, "→", startup_files(path) or "시작 훅 없음")
pip download --no-deps --only-binary :all: setuptools -d ./whl
python3 -I -S wheel_hooks.py ./whl/*.whl
# setuptools-84.2.1-py3-none-any.whl → ['distutils-precedence.pth']
# 직접 만든 가짜 휠 → ['demo-1.0.data/purelib/cook.pth', 'demo-1.0.data/purelib/sitecustomize.py']

--only-binary :all: 를 붙이는 이유는 소스 배포판(sdist)을 받으면 메타데이터를 만들려고 빌드 코드가 실행될 수 있어서예요. CI 에서는 새로 들어오거나 버전이 바뀐 휠에만 이 검사를 돌리고, 결과가 지난번과 달라졌을 때 사람이 보게 하면 부담이 적어요.

검사를 통과한 파일이 나중에 바뀌지 않게 하려면 해시를 고정해요. pip 보안 설치 문서에 따르면 요구사항 하나라도 --hash 가 있으면 모든 요구사항에 해시가 필요해지고, --require-hashes 로 이 모드를 강제할 수 있어요. 권장 알고리즘은 sha256 이고 md5·sha1 은 받지 않아요. 내려받은 파일의 SHA-256 을 공개된 값과 맞춰 볼 때는 해시 생성기로 짧은 문자열을 시험해 보거나, 파일은 shasum -a 256 으로 계산하면 돼요. 해시가 무엇을 보장하고 무엇을 보장하지 않는지는 MD5·SHA-256 차이 글에 정리해 두었어요.

해시 고정은 "내가 검사한 그 파일"만 설치되게 할 뿐, 처음부터 악성인 릴리스를 걸러 주지는 않아요. 그래서 순서가 중요해요. 새 버전을 들일 때 휠 검사 → 해시 기록 → 이후 설치는 해시로만, 이 흐름이 돼야 둘 다 의미가 있어요. 컨테이너도 latest 같은 움직이는 태그 대신 다이제스트로 고정하라는 것이 마이크로소프트 분석의 권고예요.

PEP 829 와 .start 파일, 앞으로 바뀌는 일정

.pth 의 실행 줄은 오래 비판받아 왔고, Python 3.15 에서 방향이 정해졌어요. PEP 829는 .pth 의 두 역할을 나눠요. 경로 추가는 .pth 에 그대로 두고, 시작할 때 실행할 코드는 새 <이름>.start 파일에 pkg.mod:callable 형식의 진입점으로 적게 해요. PEP 는 실행 줄에 대해 "코드 실행은 구현의 부작용"이라고 쓰고, 명시적인 진입점 개념이 없어서 패키지들이 import 줄을 남용한다고 설명해요.

PEP 829 일정: 3.13 UTF-8 우선 디코딩, 3.15 .start 도입과 import 줄 폐기 예고, 3.16·3.17 계속 실행, 3.18·3.19 조용히 무시, 3.20 경고 출력과 로캘 인코딩 지원 제거
시작 훅이 사라지는 게 아니라 .start 로 자리를 옮기는 일정이에요

PEP 와 3.15 site 모듈 문서의 규칙을 정리하면 이래요.

보안 관점에서 오해하면 안 되는 점이 있어요. .start 진입점도 시작할 때 코드를 실행해요. 바뀌는 건 실행 위치가 pkg.mod:callable 로 명시되어 찾기 쉬워진다는 점이고, sitecustomize 는 그대로예요. 그래서 위 점검 스크립트들이 .start 를 함께 찾도록 짰어요. 3.18 이 되어도 ".pth 실행 줄이 무시되니 안전하다"가 아니라 "시작 훅은 .start·sitecustomize·usercustomize 세 곳을 보면 된다"로 생각하면 맞아요.

상황별 점검 체크리스트

상황할 일
공급망 사고 공지가 났고 해당 버전을 썼을 수 있음그 환경에서 python·pip 실행 금지 → 다른 파이썬으로 -I -S 파일 점검 → 환경 재구축 → 닿을 수 있던 자격 증명 교체
CI 에 새 의존성이 들어옴--only-binary :all: 로 받아 휠 검사 → 시작 훅이 있으면 사람 확인 → 해시 기록
파이썬 CLI·테스트가 이유 없이 느려짐-X importtime 으로 시작 import 확인 → .pth 실행 줄·sitecustomize 확인
점검·포렌식용 스크립트를 돌림표준 라이브러리만 쓰고 -I -S 로 실행
사내 패키지가 시작 시 초기화가 필요함3.15 이상을 지원한다면 .start 진입점으로 옮기고, 같은 이름의 .pth 에서 import 줄을 정리

자동화할 때는 결과를 그대로 실패 처리하기보다 허용 목록을 두는 편이 현실적이에요. distutils-precedence.pth 처럼 이미 확인한 파일은 소유 패키지와 내용 해시를 같이 기록해 두고, 목록에 없거나 내용이 달라진 시작 훅만 알리면 오탐이 줄어요.

광고
광고

자주 묻는 질문

.pth 파일을 모두 지워도 되나요?

안 돼요. 대부분의 .pth 는 경로 줄만 있거나, setuptools 처럼 그 패키지가 동작하는 데 필요한 초기화를 해요. 편집 가능 설치나 일부 패키지는 .pth 로 자기 경로를 sys.path 에 붙이기 때문에 지우면 import 가 깨질 수 있어요. 소유 패키지와 내용을 보고 수상한 것만 판단하세요.

python -I 로 실행하면 안전한 것 아닌가요?

-I 는 환경 변수, 사용자 site-packages, 현재 폴더를 sys.path 에서 빼서 바깥에서 끼어드는 코드를 줄여요. 하지만 일반 site-packages 의 .pth 와 sitecustomize 는 그대로 실행돼요. 실측에서도 .pth 에 넣은 import asyncio 지연이 -I 에서 그대로 남았어요. 시작 훅까지 끄려면 -S 를 써야 하고, 그 대신 서드파티 패키지를 쓸 수 없어요.

가상 환경을 쓰면 시스템 쪽 .pth 는 영향이 없나요?

가상 환경이 시스템 site-packages 를 포함하지 않게 만들어졌다면(기본값) 시스템 site-packages 의 .pth 는 읽지 않아요. 다만 가상 환경 자신의 site-packages 에 설치된 패키지의 .pth 는 똑같이 실행되고, 인터프리터 폴더의 sitecustomize.py 도 import 돼요. 가상 환경은 범위를 나눌 뿐 시작 훅을 없애지는 않아요.

이름이 점으로 시작하는 .pth 도 실행되나요?

직접 해 본 Python 3.13.7 에서는 .hidden.pth 처럼 점으로 시작하는 파일의 import 줄이 실행되지 않았어요. 버전마다 다를 수 있으니 점검 스크립트는 이름과 상관없이 모든 .pth 를 보는 편이 안전해요. 위 스크립트도 숨김 파일을 따로 거르지 않아요.

악성 패키지를 pip uninstall 로 지우면 끝인가요?

아니에요. pip uninstall 도 그 환경의 파이썬으로 돌기 때문에 지우는 순간 시작 훅이 한 번 더 실행돼요. 그리고 이미 실행된 코드가 읽어 간 자격 증명은 파일을 지워도 되돌릴 수 없어요. 검증된 버전으로 환경을 새로 만들고, 그 환경이 닿을 수 있던 키와 토큰을 깨끗한 기기에서 교체하는 것이 복구예요.

.start 파일은 언제부터 쓸 수 있나요?

PEP 829 는 Python 3.15 를 대상으로 확정됐고, 3.15 site 모듈 문서에 .start 처리가 들어 있어요. 3.14 이하를 함께 지원하는 패키지라면 당분간 .pth 의 import 줄을 유지해야 하고, 같은 이름의 .start 를 함께 두면 3.15 이상에서는 .start 쪽만 쓰이게 할 수 있어요.

정리

비밀번호·토큰을 교체해야 하는 상황이라면 비밀번호 생성기로 새 값을 만들 수 있어요. 토큰 만료 시간을 함께 다시 정할 때는 액세스·리프레시 토큰 만료 시간 글을 참고하세요.

더 깊이 읽기

이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 파이썬 JIT 제거·PHP2Shell·vLLM 도구 호출 — IT 뉴스에서 도식과 코드로 단계별로 풀었어요.

참고한 자료

#파이썬 pth 파일#sitecustomize#usercustomize#site-packages 시작 코드#파이썬 공급망 공격 점검#python -I -S 옵션#PEP 829 start 파일#휠 설치 전 검사
← 이전 글프롬프트 인젝션 방어 고르기, 분류기·LLM 검사·CaMeL 비용과 지연 비교다음 글 →추론 모델 effort 고르는 법, 사고 토큰 비용과 지연 계산
광고

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →