TeamH4C

[빡공팟 5기] W4 : Web Hacking 로드맵 - STAGE 8 - SSRF

이유갬 2022. 10. 18. 21:14

웹 개발 언어는 HTTP 요청을 전송하는 라이브러리를 제공한다.

각 언어별 HTTP 라이브러리는 아래와 같다.


PHP : php-curl

NodeJS : http

Python : urllib, requests


이러한 라이브러리는 HTTP 요청을 보낼 클라이언트 뿐만 아니라 서버와 서버간 통신을 위해 사용되기도 한다.

일반적으로 다른 웹 애플리케이션에 존재하는 리소스를 사용하기 위한 목적으로 통신한다.

예를 들어, 마이크로서비스 간 통신, 외부 API 호출, 외부 웹 리소스 다운로드 등이 있다.

최근 웹 서비스들은  관리 및 코드 복잡도를 낮추기 위해서 마이크로서비스들로 웹 서비스들을 구현하는 추세이다.

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

서비스 간 HTTP 통신이 이뤄질 때 요청 내에 이용자 입력값이 포함될 수 있다.

이용자의 입력값으로 포함된다면 개발자가 의도하지 않은 요청이 전송 될 수 있다.

 

SSRF 취약점은 웹 서비스의 요청을 변조하는 취약점으로, 웹 서비스의 권한으로 변조된 요청을 보낼 수 있다.

 

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

 

Server-side Request Forgery(SSRF)

웹 서비스는 외부에서 접근할 수 없는 백오피스 서비스와 같은 내부망 기능을 사용할 때가 있다.

백오피스 서비스는 관리자 페이지라고도 하며,

이용자의 행위가 의심스러울 경우 해당 계정을 정지시키거나 삭제하는 등 관리자만이 수행할 수 있는 모든 기능을 구현한 서비스다.

 

이러한 서비스는 관리자만 이용할 수 있어야 하므로 외부에서 접근할 수 없는 내부망에 위치한다.

따라서, 이를 이용하여 공격자가 SSRF 취약점을 통해 웹 서비스의 권한으로 요청을 보낼 수 있다면

공격자는 외부에서 간접적으로 내부망 서비스를 이용할 수 있다.

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

 

입력 값이 포함되는 경우의 예시

 

1. 웹 서비스가 이용자가 입력한 URL 에 요청을 보내거나

2. 요청을 보낼 URL 에 이용자 번호와 같은 내용이 사용되거나

3. 이용자가 입력한 값이 HTTP Body 에 포함되거나


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)
    return (
        response.content,
        200, # HTTP 응답 코드
        {"Content-Type": response.headers.get("Content-Type", "")}, 
    )
@app.route("/request_info")
def request_info():
    return request.user_agent.string
app.run(host="127.0.0.1", port=8000)

- image_downloader

이용자가 입력한 image_url 을 requests.get 함수를 사용하여 GET 메소드로 HTTP 요청을 보낸 후 응답을 반환한다.

브라우저에 아래와 같은 URL 을 입력하면 드림핵 페이지에 요청을 보내고 응답을 반환한다.

https://dreamhack.io/assets/dreamhack_logo.png>

- 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/2.11.1 이다.

 

접속한 브라우저 정보로 위와 같이 출력된 이유는 웹 서비스에서 HTTP 요청을 보냈기 때문이다.

이와같이 이용자가 웹 서비스에서 사용하는 마이크로서비스의 API 주소를 알아내고,

image_url 에 주소를 전달하면 외부에서 직접 접근 불가능한 마이크로서비스의 기능을 임의로 사용할 수 있다.


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

INTERNAL_API = "<http://api.internal/>"

@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

<http://x.x.x.x/v1/api/user/information?user_idx=1>

이용자가 전달한 user_idx 값을 내부 API 의 URL 경로로 사용한다.

<http://api.internal/user/1>

이용자가 user_idx 를 1로 설정하고 요청을 보내면 웹 서비스는 위와 같은 주소에 요청을 보낸다.

- user_search

<http://x.x.x.x/v1/api/user/search?user_name=hello>

이용자가 전달한 user_name 값을 내부 API 의 쿼리로 사용한다.

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

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

이용자 입력 값중에 URL 구성 요소 문자를 삽입하면 API 경로를 조작할 수 있다. user_info 함수에서 user_idx 에 ../search 를 입력할 경우 아래와 같은 URL 에 요청을 보낸다. → Path Traversal 취약점

<http://api.internal/search>

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

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 은 아래와 같이 된다.

<http://api.internal/search?user_name=secret&user_type=private>

 

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

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"
    title = request.form.get("title", "")
    body = request.form.get("body", "")
    data = f"title={title}&body={body}&user={session['idx']}"
    response = requests.post(f"{INTERNAL_API}/board/write", headers=header, data=data)
    return response.content
    
@app.route("/board/write", methods=["POST"])
**def internal_board_write():**
    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를 구현한 기능이다.

 

- index

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

코드를 실행하고 다음 URL에 접속하면 title과 body를 입력하는 페이지가 표시된다.

 

입력창에 값을 입력하고 제출 버튼을 누르면 다음과 같은 응답을 확인할 수 있다.

{ "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**

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

{ "body": "body", "title": "title", "user": "admin" }

 

키워드 정리

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

구분 문자(Delimiter) :
독립적 영역 사이의 경계를 지정하는 데 사용하는 하나의 문자 혹은 문자들의 배열을 말한다.
URL 에서 구분 문자는 “/”(Path identifier), “?” (Query identifier) 등이 있으며 구분 문자에 따라 URL의 해석이 달라질 수 있다.

 

(Exercise : SSRF )

 

[초기화면]

[Image Viewer 클릭하면 나오는 화면]

- ../static 을 입력해봤는데 아래와 같은 것이 떴다.

- 인코딩 된 걸 확인해봤지만 그저 잘못된 입력이라는 에러를 알려주는 문구였다.

- 개발자 도구를 이용하여 소스를 확인해봤지만 별 다른 점을 찾지 못했다.

- 소스 코드를 분석해보자.

[소스 코드 분석]

- 소스 코드 중 이 부분이 눈에 들어왔다.

- local_host, local_port 를 만족하면 될 것 같았다.

- 특히 포트 번호는 랜덤으로 1500 ~ 1800 이므로 이것도 패킷을 이용하여 브루트 포스하면 되지 않을까? 생각이 들었다.

- 하지만 브루트 포스 속도가 너무 느려서 파이썬 스크립트를 이용했다.

- 127.0.0.1 과 localhost 는 필터링에 걸리므로 Localhost 를 이용했다.

- 포트 번호가 나왔고 Image Viewer 에 http://Localhost:1729/flag.txt 를 입력했다.

- 이미지가 나왔고 base64 로 인코딩된 문자열이 나왔다.

- base64 로 디코딩을 했다.

- 플래그를 획득했다!

- 전에 풀었던 문제랑 매우 비슷한 유형이라서 어렵지 않게 풀었던 것 같다.