Recent Posts
Recent Comments
Link
«   2025/06   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
Tags
more
Archives
Today
Total
관리 메뉴

CIDY

[Web_Hacking] stage9_SSRF 본문

Hack/DreamHack(로드맵)

[Web_Hacking] stage9_SSRF

CIDY 2022. 7. 30. 08:30

웹 개발 언어는 HTTP요청을 전송하는 라이브러리를 제공한다. HTTP라이브러리는 PHP의 php-curl, NodeJS의 경우 http, python의 경우 urllib, requests가 있다.

 

라이브러리들은 이용자가 HTTP요청을 보낼 때도 이용되지만, 서버 간 통신에도 이용된다. 서버는 다른 웹 애플리케이션에 존재하는 리소스를 이용하기 위한 목적으로 통신한다. (마이크로서비스 간 통신, 외부 API호출, 외부 웹 리소스 다운로드 등)

 

마이크로서비스들은 HTTP나 GRPC를 이용해 API통신을 한다. (웹 서비스의 구성요소가 늘어남에 따라 마이크로서비스들로 웹 서비스를 구현하는 추세 -> SSRF는 파급력이 높은 취약점임)

 

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

 

해당 통신 과정에서 요청 내에 이용자의 입력이 포함될 수 있음 -> 공격자가 원하는 요청 전송 가능 -> SSRF(Server-Side Ruquest Forgery) -> 웹 서비스의 요청을 변조하는 공격 방식

 

 

 

*SSRF

Server-side Request Forgery. 

웹 서비스는 외부에서 접근할 수 없는 내부망 기능을 사용해야 할 때가 있다. 예를 들어 백오피스 서비스의 경우, 이용자의 수상한 행위가 감지되었을 때 해당 이용자의 계정을 정지/삭제하는 것과 같이, 관리자만이 수행할 수 있는 기능을 구현한 서비스이다. -> 관리자만 접근 가능해야 함 -> 외부에서 접근 불가한 내부망에 위치해야 함

 

근데 위와 같은 백오피스 기능의 수행을 위해서는 웹 서비스가 내부망과 통신해야 함 -> 공격자의 경우 이를 이용해 웹 서비스 권한으로 요청을 보낼 수 있음 -> 내부망 서비스를 간접적으로 이용할 수 있게 되는 것

 

요청을 변조하려면 공격자의 입력이 요청에 포함되는 상황이 발생해야 함

 

-> 웹서비스가 이용자의 입력 URL에 요청을 보내는 경우

-> 요청을 보낼 URL에 이용자 번호와 같은 내용이 활용되는 경우

-> 이용자의 입력값이  HTTP Body에 포함되는 경우

 

 

1. 이용자가 입력한 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의 경우 url을 입력받고 get요청을 보내고 응답을 반환한다.

reauest_info의 경우 접속한 브라우저 정보를 반환하는 곳이다.

 

접속하면 이런거 띄워줌..

 

그럼 이미지 엔드포인트의 파라미터로 위의 주소를 입력하면 어떻게 될까?

http://127.0.0.1:8000/image_downloader?image_url=http://127.0.0.1:8000/request_info

이렇게 입력하면

 

이런 게 출력된다. 분명 접속 브라우저 정보를 반환해야 하는데 왜 이런 게 나왔냐? -> 웹서비스에서 HTTP요청을 보냈기 때문

 

웹 서비스에서 사용하는 마이크로서비스의 API주소를 알아내고, image_url인자로 넣으면 외부에서는 사용할 수 없는 마이크로서비스 기능을 이용할 수 있다.

 

 

 

2. 웹 서비스의 요청 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_idx를 받아 http://api.internal/user의 하위 경로로 이용한다.

두 번째 엔드포인트의 경우는 response를 보면 user_name에 입력받아 내부 API쿼리로 이용하는 것 같다.

 

내 입력을 그대로 경로/쿼리에 갖다쓰는 코드이므로 ../를 적절히 이용해 상위 경로에 접근할 수 있다. (Path Traversal)

 

그리고 #문자 이후로는 API경로에서 없는 것과 다름없으므로 이를 이용해 쿼리를 잘라버릴 수도 있다. (--주석쓰듯이)

 

두 번째 엔드포인트에서 secret&user_type=private#를 입력할 경우 http://api.internal/search?user_name=secret&user_type=private가 되는 것

 

 

 

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

첫 번째 엔드포인트는 게스트id로 title과 body를 받아 내부 API로 보낸다.

두 번째 엔드포인트는 위에서 요청하는 내부 API를 구현한 기능이다. 위에서 입력받은 것들을 JSON형식으로 반환한다고..

세 번째 엔드포인트는 뭐지 -> 첫 번째 엔드포인트를 호출하기 위한 페이지라고 한다.

 

 

 

위 코드를 실행하고 접속해보면 이런 게 뜬다.

아무것도 안 쓰고 제출했는데 user는 guest인 것을 볼 수 있다.

 

일단 내 입력값을 그대로 갖다쓰는 부분이므로 충분히 취약하다.

 

둘 중 한 곳에 title&user=admin를 입력하면

이렇게 되는 것. (앞에 있는 값을 가져와서 파싱하는데, user = guest가 뒤쪽에 있으니까 앞쪽 body나 title에 &연산자로 user=admin을 주면 admin이 된다.)