경계 밖의 이름이 문을 연다: DNS 리바인딩과 서브도메인 탈취

아프로·2026년 11월 1일

경계 밖의 이름이 문을 연다 표지

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 리바인딩은 남의 이름이 우리 주소를 가리키게 만들고, 서브도메인 탈취는 우리 이름이 남의 자원을 가리키게 됩니다. 어느 쪽이든 서버가 "이 이름이면 믿을 만하다"고 가정한 지점이 공격면이 됩니다.

DNS와 이름의 신뢰: 먼저 짚을 개념

두 기법을 이해하려면 개념 세 가지가 필요합니다.

  • DNS 레코드. 이름을 무엇으로 바꿀지 적은 줄입니다. A 레코드는 이름을 IPv4 주소로, CNAME은 이름을 다른 이름으로, NS는 "이 이름의 답은 저 네임서버에 물어보라"는 위임을, TXT 안의 SPF는 "이 도메인 이름으로 메일을 보낼 수 있는 서버 목록"을 적습니다.
  • TTL(Time To Live). DNS 응답을 몇 초 동안 캐시해도 되는지 알려 주는 값입니다. TTL이 짧으면 클라이언트는 같은 이름을 곧 다시 묻고, 그때 다른 답을 받을 수 있습니다.
  • 동일 출처 정책(Same-Origin Policy). 브라우저는 한 페이지의 스크립트가 다른 출처의 응답을 읽지 못하게 막습니다. 출처는 "스킴 + 호스트 이름 + 포트"로 정해집니다. 이름이 같으면, 그 이름이 어느 IP로 해석되든 같은 출처입니다.

웹의 신뢰는 대부분 IP가 아니라 이름에 걸려 있습니다. 쿠키 범위, CORS 허용 목록, OAuth 리디렉션 허용 목록, SPF, 인증서가 모두 이름을 기준으로 동작합니다. 그래서 이름과 실제 자원의 연결이 바뀌면, 그 이름에 걸린 신뢰도 함께 옮겨 갑니다.

1. DNS 리바인딩

원리: 이름은 그대로, 주소만 바뀐다

DNS 리바인딩 원리. 첫 질의는 외부 서버 IP, TTL 만료 뒤 재질의는 127.0.0.1, 로컬 서비스가 Host 검사 없이 응답하면 페이지가 응답을 읽는다. 아래는 리졸버, Host 허용 목록, localhost 토큰 인증의 세 방어 지점

DNS 리바인딩은 세 단계로 이해할 수 있습니다.

  1. 첫 질의. 피해자가 외부 도메인의 웹 페이지를 엽니다. 이 도메인의 DNS는 외부 서버 IP를 돌려주고, TTL을 아주 짧게 줍니다. 브라우저는 그 페이지를 내려받아 스크립트를 실행합니다.
  2. 재질의. TTL이 끝난 뒤 페이지의 스크립트가 같은 이름으로 다시 요청을 보냅니다. 브라우저가 이름을 다시 묻자 이번에는 DNS가 127.0.0.1이나 사내 사설 IP를 돌려줍니다.
  3. 같은 출처라는 판단. 브라우저가 보기에 이름·스킴·포트가 모두 같으므로 출처도 같습니다. 그래서 요청은 피해자 PC의 로컬 서비스로 가고, 스크립트는 그 응답을 읽을 수 있습니다.

여기서 로컬 서비스가 "127.0.0.1에서 오는 요청은 내 PC 안에서 온 것이니 인증 없이 허용한다"고 가정하면, 요청은 그대로 처리됩니다. 요청은 실제로 피해자 PC의 브라우저에서 출발했으니 출발지 주소는 정말로 127.0.0.1입니다. 출발지 주소만 보는 검사는 이 상황을 구분하지 못합니다.

두 경우를 가르는 값은 Host 헤더입니다. 브라우저는 요청에 "어떤 이름으로 접속했는지"를 Host 헤더로 적어 보냅니다. 사용자가 직접 http://localhost:8765를 열었다면 Host는 localhost:8765이고, 외부 도메인 이름을 통해 들어온 요청이라면 Host에는 그 외부 도메인 이름이 그대로 남습니다. 조사 자료도 원인으로 서버 쪽에서 Host 헤더를 검증하지 않은 것을 꼽습니다.

CORS로는 막을 수 없다는 점도 알아 둘 만합니다. CORS는 "다른 출처"의 요청에 대한 규칙입니다. 리바인딩에서는 브라우저가 처음부터 같은 출처라고 판단하므로 CORS 검사 자체가 일어나지 않습니다.

