본문 바로가기
Web hacking/개념 정리 & 심화

[Dreamhack] W - STAGE 9 Server Side Request Forgery (SSRF)

by m_.9m 2023. 5. 4.

1. ServerSide: SSRF


웹 개발 언어는 HTTP 요청을 전송하는 라이브러리를 제공한다. PHP는 php-curl, NodeJS는 http, 파이썬은 urllib, request를 예시로 들 수 있다. 이러한 라이브러리는 HTTP 요청을 보낼 클라이언트뿐만 아니라 서버와 서버간의 통신을 위해 사용되기도 한다. 일반적으로 다른 웹 애플리케이션에 존재하는 리소스를 사용하기 위한 목적으로 통신한다. 예를 들어, 마이크로 서비스 간 통신, 외부 API 호출, 외부 웹 리소스 다운로드 등이 있다.

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

기존의 웹 서비스는 단일 서비스로 구현했지만 최근의 웹 서비스는 지원하는 기능이 증가함에 따라 구성요소가 증가했다. 이에따라 관리 및 복잡도를 낮추기위해 마이크로 서비스들로 웹 서비스를 구현하는 추세이다. 각 마이크로 서비스는 주로 HTTP, GRPC 등을 사용해 API 통신을 한다.

서비스 간의 HTTP 통신에서 이용자의 입력 값이 포함될 수 있는데 SSRF는 웹 서비스의 요청을 변조하는 취약점으로 웹 서비스의 권한으로 변조된 요청을 보낸다.

1.1 Server Side Request Forgery (SSRF)


웹 서비스는 외부에서 접근할 수 없는 내부망의 기능을 사용할 때가 있다. 내부망의 기능은 백오피스 서비스를 예로 들 수 있다. 백오피스 서비스는 관리자 페이지와 같이 외부에서 접근할 수 없는 내부망에 위치하는데 입력 받는 부분이 취약할 경우 공격자는 SSRF 취약점을 통해 웹 서비스의 권한으로 요청을 보내 간접적으로 내부망을 사용할 수 있다.

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


예를 들어 이미지를 불러오는 코드와 브라우저 정보를 반환하는 코드가 다음과 같을 때 발생할 수 있는 취약점이다. 공격자는 image_url에 웹 브라우저 정보를 요청하는 엔드포인트 경로를 입력할 수 있다. image_url=http://127.0.0.1:8000/request_info

1. image_downloader
https://dreamhack.io/assets/dreamhack_logo.png>
2. 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

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


예를 들어 이용자가 전달한 user_idx, user_name의 값을 내부의 API의 URL 경로로 사용한다면 요청을 변조할 수 있다. 이용자의 입력값 중 URL의 구성요소 문자를 사용하거나 ../를 이용한 Path Traversal으로 경로를 조작할 수 잇다. 혹은 user_name에 secret&user_type=private#를 입력해 뒤의 문자열을 생략해 다음과 같은 내용을 나타낼 수 있다.

# 변경 전
<http://api.internal/search?user_name=secret&user_type=private#&user_type=public>
# 변경 후
<http://api.internal/search?user_name=secret&user_type=private>

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


다음은 이용자의 입력값이 요청의 Body에 입력되는 경우이다. 사용자가 & 구분자를 포함해 user 파라미터를 추가하는 경우 내부 API에서는 전달받은 값을 파싱할 댸 앞에 존재하는 파라미터 값을 사용하기 때문에 User의 값을 변조할 수 있다.


Figure 3. 웹 서비스의 요청 Body에 이용자의 입력값이 포함되는 경우 예시 코드


# pip3 install flask
# python main.py
from flask import Flask, request, sessionimport 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", "title": "title", "user": "admin" }

2. 실습


2.1 예제 코드

@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 # 리소스를 반환하는 웹 서버
)
def run_local_server():
    local_server.serve_forever()
    
    
threading._start_new_thread(run_local_server, ()) # 다른 쓰레드로 `local_server`를 실행합니다.

127.0.0.1과 localhost 가 막혀있기 때문에 우회는 다음과 같은 방식으로 수행한다.

  1. 127.0.0.1과 매핑된 도메인 이름 사용: 도매인 이름을 구매해 127.0.0.1과 연결해 사용
  2. 127.0.0.1의 alias 이용: 여러가지 IP 표현 식 사용
  • 각 자릿수를 16진수로 변환: 0x7f.0x00.0x00.0x01
  • .를 제거해 사용: 0x7f000001
  • 0x7f000001를 십진수로 변환한 2130706433
  • 각 자리에 0을 생략한 127.1, 127.0.1 등
  1. localhost의 alias 이용: LoCalHost
<http://vcap.me:8000/>





2.2 풀이


필터링 우회 이후 버프로 내부 서버 포트 찾기