"localhost니까 나만 쓰는 거잖아"
Claude Code나 Cursor로 작업하다 보면 MCP 서버라는 걸 하나씩 붙이게 된다. 파일을 대신 읽고 쓰게 해주는 것, 데이터베이스를 조회해주는 것, 브라우저를 자동으로 조작해주는 것. 설치 안내를 따라가면 대개 이런 문구가 뜬다. "서버가 http://127.0.0.1:8931 에서 실행 중입니다."
여기서 대부분 이렇게 생각하고 넘어간다. "127.0.0.1이면 내 컴퓨터 안이고, 외부에서는 접속이 안 되니까 인증이니 뭐니 신경 쓸 필요 없겠지." 실제로 로컬 서버는 바깥에서 직접 두드려도 열리지 않는다. 이 부분까지는 맞다.
문제는 그날 오후, 평범해 보이는 웹사이트 하나를 브라우저로 열었을 때 시작된다. 광고 링크일 수도 있고, 검색하다 들어간 낯선 블로그일 수도 있다. 그 페이지가 로드되는 순간, 페이지 안에 심어진 자바스크립트가 조용히 내 컴퓨터의 127.0.0.1:8931에 접속한다. 그리고 그 MCP 서버에게 "이 파일 내용 좀 읽어서 나한테 보내줘"라고 시킨다. 나는 그 사이트를 열었을 뿐인데, 내 로컬 서버가 남의 명령을 받아 움직인다.
이게 DNS 리바인딩(DNS rebinding) 공격이다. 이름은 낯설지만 원리는 허탈할 만큼 단순하고, 그래서 더 위험하다. 왜 localhost가 안전하다는 믿음이 통째로 깨지는지, 하나씩 풀어보자.
경비원은 이름표만 확인한다
먼저 브라우저가 어떻게 "안전한 요청"과 "위험한 요청"을 구분하는지 알아야 한다. 여기에 same-origin policy, 우리말로 동일 출처 정책이라는 규칙이 있다.
핵심만 말하면 이렇다. 브라우저는 A라는 사이트에서 실행된 자바스크립트가 B라는 사이트의 응답 내용을 함부로 읽지 못하게 막는다. 그래야 내가 열어둔 은행 사이트의 정보를 옆 탭의 악성 사이트가 훔쳐보지 못한다. 이 정책이 "같은 출처냐 아니냐"를 판단하는 기준이 세 가지다. 프로토콜(http인지 https인지), 호스트명(example.com 같은 도메인 이름), 그리고 포트 번호.
여기서 결정적인 사실이 하나 있다. 브라우저는 이 판단을 도메인 이름으로 한다. 그 도메인이 실제로 어느 IP 주소를 가리키는지는 거의 따지지 않는다.
경비원에 빗대보면 이해가 빠르다. 건물 입구에 선 경비원이 방문객을 들여보낼지 말지를 이름표만 보고 정한다고 해보자. 이름표에 "본사 직원"이라고 적혀 있으면 통과다. 그 사람이 실제로 어디 사는 누구인지는 확인하지 않는다. 이름표 글자만 맞으면 된다. 브라우저의 출처 판단이 딱 이렇다. 도메인 이름이라는 이름표만 보고, 그 이름표가 실제로 가리키는 주소는 보지 않는다.
이름표는 그대로, 주소만 바꿔치기한다
DNS 리바인딩은 바로 이 허점을 파고든다. 이름표는 그대로 두고, 그 이름표가 가리키는 실제 주소만 몰래 바꾸는 것이다.
여기서 DNS를 한 줄로 짚고 가자. DNS는 example.com 같은 도메인 이름을 93.184.216.34 같은 실제 IP 주소로 바꿔주는 전화번호부다. 브라우저가 어떤 도메인에 접속하려면 먼저 DNS에게 "이 이름 주소가 몇 번이야?"라고 물어본다. 그리고 이 답에는 유효기간이 붙는다. TTL(Time To Live)이라고 부르는데, "이 답을 몇 초 동안 기억해도 좋다"는 시간이다.
공격은 이렇게 흘러간다.
첫째, 공격자는 자기 도메인, 이를테면 evil.example.com을 가지고 있고 그 도메인의 DNS 답변을 자기 마음대로 조작할 수 있다. 그리고 TTL을 1초처럼 아주 짧게 걸어둔다. 답을 기억하는 시간을 극도로 줄여서, 브라우저가 곧바로 다시 물어보게 만들려는 것이다.
둘째, 내가 그 사이트에 접속한다. 이때 DNS는 정상적인 답을 준다. evil.example.com은 공격자 서버의 진짜 IP다. 브라우저는 그 서버에서 악성 자바스크립트가 담긴 페이지를 받아 실행한다.
셋째, TTL이 1초라 답은 금방 만료된다. 페이지 안의 자바스크립트가 잠깐 기다렸다가 같은 도메인 evil.example.com:8931로 다시 요청을 보낸다. 브라우저는 이 도메인 주소를 다시 DNS에 물어본다. 이번에는 공격자가 답을 바꿔치기해서 127.0.0.1을 내려준다.
넷째, 이제 브라우저 입장에서는 페이지가 열린 곳도 evil.example.com, 요청을 보내는 곳도 evil.example.com이다. 이름표가 같으니 같은 출처로 판정하고, 응답 내용을 자바스크립트가 읽도록 허락한다. 그런데 실제 네트워크에서는 그 도메인이 127.0.0.1을 가리키게 바뀌었으니, 요청은 내 컴퓨터의 로컬 서버로 날아간다. 응답까지 고스란히 읽혀서 공격자 서버로 넘어간다.
정리하면, 공격자는 이름표(evil.example.com)는 그대로 둔 채 그 이름표가 가리키는 실제 주소만 공격자 서버에서 내 localhost로 갈아끼운 것이다. 경비원은 이름표가 안 바뀌었으니 계속 통과시킨다. 보안 연구자들이 이걸 "브라우저를 대리인으로 삼은 요청 위조"라고 부르는 이유다. 공격자가 내 서버에 직접 접속한 게 아니라, 내 브라우저를 시켜서 접속하게 만들었다.

