본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 28. 03:31

AI 에이전트에서의 SSRF: 문자열로 169.254를 차단하는 것만으로는 충분하지 않다

요약

AI 에이전트의 web_fetch 도구에서 발생할 수 있는 SSRF 취약점과 그 방어 기제의 한계를 다룹니다. 단순 문자열 차단 방식의 위험성과 리다이렉트 검증 누락 문제를 지적하며 안전한 구현 방법을 제시합니다.

핵심 포인트

  • 단순 문자열 블랙리스트는 16진수 등 다양한 IP 표현 형식을 차단하지 못함
  • 리다이렉트된 최종 목적지에 대한 재검증이 필수적임
  • IP 정규화 및 기본 거부(Default-deny) 방식의 허용 목록 사용 권장
  • DNS 리바인딩 및 TOCTOU 공격은 일반적인 URL 필터로 방어 불가

http://169.254.169.254/는 클라우드 메타데이터 엔드포인트(cloud metadata endpoint)입니다. IAM 역할(IAM role)이 연결되어 있고 IMDSv1에 여전히 접근 가능한 EC2 인스턴스의 경우, 요청을 보낼 수 있는 누구에게나 임시 자격 증명(temporary credentials)을 제공합니다. IMDSv2는 보안 수준을 높였지만, 여전히 많은 인스턴스가 v1을 허용합니다. 당신의 에이전트가 사용하는 web_fetch 도구가 해당 URL을 차단한다면, 그것은 좋은 일입니다.

하지만 http://0xA9FEA9FE/는 차단할까요? 이는 16진수(hex)로 작성된 동일한 주소입니다. 호스트를 블랙리스트 문자열 목록과 비교하는 방어 기제는 이를 절대 감지할 수 없습니다.

저는 타인의 실수를 지적하려는 것이 아닙니다. 저 또한 몇 주 전에 60줄짜리 MCP web_fetch 도구(해당 포스트 링크)에 SSRF 방어 기제를 포함하여 배포한 적이 있습니다. 당시 방식은 단순 문자열 매칭보다는 나았습니다. OS 리졸버(OS resolver)를 통해 호스트를 먼저 해석(resolve)했기 때문에, 16진수 형태는 169.254.169.254로 변환되어 거부되었을 것입니다. 하지만 거기에는 다른 허점이 있었습니다. 바로 제가 그 포스트에서 직접 언급해 놓고도 그대로 방치해 버린 허점입니다. 해당 도구는 리다이렉트(redirects)를 따랐지만, 리다이렉트된 목적지를 다시 확인하지 않았습니다. 이것이 제가 배포했어야 하는 (올바른) 버전이었습니다.

요약 (TL;DR)

  • 나쁜 호스트(host)에 대한 문자열(string) 차단 목록(denylist)은 누출됩니다. 동일한 내부 주소라도 십진수 정수(2852039166), 16진수(0xA9FEA9FE), 또는 IPv6 매핑 리터럴([::ffff:169.254.169.254])로 작성될 수 있으며, 이 중 어느 것도 문자열 169.254.169.254와 일치하지 않습니다.
  • 아래의 표준 라이브러리(stdlib) 데모에서, 문자열 차단 목록은 8개의 URL 중 7개를 허용합니다 (그 중 6개는 위험합니다). Python의 ipaddress를 통해 모든 호스트를 정규화(normalize)하는 기본 거부(default-deny) 허용 목록(allowlist)을 사용하면 8개 중 1개, 즉 합법적인 호스트 하나만 허용합니다.
  • 리다이렉트(redirect)를 다시 확인하지 않는 사전 가져오기(pre-fetch) 검사는 169.254.169.254로 30번 리다이렉트하는 공개 호스트에 대해 무용지물입니다. 그것이 제 방어 체계에 존재했던 실제 구멍이었습니다.
  • 모델을 뒤집으세요: 기본적으로 거부하고, 명시적인 호스트 이름 목록만 허용하며, 판단하기 전에 모든 호스트를 정규화하고, 실제로 리다이렉트된 URL을 다시 확인하세요.
  • 이 스크립트는 자체적인 한계를 출력합니다: DNS 리바인딩(DNS rebinding)과 시간 점검/시간 사용(time-of-check/time-of-use, TOCTOU)은 이 필터를 포함한 그 어떤 URL 필터로도 잡아낼 수 없습니다. 피스처(Fixtures)는 합성된 것이며 라벨이 붙어 있습니다. 두 번 실행해도 동일한 바이트가 출력됩니다.

