본문 바로가기
컴퓨터보안/웹서버

Server-side Request Forgery (SSRF)

by 데이빗제이2 2024. 5. 29.

Server-side Request Forgery (SSRF)

SSRF는 웹 애플리케이션이 내부망의 기능을 사용할 때 발생할 수 있는 보안 취약점입니다. 공격자는 웹 서버를 통해 다른 서버에 임의의 요청을 보내도록 유도할 수 있습니다. SSRF는 내부망의 중요한 자원에 접근하거나 민감한 정보를 노출시킬 수 있어 매우 치명적입니다.

SSRF의 발생 원리와 예시

SSRF가 발생하는 주요 원인은 웹 애플리케이션이 사용자 입력값을 사용하여 다른 서버로 요청을 보낼 때 적절한 검증이 이루어지지 않는 경우입니다. 예를 들어, 사용자가 입력한 URL이나 데이터가 그대로 내부 API 요청에 포함될 때 취약점이 발생할 수 있습니다.

예시 코드 1: 사용자 입력 URL 요청

from flask import Flask, request
import requests

app = Flask(__name__)

@app.route("/image_downloader")
def image_downloader():
    image_url = request.args.get("image_url", "")
    response = requests.get(image_url)
    return response.content, 200, {"Content-Type": response.headers.get("Content-Type", "")}

@app.route("/request_info")
def request_info():
    return request.user_agent.string

app.run(host="127.0.0.1", port=8000)

이 코드는 두 가지 엔드포인트를 제공합니다:

  • /image_downloader: 사용자가 입력한 image_url에 대해 HTTP GET 요청을 보내고, 그 응답을 반환합니다.
  • /request_info: 접속한 브라우저의 정보(User-Agent)를 반환합니다.

사용 예시

이미지 다운로드

사용자가 다음 URL을 입력하면, Flask 애플리케이션은 image_url에 대한 요청을 보내고 이미지를 다운로드합니다.

http://127.0.0.1:8000/image_downloader?image_url=https://dreamhack.io/assets/dreamhack_logo.png
브라우저 정보 확인

사용자가 /request_info 엔드포인트에 접근하면, 해당 브라우저의 정보(User-Agent)를 반환합니다.

Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4558.0 Safari/537.36

문제점 확인

다음과 같이 image_downloader 엔드포인트의 image_url 파라미터에 내부 엔드포인트를 입력해 봅니다:

http://127.0.0.1:8000/image_downloader?image_url=http://127.0.0.1:8000/request_info

위 URL에 접속하면, image_downloader 엔드포인트는 내부의 /request_info URL에 HTTP 요청을 보내고, 그 응답을 반환합니다. 이 경우 반환된 값은 브라우저로 요청했을 때와 다르게 python-requests/<LIBRARY_VERSION>로 나타납니다.

python-requests/2.25.1

이는 웹 서버가 HTTP 요청을 보냈기 때문입니다. 이처럼 사용자가 웹 서비스에서 사용하는 마이크로서비스의 API 주소를 알아내고, image_url에 해당 주소를 전달하면, 외부에서 직접 접근할 수 없는 마이크로서비스의 기능을 임의로 사용할 수 있습니다.

 

 

예시 코드 2: 사용자 입력값 포함 URL 요청

INTERNAL_API = "http://api.internal/"

@app.route("/v1/api/user/information")
def user_info():
    user_idx = request.args.get("user_idx", "")
    response = requests.get(f"{INTERNAL_API}/user/{user_idx}")

@app.route("/v1/api/user/search")
def user_search():
    user_name = request.args.get("user_name", "")
    user_type = "public"
    response = requests.get(f"{INTERNAL_API}/user/search?user_name={user_name}&user_type={user_type}")

user_info

이용자가 전달한 user_idx 값을 내부 API의 URL 경로로 사용합니다.

http://x.x.x.x/v1/api/user/information?user_idx=1

