SSRF(Server-Side Request Forgery) 공격과 방어
요즘 웹 서비스에는 서버가 사용자 대신 외부 URL로 요청을 보내는 기능이 흔합니다. 링크를 붙여넣으면 미리보기 카드를 만들어 주고, 이미지 주소를 넣으면 썸네일을 대신 받아오고, 웹훅 주소를 등록해 두면 이벤트가 생길 때마다 서버가 그 주소로 알림을 쏴 줍니다. 편리하죠. 그런데 “사용자가 건넨 주소로 서버가 대신 요청을 보낸다”는 이 평범한 동작이, 잘못 다루면 서버를 통째로 공격자의 손발로 내주는 통로가 됩니다.
이번 글에서 다룰 SSRF(Server-Side Request Forgery) 이야기입니다. 이름부터 CSRF와 헷갈리는데요. 둘 다 “요청 위조”라는 점은 같지만 노리는 대상은 정반대입니다. SSRF가 정확히 무엇인지, 왜 그렇게 위험한지, 어떤 코드가 취약한지, 그리고 어떻게 막아야 하는지를 차례로 살펴보겠습니다.
SSRF란 무엇일까요
SSRF는 공격자가 서버를 속여서, 서버가 공격자 대신 임의의 URL로 네트워크 요청을 보내게 만드는 취약점입니다. 공격자가 직접 요청을 보내는 게 아니라, 서버를 심부름꾼으로 삼아 대신 두드리게 한다는 점이 핵심이에요.
왜 이게 문제가 될까요?
바로 신뢰 경계(trust boundary)를 넘기 때문입니다.
공격자 본인의 노트북에서는 회사 내부망이나 서버의 localhost에 접근할 수 없습니다.
방화벽이 딱 막고 있으니까요.
그런데 그 요청을 서버가 대신 보내주면 이야기가 달라집니다.
서버는 방화벽 안쪽, 내부망 한가운데에 있으니 얼마든지 닿을 수 있거든요.
정리하면 SSRF는 “공격자는 못 가지만 서버는 갈 수 있는 곳”을 노리는 공격입니다. 공격자는 자기 손이 닿지 않는 곳을, 신뢰받는 서버의 손을 빌려 두드리는 셈이죠.
무엇을 노리나: 내부망, 루프백, 메타데이터
그럼 서버의 손을 빌려서 구체적으로 어디를 두드릴까요? 크게 세 군데입니다.
첫째는 내부망입니다.
http://10.0.0.5/admin 같은 사내 전용 서비스는 방화벽 뒤에 숨어 있어서 외부에서는 닿지 못하지만, 같은 내부망에 있는 서버는 아무렇지 않게 접근합니다.
여기서 10.0.0.5 같은 주소가 왜 외부에서 안 보이는지는 사설 IP에서 자세히 다뤘습니다.
둘째는 루프백입니다.
http://127.0.0.1:6379(로컬 Redis)나 http://localhost:8080/internal처럼, 서버 자기 자신에게서만 열려 있는 서비스가 표적이 됩니다.
이런 로컬 서비스는 “어차피 바깥에서는 못 들어오니까”라는 믿음으로 인증을 아예 걸지 않은 경우가 많아요.
127.0.0.1과 localhost가 정확히 무엇을 가리키는지 헷갈린다면 localhost와 127.0.0.1의 관계를 참고하세요.
셋째가 가장 치명적인 클라우드 메타데이터 엔드포인트입니다.
AWS, GCP, Azure는 모두 http://169.254.169.254라는 링크 로컬(link-local) 주소에서 인스턴스에 대한 정보를 제공하는데요.
문제는 이 정보 안에 임시 IAM 자격 증명이 들어 있다는 점입니다.
서버가 이 주소로 요청하도록 유도하면, 클라우드 계정의 자격 증명이 통째로 공격자에게 넘어갑니다.
이게 얼마나 위험한지는 실제 사건이 증명했습니다.
2019년 미국 대형 은행 Capital One에서 약 1억 명의 고객 정보가 유출된 사건이 있었는데요.
공격자가 방화벽 설정 오류를 파고들어 SSRF로 169.254.169.254를 두드렸고, 거기서 얻은 IAM 자격 증명으로 S3 버킷 전체를 긁어간 것이 핵심 경로였습니다.
SSRF 하나가 은행 하나를 통째로 열어젖힌 셈이죠. 💥
취약한 코드는 이렇게 생겼습니다
말로만 들으면 감이 잘 안 오니 실제 코드를 봅시다. 사용자가 준 이미지 URL을 서버가 대신 받아와 미리보기를 만들어 주는, 어디서나 볼 법한 엔드포인트입니다.
// 사용자가 준 URL의 이미지를 서버가 대신 가져와 돌려준다
app.get("/preview", async (req, res) => {
const { url } = req.query;
const response = await fetch(url); // ⚠️ 아무 검증 없이 그대로 요청
const buffer = await response.arrayBuffer();
res.set("content-type", response.headers.get("content-type"));
res.send(Buffer.from(buffer));
});
기능만 보면 아무 문제가 없어 보입니다.
그런데 공격자가 url 파라미터에 이미지 주소 대신 메타데이터 엔드포인트를 넣으면 어떻게 될까요?
curl "https://example.com/preview?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/my-role"
서버는 아무 의심 없이 169.254.169.254로 요청을 보내고, 돌아온 응답을 그대로 res.send로 공격자에게 돌려줍니다.
그 응답에는 이런 JSON이 담겨 있죠.
{
"AccessKeyId": "ASIA...",
"SecretAccessKey": "wJalrXUtn...",
"Token": "IQoJb3JpZ2luX2Vj...",
"Expiration": "2026-07-03T18:00:00Z"
}
이미지 미리보기 기능 하나가 클라우드 자격 증명 유출구로 둔갑한 겁니다.
이 공격이 한 번의 요청으로 어떻게 흘러가는지 그림으로 보면 이렇습니다.
sequenceDiagram
autonumber
participant A as 공격자
participant S as 취약한 서버 (/preview)
participant M as 메타데이터 (169.254.169.254)
Note over A,M: 공격자는 169.254.169.254에<br/>직접 닿을 수 없다
A->>S: GET /preview?url=http://169.254.169.254/...<br/>/iam/security-credentials/my-role
Note over S: url을 검증 없이 그대로 신뢰
S->>M: GET (서버가 공격자 대신 요청)
M-->>S: 임시 IAM 자격 증명<br/>{ AccessKeyId, SecretAccessKey, Token }
S-->>A: 응답을 그대로 되돌려줌 (res.send)
Note over A,M: 탈취한 자격 증명으로<br/>S3 등 클라우드 자원 접근
문제의 뿌리는 단 한 줄, fetch(url)이 사용자가 준 주소를 무조건 믿었다는 데 있습니다.
CSRF와 무엇이 다를까요
여기서 이름이 비슷한 CSRF와의 차이를 짚고 넘어가면 SSRF의 정체가 더 또렷해집니다. 둘 다 “Request Forgery(요청 위조)“지만, 누구를 속여 누구의 권한을 훔치느냐가 정반대입니다.
| 구분 | CSRF | SSRF |
|---|---|---|
| 속이는 대상 | 피해자의 브라우저 | 서버 |
| 악용하는 것 | 브라우저에 저장된 인증 쿠키 | 서버의 네트워크 위치(내부망 접근) |
| 요청 방향 | 피해자 브라우저 → 서버 | 서버 → 내부 자원 |
| 노리는 것 | 피해자 이름으로 상태 변경 | 내부 자원 읽기, 자격 증명 탈취 |
CSRF는 로그인한 피해자의 브라우저를 꼬드겨, 그 사람 이름으로 서버에 요청을 날립니다. 브라우저가 쿠키를 자동으로 붙여준다는 점을 악용하는 거죠.
반면 SSRF는 서버 자체를 꼬드깁니다. 서버가 방화벽 안쪽에 자리 잡고 있다는 위치의 이점을 악용해, 공격자가 못 가는 내부로 요청을 대신 보내게 만듭니다. 한쪽은 피해자의 브라우저를 빌리고, 다른 한쪽은 서버의 위치를 빌린다고 기억하면 헷갈리지 않습니다.
어떻게 막을까요
방어의 큰 원칙은 하나입니다. “사용자가 준 URL을 절대 그대로 믿지 않는다.” 이 원칙을 여러 겹으로 쌓아 공격 표면을 좁혀야 합니다.
가장 먼저 고민할 것은 애초에 임의의 URL을 받지 않는 것입니다. 가져올 수 있는 도메인이 정해져 있다면, 위험한 주소를 하나씩 막는 블록리스트 대신 허용된 도메인만 통과시키는 얼로우리스트를 씁니다. 막아야 할 우회 경로는 끝이 없지만, 허용할 목적지는 대개 손에 꼽거든요.
임의 URL을 받아야만 한다면, 요청을 보내기 전에 호스트 이름을 실제 IP로 해석한 뒤 내부 대역을 걸러내야 합니다.
아래는 그 검증을 담당하는 함수인데요.
IP 대역을 분류하기 위해 ipaddr.js 패키지를 사용했습니다.
npm install ipaddr.js
import dns from "node:dns/promises";
import ipaddr from "ipaddr.js";
async function assertPublicUrl(rawUrl) {
const url = new URL(rawUrl);
// 1. 스킴 제한: https만 허용 (file://, gopher:// 등 차단)
if (url.protocol !== "https:") {
throw new Error("HTTPS URL만 허용됩니다");
}
// 2. 호스트 이름을 실제 IP로 해석
const { address } = await dns.lookup(url.hostname);
// 3. 사설, 루프백, 링크 로컬 등 내부 대역이면 거부
const range = ipaddr.parse(address).range();
const blocked = [
"private",
"loopback",
"linkLocal",
"uniqueLocal",
"reserved",
];
if (blocked.includes(range)) {
throw new Error(`내부 주소 접근 차단: ${address} (${range})`);
}
return address; // 검증을 통과한 IP
}
여기서 169.254.169.254는 linkLocal로, 127.0.0.1은 loopback으로, 10.0.0.5는 private로 분류되어 모두 걸러집니다.
이것 말고도 챙길 것이 몇 가지 더 있습니다.
우선 리다이렉트를 따라가지 않도록 막아야 합니다.
공격자가 겉으로는 멀쩡한 공인 주소를 주고, 그 주소가 301이나 302 응답으로 169.254.169.254를 가리키게 하면 검증을 통과한 뒤에 내부로 끌려가거든요.
또한 연결과 읽기에 2~3초짜리 짧은 타임아웃을 걸어 내부 포트 스캔에 악용되는 것을 늦추고, 가능하면 가져온 응답을 사용자에게 그대로 되돌려주지 않는 편이 좋습니다.
응답을 못 보면 공격자가 결과를 확인하기 어려워지니까요.
마지막으로 클라우드 쪽에도 방어선을 하나 더 둘 수 있습니다.
AWS라면 메타데이터 서비스를 IMDSv2로 강제하는 것인데요.
IMDSv2는 자격 증명을 얻기 전에 PUT 요청으로 세션 토큰을 먼저 받도록 요구하고 홉 제한까지 걸어서, 단순히 GET 한 번으로 뚫리는 SSRF를 막아줍니다.
앞서 본 Capital One 사건은 이 토큰이 필요 없던 옛 방식(IMDSv1) 환경에서 벌어진 일이었습니다.
IP 검사만으로는 부족합니다
그런데 위 검증 함수에는 미묘한 빈틈이 하나 남아 있습니다. IP를 확인하는 시점과 실제로 요청을 보내는 시점 사이에 틈이 있다는 점인데요.
dns.lookup으로 호스트 이름을 해석해 “이 주소는 공인 IP구나” 하고 통과시킨 다음, fetch가 다시 그 호스트 이름을 해석하는 순간 공격자가 답을 바꿔치기할 수 있습니다.
검증할 때는 멀쩡한 공인 IP를 내주고, 실제 요청이 나갈 때는 같은 도메인이 127.0.0.1을 가리키도록 DNS 응답을 조작하는 거죠.
이렇게 검사와 사용 사이의 시간 차를 노리는 수법을 DNS 리바인딩이라고 부릅니다.
그래서 제대로 막으려면 검증한 IP를 그대로 붙들고 있어야 합니다. 호스트 이름을 두 번 해석하지 말고, 처음에 확인한 IP로 직접 연결하면 중간에 답이 바뀔 틈이 사라지거든요. 검증과 실제 연결이 반드시 같은 IP를 향하도록 못 박는 것, 이것이 IP 기반 방어의 마지막 퍼즐 조각입니다.
마치며
SSRF는 “서버가 사용자가 준 URL로 요청을 보낸다”는 지극히 평범한 기능에서 출발합니다. 그 한 줄이 신뢰 경계를 흔드는 순간, 공격자는 자기 손이 닿지 않던 내부망과 루프백, 그리고 클라우드 메타데이터까지 서버의 손을 빌려 두드릴 수 있게 됩니다.
막는 방법도 결국 하나로 모입니다. 사용자가 준 URL을 그대로 믿지 않는 거죠. 허용할 목적지가 정해져 있다면 얼로우리스트로 막고, 임의 URL을 받아야 한다면 IP로 해석해 내부 대역을 걸러냅니다. 리다이렉트를 따라가지 않게 하고, 검증한 그 IP로 곧장 연결하는 것도 빠뜨리면 안 되고요. 클라우드에서는 메타데이터 서비스를 IMDSv2로 잠가 방어선을 한 겹 더 두면 좋습니다.
SSRF가 실전에서 어떻게 문제가 되는지 더 보고 싶다면, 인가 서버가 클라이언트가 건넨 URL을 직접 가져오는 CIMD 방식의 SSRF 방어 사례를 이어서 읽어보시길 권합니다. 서버가 외부 URL을 가져오는 구조라면 어디든 똑같은 고민이 필요하다는 걸 확인하실 수 있을 거예요.
더 자세한 방어 체크리스트는 OWASP SSRF Prevention Cheat Sheet를 참고하세요.
This work is licensed under
CC BY 4.0