본문 바로가기

Study/WebHacking

Server-side Request Forgery(SSRF) 정리

728x90

HTTP 요청을 전송하는 라이브러리를 제공-> HTTP 요청을 보낼 클라이언트뿐만 아니라 서버와 서버간 통신을 위해 사용됨. 다른 웹 애플리케이션에 존재하는 리소스를 사용하기 위한 목적으로 통신

ex) 마이크로서비스 간 통신, 외부 API 호출, 외부 웹 리소스 다운로드 등

  • PHP -> php-curl
  • NodeJS -> http 
  • 파이썬 -> urlib, requests

관리 및 코드의 복잡도를 낮추기 위해 마이크로서비스들로 웹 서비스 구현

마이크로서비스는 주로 HTTP, GRPC 등을 사용해 API 통신함


💡마이크로서비스란?

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

 

💡API 통신이란?

   - 애플리케이션 프로그래밍 인터페이스 (Application Programming Interfaces)

   - 애플리케이션 소프트웨어 및 서비스를 통합하는 툴, 정의, 프로토콜의 세트

   - 새로운 연결 인프라를 지속적으로 구축할 필요없이 제품 및 서비스가 서로 커뮤니케이션할 수 있도록 도와주는 기능

   - API는 전용으로 지정하거나(내부용), 파트너용(특정 파트너와 공유하여 추가 수익원 제공) 또는 공용(타사가 혁신을 촉진하기 위해 API와 통신하는 애플리케이션을 개발하도록 지원)으로 사용가능


서비스 간 HTTP 통신이 이뤄질 때 요청 내에 이용자의 입력값이 포함될 수 있음 -> 의도치 않은 요청이 전송될 가능성 큼

SSRF는 웹서비스의 요청을 변조하는 취약점으로 브라우저가 변조된 요청을 보내는 CSRF와는 다르게 웹 서비스의 권한으로 변조된 요청을 보낼 수 있음.

 

웹 서비스는 외부에서 접근할 수 없는 내부망의 기능을 사용하곤 하는데 내부망의 서비스로는 백오피스 서비스라고 관리자 페이지라고도 부르며, 이용자의 행위가 의심스러울 때 해당 계정을 정지시키거나 삭제하는 등 관리자만 수행할 수 있는 모든 기능을 구현한 서비스이다. 

 

만약 공격자가 SSRF 취약점을 통해 웹 서비스의 권한으로 요청으로 보낼 수 있다면 공격자는 외부에서 간접적으로 내부망 서비스를 이용가능

 

웹 서비스가 보내는 요청을 변조하기 위해서는 요청 내에 이용자의 입력값이 포함되어야 함

 

예시1) 이용자가 전달한 URL에 요청을 보내는 코드⏬ 

# 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)

 

/image_downloader 

@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", "")},
    )

▶️ 이용자가 입력한 image_url을 requests.get 함수를 사용하여 GET 메소드로 HTTP요청을 보내고 응답을 반환함

브라우저에서 다음과 같은 URL을 입력하면 드림핵 페이지에 요청을 보내고 응답 반환

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

 

/request_info

@app.route("/request_info")
def request_info():
    # 접속한 브라우저(User-Agent)의 정보를 출력하는 페이지 입니다.
    return request.user_agent.string

▶️ 웹 페이지에 접속한 브라우저의 정보(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

 

 

문제점 확인

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

▶️ URL에 HTTP요청을 보내고 응답 반환. 반환한 값을 확인해보면 브라우저로 request_info 엔드포인트에 접속했을 때와 다르게 브라우저가 python-requests/<LIBRARY_VERSION> 인 것을 확인가능

 

접속한 브라우저 정보로 python-requests 가 출력된 이유는 웹 서비스에서 HTTP 요청을 보냈기 때문.

이처럼 이용자가 웹 서비스에서 사용하는 마이크로서비스의 API주소를 알아내고 image_url에 주소를 전달하면 외부에서 직접 접근할 수 없는 마이크로서비스의 기능을 임의로 사용가능.

 

예시2) 웹 서비스의 요청 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함수

@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}")

▶️ 이용자가 전달한 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 함수

@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_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를 입력할 경우 웹 서비스는 http://api.internal/search에 요청을 보냄

'..'는 상위 경로로 이동하기 위한 구분자로, 해당 문자로 요청을 보내는 경로를 조작가능 -> Path Traversal 공격가능

 

이외에도 # 문자를 입력해 경로를 조작가능 

user_search함수에서 user_name에 secret&user_type=private#을 입력할 경우 웹 서비스는 http://api.internal/search?user_name=secret&user_type=private#&user_type=public에 요청을 보냄

 

# 문자는 Fragment Identifier 구분자로, 뒤에 붙는 문자열은 API 경로에서 생략됨.

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(http://127.0.0.1:8000)에 접속하면 title과 body를 입력하는 페이지 표시되면

입력창에 값을 입력하고 제출 버튼을 누르면 

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

요청을 전송할 때 세션 정보를 "guest"로 설정했기 때문에 user가 "guest"인 것을 확인가능

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

데이터를 구성할 때 이용자의 입력값이 title, body 그리고 user의 값을 파라미터 형식으로 설정

이로 인해 이용자가 URL에서 파라미터를 구분하기 위해 사용하는 구분 문자인 &를 포함하면 설정되는 data의 값을 변조가능. title에서 title&user=admin를 삽입하면 

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

이렇게 구성됨. 이용자가 & 구분자를 포함해 user 파라미터를 추가.

 

내부 API에서는 전달받은 값을 파싱할 때 앞에 존재하는 파라미터의 값을 가져와 사용하기 때문에 user의 값을 변조가능.

파라미터의 값을 가져와 사용하기 때문에 user의 값을 변조가능. 

title&user=admin를 삽입했을 때의 실행 결과를 확인해보면 user가 "admin"으로 변조된 것을 확인가능

💡파싱(parsing)? = 구문 분석, 문장이 이루고 있는 구성 성분을 분해하고 분해된 성분의 위계 관계를 분석하여 구조를 결정
     즉 데이터를 분해 분석하여 원하는 형태로 조립하고 다시 빼내는 프로그램.