이용자가 위와 같이 user_idx를 1로 설정하고 요청을 보내면 웹 서비스는 다음과 같은 주소에 요청을 보냅니다.

http://api.internal/user/1

user_search

이용자가 전달한 user_name 값을 내부 API의 쿼리로 사용합니다.

http://x.x.x.x/v1/api/user/search?user_name=hello

이용자가 위와 같이 user_name을 "hello"로 설정하고 요청을 보내면 웹 서비스는 다음과 같은 주소에 요청을 보냅니다.

http://api.internal/user/search?user_name=hello&user_type=public

문제점 확인

웹 서비스가 요청하는 URL에 이용자의 입력값이 포함되면 요청을 변조할 수 있습니다. 이용자의 입력값 중 URL의 구성 요소 문자를 삽입하면 API 경로를 조작할 수 있습니다. 예를 들어, 예시 코드의 user_info 함수에서 user_idx에 ../search를 입력할 경우 웹 서비스는 다음과 같은 URL에 요청을 보냅니다.

http://api.internal/search

..는 상위 경로로 이동하기 위한 구분자로, 해당 문자로 요청을 보내는 경로를 조작할 수 있습니다. 해당 취약점은 경로를 변조한다는 의미에서 Path Traversal이라고 불립니다.

이 외에도, # 문자를 입력해 경로를 조작할 수 있습니다. 예를 들어, user_search 함수에서 user_name에 secret&user_type=private#를 입력할 경우 웹 서비스는 다음과 같은 URL에 요청을 보냅니다.

http://api.internal/search?user_name=secret&user_type=private#&user_type=public

# 문자는 Fragment Identifier 구분자로, 뒤에 붙는 문자열은 API 경로에서 생략됩니다. 따라서 해당 URL은 실제로 아래와 같은 URL을 나타냅니다.

http://api.internal/search?user_name=secret&user_type=private

 

 

예시 코드 3: 요청 Body에 사용자 입력값 포함

# pip3 install flask
# python main.py

from flask import Flask, request, session
import requests
from os import urandom


app = Flask(__name__)
app.secret_key = urandom(32)
INTERNAL_API = "http://127.0.0.1:8000/"
header = {"Content-Type": "application/x-www-form-urlencoded"}


@app.route("/v1/api/board/write", methods=["POST"])
def board_write():
    session["idx"] = "guest" # session idx를 guest로 설정합니다.
    title = request.form.get("title", "") # title 값을 form 데이터에서 가져옵니다.
    body = request.form.get("body", "") # body 값을 form 데이터에서 가져옵니다.
    data = f"title={title}&body={body}&user={session['idx']}" # 전송할 데이터를 구성합니다.
    response = requests.post(f"{INTERNAL_API}/board/write", headers=header, data=data) # INTERNAL API 에 이용자가 입력한 값을 HTTP BODY 데이터로 사용해서 요청합니다.
    return response.content # INTERNAL API 의 응답 결과를 반환합니다.
    
    
@app.route("/board/write", methods=["POST"])
def internal_board_write():
    # form 데이터로 입력받은 값을 JSON 형식으로 반환합니다.
    title = request.form.get("title", "")
    body = request.form.get("body", "")
    user = request.form.get("user", "")
    info = {
        "title": title,
        "body": body,
        "user": user,
    }
    return info
    
    
@app.route("/")
def index():
    # board_write 기능을 호출하기 위한 페이지입니다.
    return """
        <form action="/v1/api/board/write" method="POST">
            <input type="text" placeholder="title" name="title"/><br/>
            <input type="text" placeholder="body" name="body"/><br/>
            <input type="submit"/>
        </form>
    """
    
    
app.run(host="127.0.0.1", port=8000, debug=True)

 

board_write

이용자의 입력값을 HTTP Body에 포함하고 내부 API로 요청을 보냅니다. 전송할 데이터를 구성할 때 세션 정보를 "guest" 계정으로 설정합니다.

internal_board_write

