SSRF 취약점이란? 이미지 URL·웹훅에서 생기는 원인과 방어법

SSRF 취약점이 무엇인지, 링크 미리보기·이미지 URL 가져오기·웹훅 기능에서 클라우드 키가 새는 경로와 IMDSv2·DNS 재바인딩·리다이렉트 처리까지 코드로 정리한 방어 가이드.

보안 점검 의뢰 팀 17분 분량

SSRF(Server-Side Request Forgery, 서버사이드 요청 위조)는 사용자가 넘긴 URL로 서버가 대신 요청을 보낼 때, 공격자가 그 목적지를 내부망이나 클라우드 메타데이터 주소로 바꿔치기하는 취약점입니다. 브라우저는 우리 회사 내부망에 들어올 수 없지만, 서버는 들어갈 수 있습니다. 그래서 서버를 "심부름꾼"으로 쓰면 외부에서는 닿지 않던 관리용 API, 데이터베이스 관리 화면, 클라우드 인스턴스의 자격 증명 엔드포인트까지 닿을 수 있습니다.

스타트업 서비스에서 SSRF가 생기는 곳은 대개 정해져 있습니다. 링크 미리보기(OG 태그 수집), "URL로 이미지 가져오기", 사용자가 등록하는 웹훅, PDF·스크린샷 생성, 외부 RSS·iCal 가져오기처럼 "URL을 입력받아 서버가 fetch하는" 기능입니다. 막는 방법의 핵심은 세 가지입니다. ① 가능하면 허용목록으로 목적지를 좁히고, ② 불가피하게 임의 URL을 받아야 한다면 DNS를 직접 해석해 실제로 연결하는 IP를 검증하고 리다이렉트를 매번 다시 검증하며, ③ 코드가 뚫려도 피해가 작도록 IMDSv2와 egress(나가는 트래픽) 제한을 네트워크 계층에 걸어 두는 것입니다.

OWASP Top 10 2021에서 SSRF는 A10으로 독립 항목이었고, 2025판에서는 A01 Broken Access Control에 CWE-918로 포함됐습니다. 범주가 합쳐졌다고 위험이 줄어든 것은 아닙니다. "서버가 가면 안 되는 곳에 가는 것"을 접근 제어 실패로 본다는 뜻입니다. 이 글은 흔한 기능 하나를 출발점으로 피해가 커지는 흐름을 보고, 취약한 코드와 고친 코드를 나란히 비교합니다.

SSRF가 생기는 흔한 기능 네 가지

아래 기능 중 하나라도 있다면 점검 대상입니다.

  • 링크 미리보기: 채팅·게시글에 URL을 붙이면 서버가 페이지를 받아 제목과 썸네일을 추출합니다.
  • URL로 이미지 업로드: 프로필 사진을 "이미지 주소로 등록"하면 서버가 내려받아 저장합니다. 파일 자체의 검증은 파일 업로드 취약점: 확장자 검사로 부족한 이유와 안전한 설계에서 다룬 원칙이 그대로 적용됩니다.
  • 웹훅: 고객이 이벤트를 받을 URL을 등록하면 서버가 POST를 보냅니다. 목적지가 고객마다 다르니 허용목록을 쓰기 가장 어려운 기능입니다.
  • 문서·스크린샷 렌더러: HTML을 PDF로 바꾸는 헤드리스 브라우저는 HTML 안의 <img>, <iframe>까지 서버 쪽에서 불러옵니다. 입력이 URL이 아니라 HTML이어도 SSRF가 될 수 있습니다.

공통점은 "요청 목적지를 사용자가 정한다"는 것입니다. 응답을 사용자에게 그대로 보여주는 기능(미리보기)은 내부 데이터가 바로 노출되고, 응답을 보여주지 않는 기능(웹훅)도 응답 시간·상태 코드 차이로 내부 포트를 탐색하는 데 쓰일 수 있습니다. 이를 흔히 블라인드 SSRF라고 부릅니다.

피해가 커지는 흐름: 미리보기에서 클라우드 키까지

가상의 링크 미리보기 API가 있다고 하겠습니다.