내가 배포한 방어 체계, 그리고 내가 남긴 구멍

방어 체계 부분만 발췌한 web_fetch 도구의 내용은 다음과 같습니다:

ip = ipaddress.ip_address(socket.gethostbyname(host))
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
    raise ValueError("refusing to fetch a private/internal address")

이 코드는 호스트를 해석(resolve)한 다음, 해석된 IP를 분류합니다. 이는 문자열을 매칭하는 것보다 진정으로 더 나은 방식인데, 왜냐하면 리졸버(resolver)가 ipaddress가 확인하기 전에 0xA9FEA9FE2852039166을 다시 169.254.169.254로 정규화하기 때문입니다. 이 포스트 상단에 언급된 인코딩 트릭들은 이 검사를 통과할 수 없습니다.

하지만 두 가지 사항은 이를 통과했습니다.

첫째, 이것은 차단 목록 (denylist) 방식입니다. 제가 목록에 넣는다고 생각한 주소들만 거부합니다. 제가 잊어버린 범위의 주소로 라우팅되거나, 새로운 예약된 블록이 배포되는 날에는 기본 응답이 여전히 "허용 (allow)"가 됩니다. OWASP의 SSRF 방지 치트 시트 (SSRF Prevention Cheat Sheet)는 왜 이러한 태도가 실패하는지에 대해 단호하게 설명합니다. 차단 목록 (deny-lists)은 우회하기 쉬우며, 해결책은 이미 신뢰하는 유효한 IP 주소나 도메인 이름만 허용하는 것입니다. 기본 허용 (default-allow)이 아니라 기본 차단 (default-deny)이어야 합니다.

둘째, 그리고 이것이 실제로 치명적인 부분은, 최종 홉 (final hop)에 대한 재검사 없이 follow_redirects=True를 사용하는 것입니다. 저는 에이전트가 전달한 URL을 검증했습니다. 그 후 HTTP 클라이언트는 두 번째 URL을 아무도 확인하지 않았기 때문에, http://169.254.169.254/로 향하는 302 리다이렉트를 즐겁게 따라가서 데이터를 가져왔습니다. 저는 그 포스트에 "진지한 작업에서는 최종 홉을 반드시 재검사해야 한다"라고 썼습니다. 그러고는 그것을 적용하지 않은 채 배포했습니다. 그 문장은 제가 저 자신에게 제출한 버그 리포트로 변했습니다.

문자열 차단 목록이 더 취약한 기준점인 이유

제가 현장에서 접한 대부분의 방어 기제들은 아무것도 해결하지 못합니다. 그들은 호스트 부분 문자열 (host substring)을 가져와 차단 목록 (blocklist)과 비교합니다. 이것이 제가 데모에서 깨뜨리고 싶은 기준점입니다. 왜냐하면 가장 눈에 띄는 방식으로 실패하며, 이를 증명하는 데 네트워크가 전혀 필요하지 않기 때문입니다.

동일한 주소라도 표기법은 매우 다양합니다. 169.254.169.254가 그중 하나입니다. 32비트 정수 2852039166은 동일한 4바이트입니다. 0xA9FEA9FE는 해당 바이트를 16진수 (hex)로 표현한 것입니다. [::ffff:169.254.169.254]는 IPv4-mapped IPv6 형식입니다. 리졸버 (resolver)는 이 모든 것을 동일한 목적지로 취급합니다. 하지만 문자열 비교 (string compare)는 이를 네 개의 서로 다른 알 수 없는 호스트로 취급하고 통과시켜 버립니다.

OWASP는 이 범주를 직접 명시하고 있습니다. 치트 시트에서 IP 파싱 라이브러리 (IP-parsing libraries) 검증에 대해 언급할 때, 테스트해야 할 우회 방법들을 다음과 같이 나열합니다: "16진수 (Hex), 8진수 (Octal), Dword, URL 및 혼합 인코딩 (Mixed encoding)". 이것은 이론이 아닙니다. 공격자가 하나씩 시도하며 통과할 철자 목록입니다.

해결책은 더 긴 차단 목록 (denylist)을 만드는 것이 아닙니다. 모든 나쁜 주소의 모든 표기법을 열거할 수는 없습니다. 해결책은 먼저 정규화 (normalize)를 수행한 다음 결정하는 것이며, 실제로 통신하고자 하는 이름들의 허용 목록 (allowlist)을 기반으로 결정하는 것입니다.

