
2024년 4월 8일, 보안 회사 NCC Group은 로컬 LLM 실행 도구 Ollama의 취약점 CVE-2024-28224를 공개했습니다. 0.1.29보다 낮은 버전에서는 사용자가 연 외부 웹사이트가 그 사용자 PC의 로컬 Ollama API에 요청을 보낼 수 있었습니다. 모델과 대화하고, 모델을 지우고, 파일을 빼낼 수 있는 수준이었습니다. Ollama는 127.0.0.1에서만 동작하는 서비스였고, 인터넷에서는 그 주소에 직접 닿을 수 없습니다. 공격 경로는 DNS가 돌려준 이름 하나에서 열렸습니다.
이 시리즈의 공격을 실제 쇼핑몰 코드에서 직접 재연하고 막아 보는 인프런 강의가 열렸습니다. 실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩 · 이 글과 이어지는 수업: 2-6, 11-5
이 글의 범위. DNS 리바인딩과 서브도메인 탈취의 원리를 그림과 말로 설명하고, 서버에서 막는 방법을 코드·설정·아키텍처 수준으로 정리합니다. 공격 페이지 코드, DNS 응답을 바꾸는 서버 설정, 남의 서브도메인을 찾거나 확보하는 절차는 싣지 않습니다. 코드는 모두 방어 쪽 코드이고, 도메인·포트·토큰은 가짜 값입니다. 사실관계는 각 연구 기관과 프로젝트의 공개 권고(2026-11-01 기준)를 따릅니다.
| 기법 | 한 줄 정의 | 대표 사례 | 막는 핵심 | 가장 가까운 OWASP 2025 |
|---|---|---|---|---|
| DNS 리바인딩 | 외부 도메인의 DNS 응답을 내부 IP로 바꿔, 브라우저가 로컬·사내 서비스에 요청을 보내고 응답을 읽게 만드는 공격 | Ollama CVE-2024-28224(2024-04), MCP TypeScript SDK CVE-2025-66414(2025-12), 0.0.0.0 Day(2024-08, 형제 기법) | Host 헤더 허용 목록, localhost 서비스에도 인증, 리졸버의 사설 IP 응답 차단 | A01 접근 제어 실패, A07 인증 실패 |
| 서브도메인 탈취·댕글링 DNS | 사라진 클라우드 자원·만료된 외부 도메인을 아직 가리키는 레코드를 제3자가 확보해, 우리 서브도메인 이름으로 콘텐츠·메일을 내보내는 공격 | SubdoMailing 8,000개 이상 도메인(2024-02), Sitting Ducks 3만 개 이상 탈취(2024-07 공개) | DNS를 IaC로 관리, SPF include 정리, 와일드카드 허용 목록 금지 | A02 보안 설정 오류 |
두 기법은 방향이 반대입니다. DNS 리바인딩은 남의 이름이 우리 주소를 가리키게 만들고, 서브도메인 탈취는 우리 이름이 남의 자원을 가리키게 됩니다. 어느 쪽이든 서버가 "이 이름이면 믿을 만하다"고 가정한 지점이 공격면이 됩니다.
두 기법을 이해하려면 개념 세 가지가 필요합니다.
A 레코드는 이름을 IPv4 주소로, CNAME은 이름을 다른 이름으로, NS는 "이 이름의 답은 저 네임서버에 물어보라"는 위임을, TXT 안의 SPF는 "이 도메인 이름으로 메일을 보낼 수 있는 서버 목록"을 적습니다.웹의 신뢰는 대부분 IP가 아니라 이름에 걸려 있습니다. 쿠키 범위, CORS 허용 목록, OAuth 리디렉션 허용 목록, SPF, 인증서가 모두 이름을 기준으로 동작합니다. 그래서 이름과 실제 자원의 연결이 바뀌면, 그 이름에 걸린 신뢰도 함께 옮겨 갑니다.

