Claude Code 확장 이름의 변조 빌드, 마켓에서 홈 디렉터리까지 여섯 층으로 뜯어 보기
10월 29일 Cursor 마켓에 올라온 Claude Code 2.1.312·2.1.313은 공식 빌드가 아니었습니다. 열쇠 한 줄, 바깥으로 나가는 연결 하나, 시작 항목 하나가 각각 어느 층에서 일어나고 각 층이 무엇을 볼 수 있는지를 공식 문서와 격리 컨테이너 실험으로 풀었습니다.
$ ssh-keygen -lf ~/.ssh/authorized_keys | nl -ba
1 256 SHA256:YPJM30uQgDVZiK5BFzScdR/NqyhSubTcGERgR1T/7GY dev@laptop (ED25519)
2 256 SHA256:gEoZImDv6WkjBE7FKo2m9JbyDDKFUCHnLEXprGZtqrE svc-telemetry (ED25519)
위 두 줄은 이 글을 쓰려고 격리 컨테이너에서 만든 실험용 authorized_keys의 지문 목록입니다(편집부 실험). 두 줄 모두 이 계정에 암호 없이 로그인할 수 있는 열쇠인데, 화면만 보고는 어느 쪽이 본인 것인지 알 수 없습니다. 끝의 이름표는 사람이 적은 메모일 뿐이기 때문입니다. 2026년 10월 29일 Cursor 확장 마켓에 올라온 Claude Code 확장 변조 빌드가 노린 것이 바로 이 파일의 한 줄이었고, 그 줄에 붙은 이름표가 svc-telemetry였습니다.
이 글은 그 한 줄이 놓이기까지 지나는 층을 바깥에서 안쪽으로 따라갑니다. 마켓 목록, 편집기, 확장 호스트, 사용자 계정의 파일, SSH, 재부팅 뒤에도 남는 시작 항목의 순서입니다. 사건 사실(어떤 버전이, 언제, 무엇을 했는지)은 Cursor가 올린 보안 공지 한 건에서만 가져왔습니다. 각 층이 원래 어떻게 동작하는지는 OpenSSH 매뉴얼, VS Code 문서, Apple·Microsoft 문서를 직접 읽고 쓴 ’편집부 해설’이고, 격리 컨테이너에서 직접 돌려 본 것은 ’편집부 실험’으로 표시했습니다. 공격 코드나 재현 방법은 다루지 않습니다.
마켓 목록에서 이름 하나로 설치되는 층
가장 바깥층은 마켓 목록입니다. Cursor 공지에 따르면 Anthropic.claude-code 확장의 2.1.312와 2.1.313이 Windows와 macOS용으로 Cursor의 확장 마켓에 올라와 있었고, Cursor는 이 두 버전을 Anthropic이 만들지 않았다고 밝혔습니다. 노출 구간은 10월 29일 태평양 시각 오전 2시 27분부터 오후 1시 35분까지입니다. 일광절약시간(UTC-7) 기준으로 UTC 09:27부터 20:35까지, 11시간 8분이며 이 환산은 편집부가 한 것입니다. Cursor는 다른 확장은 영향이 없었고 이 확장의 다른 버전이나 공식 배포가 영향받았다는 증거도 없다고 적었습니다.

그림 1. 노출 구간과 공지 갱신 시각(UTC). 시각은 Cursor 공지의 태평양 시각 표기를 편집부가 환산함.
이 층에서 사용자가 하는 일은 단순합니다. 목록에서 이름을 보고 설치를 누르거나 편집기가 자동 업데이트하게 둡니다. 편집기는 이름과 버전 번호로 파일을 받고, 사용자는 이름과 게시자 칸을 보고 믿습니다. 이름이 같고 번호가 올라갔으면 같은 물건의 새 버전으로 읽힙니다. 이 층에서는 파일이 누구의 것인지를 사용자가 직접 확인할 방법이 거의 없고, 확인은 마켓이 대신 해 주는 구조입니다.
마켓 쪽 장치가 무엇을 하는지는 VS Code 문서가 Visual Studio Marketplace 기준으로 설명합니다(편집부 해설). 게시된 패키지마다 여러 백신 엔진으로 악성코드 검사를 하고, 샌드박스에서 동작을 돌려 보는 동적 탐지가 있고, 공식 게시자 이름을 베끼는 이름 선점을 막고, 신고가 확인된 확장은 차단 목록에 올려 이미 설치된 곳에서도 자동으로 지우고, 패키지에 서명해 설치할 때 편집기가 서명을 확인합니다. 또 VS Code 1.97부터는 처음 보는 게시자의 확장을 설치할 때 그 게시자를 믿는지 묻는 대화상자가 뜨고, 명령줄로 설치할 때는 게시자가 자동으로 신뢰되지 않습니다. 이 목록은 Cursor 마켓에 그대로 적용된다는 뜻이 아닙니다. Cursor 공지가 밝힌 것은 이 사건 뒤에 자사 마켓에서 한 조치, 즉 문제 버전 제거와 배포 차단, 마켓의 일시 중단, 접근 기록 감사, 열쇠 교체, 마켓 재구축, 공격자 서버 차단 요청입니다. 두 마켓의 장치를 같은 것으로 놓고 읽지 않는 것이 이 절의 기준입니다.
그림 2에서 이 글 전체의 지도를 먼저 보겠습니다. 가로 방향이 바깥에서 안쪽이고, 구역마다 신뢰의 주체가 다릅니다.