데모: 8개의 URL, 두 가지 방어 기제

이 스크립트는 8개의 합성 URL을 두 가지 검사 방식을 통해 실행합니다. NAIVE는 문자열 차단 목록 (denylist) 방식입니다. GUARD는 기본 거부 (default-deny) 방식입니다. 이 방식은 호스트를 파싱하고, ipaddress를 통해 모든 IP 리터럴 (IP literal)을 정규화 (normalize)하며, 모든 원시 IP (raw IP)를 거부하고 (이름만 허용), 허용 목록 (allowlist)에 없는 모든 것을 거부하며, 리다이렉트 대상이 있는 경우 해당 대상을 다시 검사합니다.

왜 이 8개인가: 허용 목록에 포함된 합법적인 호스트 1개, 메타데이터 IP 리터럴, 세 가지 인코딩으로 표현된 동일한 IP, RFC 1918 사설 호스트, 비 HTTP 스킴 (non-http scheme), 그리고 결정적인 요소인 내부 범위로 리다이렉트되는 허용 목록 내 호스트입니다. 모든 것은 정적 리터럴 (static literal)입니다. 소켓 (socket), DNS, 시계, 무작위성이 없으므로 실행할 때마다 출력은 동일합니다. 전체 코드를 ipaddressre라는 두 개의 표준 라이브러리 (stdlib) 임포트만으로 유지하기 위해 urllib 대신 작은 정규 표현식 (regex)으로 호스트를 파싱했습니다.

몇 분의 시간을 잡아먹은 세부 사항 하나는 다음과 같습니다: 호스트 정규 표현식은 IPv6 대괄호를 명시적으로 처리해야 합니다. \[[^\]]+\] 분기가 없다면, [::ffff:169.254.169.254]는 첫 번째 콜론에서 잘리게 되고, 올바른 이유(링크 로컬 (link-local)임)가 아니라 잘못된 이유(훼손된 호스트)로 차단하게 됩니다. 잘못된 이유로 차단되는 것이야말로 방어 기제가 자체 테스트는 통과하고 운영 환경 (prod)에서는 실패하게 되는 방식입니다.