DNS 리바인딩은 세 단계로 이해할 수 있습니다.
127.0.0.1이나 사내 사설 IP를 돌려줍니다.여기서 로컬 서비스가 "127.0.0.1에서 오는 요청은 내 PC 안에서 온 것이니 인증 없이 허용한다"고 가정하면, 요청은 그대로 처리됩니다. 요청은 실제로 피해자 PC의 브라우저에서 출발했으니 출발지 주소는 정말로 127.0.0.1입니다. 출발지 주소만 보는 검사는 이 상황을 구분하지 못합니다.
두 경우를 가르는 값은 Host 헤더입니다. 브라우저는 요청에 "어떤 이름으로 접속했는지"를 Host 헤더로 적어 보냅니다. 사용자가 직접 http://localhost:8765를 열었다면 Host는 localhost:8765이고, 외부 도메인 이름을 통해 들어온 요청이라면 Host에는 그 외부 도메인 이름이 그대로 남습니다. 조사 자료도 원인으로 서버 쪽에서 Host 헤더를 검증하지 않은 것을 꼽습니다.
CORS로는 막을 수 없다는 점도 알아 둘 만합니다. CORS는 "다른 출처"의 요청에 대한 규칙입니다. 리바인딩에서는 브라우저가 처음부터 같은 출처라고 판단하므로 CORS 검사 자체가 일어나지 않습니다.
결과만 보면 내부 자원에 대한 접근이라 A01 접근 제어 실패(2025판에서 SSRF를 흡수)에, "localhost면 인증 없이 허용"이라는 점에서는 A07 인증 실패에 걸칩니다. 하지만 원인은 한 애플리케이션 안의 결함이 아닙니다. DNS 응답과 브라우저의 동일 출처 판단이 맞물려 생기는 문제입니다. Top 10은 애플리케이션 하나의 결함을 기준으로 위험을 묶기 때문에, 두 시스템 사이의 상호작용에서 생기는 이 문제에는 독립 항목이 없습니다.
2021판의 A10이었던 SSRF(서버가 대신 요청을 보내는 문제)가 2025판에서 A01로 흡수된 것도 같은 흐름입니다. 서버가 대신 요청하는 문제조차 독립 항목에서 빠졌고, 브라우저가 대신 요청하는 DNS 리바인딩은 처음부터 이름을 가진 적이 없습니다.