board_write 함수에서 요청하는 내부 API를 구현한 기능입니다. 전달된 title, body 그리고 계정 이름을 JSON 형식으로 변환하고 반환합니다.

index

board_write 기능을 호출하기 위한 인덱스 페이지입니다.

문제점 확인

위 코드를 실행하고 다음 URL에 접속하면 title과 body를 입력하는 페이지가 표시됩니다.

http://127.0.0.1:8000

입력창에 값을 입력하고 제출 버튼을 누르면 다음과 같은 응답을 확인할 수 있습니다.

{ "body": "body", "title": "title", "user": "guest" }

요청을 전송할 때 세션 정보를 "guest"로 설정했기 때문에 user가 "guest"인 것을 확인할 수 있습니다. 예시 코드를 살펴보면, 내부 API로 요청을 보내기 전에 다음과 같이 데이터를 구성하는 것을 확인할 수 있습니다.

data = f"title={title}&body={body}&user={session['idx']}"

데이터를 구성할 때 이용자의 입력값인 title, body 그리고 user의 값을 파라미터 형식으로 설정합니다. 이로 인해 이용자가 URL에서 파라미터를 구분하기 위해 사용하는 구분 문자인 &를 포함하면 설정되는 data의 값을 변조할 수 있습니다. title에서 title&user=admin를 삽입하면 다음과 같이 data가 구성됩니다.

title=title&user=admin&body=body&user=guest

이용자가 & 구분자를 포함해 user 파라미터를 추가했습니다. 내부 API에서는 전달받은 값을 파싱할 때 앞에 존재하는 파라미터의 값을 가져와 사용하기 때문에 user의 값을 변조할 수 있습니다. title&user=admin를 삽입했을 때의 실행 결과를 확인해보면 user가 "admin"으로 변조된 것을 확인할 수 있습니다.

{ "body": "body", "title": "title", "user": "admin" }

title=title&user=admin&body=body&user=guest에서 user=guest는 무시됩니다. 이유는 대부분의 웹 서버와 파싱 라이브러리가 동일한 키가 여러 번 존재할 때 첫 번째 값을 사용하기 때문

 

마치며

이번 강의에서는 웹 서비스의 요청을 변조하는 Server-side Request Forgery(SSRF)에 대해 알아보았습니다. 해당 취약점은 웹 애플리케이션의 요청을 변조할 수 있기 때문에 상황에 따라 매우 치명적인 취약점이 될 수 있습니다.

SSRF를 예방하기 위해서는 입력 값에 대한 적절한 필터링과 도메인 또는 아이피에 대한 검증이 필수적입니다.

다음 강의에서는 Web Hacking Fundamental을 마무리하는 시간을 가지겠습니다. 

키워드

  • 마이크로서비스: 소프트웨어가 잘 정의된 API를 통해 통신하는 소규모의 독립적인 서비스로 구성되어 있는 경우의 소프트웨어 개발을 위한 아키텍처 및 조직적 접근 방식
  • SSRF: 웹 서비스의 요청을 변조하는 취약점으로, 브라우저가 변조된 요청을 보내는 CSRF와는 다르게 웹 서비스의 권한으로 변조된 요청을 보낼 수 있음
  • 구분 문자(Delimiter): 일반 텍스트 또는 데이터 스트림에서 별도의 독립적 영역 사이의 경계를 지정하는 데 사용하는 하나의 문자 혹은 문자들의 배열. URL에서 구분 문자는 "/"(Path identifier), "?" (Query identifier) 등 이 있으며 구분 문자에 따라 URL의 해석이 달라질 수 있음

'컴퓨터보안 > 웹서버' 카테고리의 다른 글

File Vulnerability  (0) 2024.05.28
파일 취약성 관련 백그라운드  (0) 2024.05.28
파일 취약성  (0) 2024.05.24
서버 관련 정리(간단히)  (0) 2024.05.23
서버 관련 정리(자세히)  (0) 2024.05.23