Slopsquatting: When Your AI Assistant Invents Packages and Attackers Register Them First
Tuần này trên Dev.to có một bài khá thú vị: một bạn chạy model local để sinh code, và model tự bịa ra 162 Python package không tồn tại. Điều đáng sợ là khi chạy lại từ đầu, 161 cái tên vẫn quay lại y hệt. Nghĩa là packag
Tuần này trên Dev.to có một bài khá thú vị: một bạn chạy model local để sinh code, và model tự bịa ra 162 Python package không tồn tại. Điều đáng sợ là khi chạy lại từ đầu, 161 cái tên vẫn quay lại y hệt. Nghĩa là package do AI bịa ra không hề ngẫu nhiên. Chúng lặp lại và đoán trước được. Đây chính là điều kiện để kẻ tấn công kiếm lợi: họ cho model chạy hàng nghìn prompt, gom các tên bị bịa ra lặp lại nhiều nhất, rồi đăng ký chúng lên PyPI hoặc npm kèm payload độc hại. Kiểu tấn công này có tên là slopsquatting, họ hàng của typosquatting nhưng nguy hiểm hơn, vì người gõ lệnh cài không gõ sai chữ nào. Họ chỉ tin AI.
Bài này mình chia sẻ cách team mình phòng thủ thực tế: script kiểm tra dependency, cấu hình lock file và một bước chặn trong CI.
Tấn công diễn ra như thế nào
Kịch bản rất đời thường. Bạn hỏi AI: "parse file HEIC sang JPEG trong FastAPI thế nào?". AI trả lời rất tự tin, kèm dòng pip install fastapi-heic-converter. Package này nghe rất hợp lý nhưng chưa bao giờ tồn tại. Nếu có ai đó đã đăng ký cái tên ấy trước bạn, thì setup.py hoặc build hook của nó sẽ chạy ngay trên máy bạn lúc cài.
flowchart LR
A[Attacker chạy hàng nghìn prompt] --> B[Gom tên package bị hallucinate lặp lại]
B --> C[Đăng ký tên lên PyPI hoặc npm]
D[Developer hỏi AI] --> E[AI gợi ý pip install tên bịa]
C --> F[Package độc hại đã có sẵn]
E --> F
F --> G[setup.py hoặc postinstall chạy]
G --> H[Lộ token, SSH key, file .env]
Có ba điểm làm slopsquatting nguy hiểm hơn typosquatting:
-
Tên nghe rất hợp lý, thường ghép từ framework thật cộng với chức năng, ví dụ
flask-jwt-helperhayreact-native-secure-storage-pro. -
Coding agent tự cài luôn. Khi agent có quyền chạy shell, nó thấy
ModuleNotFoundErrorlà gõpip installngay, chẳng ai kịp liếc qua. - Tên lặp lại giữa các lần chạy, nên attacker biết chắc mình đang nhắm vào đâu.
Script kiểm tra dependency trước khi cài
Cách phòng thủ rẻ nhất là đừng tin bất kỳ tên package nào chưa được kiểm chứng. PyPI có JSON API công khai tại https://pypi.org/pypi/<name>/json, đủ để biết package có tồn tại không, có từ bao giờ, có bao nhiêu release. Mình viết một script nhỏ chỉ dùng thư viện chuẩn, chạy được với Python 3.9+:
#!/usr/bin/env python3
# check_deps.py - kiểm tra package trong requirements.txt có đáng tin không
import json
import re
import sys
import urllib.error
import urllib.request
from datetime import datetime, timezone
MIN_AGE_DAYS = 90
MIN_RELEASES = 3
def check(name):
url = f'https://pypi.org/pypi/{name}/json'
try:
with urllib.request.urlopen(url, timeout=10) as r:
data = json.load(r)
except urllib.error.HTTPError as e:
if e.code == 404:
return 'MISSING', 'không tồn tại trên PyPI'
raise
releases = data['releases']
uploads = [
f['upload_time_iso_8601']
for files in releases.values() for f in files
]
if not uploads:
return 'SUSPICIOUS', 'không có file release nào'
first = min(datetime.fromisoformat(u.replace('Z', '+00:00')) for u in uploads)
age = (datetime.now(timezone.utc) - first).days
if age < MIN_AGE_DAYS or len(releases) < MIN_RELEASES:
return 'SUSPICIOUS', f'mới {age} ngày, {len(releases)} release'
return 'OK', f'{age} ngày, {len(releases)} release'
def parse(path):
for line in open(path, encoding='utf-8'):
line = line.split('#')[0].strip()
if not line or line.startswith('-'):
continue
yield re.split(r'[<>=!~;\[ ]', line)[0]
if __name__ == '__main__':
bad = 0
for pkg in parse(sys.argv[1] if len(sys.argv) > 1 else 'requirements.txt'):
status, info = check(pkg)
print(f'{status:<11} {pkg:<30} {info}')
bad += status != 'OK'
sys.exit(1 if bad else 0)
Chạy thử:
$ python3 check_deps.py requirements.txt
OK fastapi 2580 ngày, 290 release
OK pillow-heif 1490 ngày, 68 release
MISSING fastapi-heic-converter không tồn tại trên PyPI
SUSPICIOUS pydantic-settings-pro mới 12 ngày, 1 release
Lưu ý: trạng thái MISSING cũng nguy hiểm không kém. Hôm nay tên đó chưa có ai đăng ký, nhưng ngày mai có thể đã có. Gặp MISSING thì phải xoá khỏi code ngay, đừng để nằm chờ.
Ngưỡng 90 ngày và 3 release là heuristic, không phải chân lý. Một package nội bộ của công ty bạn hoặc một lib mới ra nhưng xịn vẫn có thể bị đánh dấu. Mục đích của script là buộc con người nhìn vào, không phải để tự động chặn hết.
Bên npm cũng vậy, và còn tệ hơn
Với npm, postinstall script chạy mặc định khi cài, nên rủi ro còn cao hơn. Mình dùng registry API kết hợp download stats:
// check-npm.mjs - Node 18+ (có sẵn fetch)
import { readFileSync } from 'node:fs';
const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
for (const name of Object.keys(deps)) {
const meta = await fetch(`https://registry.npmjs.org/${name}`);
if (meta.status === 404) {
console.log(`MISSING ${name}`);
continue;
}
const { time } = await meta.json();
const ageDays = Math.floor((Date.now() - new Date(time.created)) / 864e5);
const dl = await fetch(`https://api.npmjs.org/downloads/point/last-week/${name}`);
const { downloads = 0 } = await dl.json();
const flag = ageDays < 90 || downloads < 1000 ? 'SUSPICIOUS' : 'OK';
console.log(`${flag.padEnd(11)} ${name.padEnd(30)} ${ageDays}d, ${downloads}/week`);
}
Thêm vào đó, nên tắt lifecycle script theo mặc định:
# Tắt postinstall cho toàn bộ máy dev
npm config set ignore-scripts true
# pnpm 10+ đã mặc định không chạy build script của dependency,
# chỉ những package được cho phép trong onlyBuiltDependencies mới được chạy
pnpm install
# Python: chỉ cài wheel, không build từ sdist (sdist có thể chạy setup.py tuỳ ý)
pip install --only-binary=:all: -r requirements.txt
Đưa vào CI để không ai lọt lưới
Script chạy trên máy dev là tốt, nhưng người hay quên. Team mình bắt buộc mọi PR có thay đổi file dependency phải qua pipeline sau:
flowchart TD
A[PR thay đổi requirements hoặc package.json] --> B[check_deps: tồn tại, tuổi, số release]
B -->|MISSING| X[Fail PR]
B -->|SUSPICIOUS| R[Bắt buộc reviewer approve]
B -->|OK| C[pip-audit hoặc npm audit]
R --> C
C --> D[Lock file có hash]
D --> E[Merge]
Vài công cụ thực tế:
-
uv(Astral):uv locksinhuv.lockcó hash cho từng file. Khi cài bằnguv sync --locked, nếu lock file không khớp vớipyproject.tomlthì fail luôn. Không ai lén thêm package được. -
pip-audit(của PyPA):pip-audit -r requirements.txtđể quét CVE đã biết. Nó không bắt được slopsquatting, nhưng nên chạy cùng. -
pip install --require-hashes: nếu bạn vẫn dùngrequirements.txt, sinh hash bằngpip-compile --generate-hashes(pip-tools). - Private index hoặc proxy như devpi, Artifactory, Nexus với allowlist: cách triệt để nhất cho team lớn, chỉ package đã duyệt mới cài được.
Với coding agent (Claude Code, Cursor, Aider...), mình thêm một rule vào file instruction của project: không được tự chạy pip install hay npm install package mới, phải đề xuất cho người duyệt. Kết hợp với permission mode chặn lệnh install thì agent không thể tự ý kéo package lạ về.
Kết luận
AI bịa ra package không phải bug hiếm gặp, mà là hành vi có hệ thống và lặp lại được, nên kẻ tấn công khai thác được. Mấy việc nên làm ngay tuần này:
-
Đừng copy lệnh
pip install/npm installtừ AI mà chưa kiểm tra. Mở trang PyPI/npm, xem tuổi package, số release, repo GitHub có thật và có hoạt động không. - Thêm script kiểm tra dependency vào CI. Fail với MISSING, bắt review với SUSPICIOUS.
-
Tắt install script mặc định:
npm config set ignore-scripts true, dùng pnpm 10+, ưu tiên--only-binary=:all:cho Python. -
Dùng lock file có hash (
uv.lock,package-lock.json,pip-compile --generate-hashes) và cài ở chế độ locked trong CI. - Siết quyền của coding agent: không cho tự cài package mới, mọi dependency mới phải qua người duyệt.
AI giúp mình code nhanh hơn nhiều, nhưng dependency là cửa ngõ vào toàn bộ hệ thống. Với chỗ đó, cứ coi mọi gợi ý của AI là input chưa được xác thực, giống hệt input từ user.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.