#!/usr/bin/env python3
"""SSRF 프리페치 방어 기제: 문자열 차단 목록 vs. 정규화된 허용 목록.

...

python3 -I ssrf_allowlist_guard.py로 실행하세요. 매번 동일한 출력이 나옵니다:

SSRF pre-fetch guard  --  string denylist vs. normalized allowlist
synthetic URLs, no network, stdlib {ipaddress, re}

...

두 개의 => allowed 라인을 읽어보세요. 문자열 차단 목록은 8개 중 7개의 URL을 통과시키며, 오직 글자 그대로 암기된 하나만을 차단합니다. 통과된 7개 중 6개는 금지된 대상에 도달합니다. 허용 목록 방어 기제는 정확히 하나, 즉 제가 실제로 통신하려고 의도했던 호스트만을 허용합니다.

단순한 검사를 통과해 버리는 세 가지 요소

인코딩된 리터럴 IP (Encoded literal IPs). 2852039166, 0xA9FEA9FE, [::ffff:169.254.169.254]는 모두 169.254.169.254입니다. 방어 기제는 각 호스트를 as_ip를 통해 처리하며, 이는 10진수 및 16진수 형태를 읽어 ipaddress로 전달합니다. 그 후 ip_is_internalis_private, is_loopback, is_link_local, is_reserved를 비롯하여 멀티캐스트(multicast) 및 지정되지 않은 주소(unspecified)를 확인합니다. IPv6 매핑(IPv6-mapped) 사례는 그 IPv4 형태를 기준으로 판단됩니다. 네 가지 표기 방식 모두 동일한 판정인 private-ip로 귀결됩니다.

비 HTTP 스킴 (Non-http schemes). file:///etc/passwd는 네트워크 호스트가 전혀 없으므로, 호스트 전용 검사(host-only check) 시 잡을 수 있는 대상이 없어 기본적으로 허용(allow)됩니다. 스킴 검사(scheme check)가 이를 먼저 차단합니다. http 또는 https가 아닌 모든 것은 호스트를 파싱하기도 전에 거부됩니다. 이로 인해 gopher://, ftp:// 및 유사한 스킴들도 차단됩니다.

리다이렉트 (The redirect). reviews.example.com은 허용 목록(allowlist)에 있습니다. 사전 가져오기(pre-fetch) URL은 깨끗합니다. 단순한 검사 방식은 이를 허용하고 넘어가 버립니다. 하지만 방어 기제는 그냥 넘어가지 않습니다. 리다이렉트 대상이 존재할 경우, 해당 URL에 대해서도 동일한 검사를 수행하며, 169.254.169.254로 향하는 302 리다이렉트는 redirect-to:private-ip라는 결과를 반환합니다. 이것은 요청 시점에 위험이 보이지 않는 유일한 지점이며, 제가 실제 코드에서 남겨두었던 바로 그 빈틈입니다.

여기서 허용 목록(allowlist)이 차단 목록(denylist)보다 나은 이유

차단 목록(denylist)은 "이것이 내가 나열한 나쁜 것들 중 하나인가?"라고 묻습니다. 반면 허용 목록(allowlist)은 "이것이 내가 지정한 좋은 것들 중 하나인가?"라고 묻습니다. 웹으로부터 부분적으로 얻은 지침에 따라 공개 웹을 가져오는 에이전트에게는 두 번째 질문이 안전한 기본값입니다. 왜냐하면 불완전한 허용 목록의 실패 모드는 "허용했어야 할 사이트를 거부함"이지만, 불완전한 차단 목록의 실패 모드는 "당신의 자격 증명 엔드포인트(credentials endpoint)를 가져옴"이기 때문입니다.

따라서 방어 기제는 공인 IP를 포함하여 원시 IP(raw IPs)를 완전히 거부하며, ALLOWLIST에 나타나는 호스트 이름만 허용합니다. 만약 에이전트가 정당하게 원시 IP에 접속해야 한다면, 해당 정확한 IP를 의도적으로 허용 목록에 추가하면 됩니다. 기본적으로는 어떤 것도 네트워크에 도달할 수 없습니다.

이것이 OWASP의 입장이며, 그들의 치트 시트(cheat sheet)에 명확히 명시되어 있습니다: 허용된 목적지의 허용 목록 (allow-list)을 사용하는 것을 권장합니다. 왜냐하면 당신이 신뢰하는 짧은 목록은 논리적으로 판단할 수 있지만, 신뢰하지 않는 무한한 목록은 판단할 수 없기 때문입니다.

이것이 잡아내지 못하는 것, 그리고 데모가 명시적으로 보여주는 것

이 스크립트는 스스로의 한계를 출력합니다. 왜냐하면 승리한 사례만을 보여주는 보안 게시물은 당신에게 무언가를 팔려고 하는 것이기 때문입니다. 세 가지 명시된 격차(gaps)는 다음과 같습니다:

DNS 리바인딩 (DNS rebinding) 및 TOCTOU. reviews.example.com과 같이 허용 목록에 있는 이름은 당신이 확인할 때는 공인 IP로 해석되다가, 소켓이 실제로 열리는 1밀리초 후에는 사설 IP로 해석될 수 있습니다. 이 필터는 의도적으로 DNS를 해석하지 않으므로, 구조적으로 이러한 상황에 대해 눈이 멀어 있습니다. 진정한 해결책은 한 단계 아래에 있습니다. 연결 시점에 해석(resolve)하고, 해석된 IP를 고정(pin)하며, 소켓이 연결되기 전이 아니라 연결된 후에 이를 다시 확인해야 합니다. 해석된 뷰 (resolved view)는 URL 필터가 제공할 수 없는 영역입니다.

허용 목록에 있는 호스트를 통한 프록시 (A proxying allowlisted host). 당신이 신뢰하는 호스트가 당신의 요청을 내부 서비스로 전달한다면, URL은 깨끗했지만 목적지는 그렇지 않았던 것입니다. 이는 모든 URL 수준의 검사 범위를 벗어나는 일입니다.

파서 (The parser). 여기서 사용된 호스트 정규 표현식(regex)은 교육용입니다. 실제 파서는 userinfo@host 트릭, 끝에 붙는 점(trailing dots), 그리고 IDN/punycode를 처리해야 합니다. 그렇기 때문에 실제 운영 코드(production code)는 제가 작성한 15줄짜리 parse_host 대신 견고하게 다듬어진(hardened) URL 파서에 의존해야 합니다.

실제 에이전트 트래픽 중 어느 정도의 비율이 이러한 함정을 포함하고 있는지는 말씀드리지 않겠습니다. 이 데모는 그것을 측정하지 않기 때문입니다. 이 m

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0