왜 OWASP Top 10에 이름이 없나

결과만 보면 내부 자원에 대한 접근이라 A01 접근 제어 실패(2025판에서 SSRF를 흡수)에, "localhost면 인증 없이 허용"이라는 점에서는 A07 인증 실패에 걸칩니다. 하지만 원인은 한 애플리케이션 안의 결함이 아닙니다. DNS 응답과 브라우저의 동일 출처 판단이 맞물려 생기는 문제입니다. Top 10은 애플리케이션 하나의 결함을 기준으로 위험을 묶기 때문에, 두 시스템 사이의 상호작용에서 생기는 이 문제에는 독립 항목이 없습니다.

2021판의 A10이었던 SSRF(서버가 대신 요청을 보내는 문제)가 2025판에서 A01로 흡수된 것도 같은 흐름입니다. 서버가 대신 요청하는 문제조차 독립 항목에서 빠졌고, 브라우저가 대신 요청하는 DNS 리바인딩은 처음부터 이름을 가진 적이 없습니다.

실제 사례

경계 밖의 이름이 만든 사고 연표. 2018-12 이후 Sitting Ducks 탈취, 2022-09 MSN 서브도메인 대상 도메인 재등록, 2024-02 SubdoMailing, 2024-04-08 Ollama, 2024-07-31 Sitting Ducks 공개, 2024-08 0.0.0.0 Day, 2025-12 MCP TypeScript SDK

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에서만 도는 서비스"가 하나둘이 아니게 됐습니다. 이런 서비스는 대개 빠르게 써 보려고 만든 도구라 인증이 없고, 브라우저는 그 서비스와 인터넷 사이에 늘 열려 있습니다.

서버에서 막기

코드 수준 ①: Host 헤더 허용 목록

가장 먼저 넣을 방어선입니다. 로컬 서비스라면 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 이상으로 올립니다.

코드 수준 ②: localhost에서 도는 도구에도 토큰 인증

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 헤더를 스스로 붙여 주지 않습니다. 쿠키로 인증하면 브라우저가 상황에 따라 자동으로 실어 보낼 수 있으니, 로컬 도구에는 헤더 토큰이 더 단순하고 안전합니다.

설정 수준: 리졸버에서 "외부 이름 → 사설 IP" 응답 차단

사내 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
  • 0.0.0.0 대신 127.0.0.1에 바인딩합니다. 다만 리바인딩에서 요청을 보내는 주체는 그 PC의 브라우저이므로, 127.0.0.1 바인딩만으로는 리바인딩을 막지 못합니다. 바인딩은 노출 범위를 줄이고, Host 검사와 인증이 리바인딩을 막습니다.
  • 가능하면 HTTP를 쓰지 않습니다. 편집기와 로컬 에이전트가 주고받는 통신이라면 표준 입출력(stdio)이나 Unix 소켓을 씁니다. 브라우저가 보낼 수 있는 요청은 HTTP뿐이므로, HTTP 포트가 없으면 이 공격면 자체가 없습니다.

탐지 신호

  • 낯선 Host 헤더. 로컬·내부 서비스 접근 로그에 허용 목록 밖의 Host(외부 도메인 이름)가 찍히면 리바인딩 시도를 의심합니다. 위 미들웨어처럼 거절한 요청의 Host를 구조화된 로그로 남기면 바로 집계할 수 있습니다.
  • "외부 도메인 → 사설 IP" DNS 응답. 내부 리졸버 로그에서 외부 도메인 질의의 답이 사설 IP인 경우입니다. 리바인딩 보호를 켰다면 차단 로그로 남습니다.
  • TTL이 짧고 답이 자주 바뀌는 도메인. 짧은 간격으로 같은 이름의 IP가 공인 IP에서 사설 IP로 바뀌는 질의 패턴입니다.

2. 서브도메인 탈취와 댕글링 DNS

원리: 자원은 지웠는데 이름표가 남았다

서브도메인 탈취 흐름. 캠페인 자원과 CNAME을 만들고, 자원만 지워 레코드가 남고, 제3자가 같은 이름의 자원이나 만료 도메인을 재확보하면 쿠키 범위, CORS·OAuth 허용 목록, SPF 신뢰가 함께 넘어간다

