Web Hacking - ServerSide : Server Side Request Forgery (SSRF)

SSRF

웹 개발 언어는 HTTP 언어를 전송하는 라이브러리(PHP : php-curl / NodeJS : http / Python : urllib, requests)를 제공한다. 이러한 라이브러리는 HTTP 요청을 보낼 클라이언트 뿐 아니라, 서버와 서버 간 통신에도 사용된다. 일반적으로 다른 웹의 리소스(마이크로서비스 간 통신, 외부 API 호출, 외부 웹 리소스 다운)를 사용하기 위한 목적으로 통신한다.

기존의 단일 서비스와 달리, 최근의 웹 서비스는 지원하는 기능이 증가함에 따라, 구성요소가 증가하여 관리 및 코드의 복잡도를 낮추기 위해 마이크로 서비스들로 웹 서비스를 구축한다. 이때, 각 마이크로 서비스는 주로 HTTP, GRPC 등을 사용해 API 통신을 한다. 이와 같은 통신 과정에서 요청 내에 이용자의 입력값이 포함되는 경우 개발자가 의도하지 않은 요청이 전송된 수 있다. SSRF는 웹 서비스의 요청을 변조하는 취약점으로, 브라우저가 변조된 초청을 보내는 CSRF와는 다르게, 웹 서비스의 권한으로 변조된 요청을 보낼 수 있다.

* 마이크로 서비스 : link



백오피스 서비스는 관리자 페이지라고도 불리며, 이용자의 계정을 정지시키거나, 삭제하는 등 관리자만 수행할 수 있는 기능들을 구현한 서비스이다. 이러한 서비스는 관리자만 이용할 수 있어야 하기 때문에 외부에서 접근 불가한 내부망에 위치한다. 웹 서비스는 의심스러운 행위를 탐지하고 실시간으로 대응하기 위해 백오피스의 기능을 실행할 수 있다. 만약 공격자가 SSRF 취약점을 통해 웹 서비스의 권한으로 요청을 보낼 수 있다면, 공격자는 외부에서 간접적으로 내부망 서비스를 이용할 수 있게 된다.

이용자가 입력한 URL에 요청을 보내는 경우

from flask import Flask, request
import requests
app = Flask(__name__)
@app.route("/image_downloader")
def image_downloader():
    # 이용자가 입력한 URL에 HTTP 요청을 보내고 응답을 반환
    image_url = request.args.get("image_url", "") # URL 파라미터에서 image_url 값을 가져옴
    response = requests.get(image_url) # image_url URL에 HTTP GET 메소드 요청을 보내고 결과를 response에 저장
    return ( # 아래의 3가지 정보를 반환
        response.content, # HTTP 응답으로 온 데이터
        200, # HTTP 응답 코드
        {"Content-Type": response.headers.get("Content-Type", "")}, # HTTP 응답으로 온 헤더 중 Content-Type(응답 내용의 타입)
    )

@app.route("/request_info")
def request_info():
    # 접속한 브라우저(User-Agent)의 정보를 출력
    return request.user_agent.string
app.run(host="127.0.0.1", port=8000)
image_downloader

이용자가 입력한 image_url을 requests.get 함수를 통해 GET 메소드로 HTTP 요청을 보내고, 응답을 반환한다

request_info

브라우저를 통해 해당 엔드포인트에 접근하면 접속하는데에 사용된 브라우저의 정보가 출력된다.

문제점
http://127.0.0.1:8000/image_downloader?image_url=http://127.0.0.1:8000/request_info

위와 같이 image_downloader 엔드포인트의 image_url에 request_info의 경로 주소를 입력하면, image_downloader 에서는 request_info에 HTTP 요청을 보내고, 응답을 반환한다. 이 때 반환되는 값은 브라우저로 request_info에 접속했을 때와 다르게, python-requests로 시작하는 브라우저 정보가 출력된다. 이는 웹 서비스에서 HTTP 요청을 보냈기 때문에 출력되는 것이며, 이처럼 이용자가 웹 서비스에서 사용하는 마이크로서비스의 API 주소를 알아내고, image_url에 주소를 전달하면 외부에서 직접 전달할 수 없는 마이크로서비스의 기능을 임의로 사용할 수 있다.

웹 서비스의 요청 URL에 이용자의 입력값이 포함되는 경우

INTERNAL_API = "http://api.internal/"
# INTERNAL_API = "http://172.17.0.3/"
@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 경로로 사용한다

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

문제점

웹 서비스가 요청하는 URL에 이용자의 입력값이 URL의 구성 요소 문자를 포함하면 API 경로를 조작할 수 있다. 예를 들어, user_idx에 ../search가 입력될 경우, 웹 서비스는 http://api.internal/search 에 요청을 보낸다(Path Traversal로도 볼 수 있음).

또한 user_name에 secret&user_type=private#이 입력될 경우, http://api.internal/search?user_name=secret&user_type=private 이와 같은 URL로 요청이 보내진다.

웹 서비스의 요청 Body에 이용자의 입력값이 포함되는 경우

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']}" # 전송할 데이터 구성
    # INTERNAL API 에 이용자가 입력한 값을 HTTP BODY 데이터로 사용해서 요청
    response = requests.post(f"{INTERNAL_API}/board/write", headers=header, data=data) 
    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, user을 JSON 형식으로 변환하여 반환

index

board_write 기능을 호출하기 위한 페이지

문제점

데이터를 구성할 때 이용자의 입력값인 title, body, user을 파라미터로 전달하므로, 요청에 &를 포함하면 설정되는 data의 값을 변조할 수 있다. title에 title&user=admin을 삽입할 경우, 다음과 같이 data가 구성된다.

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

이용자가 & 구분자를 포함해 user 파라미터를 추가하였으며, 내부 API에서는 전달받은 값을 파싱할 때 앞에 존재하는 파라미터의 값을 우선적으로 사용하기에, 아래와 같이 user의 값이 admin으로 변조된다.

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