Ollama CVE-2024-28224 (NCC Group, 2024-04-08). 0.1.29 미만 버전에서 외부 웹사이트가 로컬 Ollama API에 접근할 수 있었습니다. 영향은 모델과의 대화, 모델 삭제, 파일 유출까지 이어졌습니다. NCC Group의 권고는 Host 헤더 허용 목록 검증이었습니다.
MCP TypeScript SDK CVE-2025-66414 (2025-12). MCP(Model Context Protocol)는 AI 에이전트가 외부 도구를 부르는 규약이고, 이 SDK로 HTTP 기반 MCP 서버를 만들 수 있습니다. 이 SDK는 DNS 리바인딩 보호 기능이 기본으로 꺼져 있었습니다. 1.24.0부터 localhost에 바인딩할 때 보호가 기본으로 켜지고, hostHeaderValidation() 미들웨어가 제공됩니다. 보호 기능이 "있었는데 꺼져 있었다"는 점에서, 기본값이 방어의 대부분을 정한다는 것을 보여 주는 사례입니다.
0.0.0.0 Day (Oligo, 2024-08), 형제 기법. 엄밀히는 리바인딩이 아니지만 결과가 같습니다. 브라우저가 0.0.0.0 주소로 가는 요청을 막지 않아, 공개 웹사이트가 로컬 서비스(예: Ray 대시보드)에 접근할 수 있었습니다. Oligo는 실제 공격 체인에서 악용된 것을 관측했다고 밝혔고, Chrome과 Safari가 0.0.0.0 차단으로 대응했습니다.
세 사례의 공통점은 표적이 개발자 PC의 localhost라는 점입니다. 로컬 LLM 서버, MCP 서버, 개발용 관리 콘솔이 늘면서 "내 PC에서만 도는 서비스"가 하나둘이 아니게 됐습니다. 이런 서비스는 대개 빠르게 써 보려고 만든 도구라 인증이 없고, 브라우저는 그 서비스와 인터넷 사이에 늘 열려 있습니다.
가장 먼저 넣을 방어선입니다. 로컬 서비스라면 localhost, 127.0.0.1, [::1](포트 포함)만 허용하고 나머지는 403으로 거절합니다. Django라면 ALLOWED_HOSTS 설정이 이 역할을 하고, FastAPI(Starlette)에는 TrustedHostMiddleware가 있습니다.
# FastAPI: Host 헤더 허용 목록 (방어 예시)
from fastapi import FastAPI
from starlette.middleware.trustedhost import TrustedHostMiddleware
app = FastAPI()
app.add_middleware(TrustedHostMiddleware, allowed_hosts=["localhost", "127.0.0.1"])
Express에서는 미들웨어 몇 줄로 만들 수 있습니다. 포트까지 함께 비교하면 같은 PC의 다른 포트 이름으로 들어오는 요청도 걸러집니다.
// Express: Host 허용 목록 (방어 예시, 포트 8765는 가짜 값)
const ALLOWED_HOSTS = new Set(["localhost:8765", "127.0.0.1:8765", "[::1]:8765"]);
app.use((req, res, next) => {
const host = (req.headers.host || "").toLowerCase();
if (!ALLOWED_HOSTS.has(host)) {
console.warn(JSON.stringify({ event: "host_rejected", host, path: req.path }));
return res.status(403).send("forbidden host");
}
next();
});
Spring에는 같은 역할을 하는 기본 설정이 없으므로 필터로 만듭니다.
// Spring: Host 허용 목록 필터 (방어 예시)
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class HostAllowlistFilter extends OncePerRequestFilter {
private static final Set<String> ALLOWED =
Set.of("localhost:8765", "127.0.0.1:8765", "[::1]:8765");
private static final Logger log = LoggerFactory.getLogger(HostAllowlistFilter.class);
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
String host = Optional.ofNullable(req.getHeader("Host")).orElse("")
.toLowerCase(Locale.ROOT);
if (!ALLOWED.contains(host)) {
log.warn("host_rejected host={} path={}", host, req.getRequestURI());
res.sendError(HttpServletResponse.SC_FORBIDDEN);
return;
}
chain.doFilter(req, res);
}
}
사내 서비스라면 허용 목록에 그 서비스의 정식 이름(예: admin.corp.example)을 넣습니다. 프록시 뒤에 있는 서비스라면 프록시가 원래 Host를 그대로 넘기는지, X-Forwarded-Host를 믿는다면 그 값을 프록시만 쓸 수 있는지도 함께 확인합니다.
MCP TypeScript SDK로 HTTP 서버를 만들었다면 1.24.0 이상으로 올리고, 제공되는 hostHeaderValidation() 미들웨어를 켰는지 확인합니다. Ollama는 0.1.29 이상으로 올립니다.
Host 검사가 첫 번째 방어선이라면, 인증은 두 번째 방어선입니다. "로컬이니까 안전하다"는 전제를 버리고, 로컬 개발 도구와 에이전트 서버에도 토큰을 요구합니다. 토큰은 처음 실행할 때 만들어 사용자만 읽을 수 있는 파일에 두고, 클라이언트(CLI, 편집기 확장)가 그 파일을 읽어 헤더에 붙입니다.
# 로컬 도구의 토큰 인증 (방어 예시, 경로는 가짜 값)
import secrets
from pathlib import Path
from fastapi import Request
from fastapi.responses import JSONResponse
TOKEN_FILE = Path.home() / ".config" / "example-devtool" / "token"
def load_or_create_token() -> str:
TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
if not TOKEN_FILE.exists():
TOKEN_FILE.write_text(secrets.token_urlsafe(32))
TOKEN_FILE.chmod(0o600) # 이 사용자만 읽을 수 있게
return TOKEN_FILE.read_text().strip()
TOKEN = load_or_create_token()
@app.middleware("http")
async def require_token(request: Request, call_next):
sent = request.headers.get("authorization", "")
if not secrets.compare_digest(sent, f"Bearer {TOKEN}"):
return JSONResponse({"error": "unauthorized"}, status_code=401)
return await call_next(request)
이 방식이 리바인딩에 효과가 있는 이유는 두 가지입니다. 외부 페이지의 스크립트는 사용자 PC의 파일을 읽을 수 없고, 브라우저는 Authorization 헤더를 스스로 붙여 주지 않습니다. 쿠키로 인증하면 브라우저가 상황에 따라 자동으로 실어 보낼 수 있으니, 로컬 도구에는 헤더 토큰이 더 단순하고 안전합니다.
사내 DNS 리졸버(내부 이름을 풀어 주는 서버)가 외부 도메인의 답으로 사설 IP가 오면 버리게 설정할 수 있습니다. 이 기능을 흔히 DNS 리바인딩 보호라고 부릅니다. 사내망 전체의 브라우저를 한 번에 보호하는 설정입니다.
# dnsmasq (방어 예시)
stop-dns-rebind # 외부 응답에 사설 IP가 있으면 버린다
rebind-domain-ok=/corp.example/ # 사내 도메인은 사설 IP를 돌려줘야 하므로 예외
# Unbound (방어 예시)
server:
private-address: 10.0.0.0/8
private-address: 172.16.0.0/12
private-address: 192.168.0.0/16
private-address: 169.254.0.0/16
private-address: 127.0.0.0/8
private-address: ::1/128
private-address: fd00::/8
private-domain: "corp.example" # 사내 도메인만 사설 주소 응답 허용
이 설정은 사내망 안의 PC만 보호합니다. 재택 근무 PC나 카페 와이파이에 붙은 노트북은 다른 리졸버를 쓰므로, 애플리케이션의 Host 검사와 인증이 여전히 필요합니다.
# 위험한 모양: 모든 네트워크 인터페이스에 연다. 같은 와이파이의 다른 기기도 접근할 수 있다
uvicorn app:app --host 0.0.0.0 --port 8765
# 고친 모양: 이 PC 안에서만 받는다
uvicorn app:app --host 127.0.0.1 --port 8765
# 더 좁은 모양: Unix 소켓. 브라우저는 이 경로로 요청을 보낼 수 없다
uvicorn app:app --uds /run/user/1000/example-devtool.sock

