당신의 코딩 에이전트는 당신의 기기에서 셸(shell)을 실행합니다. 저는 제 것을 감사했습니다.
요약
코딩 에이전트가 로컬 셸을 실행할 때 발생할 수 있는 보안 위협과 이를 방지하기 위한 설계 원칙을 다룹니다. 단순한 명령 문자열 필터링의 한계를 지적하며, OS 레벨의 권한 제어와 네트워크 태세 관리가 핵심임을 강조합니다.
핵심 포인트
- 텍스트 기반 명령 필터링은 우회 가능성이 높아 보안에 취약함
- OS 레벨의 프로세스 권한 제어가 근본적인 해결책임
- 네트워크 태세(CORS, Origin 게이트 등)를 통한 접근 제어 필수
- 에이전트 도구 설계 시 로컬 데몬의 보안 취약점 점검 필요
당신의 노트북에서 bash를 실행할 수 있는 코딩 에이전트는 localhost 상의 원격 셸(remote shell)과 같습니다. 누군가가 그렇게 설계했든 아니든, 이는 다음과 같은 위협 모델(threat model)을 가집니다 — 브라우저 드라이브바이(browser drive-bys), DNS 리바인딩(DNS rebinding), curl | bash, 그리고 웹이 이미 저지르고 기록해 온 20년간의 localhost-데몬(localhost-daemon) 실수들입니다.
지난주 wren.wtf는 opencode에 대한 분석(teardown)을 게시했습니다: opencode 사용을 중단하세요. 전문을 읽어보시기 바랍니다. 헤드라인은 실제 CVE(Common Vulnerabilities and Exposures)였습니다 — 허용적인 CORS를 가진 기본 HTTP 서버와 임의의 셸 명령을 실행하는 엔드포인트가 있어, 당신이 방문하는 어떤 웹사이트든 기기를 장악할 수 있었습니다.
저는 의도적으로 동일한 위험한 동작을 수행하는 오픈 런타임(open runtime)인 agentproto를 유지 관리하고 있습니다: 에이전트가 사용자의 기기에서 명령을 실행하고, 파일을 읽고, 터널을 열 수 있게 해주는 로컬 데몬(local daemon)입니다. 그래서 저는 그 분석 내용을 체크리스트로 삼아 제 코드에 적용해 보았습니다. 아래는 제가 발견한 것, 제가 변경한 것, 그리고 — 여러분에게 전달될 부분인 — 여러분이 사용하는 어떤 에이전트 도구에도 적용할 수 있는 방법론입니다.
단 하나의 아이디어
"텍스트 기반의 명령 필터링(Textual command filtering)은 기우에 불과합니다." — 분석 내용 중
그 문장이 핵심입니다. 대부분의 에이전트 도구들은 명령 문자열을 파싱(parsing)하고 나쁜 명령을 차단함으로써 bash를 안전하게 만들려고 시도합니다. 하지만 그것은 작동하지 않으며, 해당 기사는 그 이유를 나열합니다: bash로의 파이프(pipe), base64-디코딩(base64-decode), env git, 헤레독(heredoc), python3 -c, 절대 경로(absolute path) 등. 이 모든 것들은 문자열 필터를 통과합니다.
해결책은 더 나은 정규 표현식(regex)이 아닙니다. 문자열이 절대 건드리지 못하는 두 가지 경계입니다:
- 프로세스가 허용된 작업 — OS 레벨의 권한(OS-level rights).
- 누가 요청할 수 있는가 — 네트워크 태세(network posture).
이후의 모든 내용은 이 두 가지 중 하나에 해당합니다.
먼저 당신의 도구에 이 네 가지 질문을 던져보세요
5분 안에 답변할 수 있습니다:
- TCP 포트를 바인딩(bind)하나요 —
127.0.0.1또는0.0.0.0으로? - 임의의 웹사이트가 해당 포트로
fetch()를 수행하고 응답을 읽을 수 있나요? 아무 페이지에서나 개발자 도구(devtools)를 열어 시도해 보세요. - "bash 허용"이라고 할 때, 무엇을 허용 검사하나요 — 명령 문자열(command string)인가요, 아니면 바이너리(binary)인가요?
- 사용자가 모델을 선택하기 전에 원격 모델(remote model)에 접속하나요?
답변이 마음에 들지 않는다면, 당신은 제가 처했던 상황에 놓인 것입니다.
위협 클래스별 발견 사항
| 위협 (Threat) | 실수 (The mistake) | 해결책 (The fix) |
|---|---|---|
| 브라우저 드라이브 바이 RCE (Browser drive-by RCE) | 루프백 우회 인증 (loopback-bypassed auth) + 명령 엔드포인트에서의 반사된 CORS (reflected CORS) | Origin 게이트 적용, 우회 금지 |
| ... |
**브라우저 드라이브 바이 (Browser drive-by)**가 가장 날카로운 위협이었습니다. 명령을 실행하는 엔드포인트는 _루프백 우회 (loopback bypass)_가 포함된 인증 검사로 보호되고 있었습니다. 이는 토큰 없이 로컬 클라이언트가 통과할 수 있도록 하기 위함이었습니다. 하지만 브라우저의 127.0.0.1에 대한 fetch()는 루프백(loopback)에 해당합니다. 반사된 오리진 CORS(reflected-origin CORS)가 있으면 어떤 페이지든 이를 조종할 수 있습니다.
실제 로컬 클라이언트와 드라이브 바이 공격을 구분하는 신호는 페이지가 위조할 수 없는 하나의 헤더입니다:
// 브라우저는 교차 출처 요청(cross-origin request) 시 항상 Origin을 전송합니다.
// 네이티브 클라이언트(CLI, curl, 에디터 등)는 아무것도 전송하지 않습니다.
if (origin && !allowlisted(origin)) return reject(403)
evil.com에 있는 페이지는 허용 목록(allowlist)에 없는 Origin: https://evil.com을 전송하며, 기본 설정에서는 루프백을 통해 403 에러를 받게 됩니다. 네이티브 클라이언트는 Origin을 보내지 않으므로 아무런 영향 없이 계속 작동합니다. 헤더 하나가 이 클래스의 위협을 차단했습니다.
읽기(Reading) 또한 데이터 유출(exfiltration)입니다. 동일한 데몬이 대화 기록(conversation transcripts)과 라이브 이벤트 스트림을 GET을 통해 제공하고 있었습니다. 이는 셸(shell)이 필요 없이 교차 출처(cross-origin)에서 읽을 수 있는 상태였습니다. Origin 게이트를 읽기 경로(read routes)에도 적용했습니다. 만약 어떤 경로가 상태(state)를 유출한다면, 상태를 쓰는(write) 경로와 마찬가지로 게이트를 설치해야 합니다.
**DNS 리바인딩 (DNS rebinding)**은 그 다음 단계의 공격입니다: 페이지가 evil.com을 127.0.0.1로 리바인딩하면, 이제 Origin만으로는 충분하지 않습니다. 따라서 루프백 경로에서도 Host 헤더를 검증해야 합니다. 리바인딩 요청은 여전히 자신의 호스트 이름을 가지고 있으므로, 루프백 Host가 아닌 모든 것은 거부됩니다.
명령 필터(command-filter)의 신화, 그리고 그것을 대체하는 것
agentproto는 명령 문자열(command strings)을 파싱하지 않습니다. 이 시스템은 shell: false 설정과 있는 그대로의 argv를 사용하여 실행되며, 문자열이 아닌 _바이너리(binaries)_에 대한 기본 거부(default-deny) 허용 목록(allowlist)을 따릅니다. 이는 구조적으로 모든 우회(bypass) 가능성을 차단합니다. 즉, 주입(inject)할 셸 자체가 존재하지 않습니다.
남아있는 문제는 정직합니다. 만약 당신이 인터프리터 — bash, node, python3 — 를 허용 목록에 추가한다면, 당신은 임의의 코드(arbitrary code)를 허용한 것이며, 작업 디렉토리(working-directory) 고정만으로는 해당 코드가 ~/.ssh/id_rsa를 읽는 것을 막을 수 없습니다. 이 기사의 권장 사항 자체도 올바릅니다. 문자열이 아니라 OS 수준에서 제한(confine)해야 합니다. 따라서 명령 실행은 프로세스가 읽고 쓸 수 있는 범위를 제한하고, 엄격 모드(strict mode)에서는 네트워크 연결 여부까지 제어하는 선택적 샌드박스(opt-in sandbox) — macOS Seatbelt, Linux bubblewrap —를 갖추게 되었습니다.
경계를 증명하세요; 추측한 프로필을 배포하지 마세요
이 부분이 제가 가장 강력하게 주장하는 대목입니다. 무언가를 거부하는 것을 직접 관찰하지 못한 샌드박스는 통제(control)가 아니라 단순한 주석(comment)에 불과합니다.
그래서 샌드박스의 코드를 한 줄 쓰기 전에, 실제 프로세스에서 무언가를 거부하는지 확인했습니다:
- macOS Seatbelt — 작업 공간 읽기(workspace read)는 성공하지만,
cat ~/.ssh/id_rsa는Operation not permitted를 반환합니다. - Linux bubblewrap (컨테이너 내) — 작업 공간은 바인딩되어 보이지만,
~/.ssh는 마운트되지 않았으므로 읽기 시ENOENT가 발생합니다. 또한--unshare-net사용 시 소켓 연결(socket connect)은ENETUNREACH를 반환합니다.
그제서야 제가 작동하는 것을 확인한 플래그(flags)들을 사용하여 코드를 작성했고, 실제 샌드박스가 존재하는 환경에서 실행되는 테스트를 수행했습니다. 제한(confinement)은 희망이 아닌 증거와 함께 배포됩니다.
홈으로 전화하지 않기 (Not calling home)
해당 분석의 또 다른 측면은 다음과 같습니다: opencode는 기본적으로 원격 모델에 연결하고, 제3자로부터 기본 모델의 URL을 가져오며, 이미 열려 있는 셸을 통해 당신의 파일을 읽기 시작합니다.
제가 agentproto에 요구하는 태도는 그 반대이며, 의도적으로 지루할 만큼 단순합니다:
- 데몬(daemon)은
0.0.0.0이 아닌127.0.0.1에 바인딩(bind)됩니다. - 데몬은 스스로 어떤 원격 모델과도 통신하지 않습니다. 모델은 당신이 지정하는 무엇이든 될 수 있습니다. 로컬(local)이든 호스팅(hosted)된 것이든, 당신의 키(key)를 사용하고 당신의 호출(call)을 따릅니다.
- 공개(Going public)는 하나의 명시적인 동사이며, 데몬은 베어러 토큰(bearer token)을 생성하고 터널(tunnel)에 게이트(gate)를 설치합니다. 데몬이 제어할 수 없는 패스스루 터널(passthrough tunnel)이 있다면, 데몬은 자신의 결과값에 이를 명시적으로 드러냅니다.
로컬 우선(Local-first)은 나중에 추가하는 기능이 아닙니다. 그것은 당신이 결코 타협하지 않는 기본값(default)입니다.
방법론, 다섯 줄 요약
- 에이전트 런타임(agent runtime)을 localhost 상의 원격 셸(remote shell)로 취급하십시오. 그것은 실제로 원격 셸입니다.
- 기본적으로 거부(Deny by default)하십시오. 허용 목록(allowlist)은 어떤 바이너리(binary)를 실행할지 제어하고, 운영체제(OS)는 그것이 무엇을 건드릴 수 있는지를 제어합니다.
- "누가 요청할 수 있는가"와 "무엇이 실행되는가"를 분리하십시오. 페이지는
Origin을 위조할 수 없으며, 루프백(loopback)Host를 위조할 수 없습니다. 이를 활용하십시오. - 읽기(Reading)는 데이터 유출(exfiltration)입니다. 쓰기(writes)를 제어하는 것처럼 읽기도 제어하십시오.
- 신뢰하기 전에 실제 프로세스에서 경계(boundary)를 증명하십시오.
결과
8개의 풀 리퀘스트(pull requests)가 모두 공개되었습니다. 해체 분석(teardown)에서 발견된 모든 사항은 해결되었으며, 두 가지 샌드박스(sandbox) 백엔드 모두 배포 전 실제 프로세스에서 검증되었습니다:
- #570 — 브라우저 드라이브 바이(drive-by) 공격으로부터
/mcp를 보호 - #571 — 교차 출처 읽기(cross-origin reads) 차단, CORS 강화, 로그에서 토큰 삭제(redact)
- #572 — 비대화형 컨텍스트에서 검증되지 않은
curl | bash설치 프로그램 거부 - #573 — 루프백 신호(loopback signal) 강화, 인증되지 않은 터널 플래그 표시
- #578 + #581 + #585 — 인터프리터의 실수 유발(footgun) 방지: 경고 후 격리 (macOS Seatbelt, Linux bubblewrap)
- #582 — DNS 리바인딩(DNS-rebinding)
Host보호
런타임이 이미 올바르게 수행하고 있었던 한 가지, 즉 문자열 필터링(string filtering) 대신 argv와 바이너리 허용 목록(binary allowlist)을 사용한 덕분에 최악의 유형의 공격은 적용되지 않았습니다.
원본 teardown을 읽어보세요. 그런 다음, 당신의 기기에서 명령어를 실행하는 어떤 에이전트든 대상으로 네 가지 질문을 던져보세요. 무언가를 발견하게 될 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기