어제 팀 슬랙에 이런 질문이 올라왔다. "AI가 pip install 하라고 알려준 패키지가 있는데, 검색해도 GitHub이 안 나와요. 이거 써도 되나요?" 나는 순간 등골이 서늘했다. 이게 정확히 요즘 뜨는 slopsquatting 공격이 노리는 그 순간이기 때문이다.
이 글에서는 slopsquatting이 뭔지, 왜 우리가 평소 쓰던 방어책이 이걸 못 막는지, 그리고 CI/CD 파이프라인에 실제로 어떤 게이트를 걸어야 하는지를 실무자 입장에서 정리한다.
1. 왜 지금 이게 문제인가
Typosquatting은 오래된 수법이다. express 옆에 expres를, python-dateutil 옆에 python-dateutl을 등록해두고 누군가 오타를 내길 기다린다. 성공률은 낮다. 대부분은 철자를 제대로 친다. 공격자는 실수하는 극소수를 낚는 낚시질을 하는 셈이다.
Slopsquatting은 이 "인간의 실수"라는 전제를 완전히 없앤다. Python Software Foundation의 Seth Larson이 2025년에 이름 붙인 개념인데, 핵심 차이는 이렇다.
- Typosquatting: 당신의 오타에 베팅한다. 공격자가 당신 손가락이 어디로 미끄러질지 추측한다.
- Slopsquatting: 당신의 AI 어시스턴트에 베팅한다. 공격자는 추측하지 않는다. 모델이 실제로 뱉는 출력을 대규모로 관찰하고, 반복해서 나오는 이름을 등록한다.
여기서 소름 돋는 부분은, 개발자는 이름을 정확하게 친다는 거다. 오타가 아니라, AI가 지어낸 이름을 그대로 복사해서 붙여넣기 때문이다. 실수는 이미 상류(모델)에서 발생했고, 사람은 그걸 충실히 재현할 뿐이다.
2. 동작 원리: AI가 없는 패키지를 추천하는 순간
이런 상황을 상상해보자. requests로 OAuth2 토큰을 깔끔하게 붙이는 방법을 물었더니 AI가 이렇게 답한다.
pip install requests-oauth2-helper
이름이 완벽하다. 케이싱도 관용적이고, 하이픈 위치도 PyPI 생태계 관례에 맞고, 실제로 있을 법한 헬퍼 패키지 오십 개와 똑같은 느낌이 난다. 그래서 그냥 실행한다. 테스트 통과. 다음 작업으로 넘어간다.
문제는 requests-oauth2-helper가 모델이 학습하던 시점에 존재하지 않았다는 것이다. 모델이 지어냈다. 그리고 공격자가 주의 깊게 지켜보고 있었다면, 그 이름은 더 이상 빈자리가 아니다.
왜 이게 실제 위협인가: 환각은 자주, 그리고 반복해서 발생한다
USENIX Security 2025의 We Have a Package for You! 연구가 이 공격의 경제성을 뒤집는 숫자를 내놨다. 16개 LLM으로 Python/JavaScript 코드 샘플 57만 6천 개를 생성해 추천된 패키지를 전부 검증했더니:
- 추천된 패키지의 19.7%가 존재하지 않았다. 다섯 개 중 하나꼴이다.
- 구별되는 환각 패키지 이름이 205,474개 로깅됐다.
- 상용 모델은 평균 최소 5.2%, 오픈소스 모델은 최소 21.7%. 깨끗한 모델은 없었다.
여기까지면 그래도 관리 가능하다. 환각이 매번 다른 눈송이라면 공격자는 20만 개를 다 등록할 수도 없고, 어떤 게 다시 나올지도 모르니까. 그런데 진짜 무서운 발견은 재현성이다.
- 가짜 패키지를 만들어낸 프롬프트 500개를 각각 10번씩 더 돌렸더니, 43%가 매번 똑같이 다시 나왔다.
- 58%는 두 번 이상 등장했다.
- 39%만 다시는 안 나타났다.
이게 경제성을 완전히 바꾼다. 공격자는 전부 등록할 필요가 없다. 모델 출력을 채굴해서 반복적으로 나오는 이름만 골라 등록하면 된다. 재현성 자체가 정찰(reconnaissance)이 되는 셈이다. 모델이 공격자에게 "미래의 개발자가 어떤 가짜 이름을 받게 될지"를 반복해서 알려준다.
게다가 이름이 진짜 패키지처럼 안 생겨도 된다. Levenshtein distance로 분석했더니 환각 이름 중 단순 오타 형태는 13%뿐이었다. 약 38%는 중간 정도 유사, 나머지 절반 가까이는 완전히 지어냈지만 문맥상 그럴듯한 이름이었다. 이 마지막 그룹이 기존 typosquat 탐지를 그대로 통과한다.
킬 체인 4단계
- 모델이 import를 제안한다. 문제 모양에 맞는 의존성 이름을 뱉는다. 지어낸 이름이지만 문법은 완벽하다.
- 이름이 그럴듯해서 신뢰한다. 이게 하중을 지탱하는 단계다. 기술이 아니라 심리 문제다. 자신감 넘치는 시니어의 PR을 통과시키듯, 자신감 넘치는 AI 출력도 경계를 뚫는다.
- install이 코드를 실행한다. npm과 pip은 설치만으로 스크립트가 자동 실행된다. postinstall 훅, setup.py 빌드 스텝. import하거나 함수를 호출할 필요도 없다. install이 끝났으면 코드는 이미 돌았다.
- 크리덴셜이 빠져나간다. 페이로드가 빌드 에이전트에 늘 굴러다니는 것들(환경변수,
~/.aws/credentials,~/.npmrc토큰,GITHUB_TOKEN,.env)을 읽어 공격자 엔드포인트로 POST한다. 밖에서 보면 그냥 install 중 메타데이터 받아오는 것처럼 보인다.
이건 이론이 아니다: huggingface-cli 사례
Lasso Security의 Bar Lanyado가 모델들이 huggingface-cli라는 Python 패키지를 반복적으로 환각하는 걸 발견했다. 그래서 실험 삼아 그 이름으로 빈 패키지를 PyPI에 등록했다. 3개월간 실제 다운로드 15,000건 이상이 발생했고, Alibaba의 GraphTranslator 프로젝트 README가 pip install huggingface-cli를 추천하기까지 했다. 진짜 도구는 pip install -U "huggingface_hub[cli]"로 설치하는데도 말이다. Lanyado의 패키지는 일부러 무해했다. slopsquatter의 것은 아닐 거다.
3. 실무 관점: 생태계별 위험도와 흔한 함정
"JavaScript, PHP, Go 다 위험함"으로 뭉뚱그리면 방어 예산을 어디 쓸지 모른다. 생태계마다 공격자에게 주는 밧줄 길이가 다르다.
npm — 가장 위험
lifecycle 스크립트(preinstall, install, postinstall)가 npm install 시 자동 실행된다. 고전적인 벡터다.
{
"name": "requests-oauth2-helper",
"version": "1.0.3",
"scripts": {
"postinstall": "node ./collect.js"
}
}
이 때문에 생태계가 움직이기 시작했다. pnpm v10은 2025년 초 의존성 lifecycle 스크립트를 기본 비활성화했고, npm도 v12에서 자동 스크립트 실행을 기본 끄기로 했다(2026년 6월 발표). 해당 버전으로 올리기 전까지 postinstall은 CI를 겨눈 장전된 총이다.
pip — 그 다음
source distribution은 설치 시 setup.py를 실행해 메타데이터/빌드를 처리한다. 즉 pip install만으로 임의 코드가 돈다. 반면 wheel(.whl)은 설치 시점 코드를 그렇게 돌리지 않는다. 그래서 --only-binary가 실제 하드닝 레버가 된다.
# source 빌드 거부, 사전 빌드된 wheel만 허용
pip install --only-binary :all: requests-oauth2-helper
Composer — 설계상 가장 안전
Composer는 root 패키지의 composer.json에 정의된 스크립트만 실행한다. 의존성 자신의 scripts 블록은 무시된다. 다만 플러그인이 install 이벤트를 후킹할 수 있어서, 신뢰 안 되는 트리엔 composer install --no-plugins --no-scripts를 쓴다.
Go — 나중에, 하지만 영원히 기억한다
Go엔 install 스크립트가 없다. 악성 코드는 프로그램이 실제로 실행될 때, init() 함수나 go test 시점에 돈다. 대신 module proxy가 고약한 트위스트를 준다. Socket이 발견한 BoltDB의 백도어 typosquat(github.com/boltdb-go/bolt)은 2021년 11월 업로드돼 Go 모듈 미러에 캐시됐고, 이후 Git 태그를 깨끗한 코드로 바꿔치기했는데도 프록시가 캐시된 악성 버전을 계속 서빙했다. 3년 넘게 탐지되지 않았다. 재현 가능한 빌드를 위한 캐싱이 오염된 버전도 오래 살아남게 만든 것이다.
왜 평소 방어책이 이걸 못 잡나
이 부분이 진짜 불편하다. 우리가 이미 돌리는 통제들은 대부분 다른 위협 모델용으로 만들어졌다.
- Lockfile은 첫 설치 이후에만 돕는다.
package-lock.json이나composer.lock은 이미 검증하고 락한 패키지 버전을 고정한다. 하지만 slopsquatting은 완전히 새 이름의 첫 설치를 노린다. lockfile엔 아직 아무것도 없다. AI가 30초 전에 추천했으니까. lockfile은 처음 받아온 악성 버전을 충실히 기록하고 이제 그걸 핀으로 박아버린다. Lockfile은 연속성을 보호하지 첫 접촉을 못 막는다. - 스캐너는 known-bad를 찾는데 이건 never-seen이다. 어제 등록된, 이번 분기부터 반복되기 시작한 환각을 노린 패키지엔 CVE도, 권고문도, 평판도, 이력도 없다. 부재로 인해 "깨끗해" 보인다.
- Typosquat 탐지는 edit distance로 잡는데 절반은 아무것도 안 닮았다. 앞서 말한 대로 환각 이름의 절반 가까이가 원본과 매우 다르다. 문자열 유사도 필터를 그대로 통과한다.
흔한 함정: 실제로 만나는 에러
AI가 준 이름을 실행했는데 진짜로 없는 패키지면 그나마 다행이다. 이런 에러가 뜬다.
$ pip install requests-oauth2-helper
ERROR: Could not find a version that satisfies the requirement requests-oauth2-helper (from versions: none)
ERROR: No matching distribution found for requests-oauth2-helper
npm이면 이렇게 나온다.
$ npm install requests-oauth2-helper
npm error code E404
npm error 404 Not Found - GET https://registry.npmjs.org/requests-oauth2-helper - Not found
npm error 404
npm error 404 'requests-oauth2-helper@*' is not in this registry.
여기서 진짜 함정. 이 404가 뜨면 대부분의 개발자는 "아 이건 아직 없나 보네" 하고 다른 이름을 찾는다. 문제는 공격자가 이미 그 이름을 등록해둔 경우다. 그러면 404가 아니라 설치가 정상적으로 성공한다. 즉 "설치가 됐다 = 안전한 진짜 패키지다"라는 등식이 성립하지 않는다. 설치 성공은 아무것도 보장하지 않는다. 이 심리적 함정을 파이프라인 게이트로 막아야 하는 이유가 여기 있다.
4. 방어: 설치 전에 패키지 실재성을 검증하는 게이트
핵심 아이디어는 단순하다. install이 코드를 실행하기 전에, 이 패키지가 애초에 사람이 선택할 만한 실재하는 물건인지 먼저 확인한다. 아래는 PyPI JSON API로 패키지 존재/나이/다운로드를 사전 검증하는 스크립트다.
#!/usr/bin/env bash
# check-pkg.sh — 설치 전 PyPI 패키지 실재성 검증
set -euo pipefail
PKG="$1"
API="https://pypi.org/pypi/${PKG}/json"
HTTP=$(curl -s -o /tmp/pkg.json -w "%{http_code}" "$API")
if [ "$HTTP" = "404" ]; then
echo "❌ '${PKG}' : PyPI에 존재하지 않음. AI 환각 가능성 높음. 설치 중단."
exit 1
fi
if [ "$HTTP" != "200" ]; then
echo "⚠️ API 조회 실패 (HTTP ${HTTP}). 수동 확인 필요."
exit 2
fi
# 최초 릴리스 시점과 프로젝트 URL 확인
UPLOAD=$(python3 -c "import json;d=json.load(open('/tmp/pkg.json'));r=d['releases'];print(min((f['upload_time'] for v in r.values() for f in v), default='unknown'))")
HOME=$(python3 -c "import json;d=json.load(open('/tmp/pkg.json'));print(d['info'].get('home_page') or d['info'].get('project_url') or 'none')")
echo "✅ '${PKG}' 존재함"
echo " 최초 업로드: ${UPLOAD}"
echo " 프로젝트 URL: ${HOME}"
echo " ⚠️ 등록일이 최근(수일~수주)이고 URL이 없으면 slopsquat 의심."
실행 결과는 이렇게 나온다.
$ ./check-pkg.sh requests-oauth2-helper
❌ 'requests-oauth2-helper' : PyPI에 존재하지 않음. AI 환각 가능성 높음. 설치 중단.
$ ./check-pkg.sh requests
✅ 'requests' 존재함
최초 업로드: 2011-02-14T15:33:52
프로젝트 URL: https://requests.readthedocs.io
⚠️ 등록일이 최근(수일~수주)이고 URL이 없으면 slopsquat 의심.
포인트는 단순 존재 여부만 보지 않는다는 거다. huggingface-cli 사례처럼 공격자가 이미 등록했다면 존재는 한다. 그래서 "최초 등록일이 최근이고, 프로젝트 URL/GitHub이 없고, 다운로드 이력이 얕다"는 신호를 함께 본다.
CI/CD 파이프라인에 게이트 걸기
이 검증을 requirements.txt 전체에 돌리고, 하나라도 걸리면 빌드를 실패시킨다. GitHub Actions 예시다.
name: dep-sanity
on: [pull_request]
jobs:
verify-packages:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Verify each requirement exists on PyPI
run: |
fail=0
while read -r line; do
# 주석/빈 줄 스킵, 버전 지정자 제거
pkg=$(echo "$line" | sed 's/[<>=!~;].*//' | tr -d '[:space:]')
[ -z "$pkg" ] && continue
[[ "$pkg" == \#* ]] && continue
code=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/${pkg}/json")
if [ "$code" = "404" ]; then
echo "::error::존재하지 않는 패키지: ${pkg}"
fail=1
else
echo "ok: ${pkg}"
fi
done < requirements.txt
exit $fail
여기에 더해 실무에서 같이 거는 레버들:
- pip은
--only-binary :all:로 source 빌드(=install 시 코드 실행)를 원천 차단한다. wheel 없는 패키지는 CI에서 빌드 실패시키고 사람이 검토하게 한다. - npm은
npm ci --ignore-scripts로 lifecycle 스크립트를 끄고 설치한다. 정말 postinstall이 필요한 신뢰 패키지만 allowlist로 관리한다. - 내부 프록시/미러를 둔다. 개발자가 public registry에 직접 붙지 않게 하고, 승인된 패키지만 통과시킨다. 첫 접촉 자체를 게이트 뒤로 밀어넣는 방식이다.
- lockfile + hash 검증은 첫 접촉을 못 막지만, 한번 검증한 뒤엔 반드시 걸어라. 연속성 보호는 여전히 유효하다.
트레이드오프
PyPI/npm API를 PR마다 호출하면 rate limit과 빌드 지연이 생긴다. 큰 의존성 트리에선 캐싱이 필수다. 그리고 이 게이트는 "존재 여부"까지만 자동화할 수 있고, "이 패키지가 정말 내가 의도한 그 물건인가"라는 최종 판단은 결국 사람 몫으로 남는다. 자동 게이트를 만능으로 착각하면 안 된다.
'Tech_News' 카테고리의 다른 글
| 마이크로커널, 이번엔 진짜일까 — IOMMU 시대에 다시 꺼내보는 커널 격리 이야기 (0) | 2026.07.28 |
|---|---|
| Postgres LISTEN/NOTIFY는 확장 안 된다는 편견, 배치 버퍼링으로 초당 6만 건까지 뚫는 법 (0) | 2026.07.28 |
| 보안 카메라 펌웨어에서 GitHub 관리자 토큰이 나왔다 — 시크릿 유출은 왜 반복되는가 (0) | 2026.07.26 |
| 과제형 면접 ZIP을 열었더니 .git/hooks에 악성코드가 심어져 있었다 (0) | 2026.07.25 |
| Passkey는 왜 엔지니어인 우리조차 헷갈리는가: FIDO2/WebAuthn 실무 도입기 (0) | 2026.07.24 |
