본문 바로가기

KERT/HackHeat

SSRF(Server-side Request Forgery)

SSRF(Server-side Request Forgery)

공격자가 (웹 서비스의 권한으로) 웹 서비스의 요청을 변조하는 취약점

  • 외부의 공격자가 내부망 서비스를 이용할 수 있게 된다
  • 최근의 웹 서비스는 마이크로서비스로 구현하는 추세라서 SSRF 의 파급력이 높다

 

*마이크로서비스 란?

소프트웨어(애플리케이션)끼리 API 통신을 하는 소규모의 독립적인 서비스

데이터통신 과목에서 배운 바로는 Application Layer에서 발생할 수 있는 취약점인 것 같다.

사진을 보면, 사용자가 외부로 노출된 웹 애플리케이션에는 접근할 수 있지만,

웹 애플리케이션 내부의 서비스들에는 접근할 수 없게 되어 있다.

예시로, 백오피스 서비스(관리자 페이지)가 있다.

 

이러한 내부 서비스는 관리자만 이용할 수 있어야 하기 때문에,

외부에서 접근할 수 없는 내부망에 위치한다.

 

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

세 가지 경우가 있다.

 

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

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

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

 

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

이용자가 입력한 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

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

이런 식으로 image_downloader 엔드포인트의 image_url 에 request_info 엔드포인트 경로를 입력하고,

위 경로에 접속하면 image_downloader에서는 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

이용자가 전달한 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 경로를 조작할 수 있다.

 

1) ../ 구분자를 이용한 Path Traversal

user_info 함수에서 user_idx에 ../search를 입력할 경우

웹 서비스는 다음과 같은 URL에 요청을 보낸다.

http://api.internal/search

.. 는 상위 경로로 이동하기 위한 구분자이다.

 

2)  # 문자를 이용한 문자열 생략

user_search 함수에서 user_name에 secret&user_type=private#를 입력할 경우

웹 서비스는 다음과 같은 URL에 요청을 보낸다.

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

# 문자에 의해 뒤에 붙는 문자열은 생략되어 실제로 아래와 같은 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" 계정으로 설정한다.

예시 코드를 살펴보면, 내부 API로 요청을 보내기 전에 다음과 같이 데이터를 구성하는 것을 확인할 수 있다.

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

internal_board_write

board_write 함수에서 요청하는 내부 API를 구현한다.

전달된 title, body 그리고 계정 이름을 JSON 형식으로 변환하고 반환한다.

index

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

문제점

http://127.0.0.1:8000에 접속하면 title과 body를 입력하는 페이지가 표시된다.

 

데이터를 구성할 때 이용자의 입력값인 title, body 그리고 user의 값을 파라미터로 보낸다.

 

파라미터 구분자인 &를 포함하여 입력값을 주면 설정되는 data의 값을 변조할 수 있습니다.

 

예를 들어, title에서 title&user=admin를 삽입하면

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

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

실행 결과를 확인해보면 user가 "admin"으로 변조된 것을 알 수 있다.

SSRF 예방하기 위해서는?

  • 입력 값에 대한 적절한 필터링
  • 도메인 또는 아이피에 대한 검증

연습 문제 풀어보기

flask로 작성된 image viewer 서비스입니다.

SSRF 취약점을 이용해 플래그를 획득하세요.

플래그는 /app/flag.txt에 있습니다.

서비스 분석

/img_viewer는 GET과 POST 요청을 처리합니다.

  • GET: img_viewer.html을 렌더링합니다.
  • POST: 이용자가 입력한 url에 HTTP 요청을 보내고, 응답을 img_viewer.html의 인자로 하여 렌더링합니다.
@app.route("/img_viewer", methods=["GET", "POST"])
def img_viewer():
    if request.method == "GET": 
        return render_template("img_viewer.html") 
    elif request.method == "POST":
        url = request.form.get("url", "")
        urlp = urlparse(url) 
        if url[0] == "/":
            url = "http://localhost:8000" + url
        elif ("localhost" in urlp.netloc) or ("127.0.0.1" in urlp.netloc): 
            data = open("error.png", "rb").read() 
            img = base64.b64encode(data).decode("utf8")
            return render_template("img_viewer.html", img=img)
        try:
            data = requests.get(url, timeout=3).content
            img = base64.b64encode(data).decode("utf8")
        except:
            data = open("error.png", "rb").read() 
            img = base64.b64encode(data).decode("utf8")
        return render_template("img_viewer.html", img=img)

파이썬의 기본 모듈인 http를 이용하여 127.0.0.1의 임의 포트에 HTTP 서버를 실행합니다. http.server.HTTPServer의 두 번째 인자로 http.server.SimpleHttpRequestHandler를 전달하면, 현재 디렉터리를 기준으로 URL이 가리키는 리소스를 반환하는 웹 서버가 생성됩니다.

호스트가 127.0.0.1이므로 외부에서 이 서버에 직접 접근하는 것은 불가능합니다.

local_host = "127.0.0.1"
local_port = random.randint(1500, 1800)
local_server = http.server.HTTPServer(
    (local_host, local_port), http.server.SimpleHTTPRequestHandler # 리소스를 반환하는 웹 서버
)
def run_local_server():
    local_server.serve_forever()
    
    