마케팅팀이 이벤트 페이지를 클라우드 호스팅에 올렸다고 해 봅시다. 개발자는 DNS에 promo.example.com CNAME old-campaign.example-cloud.net 레코드를 추가합니다. 이벤트가 끝나고 호스팅 자원은 지웠지만 DNS 레코드는 아무도 지우지 않았습니다. 이렇게 대상이 사라진 레코드를 댕글링(dangling, 매달린) 레코드라고 부릅니다.
여기서 제3자가 같은 이름의 자원(old-campaign)을 클라우드에 새로 만들거나, CNAME이 외부 도메인을 가리켰다면 그 도메인이 만료된 뒤 다시 등록합니다. 그 순간부터 promo.example.com은 제3자의 서버로 연결됩니다. 우리 DNS는 바뀐 것이 없는데 우리 이름이 남의 자원을 가리키게 됩니다.
이름이 넘어가면 그 이름에 걸린 신뢰가 함께 넘어갑니다.
Domain=.example.com으로 발급했다면, 브라우저는 그 쿠키를 promo.example.com에도 보냅니다.*.example.com을 허용하는 CORS 설정이나 OAuth 리디렉션 목록이 있다면 그 서브도메인도 허용 대상입니다.include가 가리키던 도메인이 넘어가면, 그 도메인이 지정한 메일 서버가 우리 이름으로 보낸 메일이 SPF 검사를 통과합니다.변형: NS 위임 수준의 탈취(Sitting Ducks). NS 레코드에서도 같은 일이 생깁니다. 도메인의 NS가 어떤 DNS 제공자를 가리키는데 그 제공자에는 이 도메인의 영역(zone)이 없는 상태를 lame delegation(잘못된 위임)이라고 합니다. DNS 제공자가 "이 도메인의 영역을 만들려는 사람이 정말 소유자인가"를 확인하지 않으면, 제3자가 그 제공자에 계정을 만들고 영역을 등록해 도메인 전체의 답을 정할 수 있습니다.
가장 가까운 항목은 A02 보안 설정 오류입니다. 하지만 서버 설정 파일이나 코드에는 결함이 없습니다. 문제는 DNS 레코드와 도메인의 수명주기에서 생깁니다. 자원을 만들 때와 지울 때의 절차, 외부 도메인의 등록 만료일, 마케팅 도구 해지 같은 운영 사건이 원인입니다. Top 10이 다루는 "애플리케이션"의 바깥에 있고, 코드 리뷰나 취약점 스캐너로는 잘 드러나지 않습니다.
SubdoMailing (Guardio Labs, 2024-02). 8,000개 이상의 도메인이 악용됐고, MSN, VMware, McAfee, eBay, CBS 같은 기업의 서브도메인이 포함됐습니다(서브도메인 수 1.3만 개 이상은 [추정]). 대표 예는 MSN입니다. MSN 서브도메인이 CNAME으로 가리키던 20여 년 전 캠페인 도메인이 2022년 9월 다시 등록됐고, 그 결과 SPF가 공격자의 메일 서버를 허용하게 됐습니다. 이 구조로 수백만 통 규모의 스팸·피싱 메일이 SPF 등 메일 인증을 통과했습니다(일일 발송량 수치는 [추정]). 받는 사람의 메일 서버가 보기에는 정상적으로 인증된 대기업 메일이었습니다.
Sitting Ducks (Infoblox·Eclypsium, 2024-07-31 공개). DNS 제공자의 잘못된 위임과 소유 확인 부족을 이용해 도메인 자체를 탈취하는 방식입니다. 두 회사는 약 100만 개 도메인이 취약한 것으로 추정했고, 2018년 12월 이후 3만 개 이상이 실제로 탈취됐다고 밝혔습니다.
두 사례 모두 회사가 "잊은" 이름에서 시작했습니다. 지금 운영하는 서비스는 모니터링을 받지만, 몇 년 전 이벤트와 해지한 마케팅 서비스의 흔적은 아무도 보지 않습니다.
가장 효과가 큰 방법은 클라우드 자원과 그것을 가리키는 DNS 레코드를 같은 IaC(Infrastructure as Code, 인프라를 코드로 관리하는 방식) 모듈에 두는 것입니다. 그러면 자원을 지울 때 레코드도 함께 지워집니다. 레코드 값이 자원의 속성을 참조하므로, 자원 없이 레코드만 남는 상태를 만들기 어렵습니다.
# Terraform: 자원과 DNS 레코드를 한 모듈에 (방어 예시, 이름은 가짜 값)
resource "aws_cloudfront_distribution" "promo" {
# ... 이벤트 페이지 배포 설정 ...
}
resource "aws_route53_record" "promo" {
zone_id = var.zone_id
name = "promo.example.com"
type = "CNAME"
ttl = 300
records = [aws_cloudfront_distribution.promo.domain_name] # 자원 속성을 직접 참조
}
# 이 모듈을 삭제하면 배포와 레코드가 함께 사라진다
여기에 콘솔에서 레코드를 손으로 만들지 않는다는 규칙을 더합니다. 손으로 만든 레코드는 누가 왜 만들었는지 기록이 없어서 지울 때도 판단할 근거가 없습니다. DNS 쓰기 권한은 배포 파이프라인 계정에만 주면 규칙이 지켜집니다.
이미 쌓인 레코드는 점검 스크립트로 찾습니다. 우리 존의 레코드 목록(존 파일이나 IaC 상태에서 뽑은 것)을 입력으로 받아 각 CNAME의 대상 이름이 아직 존재하는지 확인합니다. 우리 소유 레코드만 점검하는 스크립트입니다.
# 우리 존의 CNAME 대상이 살아 있는지 점검 (방어 예시, 도메인은 가짜 값)
import dns.resolver, dns.exception # pip install dnspython
RECORDS = { # 존 파일이나 Terraform 상태에서 뽑은 우리 레코드
"promo.example.com": "old-campaign.example-cloud.net",
"docs.example.com": "example-docs.hosting.example",
}
def check(target: str) -> str:
try:
dns.resolver.resolve(target, "A")
return "ok"
except dns.resolver.NXDOMAIN:
return "dangling" # 대상 이름이 없다: 레코드 삭제 대상
except (dns.resolver.NoAnswer, dns.exception.Timeout):
return "check" # 사람이 확인
for name, target in RECORDS.items():
status = check(target)
if status != "ok":
print(f"[{status}] {name} -> {target}")
SPF의 include에는 지금 실제로 메일을 보내는 서비스만 남깁니다. 해지한 마케팅·뉴스레터 서비스의 include가 남아 있으면, 그 서비스의 도메인이나 계정이 다른 사람 손에 넘어갔을 때 우리 이름의 메일이 인증을 통과합니다.
; 정리 전 (예시, 가짜 도메인): 2년 전 해지한 뉴스레터 서비스가 남아 있다
example.com. TXT "v=spf1 include:_spf.mail-a.example include:spf.old-newsletter.example ~all"
; 정리 후: 지금 쓰는 발송 서비스만, 나머지는 거부
example.com. TXT "v=spf1 include:_spf.mail-a.example -all"
DMARC(SPF·DKIM 결과를 바탕으로 수신 측의 처리 방침과 보고 주소를 알리는 레코드)를 설정해 보고서를 받으면, 우리 이름으로 메일을 보낸 IP 목록을 볼 수 있습니다. 모르는 발송 IP가 SPF를 통과했다면 include 중 하나가 넘어간 것입니다.
# 위험한 모양: 모든 서브도메인으로 전송된다
Set-Cookie: session=FAKE-SESSION-0001; Domain=.example.com; Secure; HttpOnly; SameSite=Lax; Path=/
# 고친 모양: Domain 속성을 빼면 이 호스트(www.example.com)에만 전송된다
Set-Cookie: session=FAKE-SESSION-0001; Secure; HttpOnly; SameSite=Lax; Path=/
// CORS 허용 목록: 와일드카드 서브도메인 대신 정확한 출처만 (방어 예시)
const ALLOWED_ORIGINS = new Set(["https://www.example.com", "https://app.example.com"]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
}
next();
});
// 쓰지 않는 모양: /^https:\/\/.*\.example\.com$/ 처럼 모든 서브도메인을 허용하는 정규식
OAuth 리디렉션 URI 허용 목록도 같은 원칙입니다. https://*.example.com/callback 같은 와일드카드 대신 실제로 쓰는 주소를 하나씩 등록합니다. 서브도메인 하나를 잃어도 그 서브도메인이 신뢰 범위 안에 들어 있지 않으면 피해가 그 이름에서 멈춥니다.

Domain 속성이 없고, CORS·OAuth 허용 목록에 와일드카드 서브도메인이 없다오늘 본 사고의 원인은 대부분 서버와 코드의 기본기에서 막을 수 있는 것들이었습니다. 직접 손으로 막아 보고 싶다면 이런 강의가 있습니다.
실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩
서버 보안 기초부터 차근차근 시작하고 싶다면 이 강의도 있습니다.
백엔드 개발자를 위한 서버 보안 실무: 해킹 방어부터 정보유출 방지까지
수치와 사실은 2026년 11월 1일 기준 공개 자료입니다. 연구 기관이 추정으로 밝힌 수치는 본문에 [추정]으로 표시했고, 이후 바뀔 수 있습니다. 이 글은 법률 자문이 아닙니다.
이전 글 남의 계정 페이지가 캐시에 남는다: 캐시 속임과 캐시 오염 막기
다음 글 쿠폰 한 장으로 열 번 할인: 경쟁 조건과 웹훅 멱등성을 DB로 막기
시리즈 전체 목록 해킹 방식 VS 방어 방법 전체 목록