// ❌ 취약한 예: 받은 URL을 그대로 요청
app.post('/api/preview', async (req, res) => {
  const { url } = req.body;
  const r = await fetch(url);          // 리다이렉트 자동 추적, 목적지 검증 없음
  const html = await r.text();
  res.json({ title: extractTitle(html), raw: html.slice(0, 2000) });
});

이 코드에서 피해가 커지는 단계는 이렇습니다.

  1. 루프백·내부 서비스 접근: http://localhost:8080/admin, http://10.0.0.5:9200/ 같은 주소를 넣으면 외부에 열려 있지 않던 관리 API나 검색 엔진에 서버가 대신 접근합니다. "내부망이니까 인증 없이 둬도 된다"고 판단한 서비스가 가장 먼저 노출됩니다.
  2. 클라우드 메타데이터 접근: AWS EC2에서는 인스턴스 내부에서 169.254.169.254로 메타데이터 서비스(IMDS)에 접근할 수 있고, 인스턴스에 IAM 역할이 붙어 있다면 그 역할의 임시 자격 증명도 여기서 받습니다. IMDSv1만 켜져 있으면 단순 GET 한 번으로 응답을 받을 수 있기 때문에, 위 코드처럼 응답을 돌려주는 SSRF에서는 자격 증명이 그대로 노출될 수 있습니다.
  3. 권한 확장: 그 역할에 S3 전체 읽기 같은 넓은 권한이 있었다면, 서버 밖에서도 그 키로 버킷을 조회할 수 있게 됩니다. 이 단계부터는 웹 취약점이 아니라 클라우드 계정 사고가 됩니다.

여기서 알 수 있듯이 SSRF의 피해 크기는 코드 결함 × 서버가 닿을 수 있는 범위 × 서버가 가진 권한으로 결정됩니다. 방어도 이 세 축에 각각 걸어야 합니다.

1단계: 허용목록으로 목적지를 좁힌다

OWASP SSRF Prevention Cheat Sheet는 애플리케이션이 알려진 시스템에만 요청하면 되는 경우 허용목록을 쓰라고 권합니다. 가능하면 사용자에게 URL 전체를 받지 말고, 서버가 스킴·호스트·경로를 직접 조립하는 방식이 가장 안전합니다.

// ✅ 목적지가 정해진 경우: 사용자는 '선택지'만 고르고 URL은 서버가 만든다
const PROVIDERS = {
  youtube: (id) => `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${encodeURIComponent(id)}`,
  example: (id) => `https://media.example.com/images/${encodeURIComponent(id)}`,
};

app.post('/api/embed', async (req, res) => {
  const { provider, id } = req.body;
  const build = PROVIDERS[provider];
  if (!build || !/^[A-Za-z0-9_-]{1,64}$/.test(id)) return res.status(400).end();
  const r = await fetch(build(id), { redirect: 'error' });
  // ...
});

호스트 문자열 비교로 허용목록을 구현할 때는 url.includes('example.com')이나 startsWith 같은 문자열 검사를 쓰지 마세요. example.com.attacker.test나 example.com@다른호스트 같은 형태를 통과시키기 쉽습니다. 반드시 new URL()로 파싱한 뒤 hostname을 정확히 일치로 비교합니다.

다만 허용목록은 "도메인"을 검증할 뿐입니다. 허용된 도메인이 내부 IP로 해석되도록 바뀌는 경우까지 막지는 못하므로, 임의 URL을 받는 기능에는 다음 단계가 필요합니다.

2단계: 임의 URL은 "연결하는 IP"를 검증한다

링크 미리보기나 웹훅처럼 목적지를 미리 알 수 없는 경우가 가장 까다롭습니다. OWASP 치트시트가 짚는 함정은 다음과 같습니다.

  • IP 표기 변형: 127.0.0.1은 정수·16진수·8진수 등 다른 형태로도 쓸 수 있고, 일부 파서가 이를 받아들입니다. 문자열 정규식이 아니라 검증된 라이브러리로 파싱한 결과를 비교해야 합니다.
  • DNS 재바인딩: 검증할 때 DNS를 한 번 조회하고, 실제 요청할 때 HTTP 클라이언트가 다시 조회하면 그 사이에 응답이 내부 IP로 바뀔 수 있습니다. 검증한 IP와 연결하는 IP가 같아야 합니다.
  • 여러 개의 A/AAAA 레코드: 하나만 검사하면 나머지 주소로 연결될 수 있습니다. 모두 검사합니다.
  • IPv6와 IPv4-mapped 주소: ::ffff:127.0.0.1 같은 주소도 루프백입니다.

