서버 사이드 요청 위조 (SSRF)

Server-Side Request Forgery (SSRF)

설명

서버 사이드 요청 위조(SSRF)는 서버가 외부로 보내는 HTTP 요청의 주소(URL)나 Host를 사용자 입력으로 그대로 사용해서 발생하는 취약점입니다. 공격자는 요청 파라미터나 헤더 등에 악의적인 URL을 넣어 서버에게 대신 요청을 보내게 만들 수 있습니다. 이 때 서버는 루프백(127.0.0.1), 메타데이터 엔드포인트, 사설 IP이나 원래는 외부와 통신하면 안 되는 서비스로 요청을 보낼 수 있어, 인증 우회, 내부 시스템 정보 조회, 포트 스캐닝 등 다양한 공격이 가능합니다.

잠재적 영향

  • 내부 네트워크 접근 (내부망 무단 접근): 서버가 있는 네트워크 내부(사설 IP, 내부 API, DB 관리 콘솔 등)에 사용자가 서버를 통해 요청을 보낼 수 있습니다. 이를 통해 내부 시스템 구조 파악, 추가 공격(예: RCE로 이어지는 취약 API 호출) 등이 가능합니다.
  • 민감 정보 유출 (내부 정보 노출): 클라우드 메타데이터 엔드포인트(예: AWS 169.254.169.254)나 내부 설정 서버에 요청을 보내 토큰, 크레덴셜, 설정 파일 등의 민감 정보를 획득할 수 있습니다.
  • 서비스 악용 및 우회 (보안 정책 우회): 원래는 방화벽, IP 기반 ACL, WAF 등으로 막혀 있어야 하는 대상에게, 애플리케이션 서버를 프록시처럼 이용해 요청을 보낼 수 있어 보안 정책이 우회됩니다.
  • 서비스 장애 유발 (서비스 거부): 의도적으로 느리거나 응답이 없는 주소, 매우 큰 응답을 보내는 서버로 요청을 반복해 보내 서버 자원을 소모시키고, 결과적으로 서비스 장애(DoS)를 일으킬 수 있습니다.

해결 방법

  • 외부 요청 대상은 코드에서 정한 도메인·호스트 허용 목록으로 제한하세요.
  • 필요한 http/https 스킴과 포트만 허용하고, 다른 스킴은 거부하세요.
  • 실제 IPv4·IPv6 연결 주소에서 루프백, 사설, 링크 로컬, 메타데이터 등 허용하지 않는 대역을 제한하세요. DNS 재바인딩을 막으려면 검증한 주소를 실제 연결에도 사용해야 합니다.
  • 전체 URL 대신 서버에서 정한 고정 대상의 식별자를 받으세요. 경로나 쿼리가 필요하면 허용한 형태로 제한하고 적절히 인코딩하세요.
  • 타임아웃, 리다이렉트 정책과 응답 크기 상한을 적용하세요.
  • SSRF 우회 입력을 시험하고 의심스러운 아웃바운드 요청을 모니터링하세요.

예시

변경 전

go
package main

import (
    "io"
    "log"
    "net/http"
)

// 비안전한 SSRF 예제
func proxyHandler(w http.ResponseWriter, r *http.Request) {
    // 사용자 입력으로부터 전체 URL을 그대로 받음
    target := r.URL.Query().Get("url")
    if target == "" {
        http.Error(w, "missing url", http.StatusBadRequest)
        return
    }

    // 취약: 사용자가 준 URL로 서버가 직접 요청을 보냄
    // 예: http://myserver/proxy?url=http://169.254.169.254/latest/meta-data/
    resp, err := http.Get(target)
    if err != nil {
        http.Error(w, "request failed", http.StatusBadGateway)
        return
    }
    defer resp.Body.Close()

    // 단순히 결과를 그대로 전달
    w.WriteHeader(resp.StatusCode)
    if _, err := io.Copy(w, resp.Body); err != nil {
        log.Println("write response error:", err)
    }
}

func main() {
    http.HandleFunc("/proxy", proxyHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

변경 후

go
package main

import (
    "io"
    "log"
    "net/http"
    "time"
)

var outboundClient = &http.Client{
    Timeout: 5 * time.Second,
    CheckRedirect: func(req *http.Request, via []*http.Request) error {
        // 허용된 호스트가 내부 주소나 다른 호스트로 리다이렉트하지 못하게 합니다.
        return http.ErrUseLastResponse
    },
}

func safeProxyHandler(w http.ResponseWriter, r *http.Request) {
    endpointID := r.URL.Query().Get("endpoint")
    var target string
    // 외부 입력은 URL이 아니라 서버가 관리하는 고정 엔드포인트만 선택합니다.
    switch endpointID {
    case "users":
        target = "https://api.example.com/v1/users"
    case "status":
        target = "https://status.example.com/api/status"
    default:
        http.Error(w, "unknown endpoint", http.StatusBadRequest)
        return
    }

    req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target, nil)
    if err != nil {
        http.Error(w, "request build failed", http.StatusInternalServerError)
        return
    }

    resp, err := outboundClient.Do(req)
    if err != nil {
        http.Error(w, "request failed", http.StatusBadGateway)
        return
    }
    defer resp.Body.Close()

    w.Header().Set("Content-Type", "application/octet-stream")
    w.Header().Set("X-Content-Type-Options", "nosniff")
    w.WriteHeader(resp.StatusCode)
    const maxResponseBytes = 1024 * 1024
    if _, err := io.Copy(w, io.LimitReader(resp.Body, maxResponseBytes)); err != nil {
        log.Println("write response error:", err)
    }
}

func main() {
    http.HandleFunc("/proxy", safeProxyHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

설명:

  • 변경 전: 이 예시에서는 사용자가 query parameter "url"로 보낸 값을 아무 검증 없이 http.Get에 그대로 전달합니다. 이 경우 공격자는 내부망이나 클라우드 메타데이터 엔드포인트 같은 민감한 주소를 넣어 서버에게 대신 요청을 보내게 만들 수 있습니다. 서버는 네트워크 상에서 더 신뢰받는 위치에 있기 때문에, 원래는 외부에서 직접 접근할 수 없는 시스템에 접근이 가능해지고, 그 응답 내용을 공격자에게 그대로 전달할 수 있어 SSRF 취약점이 발생합니다.
  • 변경 후: 다음과 같이 SSRF 위험을 줄입니다.
  • 사용자 입력은 URL이나 경로가 아니라 서버가 관리하는 고정 엔드포인트의 ID만 선택합니다.
  • 실제 요청 URL은 코드에 정의된 고정 분기에서 선택하므로 외부 입력이 스킴·호스트·포트·경로를 바꿀 수 없습니다.
  • CheckRedirect가 리다이렉트를 따르지 않도록 하여 허용된 외부 서버가 내부 주소로 요청을 우회시키는 것을 막습니다.
  • 요청 타임아웃과 응답 크기 상한을 적용해 느리거나 큰 응답에 의한 자원 고갈을 제한합니다.
  • 업스트림 콘텐츠를 애플리케이션 출처의 HTML로 해석하지 않도록 이진 콘텐츠 타입과 nosniff를 강제합니다. 고정된 대상의 DNS와 서비스도 신뢰할 수 있어야 하며, 필요한 아웃바운드 네트워크 제한을 별도로 적용하세요. DNS/IP 검사를 사용하는 더 동적인 설계라면 검증한 IP가 실제 연결에도 사용되도록 커스텀 DialContext에 결합해야 합니다. 별도 LookupIP 결과만 확인한 뒤 기본 클라이언트로 다시 해석하면 DNS 재바인딩을 막을 수 없습니다.

참조