Tuần này, trên Hacker News có một thread gần 800 điểm về chuyện phát hiện backdoor trong một extension mạo danh "Claude Code for VS Code" trên marketplace mà Cursor dùng. Cùng lúc đó còn có tin Northflank bị breach. Đọc xong, mình mở terminal chạy code --list-extensions | wc -l thì ra 63. Trong 63 cái đó, mình chỉ nhớ đã cài chủ động chừng 20. Số còn lại được cài kèm theo extension pack, được ai đó recommend trong .vscode/extensions.json của project, hoặc mình cài để thử một lần rồi quên luôn.
Vấn đề nằm ở chỗ: extension của VS Code không chạy trong sandbox. Nó chạy trong Extension Host, là một process Node.js mang quyền của chính user bạn. Nghĩa là nó đọc được ~/.ssh, ~/.aws/credentials, file .env của mọi project, và gọi được child_process.exec để chạy bất cứ thứ gì. Bài này mình chia sẻ quy trình audit extension mà team mình đang áp dụng. Nó áp dụng được cho VS Code, Cursor, Windsurf, VSCodium, tức là mọi editor dựa trên nhân VS Code.
Extension độc hại tấn công bạn như thế nào
Pattern thường gặp không có gì phức tạp. Kẻ tấn công publish một extension có tên gần giống tool đang hot (typosquatting), hoặc mua lại một extension đã có sẵn user base rồi push bản update có chèn mã độc. Open VSX, registry mà Cursor và VSCodium dùng, cho đến giờ vẫn có quy trình verify publisher lỏng hơn Microsoft Marketplace. Vì vậy các editor fork thường bị nhắm tới đầu tiên.
flowchart LR
A[Attacker publish extension giả] --> B[Marketplace / Open VSX]
B --> C[Dev search và cài đặt]
C --> D[activationEvents: * hoặc onStartupFinished]
D --> E[Extension Host chạy code với quyền user]
E --> F[Đọc ~/.ssh, .env, token]
F --> G[Exfiltrate về C2 server]
Điểm đáng chú ý là activationEvents. Extension hợp lệ thường chỉ activate khi bạn mở đúng loại file, ví dụ onLanguage:python. Extension độc hại thì muốn chạy ngay khi editor vừa mở, nên hay khai báo * hoặc onStartupFinished. Đây chưa phải bằng chứng, vì nhiều extension tử tế cũng làm vậy, nhưng đủ là một tín hiệu để soi kỹ hơn.
Bước 1: Kiểm kê và tạo baseline
Muốn bảo vệ thì trước hết phải biết mình đang có gì. Mình tạo baseline rồi commit vào một repo dotfiles riêng:
# Snapshot extension kèm version cho từng editor
code --list-extensions --show-versions | sort > ~/dotfiles/ext/vscode.txt
cursor --list-extensions --show-versions | sort > ~/dotfiles/ext/cursor.txt
# Định kỳ so sánh: có gì mới hoặc version nào nhảy mà mình không biết?
code --list-extensions --show-versions | sort | diff ~/dotfiles/ext/vscode.txt -
# Extension nào vừa bị thay đổi trên disk trong 7 ngày qua
find ~/.vscode/extensions ~/.cursor/extensions -maxdepth 1 -type d -mtime -7
# Gỡ những cái không dùng
code --uninstall-extension publisher.extension-name
Chỉ riêng bước này đã giúp mình gỡ được hơn 30 extension. Nhớ rằng mỗi extension bị gỡ là bớt đi một bên thứ ba có quyền đọc toàn bộ home directory của bạn.
Bước 2: Scan nhanh bằng một script Python
Audit tay 60 extension là việc không ai làm nổi. Mình viết một script nhỏ (Python 3.10+, không cần thư viện ngoài) để chấm điểm rủi ro dựa trên heuristic:
#!/usr/bin/env python3
import json
from pathlib import Path
ROOTS = [Path.home() / d / 'extensions'
for d in ('.vscode', '.cursor', '.vscode-oss', '.windsurf')]
SUSPICIOUS = ['child_process', 'eval(', 'new Function(', 'atob(',
'.ssh', '.aws', 'process.env', 'os.homedir']
EAGER = {'*', 'onStartupFinished'}
def scan(ext_dir: Path):
pkg = json.loads((ext_dir / 'package.json').read_text(encoding='utf-8'))
eager = EAGER & set(pkg.get('activationEvents', []))
hits = {}
for js in ext_dir.rglob('*.js'):
if 'node_modules' in js.parts:
continue
src = js.read_text(encoding='utf-8', errors='ignore')
for p in SUSPICIOUS:
if p in src:
hits[p] = hits.get(p, 0) + 1
score = len(hits) + (3 if eager else 0)
return score, f"{pkg.get('publisher')}.{pkg.get('name')}@{pkg.get('version')}", eager, hits
results = []
for root in ROOTS:
if root.exists():
for d in root.iterdir():
if (d / 'package.json').exists():
try:
results.append(scan(d))
except (OSError, json.JSONDecodeError):
pass
for score, name, eager, hits in sorted(results, reverse=True)[:15]:
print(f'[{score:2}] {name} eager={sorted(eager)} {sorted(hits)}')
Kết quả là danh sách top 15 extension cần soi. Lưu ý quan trọng: điểm cao không có nghĩa là độc hại. ESLint hay Python extension đều dùng child_process, vì chúng phải spawn language server. Script này chỉ giúp bạn biết nên đọc kỹ cái nào trước. Với extension nằm trong top mà publisher lạ, ít download, repo GitHub không khớp với code đã bundle, hãy mở file extension.js ra đọc. Gặp chuỗi base64 dài hoặc URL trỏ thẳng tới một IP là gỡ ngay.
Bước 3: Khóa cửa bằng allowlist và tắt auto-update
Phát hiện thôi chưa đủ, còn phải chặn được từ đầu. Từ VS Code 1.96 có setting extensions.allowed, cho phép khai báo allowlist theo publisher hoặc extension ID, kèm cả version. Cursor và các fork mới cũng đã kế thừa setting này, nhưng bạn nên tự test trên bản mình đang dùng.
// settings.json (User) hoặc đẩy qua MDM / Group Policy cho cả team
{
"extensions.allowed": {
"*": false,
"microsoft": true,
"ms-python": true,
"dbaeumer.vscode-eslint": true,
"esbenp.prettier-vscode": ["11.0.0"],
"golang.go": "stable"
},
"extensions.autoUpdate": false,
"extensions.ignoreRecommendations": true,
"security.workspace.trust.enabled": true,
"security.workspace.trust.untrustedFiles": "prompt"
}
Vài lý do đằng sau các lựa chọn trên:
-
"*": false: mặc định chặn tất cả, chỉ mở cho những gì đã review. Extension nằm ngoài list sẽ bị disable ngay cả khi đã cài từ trước. -
Tắt
autoUpdate: trong kịch bản account takeover, chính bản update là thứ mang mã độc vào. Mình update thủ công mỗi 2 tuần, sau khi xem changelog. -
ignoreRecommendations: clone một repo lạ về, bạn không muốn editor gợi ý cài extension do chính repo đó chỉ định. - Workspace Trust: repo chưa được trust sẽ mở ở Restricted Mode, task và debug config không tự chạy.
flowchart TD
A[Yêu cầu cài extension] --> B{Có trong extensions.allowed?}
B -- Không --> X[Bị chặn]
B -- Có --> C{Version được pin?}
C -- Không khớp --> X
C -- Khớp --> D[Cài đặt]
D --> E[Scan định kỳ + diff baseline]
E -- Bất thường --> F[Gỡ + rotate credentials]
Bước 4: Giảm thiệt hại khi có chuyện
Phải giả định trước rằng sẽ có ngày một extension nào đó vượt qua được mọi lớp kiểm tra. Lúc đó, việc cần làm là hạn chế những gì nó lấy được:
-
Không để secret dạng plaintext trong home directory. Dùng
aws sso loginvới credential ngắn hạn thay cho access key cố định. SSH key thì để trong hardware key hoặc agent (1Password, Secretive trên macOS) để file private key không nằm trên disk. - Tách môi trường. Project nhạy cảm như infra hay production thì mở trong Dev Container hoặc remote SSH, với một bộ extension tối thiểu cài riêng trong container.
-
Có sẵn runbook rotate. Nếu một extension bị xác nhận là độc hại, bạn cần biết trong 15 phút phải rotate những gì: GitHub PAT, npm token, cloud key, và các file
.envmà editor từng mở.
Kết luận
Editor là tool có quyền lực nhất trên máy dev, vậy mà lại ít được audit nhất. Checklist mình đề xuất làm ngay trong tuần này:
-
Chạy
--list-extensions --show-versions, gỡ hết những gì không dùng trong 30 ngày qua, rồi commit baseline. - Chạy script scan ở trên, đọc code của top 5 extension có điểm cao nhất mà publisher không phải tên quen.
-
Bật
extensions.allowedvới"*": falsevà tắtextensions.autoUpdate. - Đưa credential ra khỏi plaintext: dùng SSO, credential ngắn hạn, SSH agent.
-
Với team: đẩy allowlist qua MDM hoặc policy, review extension mới giống như review một dependency mới trong
package.json.
Supply chain attack không chỉ xảy ra ở npm hay PyPI. Mỗi extension bạn cài cũng là một dependency chạy với full quyền, và nó cần được review kỹ như bất kỳ dependency nào khác.
Top comments (1)
The point about extensions running with user privileges makes the audit advice feel urgent rather than theoretical. I liked the baseline idea of listing versions first, because without that list it is hard to notice a quiet change. The allowed list approach seems like the practical step that turns a one time review into ongoing control.