threading._start_new_thread(run_local_server, ()) # 다른 쓰레드로 `local_server`를 실행합니다.

취약점 분석

img_viewer는 이용자가 POST로 전달한 url에 HTTP 요청을 보내고, 응답을 반환합니다. 그런데 img_viewer는 서버 주소에 “127.0.0.1”, “localhost”이 포함된 URL로의 접근을 막습니다. 이를 우회하면 SSRF를 통해 내부 HTTP 서버에 접근할 수 있을 것입니다.

 

URL 필터링은 다음 두 가지로 나뉜다.

  • 블랙리스트 필터링: URL에 포함되면 안되는 문자열로 블랙리스트를 만들고, 이를 이용하여 이용자의 접근을 제어
  • 화이트리스트 필터링: 접근을 허용할 URL로 화이트리스트를 만들고, 이용자가 화이트리스트 외의 URL에 접근하려 하면 이를 차단

익스플로잇

URL 필터링 우회

1) 127.0.0.1과 매핑된 도메인 이름 사용

  • 도메인 이름을 구매하면, 이를 DNS 서버에 등록하여 원하는 IP 주소와 연결할 수 있습니다. 이후에는 등록한 이름이 IP 주소로 리졸브(Resolve) 됩니다.
  • 따라서 임의의 도메인 이름을 구매하여 127.0.0.1과 연결하고, 그 이름을 url로 사용하면 필터링을 우회할 수 있습니다. 이미 127.0.0.1에 매핑된 "*.vcap.me"를 이용하는 방법도 있습니다.

2) 127.0.0.1의 alias 이용

  • 하나의 IP는 여러 방식으로 표기될 수 있습니다. 예를 들어, 127.0.0.1은 각 자릿수를 16진수로 변환한 0x7f.0x00.0x00.0x01, "0x7f.0x00.0x00.0x01"에서 .을 제거한 0x7f000001, 0x7f000001을 10진수로 풀어 쓴 2130706433, 그리고 각 자리에서 0을 생략한 127.1, 127.0.1과 같은 호스트를 가리킵니다.
  • 특히 127.0.0.1부터 127.0.0.255 까지의 IP는 루프백(loop-back) 주소라고 하여 모두 로컬 호스트를 가리킵니다.

3) localhost의 alias 이용

  • URL에서 호스트와 스키마는 대소문자를 구분하지 않습니다. 따라서 "localhost"의 임의 문자를 대문자로 바꿔도 같은 호스트를 의미합니다.

4) Proof-of-Concept

이런 식으로 URL 필터링 우회를 한다.

랜덤한 포트 찾기

내부 HTTP 서버는 포트 번호가 1500이상 1800이하인 임의 포트에서 실행되고 있다.

위의 우회된 URL을 활용하여 브루트포스로 포트를 찾는 스크립트를 작성한다.

플래그 획득

문제 설명에 의하면 플래그는 “/app/flag.txt”에 있습니다.

내부 HTTP 서버가 “/app”에서 실행되고 있으므로, 해당 서버의 /flag.txt를 읽으면 플래그를 획득할 수 있습니다.


실습 문제

flask로 작성된 image viewer 서비스 입니다.

SSRF 취약점을 이용해 플래그를 획득하세요. 플래그는 /app/flag.txt에 있습니다.

#!/usr/bin/python3
from flask import (
    Flask,
    request,
    render_template
)
import http.server
import threading
import requests
import os, random, base64
from urllib.parse import urlparse

app = Flask(__name__)
app.secret_key = os.urandom(32)

try:
    FLAG = open("./flag.txt", "r").read()  # Flag is here!!
except:
    FLAG = "[**FLAG**]"


@app.route("/")
def index():
    return render_template("index.html")


@app.route("/img_viewer", methods=["GET", "POST"])
def img_viewer():
    if request.method == "GET":
        return render_template("img_viewer.html")
    elif request.method == "POST":
        url = request.form.get("url", "")
        urlp = urlparse(url)
        if url[0] == "/":
            url = "http://localhost:8000" + url
        elif ("localhost" in urlp.netloc) or ("127.0.0.1" in urlp.netloc):
            data = open("error.png", "rb").read()
            img = base64.b64encode(data).decode("utf8")
            return render_template("img_viewer.html", img=img)
        try:
            data = requests.get(url, timeout=3).content
            img = base64.b64encode(data).decode("utf8")
        except:
            data = open("error.png", "rb").read()
            img = base64.b64encode(data).decode("utf8")
        return render_template("img_viewer.html", img=img)


local_host = "127.0.0.1"
local_port = random.randint(1500, 1800)
local_server = http.server.HTTPServer(
    (local_host, local_port), http.server.SimpleHTTPRequestHandler
)
print(local_port)


def run_local_server():
    local_server.serve_forever()


threading._start_new_thread(run_local_server, ())

app.run(host="0.0.0.0", port=8000, threaded=True)

아직 몬풀었습니다..

'KERT > HackHeat' 카테고리의 다른 글

파일 취약점(File Vulnerability)  (0) 2023.08.14
Command Injection  (0) 2023.08.14
NoSQL Injection  (0) 2023.07.27
NoSQL 개념과 MongoDB 기본 문법  (0) 2023.07.26
SQL Injection  (0) 2023.07.21