그림 2. 공지가 서술한 동작을 층별로 배치한 도식. 확장 호스트가 사용자 계정 구역의 파일에 닿는 것과 관리자 경계가 점선인 것이 핵심.
그림에서 읽을 것은 두 가지입니다. 마켓 구역을 지난 파일은 그다음부터 편집기와 확장 호스트라는 같은 구역 안에서 움직이고, 거기서 사용자 계정 구역의 파일 두 군데(열쇠 파일과 시작 항목)로 직접 닿습니다. 그리고 관리자 경계는 점선으로 남아 있습니다. 공지는 SSH를 켜려는 시도가 관리자 권한 없이는 보통 실패한다고 적었고, 그 경계가 이 사건에서 어떤 의미인지는 마지막 절에서 실험으로 다룹니다.
확장 호스트는 편집기와 같은 권한으로 돈다
마켓을 통과한 파일이 도착하는 곳은 확장 호스트입니다. VS Code 확장 호스트 문서는 구성을 세 가지로 나눕니다. 편집기 화면과 같은 기계에서 도는 Node.js 기반 로컬 호스트, 브라우저 안의 웹 호스트, 원격 컨테이너·SSH·WSL 같은 곳에서 도는 원격 호스트입니다. Node.js 호스트에서 도는 확장은 매니페스트의 main 항목이 가리키는 파일로 올라오고, 이 사건의 공지가 지표로 적은 extension.js가 바로 그런 진입 파일입니다. 해시값은 공지에 있습니다(SHA-256 ecb58cb2…6bf15).
확장 호스트라는 이름은 격리 상자처럼 들리지만 문서가 이 호스트를 설명하는 이유는 다릅니다. 확장 호스트가 막아 주는 것으로 문서가 꼽는 것은 시작 성능에 영향을 주는 일, UI 조작을 느리게 하는 일, UI를 임의로 바꾸는 일입니다. 안정성과 성능을 위한 분리이고 보안 경계가 아닙니다. 보안 쪽 설명은 확장 런타임 보안 문서가 직접 합니다. 확장 호스트는 VS Code와 같은 권한을 갖기 때문에 VS Code가 할 수 있는 일은 확장도 할 수 있고, 예로 파일 읽기와 쓰기, 네트워크 요청, 외부 프로세스 실행, 작업 영역 설정 변경을 듭니다.
이 문장이 Cursor에도 그대로 적용되는지는 Cursor가 VS Code 계열이라는 점에서 구조가 같다고 볼 근거가 있습니다(편집부 해설). 그래도 아래 실험은 특정 편집기가 아니라 Node.js 프로세스 일반의 성질을 확인하는 것입니다. 확장 호스트가 Node.js 런타임이라는 문서의 설명을 전제로, 일반 사용자 계정으로 띄운 Node 프로세스가 홈 디렉터리의 SSH 폴더에서 무엇을 할 수 있는지 봅니다. 파일 내용은 읽지 않고 이름, 모드, 쓰기 가능 여부만 조회했습니다.
$ su dev -c 'node /tmp/probe.js' # 편집부 실험: OpenSSH 9.2가 든 일회용 컨테이너, 외부 네트워크 없음
uid: 1001 user: dev
.ssh/authorized_keys mode 600 writable-by-this-process: yes
.ssh/id_other mode 600 writable-by-this-process: yes
.ssh/id_other.pub mode 644 writable-by-this-process: yes
.ssh/id_owner mode 600 writable-by-this-process: yes
.ssh/id_owner.pub mode 644 writable-by-this-process: yes
.config 없음
spawn: dev
마지막 줄이 이 층의 성질을 가장 짧게 보여 줍니다. 프로세스는 사용자와 같은 uid로 돌고, 외부 프로그램을 실행(spawn)하면 그것도 같은 사용자입니다. 모드 600은 다른 사용자를 막는 장치이고, 파일 주인과 같은 계정으로 도는 프로세스에는 의미가 없습니다. 앞 절의 마켓 층이 통과되면, 이 층에서는 막는 설계가 따로 없고 그 사용자가 할 수 있는 일이 곧 확장이 할 수 있는 일입니다.
activate()가 불리는 때와 작업 영역 신뢰의 한계
확장이 설치된 것만으로 코드가 도는 것은 아닙니다. VS Code는 확장이 매니페스트의 활성화 이벤트(activationEvents)에 선언한 조건이 발생하면 그 확장의 activate() 함수를 부릅니다. 문서에 따르면 이 함수는 선언된 이벤트 중 어느 하나가 처음 발생할 때 한 번만 호출됩니다. 이벤트의 종류는 특정 언어 파일을 열 때, 특정 명령을 실행할 때, 작업 종류를 해석할 때, 인증 세션이 필요할 때 등으로 다양하고, 확장은 필요한 때까지 로드되지 않는 지연 로딩이 기본입니다.
그중 시작 시점을 가리키는 이벤트가 두 개 있습니다. *는 VS Code가 시작할 때마다 확장을 활성화하고, 문서는 다른 이벤트 조합으로 안 될 때만 쓰라고 권합니다. onStartupFinished는 시작이 끝난 뒤 얼마간 지나 활성화하며, *와 비슷하지만 시작 속도를 늦추지 않는다고 설명합니다. 정상 확장도 시작 이벤트를 선언하는 경우가 많습니다. 시작 이벤트를 선언한다는 사실 자체가 수상하다는 뜻은 아니고, 이 이벤트가 있으면 사용자가 아무것도 하지 않아도 편집기를 켜는 것만으로 activate()가 불린다는 구조적 의미를 갖는다는 점이 이 절의 핵심입니다. 이 절의 내용은 활성화 모델의 일반 원리이고, 변조 확장이 선언한 이벤트에 대한 서술이 아닙니다.
그렇다면 작업 영역 신뢰(Workspace Trust)가 막아 주지 않느냐는 질문이 나옵니다. VS Code 문서에서 이 기능은 낯선 폴더를 열 때 제한 모드(Restricted Mode)로 시작해 작업, 디버깅, 터미널, 작업 영역 설정, 확장의 자동 실행을 제한하는 기능입니다. 확장은 작업 영역 신뢰를 명시적으로 지원한다고 선언하지 않았다면 제한 모드에서 기본적으로 꺼집니다. 그런데 같은 문서에는 이 기능이 악성 확장을 막지 못한다는 경고가 있습니다. 작업 영역 신뢰는 악성 확장이 코드를 실행하고 제한 모드를 무시하는 것까지 막을 수 없으므로, 잘 알려진 게시자가 낸 확장만 설치하라는 것이 문서의 안내입니다.
이 문서가 알려 주는 구조는 이렇습니다(편집부 해설). 작업 영역 신뢰가 막는 대상은 프로젝트 안의 코드(누군가 만든 저장소를 열었을 때 그 안에서 자동으로 돌 수 있는 것)입니다. 그에 비해 이번 사건의 변조 확장은 사용자가 설치한 도구의 형태로 들어옵니다. 두 경로는 위협 모델이 다르고, 앞의 것을 막는 장치가 뒤의 것에도 작동하리라고 기대하면 안 됩니다. 확장이 신뢰받는 설치물이 되는 순간, 그 확장이 어떤 폴더를 열고 있든 제한 모드와는 상관없이 활성화될 수 있는 것이 모델상의 기본입니다.
홈 디렉터리 안에서는 파일 권한이 막아 주지 않는다
확장 호스트가 닿을 수 있는 곳은 사용자의 홈 디렉터리 전체입니다. Cursor 공지가 지표로 적은 위치만 봐도 한 계정의 홈 아래에서 세 군데가 걸립니다. SSH 설정 폴더의 ~/.ssh/authorized_keys, 설정 폴더의 ~/.config/.smt와 ~/.config/.smtk(Windows는 %USERPROFILE%\.config\smt.cmd), 그리고 macOS의 ~/Library/LaunchAgents/com.apple.sysmond.plist입니다. 임시 폴더에도 .stk_ 다음에 열 자리 16진수가 붙은 파일이 지표로 올라 있습니다. 전부 일반 사용자 권한으로 쓸 수 있는 곳입니다. 공지는 관리자 권한이 필요한 쪽(SSH 켜기)은 보통 실패한다고 하면서도 열쇠는 이미 추가되었고 지속 장치도 설치되었다고 적었는데, 이 두 가지가 모두 사용자 계정의 홈 영역에서 끝나는 일이기 때문입니다.
앞 절의 실험을 다시 보면 이 층의 두 번째 성질이 보입니다. authorized_keys는 mode 600이고 SSH 개인키도 600입니다. SSH 자체가 이 모드를 강제합니다. 열쇠 파일의 모드가 느슨하면 ssh가 개인키 사용을 거부하고, sshd는 홈 디렉터리와 .ssh 폴더의 쓰기 권한이 느슨한 경우 열쇠 파일을 신뢰하지 않는 기본 설정(StrictModes)을 갖습니다. 이런 권한 검사는 다른 사용자가 그 파일을 고쳤을 때를 막는 장치이고, 파일 주인과 같은 계정으로 도는 코드는 이 검사를 통과하는 정상 상태의 파일을 정상적인 방법으로 고칩니다. 그래서 authorized_keys에 줄이 하나 더 생겼을 때, 파일 모드와 폴더 권한은 모두 정상으로 남습니다. 모드가 맞는지 보는 점검은 이 사건에서 신호를 주지 못합니다.
그럼 어떤 신호가 남을까요. 파일 시각과 크기는 남습니다. 실험 환경에서 같은 파일의 상태를 stat으로 보면 mtime과 크기가 나옵니다(size=187). 문제는 이 값을 평소에 기억하는 사람이 없다는 점입니다. 변경 시각은 기준선과 비교할 때에만 신호가 되고, 개인 개발 기기에는 기준선이 없습니다. 그래서 사고 뒤에 읽는 쪽에서 쓸 수 있는 가장 단단한 단서는 파일 안의 열쇠 지문과 줄 번호입니다. 이 두 가지는 다음 절에서 sshd 로그와 함께 다룹니다.
authorized_keys 한 줄을 뜯어 보기
sshd(8) 매뉴얼의 AUTHORIZED_KEYS FILE FORMAT 절은 한 줄의 구조를 이렇게 정의합니다. 한 줄에 열쇠 하나이고, 빈 줄과 #로 시작하는 줄은 무시되며, 공백으로 구분되는 필드는 옵션, 열쇠 종류, base64로 인코딩된 열쇠, 주석 네 개입니다. 옵션은 생략할 수 있습니다. 같은 절에는 이 글에서 가장 중요한 문장이 있습니다. 주석 필드는 아무 용도로도 쓰이지 않고, 사용자가 열쇠를 식별하는 데 편리할 수 있을 뿐이라는 것입니다. 한 줄은 수백 바이트이고 최대 8킬로바이트까지 허용됩니다.
Cursor 공지가 지표로 적은 svc-telemetry는 이 네 번째 필드입니다. 공지는 침해 여부를 확인하는 첫 방법으로 이 이름표를 찾으라고 권했고, 공격자의 열쇠가 그 이름으로 들어갔다는 것이 공지가 밝힌 사실입니다. 그런데 이름표는 sshd가 읽지도 않는 필드이므로, 이름표가 다른 같은 열쇠는 똑같이 로그인됩니다. 아래 실험에서 두 열쇠 모두 이름표 필드와 무관하게 로그인되는지 확인했습니다. 먼저 svc-telemetry라는 이름표가 달린 열쇠로 접속하고, 같은 파일에서 이름표만 backup-agent로 바꾼 뒤 같은 열쇠로 다시 접속했습니다.
$ awk '{print $1, substr($2,1,12)"...", $3}' ~/.ssh/authorized_keys # 편집부 실험
ssh-ed25519 AAAAC3NzaC1l... dev@laptop
ssh-ed25519 AAAAC3NzaC1l... svc-telemetry
# 이름표가 svc-telemetry인 열쇠로 로그인
dev
1dc059bc7eb1
# 같은 파일에서 이름표만 backup-agent로 바꾸고, 같은 열쇠로 다시 로그인
ok-with-new-label
# sshd 로그 (LogLevel VERBOSE)
Accepted key ED25519 SHA256:xSONXf...C4CV8 found at /home/dev/.ssh/authorized_keys:2
Accepted publickey for dev from 127.0.0.1 port 48866 ssh2: ED25519 SHA256:xSONXf...C4CV8
세 가지가 확인됩니다. 이름표를 바꿔도 로그인은 똑같이 됩니다. sshd 로그에는 이름표가 어디에도 나오지 않고, 대신 열쇠 지문과 authorized_keys:2처럼 파일 이름과 줄 번호가 찍힙니다. 이 로그 줄은 LogLevel VERBOSE에서 나온 것이고 기본 로그 수준(INFO)에서는 Accepted publickey 줄만 남아 있으므로, 로그에서 줄 번호를 얻으려면 이 설정이 켜져 있어야 합니다.
여기서 방어 쪽 시사점이 나옵니다(편집부 해설). svc-telemetry를 grep으로 찾는 점검은 이 사건의 열쇠가 그 이름표로 들어갔다는 공지의 지표를 쓰는 올바른 첫걸음입니다. 다만 이름표는 바꾸기 쉬운 필드여서, 이름이 없다는 사실로 열쇠가 없다는 결론을 내리지 못합니다. 이름표와 무관하게 읽는 방법은 ssh-keygen -lf ~/.ssh/authorized_keys로 줄마다 지문을 뽑는 것입니다. 이 명령은 파일을 바꾸지 않고, 줄 번호와 짝지어 사람이 가진 개인키의 지문(ssh-keygen -lf ~/.ssh/id_ed25519.pub)과 하나씩 대조할 수 있습니다. 내가 만든 열쇠가 아닌 지문이 나오면 이름표가 어떻든 그 줄이 확인 대상입니다.
줄 앞 옵션이 열쇠의 능력을 줄이는 방식
같은 열쇠도 줄 앞의 옵션에 따라 할 수 있는 일이 달라집니다. sshd(8) 매뉴얼이 정의한 옵션 중 이 글과 관련이 큰 것은 다섯 가지입니다. command="…"는 이 열쇠로 인증하면 항상 지정한 명령만 실행하고 사용자가 보낸 명령은 무시합니다. 매뉴얼은 원격 백업만 허용하는 열쇠를 예로 듭니다. restrict는 포트·에이전트·X11 전달과 터미널 할당, 사용자 rc 실행을 한꺼번에 끄는 모든 제한의 묶음입니다. no-port-forwarding은 TCP 전달 요청을 오류로 돌려보냅니다. from="패턴 목록"은 열쇠에 더해 접속하는 호스트 이름이나 IP도 맞아야 하게 하며, 매뉴얼은 열쇠가 도난당했을 때 침입자가 세계 어디에서나 로그인하는 것을 어렵게 하려는 용도라고 설명합니다. expiry-time은 지정한 시각 뒤에는 그 열쇠를 받아들이지 않습니다. -R 전달에 한정해서는 permitlisten이 어느 호스트와 포트에서만 수신하게 할지를 지정합니다.
옵션이 없는 줄은 위 제한이 모두 풀린 줄입니다. 실제로 같은 열쇠를 세 가지 모양의 줄에 넣고 같은 요청을 했습니다. 옵션 없음, restrict,command="echo forced-command-only", 그리고 원격 포트 전달(-R)을 restrict와 옵션 없음에서 각각 3초간 시도한 결과입니다.
# 편집부 실험: 같은 열쇠, 같은 요청 'echo shell-ok; id -un'
줄 A (옵션 없음)
shell-ok
dev
줄 B restrict,command="echo forced-command-only"
forced-command-only # 요청한 명령은 실행되지 않고 지정된 명령만 실행됨
# 원격 포트 전달 ssh -N -R 0:localhost:22 를 3초간 유지(-o ExitOnForwardFailure=yes)
[옵션 없음] Allocated port 43383 for remote forward to localhost:22 exit=124 (3초 동안 유지됨)
[restrict] Error: remote port forwarding failed for listen port 0 exit=255 (거부됨)
옵션이 없는 줄은 셸도 되고 포트 전달도 됩니다. restrict만 붙여도 포트 전달은 거부됩니다. 공지가 서술한 역방향 터널은 공격자 서버 쪽으로 나가는 연결이므로 심긴 열쇠 줄의 옵션과는 직접 관련이 없습니다. 옵션 실험이 중요한 이유는 반대편에서 나옵니다. 본인이 자동화에 쓰는 열쇠(배포, 백업, CI 접속)는 대부분 한 가지 일만 하면 되는데, 옵션 없이 등록되면 도난 시 전체 셸이 됩니다. restrict와 command, from, expiry-time을 붙인 열쇠는 도난당해도 할 수 있는 일이 줄어듭니다. 이 사건의 교훈이라기보다 같은 파일이 같은 방식으로 쓰이는 평소의 위생입니다.
안에서 밖으로 낸 연결이 밖에서 안으로 오는 길이 되는 과정
공지가 밝힌 동작에서 가장 구조가 흥미로운 것은 역방향 SSH 터널입니다. ssh(1) 매뉴얼의 -R 옵션 설명은 이렇습니다. 원격(서버) 쪽의 지정한 포트로 들어오는 연결을 로컬 쪽으로 전달합니다. 서버 쪽에 수신 소켓을 만들고, 거기에 연결이 들어오면 보안 채널을 통해 로컬 쪽의 지정한 목적지로 연결을 만들어 줍니다. -N은 원격 명령을 실행하지 않고 포트 전달만 하라는 옵션입니다. 공지의 지표에는 ssh -N -R '*:0:localhost:22'와 autossh -M 0이 있고, 연결이 끊기면 약 60초마다 다시 시도한다고 적혀 있습니다.
이 명령을 풀어 읽으면, 포트 0은 서버가 빈 포트를 알아서 고르라는 뜻입니다. 로컬 목적지 localhost:22는 피해 호스트 자신의 SSH 서버입니다. *는 수신 주소를 모든 주소로 하자는 요청이며, 서버가 허용하는지는 서버 설정이 정합니다. 이 부분을 직접 확인했습니다. 연결을 받는 쪽 sshd의 GatewayPorts가 기본값(no)이면 -R '*:0:…'로 요청해도 서버는 루프백에만 수신 소켓을 엽니다. GatewayPorts yes로 바꾸면 같은 명령이 와일드카드 주소에 소켓을 엽니다.
# 편집부 실험: 같은 클라이언트 명령 ssh -N -R '*:0:localhost:22', 서버 쪽 설정만 변경 (ss -tlnH 의 수신 주소)
서버 GatewayPorts=no(기본) Allocated port 33065 ... 127.0.0.1:33065 [::1]:33065
서버 GatewayPorts yes Allocated port 35265 ... 0.0.0.0:35265 [::]:35265
즉 터널 포트가 공격자 서버의 공개 주소에서 열리는지, 서버 안쪽에서만 열리는지는 공격자 서버의 설정이 정합니다. 피해 호스트에서 보이는 모습은 어느 쪽이든 같습니다.
그림 3은 전체 순서입니다. 피해 호스트의 ssh가 먼저 바깥 서버로 나가는 연결을 하나 만들고, 그 연결을 통로로 삼아 이후 바깥에서 오는 요청이 안쪽으로 전달됩니다.