왜 하필 MCP 서버가 위험한가
로컬에 뜨는 서버는 많다. 개발용 웹 서버도 있고 데이터베이스도 있다. 그런데 MCP 서버가 유독 위험한 이유가 있다. MCP 서버는 AI 에이전트를 대신해서 실제 일을 하는 손발이기 때문이다.
MCP는 Claude Code 같은 AI 도구가 바깥 세상의 기능을 쓸 수 있게 연결해주는 규격이다. 파일을 읽고 쓰거나, 셸 명령을 실행하거나, 데이터베이스를 조회하거나, 깃을 다루는 일을 MCP 서버가 대신 수행한다. 다시 말해 MCP 서버는 내 컴퓨터에서 꽤 강한 권한을 들고 있는 창구다. 이 창구가 127.0.0.1에 열려 있고 "로컬에서 온 요청은 다 믿는다"는 태도로 동작한다면, DNS 리바인딩으로 그 창구를 넘겨받은 공격자는 내 파일을 읽고, 명령을 실행하고, 데이터를 빼갈 수 있다. 로컬 개발 환경 전체를 통째로 내주는 셈이다.
이건 가정이 아니라 이미 여러 번 실제로 터진 문제다. 2025년에 공개된 CVE-2025-66416은 MCP 파이썬 SDK, 그러니까 많은 사람이 MCP 서버를 만들 때 쓰는 FastMCP 라이브러리의 문제였다. HTTP 방식으로 서버를 localhost에 띄울 때 DNS 리바인딩 방어가 기본으로 꺼져 있었다. 개발자가 따로 보안 설정을 켜지 않으면, 들어오는 요청의 출처를 아무도 확인하지 않았다. 이 라이브러리는 1.23.0 버전에 와서야 로컬에 바인딩하면 방어가 자동으로 켜지도록 고쳐졌다.
같은 해 마이크로소프트의 Playwright MCP 서버에서도 CVE-2025-9611이 나왔다. 0.0.40 이전 버전은 들어오는 요청의 Origin 헤더, 즉 "이 요청이 어느 사이트에서 출발했는가"를 검증하지 않았다. 그래서 악성 웹페이지가 DNS 리바인딩으로 로컬 MCP 서버에 무단 요청을 보낼 수 있었다. 러스트로 만든 MCP SDK에서도 비슷한 취약점 권고가 올라왔다.
공통점이 보인다. 이들은 특별히 허술하게 만든 서버가 아니다. 널리 쓰이는 공식 SDK들이었고, 문제는 "안전한 기본값"이 빠져 있었다는 것이다. 만드는 사람도, 쓰는 사람도 "어차피 localhost인데"라고 생각했기 때문에 아무도 창구에 신분 확인 절차를 두지 않았다.
"localhost라서 안전하다"가 왜 틀렸나
여기서 처음의 착각으로 돌아가 보자. "127.0.0.1에만 바인딩했으니 외부에서 접속 못 하잖아." 이 말은 절반만 맞다.
맞는 절반은 이렇다. 공격자가 자기 컴퓨터에서 내 IP를 향해 직접 연결을 시도해도, 내 로컬 서버는 그 연결을 받지 않는다. 바깥에서 직접 두드리는 문은 확실히 잠겨 있다.
틀린 절반은 이렇다. 공격자는 내 서버에 직접 연결할 필요가 없다. 대신 내 브라우저를 심부름꾼으로 쓴다. 집 문을 안에서 단단히 잠갔더라도, 집 안에 있는 심부름꾼에게 "저 방에 들어가서 서류 좀 꺼내와"라고 시키면 서류는 밖으로 나간다. 브라우저가 바로 그 심부름꾼이다. 브라우저는 내 컴퓨터 안에서 돌고, localhost에 자유롭게 접속할 수 있으며, 방문한 웹페이지의 자바스크립트를 군말 없이 실행한다.
그러니까 "외부에서 직접 TCP 연결이 안 된다"는 것과 "외부 사이트가 내 브라우저를 경유해 간접적으로 요청을 보낼 수 없다"는 것은 완전히 다른 이야기다. DNS 리바인딩은 정확히 이 둘 사이의 틈을 파고든다. localhost 바인딩은 첫 번째 문만 잠갔을 뿐, 브라우저라는 뒷문은 그대로 열려 있다.
그래서 어떻게 막나
다행히 방어는 어렵지 않다. 핵심은 로컬 서버가 "로컬에서 왔으니 믿는다"를 버리고 "이 요청이 정말 내 도구에서 온 게 맞는지" 확인하게 만드는 것이다.
가장 확실한 방어는 요청의 출처 헤더를 검사하는 것이다. 브라우저는 요청을 보낼 때 이 요청이 어느 사이트에서 출발했는지를 Origin 헤더에, 어떤 이름으로 서버를 불렀는지를 Host 헤더에 담아 보낸다. DNS 리바인딩 공격에서 브라우저는 여전히 Origin: http://evil.example.com을 붙여서 보낸다. 서버가 "나는 내 IDE(http://localhost 같은 정해진 출처)에서 온 요청만 받는다"고 정해두면, evil.example.com에서 온 요청은 그 자리에서 거절된다. 이름표를 바꿔치기하지 못하게, 서버가 직접 이름표를 확인하는 것이다.
이건 권장 사항이 아니라 MCP 규격 자체가 요구하는 사항이다. MCP 공식 스펙은 HTTP 방식 서버라면 들어오는 모든 연결에서 Origin 헤더를 반드시 검증해 DNS 리바인딩을 막아야 한다고 못 박고 있다. 여기에 더해, 로컬에서 돌릴 때는 0.0.0.0이 아니라 localhost에만 바인딩하라고 권한다. 두 가지를 겹쳐서 방어막을 이중으로 두라는 뜻이다.
바이브 코더 입장에서 당장 할 수 있는 건 세 가지다. 첫째, MCP 서버나 SDK를 최신 버전으로 유지한다. 앞의 CVE들은 대부분 최신 버전에서 안전한 기본값으로 고쳐졌으니, 버전만 올려도 상당수가 막힌다. 둘째, 직접 MCP 서버를 만든다면 Origin과 Host 검증을 반드시 넣는다. 셋째, 파일 삭제나 명령 실행처럼 위험한 기능은 브라우저가 닿을 수 있는 HTTP 포트에 그냥 노출하지 말고, 인증 토큰을 요구하거나 별도 통로로 분리한다.

정리
localhost는 바깥에서 직접 두드리는 문을 잠가줄 뿐, 내 브라우저라는 뒷문까지 잠가주지는 않는다. DNS 리바인딩은 이름표는 그대로 둔 채 그 이름표가 가리키는 주소만 내 컴퓨터로 바꿔치기해서, 내가 무심코 연 웹사이트가 내 로컬 MCP 서버를 조종하게 만든다. MCP 서버는 파일과 명령과 데이터를 다루는 강한 창구라 피해가 유독 크다.
방어의 출발점은 생각을 바꾸는 데 있다. 로컬 서버라고 해서 나만 접속하는 게 아니다. 내 브라우저가 방문하는 모든 웹사이트가 잠재적 방문자다. 그러니 로컬 서버도 "아무나 접근할 수 있다"는 전제로 설계해야 한다. SDK를 최신으로 유지하고, 출처를 검증하고, 위험한 기능에는 잠금장치를 다는 것. 몇 줄 안 되는 이 습관이 내 개발 환경을 통째로 내주는 사고와 그렇지 않은 하루를 가른다.