마케팅팀이 이벤트 페이지를 클라우드 호스팅에 올렸다고 해 봅시다. 개발자는 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에도 보냅니다.
  • CORS·OAuth 허용 목록. *.example.com을 허용하는 CORS 설정이나 OAuth 리디렉션 목록이 있다면 그 서브도메인도 허용 대상입니다.
  • SPF 신뢰. SPF의 include가 가리키던 도메인이 넘어가면, 그 도메인이 지정한 메일 서버가 우리 이름으로 보낸 메일이 SPF 검사를 통과합니다.

변형: NS 위임 수준의 탈취(Sitting Ducks). NS 레코드에서도 같은 일이 생깁니다. 도메인의 NS가 어떤 DNS 제공자를 가리키는데 그 제공자에는 이 도메인의 영역(zone)이 없는 상태를 lame delegation(잘못된 위임)이라고 합니다. DNS 제공자가 "이 도메인의 영역을 만들려는 사람이 정말 소유자인가"를 확인하지 않으면, 제3자가 그 제공자에 계정을 만들고 영역을 등록해 도메인 전체의 답을 정할 수 있습니다.

왜 OWASP Top 10에 이름이 없나

가장 가까운 항목은 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를 코드로, 자원과 같은 모듈에

가장 효과가 큰 방법은 클라우드 자원과 그것을 가리키는 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}")
  • 대상 이름이 NXDOMAIN(존재하지 않음)이면 바로 지웁니다. 대상이 해석되더라도 클라우드의 기본 "없는 자원" 페이지가 뜬다면 같은 상태입니다.
  • CNAME·NS·MX 대상이 외부 도메인이면 그 도메인의 등록 만료일도 함께 점검합니다. SubdoMailing의 MSN 사례는 외부 도메인 만료에서 시작했습니다.
  • 분기마다 돌리고, 결과를 담당 팀에 티켓으로 보냅니다. 자원 삭제 체크리스트에 "DNS 레코드 삭제"를 한 줄 넣는 것도 같은 효과를 냅니다.

설정 수준: SPF include 정리와 DMARC

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 같은 와일드카드 대신 실제로 쓰는 주소를 하나씩 등록합니다. 서브도메인 하나를 잃어도 그 서브도메인이 신뢰 범위 안에 들어 있지 않으면 피해가 그 이름에서 멈춥니다.

탐지 신호

  • CNAME 대상이 NXDOMAIN이거나 클라우드의 기본 "없는 자원" 페이지를 돌려줍니다. 위 점검 스크립트가 잡는 신호입니다.
  • 우리가 발급하지 않은 인증서가 CT 로그에 나타납니다. CT(Certificate Transparency) 로그는 공개 인증서 발급 기록이고, crt.sh 같은 검색 서비스로 볼 수 있습니다. 우리 서브도메인으로 모르는 인증서가 발급됐다면 누군가 그 이름을 쓰고 있다는 뜻입니다.
  • DMARC 보고서에 모르는 발송 IP가 있고, 그 메일이 SPF를 통과했습니다.

설계 원칙으로 정리

경계 밖의 이름을 막는 네 겹. 코드(Host 허용 목록, localhost 토큰, 정확한 출처만), 설정(리졸버 차단, SPF 정리, 호스트 전용 쿠키), 아키텍처(127.0.0.1 바인딩, stdio·Unix 소켓, DNS IaC), 운영·탐지(낯선 Host, 대상 없는 CNAME, 만료일, CT 로그, DMARC)

  1. 요청이 어떤 이름으로 왔는지 서버가 확인합니다. 출발지 IP는 요청을 보낸 주체를 알려 주지 않습니다. Host 헤더를 허용 목록으로 검사하는 것이 첫 번째 방어선입니다.
  2. "로컬이니까 안전하다"는 전제를 두지 않습니다. localhost 서비스는 브라우저를 통해 인터넷과 이어져 있습니다. 개발 도구도 서버이고, 인증이 필요합니다.
  3. 우리 이름이 가리키는 대상은 자원과 함께 태어나고 함께 사라져야 합니다. DNS 레코드를 IaC로 관리하고 자원과 같은 모듈에 둡니다.
  4. 신뢰 범위는 이름 하나 단위로 좁힙니다. 호스트 전용 쿠키, 정확한 출처만 담은 CORS·OAuth 허용 목록, 지금 쓰는 서비스만 담은 SPF. 이름 하나를 잃어도 피해가 그 이름에서 멈추게 합니다.
  5. 잊힌 이름을 정기적으로 찾습니다. 대상 생존, 외부 도메인 만료일, CT 로그, DMARC 보고서를 주기적으로 확인합니다.