Node.js에서는 http/https 요청의 lookup 옵션을 쓰면 실제 연결에 쓰이는 DNS 해석 결과를 직접 검사할 수 있습니다. 호스트 이름은 그대로 유지되므로 Host 헤더와 TLS 인증서 검증도 정상 동작합니다.

// ✅ 연결 시점의 IP를 검증하는 lookup (ipaddr.js 사용)
import dns from 'node:dns';
import ipaddr from 'ipaddr.js';

function isPublicAddress(address) {
  try {
    // process(): IPv4-mapped IPv6(::ffff:a.b.c.d)를 IPv4로 바꿔서 판단
    return ipaddr.process(address).range() === 'unicast';
  } catch {
    return false;
  }
}

export function safeLookup(hostname, options, callback) {
  dns.lookup(hostname, { all: true, verbatim: true }, (err, addrs) => {
    if (err) return callback(err);
    // 하나라도 사설·루프백·링크로컬(169.254.x.x 포함)이면 전체 거부
    if (addrs.length === 0 || !addrs.every((a) => isPublicAddress(a.address))) {
      return callback(new Error('blocked destination'));
    }
    // 최신 Node는 all:true로 lookup을 호출할 수 있으므로 두 형태 모두 지원
    if (options && options.all) return callback(null, addrs);
    callback(null, addrs[0].address, addrs[0].family);
  });
}

range() === 'unicast'는 ipaddr.js가 사설(private), 루프백, 링크로컬, 멀티캐스트, 예약 대역이 아닌 일반 공인 주소로 분류한 경우입니다. 허용 조건으로 쓰는 편이 차단 대역을 하나씩 나열하는 것보다 누락이 적습니다.

3단계: 리다이렉트, 스킴, 응답 크기까지 통제한다

허용된 공인 서버가 302 Location: http://169.254.169.254/...로 응답하면, 자동으로 리다이렉트를 따라가는 클라이언트는 첫 검증을 그대로 우회합니다. OWASP 치트시트도 HTTP 클라이언트의 자동 리다이렉트를 끄라고 권합니다. fetch와 axios는 기본적으로 리다이렉트를 따라갑니다.

// ✅ 리다이렉트를 직접 처리하며 매 홉마다 다시 검증
import https from 'node:https';
import { safeLookup } from './safe-lookup.js';

const MAX_REDIRECTS = 3;
const MAX_BYTES = 2 * 1024 * 1024;

export async function safeFetch(input, hops = 0) {
  const url = new URL(input);
  if (url.protocol !== 'https:') throw new Error('https only');
  if (url.port && url.port !== '443') throw new Error('port not allowed');
  if (url.username || url.password) throw new Error('credentials in URL');

  return new Promise((resolve, reject) => {
    const req = https.get(url, { lookup: safeLookup, timeout: 5000 }, (res) => {
      if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
        res.resume();
        if (hops >= MAX_REDIRECTS) return reject(new Error('too many redirects'));
        const next = new URL(res.headers.location, url); // 상대 경로 처리
        return safeFetch(next.href, hops + 1).then(resolve, reject); // 처음부터 재검증
      }
      let size = 0;
      const chunks = [];
      res.on('data', (c) => {
        size += c.length;
        if (size > MAX_BYTES) { req.destroy(new Error('too large')); return; }
        chunks.push(c);
      });
      res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }));
    });
    req.on('timeout', () => req.destroy(new Error('timeout')));
    req.on('error', reject);
  });
}

정리하면 이 함수가 하는 일은 다음과 같습니다.

  • https만 허용하고 file:, gopher:, ftp: 같은 스킴을 차단합니다.
  • 포트를 443으로 제한해 내부 서비스의 비표준 포트 탐색을 줄입니다.
  • 리다이렉트마다 safeFetch를 처음부터 다시 타서 스킴·포트·IP를 재검증합니다.
  • 타임아웃과 응답 크기 상한을 둬서 느린 응답이나 거대한 파일로 워커가 묶이지 않게 합니다.

