IT/웹 보안

[Server Side Request Forgery (SSRF)]

kykyky 2024. 6. 8. 08:40

Server Side Request Forgery (SSRF)

: 서비스 간 HTTP 통신이 이뤄질 때, 외부에 있는 공격자의 입력값이 요청 내에 포함됨으로써 요청이 조작되면, 웹 서비스의 권한으로 전송되어, 간접적으로 내부망 서비스를 이용 

 


 

웹 개발 언어는 HTTP 요청을 전송하는 라이브러리를 제공한다.
PHP의 php-curl, NodeJS는 http, 파이썬은 urllib, requests

이러한 라이브러리는 HTTP 요청을 보낼 클라이언트 뿐만 아니라 서버와 서버 간 통신, 또는 다른 웹 애플리케이션에 존재하는 리소스를 사용하기 위한 목적의 통신에 쓰인다.
eg. 마이크로서비스 간 통신, 외부 API 호출, 외부 웹 리소스 다운로드 

 

※ 마이크로서비스


최근의 웹 서비스는 단일 서비스로 구현되지 않고 마이크로서비스들로 구성된다.

※ 마이크로서비스: 소프트웨어가 잘 정의된 API를 통해 통신하는 소규모의 독립적인 서비스로 구성되어 있는 경우의 소프트웨어 개발을 위한 아키텍처 및 조직적 접근 방식

 

이때 각 마이크로서비스는 주로 HTTP, GRPC 등을 사용해 API 통신을 한다.

 

 

취약 코드 예시 1

# pip3 install flask requests # 파이썬 flask, requests 라이브러리를 설치하는 명령입니다.
# python3 main.py # 파이썬 코드를 실행하는 명령입니다.

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) # requests 라이브러리를 사용해서 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)

위 코드는 이용자가 입력한 URL에 요청을 보낸다.


image_downloader 엔드포인트에 의해,

브라우저에서

http://127.0.0.1:8000/image_downloader?image_url=https://dreamhack.io/assets/dreamhack_logo.png

위 URL을 입력하면

드림핵 페이지에 요청을 보내고 응답을 반환한다.

공격

목표: 웹 서비스에서 사용하는 마이크로서비스의 API 주소를 알아내고, image_url에 그 주소를 전달하여, 외부에서 직접 접근할 수 없는 마이크로서비스의 기능을 임의로 사용할 수 있다.

 

image_downloader 엔드포인트의 image_url에 request_info 엔드포인트 경로를 입력하여 접속한다:
http://127.0.0.1:8000/image_downloader?image_url=http://127.0.0.1:8000/request_info

image_downloader에서는 http://127.0.0.1:8000/request_info URL에 HTTP 요청을 보내고 응답을 반환한다.

: 반환값은 브라우저로 request_info 엔드포인트에 접속했을 때와는 다르게 브라우저 정보가 python-requests/<LIBRARY_VERSION>이다!

 

취약 코드 예시 2

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


@app.route("/v1/api/user/information")
def user_info():
# 이용자가 전달한 user_idx 값을 내부 API의 URL 경로로 사용
	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 값을 내부 API의 쿼리로 사용
	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}")

 

위 코드는 웹 서비스의 요청 URL에 이용자의 입력값이 포함될 수 있다.

 

이용자가

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

위와 같이 user_idx를 1로 설정하고 요청을 보내면,

 

user_info에 의해 웹 서비스는

http://api.internal/user/1 

위 주소에 요청을 보낸다.

이용자가

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

위와 같이 user_name을 “hello”로 설정하고 요청을 보내면,

 

user_search에 의해 웹 서비스는

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

위 주소에 요청을 보낸다.

공격

예시 코드의 user_info 함수에서, user_idx에 ../search를 입력 (path traversal)하면,

웹 서비스는

http://api.internal/search

위 URL에 요청을 보낸다.

user_search 함수에서, user_name에 secret&user_type=private#를 입력하면,

웹 서비스는

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

위 URL에 요청을 보낸다.
이때 #는 Fragment Identifier 구분자로서, 뒤에 붙는 문자열은 API 경로에서 생략된다.

따라서 해당 URL은 실제로 

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

위 URL을 나타낸다.

취약 코드 예시 3

# 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():
# 이용자의 입력값을 HTTP Body에 포함하고 내부 API로 요청을 보냄
# 전송할 데이터를 구성할 때 세션 정보를 "guest" 계정으로 설정
    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']}" 
      # 전송할 데이터를 구성: 이용자의 입력값인 title, body, user 값을 파라미터 형식으로 설정
    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():
# board_write 함수에서 요청하는 내부 API가 구현됨
# 전달된 title, body, 계정 이름을 JSON 형식으로 변환하고 반환
    # 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)

위 코드에서는 웹 서비스의 요청 Body에 이용자의 입력값이 포함된다.

 

공격

위 코드를 실행한 뒤,

http://127.0.0.1:8000

위 URL에 접속하고,

title과 body를 입력하는 페이지에서 입력창에 값을 입력하고 제출 버튼을 누르면 

이 요청의 세션 정보가 "guest"였기 때문에

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

위와 같은 응답이 뜸

line 22에 의해, 이용자가 URL에 &(: 파라미터를 구분할 때 사용하는 구분 문자)를 포함하면 data의 값을 변조할 수 있다

: title에 title&user=admin를 삽입함으로써 user 파라미터를 추가하면, 
title=title&user=admin&body=body&user=guest

위처럼 data가 구성된다.

그럼 아래와 같이 user가 "admin"으로 변조된다.
{ "body": "body", "title": "title", "user": "admin" }

 

예방

입력 값에 대한 적절한 필터링

도메인 또는 아이피에 대한 검증