점검 체크리스트

  • 로컬·사내 HTTP 서비스가 Host 헤더를 허용 목록으로 검사하고, 목록 밖이면 403을 돌려준다
  • localhost에서만 도는 개발 도구·에이전트 서버·관리 콘솔도 토큰 인증을 요구한다
  • 로컬 서비스는 0.0.0.0이 아닌 127.0.0.1에 바인딩하고, 가능하면 stdio·Unix 소켓을 쓴다
  • Ollama, MCP TypeScript SDK 같은 로컬 AI 도구를 보호 기능이 들어간 버전(각 0.1.29, 1.24.0 이상)으로 올렸다
  • 사내 DNS 리졸버가 외부 도메인의 사설 IP 응답을 버린다(사내 도메인만 예외)
  • DNS 레코드를 IaC로 관리하고, 자원과 레코드가 같은 모듈에서 함께 만들어지고 삭제된다
  • 콘솔에서 손으로 만든 DNS 레코드가 없고, DNS 쓰기 권한은 배포 파이프라인에만 있다
  • 분기마다 CNAME·NS·MX 대상의 생존과 외부 도메인 만료일을 자동으로 점검한다
  • SPF include에 지금 쓰는 발송 서비스만 있고, DMARC 보고서를 받아 본다
  • 로그인 쿠키에 Domain 속성이 없고, CORS·OAuth 허용 목록에 와일드카드 서브도메인이 없다
  • CT 로그에서 우리 도메인의 새 인증서 발급을 감시한다

정리

  • DNS 리바인딩은 이름은 그대로 두고 주소만 바꿔, 브라우저가 로컬·사내 서비스에 요청을 보내고 응답을 읽게 만듭니다. Ollama, MCP SDK, 0.0.0.0 Day 모두 표적은 개발자 PC의 localhost였습니다.
  • 막는 첫 줄은 Host 헤더 허용 목록입니다. 거기에 localhost 서비스 인증, 127.0.0.1 바인딩, 리졸버 차단을 겹칩니다.
  • 서브도메인 탈취는 지운 자원을 아직 가리키는 레코드에서 시작합니다. SubdoMailing은 20여 년 전 캠페인 도메인 하나로 대기업 이름의 메일이 인증을 통과하게 만들었습니다.
  • 웹의 신뢰는 이름에 걸려 있으므로, 이름과 자원을 함께 관리하고 신뢰 범위를 이름 하나 단위로 좁힙니다.

더 깊게

보안을 더 알고 싶다면

오늘 본 사고의 원인은 대부분 서버와 코드의 기본기에서 막을 수 있는 것들이었습니다. 직접 손으로 막아 보고 싶다면 이런 강의가 있습니다.

실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩

  • AI로 자동화된 해킹 시대, 오픈소스 쇼핑몰 코드에서 실제 사고 22건을 재연하고 직접 취약점을 찾아 한 건씩 막습니다.
  • OWASP Top 10:2025 전체와 목록 밖 위험 20가지를 102개 수업으로 다루고, 취약점 37개를 모두 막은 최종 소스를 가져갑니다. Spring Boot와 Express 모두 다룹니다.
  • 이 글과 이어지는 수업: 2-6, 11-5

서버 보안 기초부터 차근차근 시작하고 싶다면 이 강의도 있습니다.

백엔드 개발자를 위한 서버 보안 실무: 해킹 방어부터 정보유출 방지까지

  • 서버를 공격하는 흐름을 로그로 보고, 내 서버와 Spring·Express 코드에서 직접 막아 봅니다.
  • SSH 하드닝, 인젝션, 인증·인가, 시크릿과 개인정보 보호, 침해사고 첫 한 시간까지 8개 섹션 54개 수업입니다.
  • 이 글과 이어지는 수업: 1-4, 2-6, 4-5, 6-2

출처와 확인 날짜

수치와 사실은 2026년 11월 1일 기준 공개 자료입니다. 연구 기관이 추정으로 밝힌 수치는 본문에 [추정]으로 표시했고, 이후 바뀔 수 있습니다. 이 글은 법률 자문이 아닙니다.


이전 글 남의 계정 페이지가 캐시에 남는다: 캐시 속임과 캐시 오염 막기
다음 글 쿠폰 한 장으로 열 번 할인: 경쟁 조건과 웹훅 멱등성을 DB로 막기
시리즈 전체 목록 해킹 방식 VS 방어 방법 전체 목록

profile
고인물 개발자(OG)

0개의 댓글