그리고 응답 원문을 사용자에게 돌려주지 마세요. 미리보기라면 제목·설명·이미지 URL만 추출해 반환하고, 이미지 가져오기라면 Content-Type과 실제 파일 시그니처를 확인한 뒤 재인코딩해 저장합니다. 오류 메시지도 "가져올 수 없는 URL입니다" 하나로 통일하면 내부 포트 탐색에 쓰일 단서가 줄어듭니다. Node 내장 fetch(undici)를 쓴다면 redirect: 'manual'과 함께 연결 단계의 lookup을 지정할 수 있는 dispatcher를 구성해야 같은 효과를 얻습니다. Python requests라면 allow_redirects=False가 출발점입니다.

웹훅에 추가로 필요한 것

웹훅은 등록 시점에 검증해도, 실제 발송 시점에 DNS가 바뀌어 있을 수 있습니다. 검증은 등록 때와 발송 때 모두 하고, 발송은 위의 safeFetch 같은 공용 클라이언트로만 하게 합니다. 또 사용자가 HTTP 메서드나 임의 헤더를 지정하게 하지 마세요. 메서드와 헤더까지 조작할 수 있으면 아래에서 설명할 IMDSv2의 보호 효과도 약해집니다. 웹훅 발송 워커는 API 서버와 분리된 네트워크·IAM 역할로 운영하는 것이 이상적입니다.

4단계: 코드가 뚫려도 피해를 줄이는 인프라 설정

애플리케이션 검증은 언젠가 누락될 수 있습니다. 새 기능을 만든 동료가 공용 클라이언트 대신 axios.get(url)을 쓰는 순간 구멍이 생깁니다. 그래서 OWASP 치트시트는 네트워크 계층 방어를 함께 두라고 강하게 권합니다.

IMDSv2 강제(AWS) IMDSv2는 메타데이터를 읽기 전에 HTTP PUT으로 세션 토큰을 받아야 하고, 이후 요청에 그 토큰을 헤더로 붙여야 합니다. AWS 보안 블로그는 이 방식이 패치되지 않은 SSRF, 잘못 열린 리버스 프록시·WAF에 대한 심층 방어라고 설명합니다. GET만 보낼 수 있는 일반적인 SSRF로는 토큰을 얻기 어렵기 때문입니다.

# 기존 인스턴스를 IMDSv2 전용으로 전환 (적용 전 IMDSv1 의존 에이전트가 없는지 먼저 확인)
aws ec2 modify-instance-metadata-options \
  --instance-id i-0123456789abcdef0 \
  --http-tokens required \
  --http-endpoint enabled

AWS 문서 기준으로 HttpTokens를 required로 두면 IMDSv1이 꺼지고, 계정·리전 단위 기본값과 IAM 조건 키로 새 인스턴스에 이를 강제할 수도 있습니다. 응답 홉 제한(hop limit)은 1~64 사이로 설정하며, 컨테이너 환경에서는 1로 두면 컨테이너 안에서 메타데이터 접근에 문제가 생길 수 있다고 안내합니다. 컨테이너 워크로드가 IMDS를 쓸 필요가 없다면 이 특성을 오히려 차단 수단으로 활용할 수 있고, 필요하다면 테스트를 거쳐 값을 정하세요. 애초에 메타데이터가 필요 없는 인스턴스라면 IMDS 자체를 비활성화하는 선택지도 있습니다.

최소 권한 IAM 역할 URL을 가져오는 서버의 IAM 역할에는 그 기능에 필요한 권한만 둡니다. 미리보기 워커가 업로드 버킷의 쓰기 권한 하나만 가지고 있다면 자격 증명이 노출돼도 피해 범위가 크게 줄어듭니다.