그림 3. 역방향 터널을 거친 로그인의 순서. 실험실 모델이며, 컨테이너 안에서 루프백 주소로만 재현했다.
이 순서에서 보이는 것은 방화벽의 위치입니다. 방화벽이나 공유기는 바깥에서 들어오는 새 연결을 막는 곳이 대부분이고, 안에서 나가는 연결은 허용합니다. 역방향 터널은 첫 연결이 안에서 밖으로 나가는 방향이므로 허용되고, 그 뒤 요청이 같은 연결 위에 실려 안으로 들어옵니다. 개발 기기가 사내 망이나 가정용 공유기 뒤에 있어도 바깥에서 닿는 길이 생깁니다. 기기의 사설 주소는 공격자에게 필요하지 않습니다.
로그인에서 다른 한 줄도 확인했습니다. 터널로 들어온 로그인이 피해 호스트의 sshd에는 어떻게 보이는지입니다. 실험에서 바깥 서버 역할의 계정이 할당된 포트로 접속해 피해 호스트 sshd에 공개 열쇠로 로그인했고, 두 쪽의 화면은 그림 4입니다.

그림 4. 터널을 연 쪽(피해 호스트)에서 볼 수 있는 세 가지 흔적. 실험 환경이라 주소가 모두 루프백이다.
화면에서 눈에 띄는 것은 세 가지입니다. 첫째, 프로세스 목록에 ssh -N -R 0:localhost:2201 …이 그대로 남습니다(실험에서는 sshd 포트를 2201로 썼습니다). 둘째, 바깥으로 나가는 연결은 목적지가 하나인 장수 연결이고 ServerAliveInterval 같은 옵션이 있으면 주기적으로 작은 패킷이 오갑니다. 셋째, sshd 로그에는 접속 출발지가 **127.0.0.1**로 찍힙니다. 터널을 통해 들어온 연결은 피해 호스트 안에서 ssh가 localhost:22로 연 연결이므로, sshd는 로컬 프로세스가 연결한 것으로 기록합니다. 출발지 IP를 보고 바깥 접속인지를 판단하는 규칙(예: 특정 대역 밖의 접속을 경고)은 이 경로에서 빗나갑니다. 접속 환경 변수 SSH_CONNECTION도 실험에서 127.0.0.1 36898 127.0.0.1 2201로 나왔습니다.
재부팅을 넘기는 두 운영체제의 시작 항목
터널은 프로세스이므로 재부팅하면 사라집니다. 공지는 확장이 이를 되살리는 장치를 설치했다고 밝혔고, macOS의 LaunchAgent, Windows의 시스템 권한 예약 작업, 예비로 Windows 시작 프로그램 스크립트를 들었습니다. 이 둘이 원래 어떤 장치인지 공식 문서로 확인합니다(편집부 해설).
macOS의 launchd는 로그인한 사용자마다 사용자별 launchd를 띄우고, 이 launchd는 /System/Library/LaunchAgents, /Library/LaunchAgents, 그리고 사용자 홈의 Library/LaunchAgents 폴더에서 속성 목록 파일(plist)을 읽어 에이전트를 등록합니다. Apple 문서는 에이전트와 데몬을 구분하는 기준이 파일을 어느 폴더에 두었는가라고 설명합니다. plist의 Label은 작업을 식별하는 문자열이고, ProgramArguments는 실행할 프로그램과 인자이며, KeepAlive는 이 작업을 항상 실행 상태로 유지할지를 정합니다. 즉 사용자 홈의 LaunchAgents 폴더에 plist를 하나 두면, 그 사용자가 로그인할 때마다 launchd가 지정한 프로그램을 띄우고 KeepAlive가 있으면 죽어도 다시 띄우는 구조입니다. 관리자 권한은 필요 없습니다. 사용자 폴더에 쓰는 일이기 때문입니다.
공지가 적은 파일 이름 com.apple.sysmond.plist는 이 구조에서 의미를 갖습니다. Label과 파일 이름이 애플처럼 보여도 위치가 ~/Library/LaunchAgents/라면 그것은 사용자 영역에 놓인 항목이고, 애플이 시스템과 함께 배포하는 항목은 /System/Library 아래에 있습니다. 이름의 접두어만 보고 신뢰하는 점검은 같은 이름표 문제를 반복하는 셈입니다. 위치와 서명 주체를 함께 읽는 것이 맞는 읽는 법입니다.
Windows의 작업 스케줄러는 Microsoft 문서에 따르면 시간, 시스템 부팅, 사용자 로그온, 유휴 상태 같은 조건을 트리거로 작업을 실행합니다. schtasks 명령 문서는 작업이 기본적으로 현재 사용자의 권한으로 돌며, 다른 계정이나 시스템 권한으로 돌리려면 /ru 매개변수를 쓰라고 안내합니다. 공지의 지표에 schtasks /create /tn SysMon이 있고 시스템(SYSTEM) 권한 예약 작업이라고 적혀 있습니다. SYSTEM 권한 작업은 관리자 권한이 있어야 만들 수 있으므로, 공지가 말한 대로 시도 가운데 일부는 권한이 부족하면 실패합니다. 그때를 위한 예비가 사용자 시작 프로그램 폴더의 스크립트 sysmon.cmd입니다. 사용자 영역에서 끝나는 장치를 두고 상위 권한을 시도하는 두 겹 구조입니다.
세 장치(macOS 에이전트, SYSTEM 예약 작업, 시작 프로그램 스크립트)의 공통점은 로그인이나 부팅 때 편집기와 무관하게 실행된다는 것입니다. 확장을 지워도 이 항목은 남습니다. Cursor가 확장을 삭제하는 것만으로는 부족할 수 있다고 한 이유가 여기에 있습니다. 지워지는 것은 편집기 폴더의 extension.js이고, 시작 항목과 터널 스크립트(~/.config/.smt 등)는 사용자 계정 쪽에 독립된 파일로 놓여 있습니다. 그림 2에서 시작 항목 상자가 편집기 구역 밖에 있던 이유입니다.
층마다 볼 수 있는 것과 볼 수 없는 것
여섯 층을 지나며 확인한 것을 방어하는 쪽에서 다시 읽으면, 각 층의 관찰 장치는 서로 다른 것만 볼 수 있습니다. 표는 편집부가 앞 절의 실험과 공식 문서를 바탕으로 정리한 것이며, 이 사건에서 어떤 장치가 실제로 작동했다는 뜻이 아닙니다.
| 층 | 거기서 볼 수 있는 것 | 볼 수 없는 것 |
|---|---|---|
| 마켓 | 게시 시점의 패키지 내용, 서명, 이름 선점 | 사용자 기기에서 실행된 뒤의 동작 |
| 편집기·확장 호스트 | 설치된 확장 이름과 버전, 활성화 이벤트 | 확장이 파일과 프로세스에 한 일(같은 권한) |
| 사용자 계정 파일 | 파일 이름, 변경 시각, 열쇠 지문 | 변경한 주체(확장인지 사람인지) |
| sshd 로그 | 열쇠 지문, 줄 번호, 접속 출발지 | 이름표(안 남음), 터널 너머의 실제 출발지(127.0.0.1) |
| 네트워크 출구 | 나가는 장수 연결, 목적지, 지표의 IP와 포트(8099 비콘) | 연결 안의 내용(암호화), 연결을 연 프로세스 |
| 시작 항목 | 등록된 항목의 위치와 이름 | 항목이 가리키는 스크립트의 의도 |
이 표에서 눈에 띄는 것은 어느 한 층의 관찰만으로는 사건 전체가 한 번에 보이지 않는다는 점입니다. sshd 로그는 로그인이 있었다는 사실을 말해 주지만 그 로그인이 터널 너머에서 왔다는 것은 가려집니다. 네트워크 출구는 연결을 보지만 어떤 프로세스인지는 호스트에서 확인해야 합니다. 마켓의 서명 검증은 마켓이 올린 파일이 올린 그대로라는 것을 보증할 뿐, 올린 주체가 맞는 주체인지는 별개의 질문입니다. Cursor 공지가 침해 지표를 한 종류가 아니라 해시, IP, 파일, 열쇠 이름표, 명령줄로 여러 종류로 적은 것도 같은 이유에서 읽힙니다. 지표마다 다른 층의 관찰 장치가 찾을 수 있는 것입니다. 해시는 파일 층과 마켓 층, IP와 포트는 네트워크 출구, 열쇠 이름표는 사용자 계정 파일, 파일 이름은 시작 항목, 명령줄은 프로세스 목록이 대상입니다.
공지의 권고 여섯 가지를 층에 놓아 보기
Cursor가 공지에서 권고한 조치를 각각 어느 층에서 작동하는지로 분류하면 그 순서가 왜 그런지가 보입니다. 아래는 공지의 권고 여섯 가지를 그대로 따라 쓰고, 각각이 작동하는 층을 편집부가 덧붙인 것입니다.
- 침해 여부를
svc-telemetry열쇠로 확인. 사용자 계정 파일 층의 단서입니다. 앞에서 본 대로 이름표는 바뀔 수 있어서, 이름표가 없는 경우는 지문 대조가 필요합니다. - 엔드포인트, 네트워크, 확장 텔레메트리에서 지표 검색. 층마다 다른 지표를 각 층의 로그에서 찾는 단계로, 위 표의 열이 그대로 검색 범위가 됩니다.
- 페이로드 실행이나 뒷문 설치 증거가 있는 호스트 격리. 네트워크 출구 층에서 터널을 끊는 조치입니다. 열쇠가 남아 있어도 터널 연결이 없으면 바깥에서 들어올 길이 닫힙니다.
- 무단 열쇠, 지속 장치, 터널 프로세스 제거. 사용자 계정 파일 층(열쇠)과 시작 항목 층(지속 장치), 프로세스 목록(터널)에서 세 가지를 함께 지우는 조치입니다. 하나라도 남으면 나머지가 되살립니다. 시작 항목이 터널을 다시 띄우고, 터널이 열쇠가 있는 로그인을 받기 때문입니다.
- 확장 삭제만으로 부족할 수 있으며 호스트 재구축을 고려. 확장 호스트 층의 삭제가 사용자 계정 층의 결과물까지 지우지 못하는 데서 나온 권고입니다.
- 신뢰할 수 있는 시스템에서 접근 가능했던 자격 증명과 세션 교체. 같은 권한 범위(홈 디렉터리 전체)에 있던 비밀이 모두 대상입니다. 침해된 호스트에서 새 비밀을 만들면 새 비밀도 함께 노출되므로 다른 시스템에서 하라는 것입니다.
공지는 이와 별도로 플랫폼별 정리 스크립트(macOS는 일반 사용자, Windows는 관리자 권한)를 제공한다고 적었습니다. 스크립트를 받는 곳은 공지 페이지에 연결된 공식 주소여야 하고, 어떤 정리 스크립트든 실행 전에 내용을 읽는 것은 열쇠와 시작 항목을 지우는 도구도 같은 사용자 권한으로 도는 코드이기 때문입니다.
여섯 가지를 층으로 다시 놓으면 하나가 눈에 띕니다. 12번은 어디를 볼지, 3번은 바깥 경로 차단, 45번은 안쪽 결과물 제거, 6번은 같은 권한 범위의 비밀 폐기입니다. 마지막 조치의 범위가 가장 넓은 것도 앞서 본 구조에서 나옵니다. 확장 호스트가 닿는 곳이 홈 디렉터리 전체이므로, 열쇠가 하나도 심기지 않은 호스트에서도 그 시간대에 이 확장이 돌았다면 접근 가능했던 비밀의 범위는 홈 전체입니다.
sshd가 꺼져 있으면 터널은 어디에 닿는가
공지 안에는 앞뒤가 맞지 않아 보이는 문장이 하나 있습니다. SSH를 켜는 시도는 관리자 권한이 없으면 보통 실패한다고 하면서, 열쇠가 추가되고 지속 장치가 설치된 것은 사실이라고 적었습니다. 역방향 터널의 목적지는 localhost:22입니다(지표의 -R '*:0:localhost:22'). 피해 호스트에서 SSH 서버가 꺼져 있다면 이 터널은 무엇에 닿을까요. 이 질문은 공지의 문장에서 바로 나오는 것이어서 격리 환경에서 직접 확인했습니다. 터널은 열고, 피해 호스트의 sshd는 켜지 않은 상태에서 바깥 역할의 계정이 터널 포트로 접속해 봤습니다.
# 편집부 실험: 터널은 열렸고, 피해 호스트에서 sshd(2201)는 꺼진 상태
Allocated port 34395 for remote forward to localhost:2201
2201 수신 소켓 수: 0
# 바깥 서버 쪽에서 터널 포트로 접속
debug1: Connection established.
kex_exchange_identification: read: Connection reset by peer
Connection reset by 127.0.0.1 port 34395
# 터널을 연 ssh(피해 호스트)가 남긴 경고
connect_to localhost port 2201: failed.
# 피해 호스트에서 sshd를 켠 뒤 같은 접속
dev
결과는 단순합니다. 터널 자체는 문제없이 열리고, 포트도 할당됩니다. 연결 자체도 바깥 서버까지는 성립합니다. 그러나 피해 호스트 안에서 localhost:22에 연결할 수 없으므로(connect_to … failed) 바깥에서 보면 연결이 곧바로 끊깁니다. 로그인 단계까지 가지 못합니다. sshd를 켜고 나면 같은 접속이 그대로 로그인됩니다. 이 실험은 터널과 SSH 서버가 서로 다른 부품이라는 것을 보여 줍니다. 터널은 바깥 서버가 피해 호스트의 localhost:22에 닿는 전선이고, 그 끝에 받아 줄 서버가 있어야 로그인이 됩니다.
공지의 문장과 연결하면 이렇게 읽힙니다(편집부 해설, 사건 사실은 공지 서술에 한정). 관리자 권한이 필요한 SSH 켜기가 실패한 호스트에서는, 원래 원격 로그인이 꺼져 있었다면 열쇠와 터널이 모두 있어도 마지막 연결이 받아지지 않는 구조입니다. 반대로 원격 로그인이 이미 켜져 있는 호스트(개발 기기에서 흔한 설정)에서는 그 마지막 연결이 곧바로 열쇠를 받습니다. 공지가 “보통 실패한다”고 쓰면서도 열쇠와 지속 장치의 존재를 따로 강조한 것은, 한 단계가 실패해도 나머지 단계의 결과물이 호스트에 남는다는 점을 짚은 것입니다. 이 실험은 그 구조에서 마지막 연결이 어디에서 막히는지를 보여 줍니다.
이 구조가 방어에 주는 의미는 하나입니다. 개발 기기에서 SSH 서버를 평소 꺼 두는 것(macOS의 원격 로그인 설정, Windows의 OpenSSH 서버 서비스)은 이 종류의 터널에 대해 한 겹의 실질적인 차단입니다. 꺼 둔 호스트에서는 터널의 끝이 막혀 있고, 이를 켜려면 관리자 권한이라는 경계를 넘어야 하며, 그 경계가 바로 공지가 ’보통 실패한다’고 적은 곳입니다. 다만 이 차단은 SSH라는 한 가지 문이 닫혀 있다는 뜻입니다. 같은 사용자 권한으로 접근 가능한 홈 디렉터리의 비밀(클라우드 토큰, 저장소 접근 토큰, 개인키)은 터널이 닿든 닿지 않든 확장이 로드된 시점에 이미 같은 권한 안에 있었다는 점은 앞의 층들에서 확인한 구조 그대로입니다.
출처
- Cursor 보안 공지 cursor.com
- sshd(8) — OpenBSD 매뉴얼 man.openbsd.org
- ssh(1) — OpenBSD 매뉴얼 man.openbsd.org
- VS Code 확장 런타임 보안 문서 code.visualstudio.com