egress 제한 보안 그룹·방화벽에서 외부로 나가는 트래픽을 필요한 포트로 제한하고, 내부망 대역으로의 연결을 막습니다. URL fetch 전용 워커를 별도 서브넷에 두고, 그 서브넷에서 사설 대역과 메타데이터 주소로 가는 경로를 차단하면 애플리케이션 코드에 실수가 있어도 마지막 방어선이 됩니다. 내부 서비스도 "내부망이니까 인증 없음"으로 두지 말고 최소한의 인증을 거세요.

개발 단계 점검 체크리스트

배포 전에 아래 항목을 하나씩 확인해 보세요.

  1. 코드베이스에서 사용자 입력이 들어가는 fetch, axios, requests, http.get, 헤드리스 브라우저 호출 지점을 모두 찾았는가
  2. 목적지가 정해진 기능은 사용자 URL 대신 서버가 URL을 조립하는가
  3. 허용목록 비교가 문자열 포함 검사가 아니라 파싱된 hostname 정확 일치인가
  4. 임의 URL은 DNS 해석 결과 전체를 검증하고, 검증한 IP로만 연결하는가
  5. 자동 리다이렉트를 끄고, 매 홉마다 같은 검증을 다시 하는가
  6. 스킴(https)·포트·URL 내 계정 정보를 제한하는가
  7. 타임아웃, 응답 크기 상한, 동시 요청 수 제한이 있는가
  8. 응답 원문 대신 필요한 필드만 반환하고, 오류 메시지가 통일되어 있는가
  9. 웹훅은 등록 시점과 발송 시점 모두 검증하며, 메서드·헤더를 사용자가 정할 수 없는가
  10. EC2 인스턴스가 HttpTokens=required이고, 불필요한 곳은 IMDS가 꺼져 있는가
  11. fetch 담당 서버의 IAM 권한이 최소화되어 있고 egress가 제한되어 있는가
  12. 차단된 요청을 로그로 남겨 탐색 시도를 볼 수 있는가

자동화 도구만으로는 블라인드 SSRF나 리다이렉트 우회 같은 경로를 놓치기 쉽습니다. 어떤 진단 방식을 택할지 고민된다면 모의해킹과 취약점 진단 차이를 참고하세요.

자주 묻는 질문

169.254.169.254만 차단하면 충분하지 않나요?

충분하지 않습니다. 같은 주소의 다른 표기, IPv6 엔드포인트, 내부 IP로 해석되는 도메인, 리다이렉트로 우회될 수 있습니다. 또 메타데이터 외에도 사설 대역의 내부 서비스가 표적이 됩니다. 차단 목록보다 "공인 유니캐스트 주소만 허용"하는 방식을 연결 시점에 적용하고, IMDSv2와 egress 제한을 함께 두는 편이 안전합니다.

AWS가 아니라 다른 클라우드나 자체 서버를 쓰면 상관없나요?

다른 클라우드도 메타데이터 서비스를 제공하므로 각 사업자의 보호 설정을 확인해야 합니다. 클라우드를 쓰지 않더라도 내부 관리 페이지, 캐시, 데이터베이스 관리 도구 같은 내부 서비스는 SSRF로 노출될 수 있습니다. 원리는 같고, 표적만 달라집니다.

서버리스(Lambda 등)라면 SSRF 걱정이 줄어드나요?

인스턴스 메타데이터 경로는 다르지만, 함수 실행 환경에도 자격 증명이 존재하고 VPC에 연결된 함수는 내부 리소스에 닿을 수 있습니다. URL을 받아 요청하는 함수라면 같은 검증 로직과 최소 권한 원칙을 그대로 적용하세요.

정리

  • SSRF는 "URL을 받아 서버가 대신 요청하는" 기능에서 생기며, 링크 미리보기·이미지 가져오기·웹훅이 대표적입니다.
  • 목적지가 정해져 있으면 허용목록과 서버 측 URL 조립으로, 임의 URL이면 연결 시점 IP 검증·리다이렉트 재검증·스킴/포트 제한으로 막습니다.
  • 코드는 언젠가 누락되므로 IMDSv2 강제, 최소 권한 IAM, egress 제한으로 피해 범위를 함께 줄여 두세요.
  • 우리 서비스의 URL 처리 기능을 직접 점검하기 어렵다면 무료 분석 신청으로 점검을 받아볼 수 있습니다.

참고 자료

함께 읽으면 좋은 글