보안/Web hacking

[Dreamhack] Secure Secret

melonbbang-ruffy 2024. 11. 14. 13:11

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

 

Secure Secret

Description (en) The flag file is placed hidden in a random directory, and concealed its directory inside the session. Find the vulnerability and get the flag! The flag format for this challenge is DH{...}. Description (ko) 플래그 파일을 무작위한

dreamhack.io

세션에서 힌트를 찾아 플래그 파일이 위치한 디렉토리를 알아내야 하는 문제인듯 하다.

일단 웹사이트로 들어가보자.

경로를 입력하면 파일을 읽어오는 구조인듯

올바른 플래그 파일 경로를 입력 -> 파일을 읽어오는듯

코드를 분석해보자

#!/usr/bin/env python3
import os
import string
from flask import Flask, request, abort, render_template, session

SECRETS_PATH = 'secrets/'
ALLOWED_CHARACTERS = string.ascii_letters + string.digits + '/'

app = Flask(__name__)
app.secret_key = os.urandom(32)

# create sample file
with open(f'{SECRETS_PATH}/sample', 'w') as f:
    f.write('Hello, world :)')

# create flag file
flag_dir = SECRETS_PATH + os.urandom(32).hex()
os.mkdir(flag_dir)
flag_path = flag_dir + '/flag'
with open('/flag', 'r') as f0, open(flag_path, 'w') as f1:
    f1.write(f0.read())


@app.route('/', methods=['GET'])
def get_index():
    # safely save the secret into session data
    session['secret'] = flag_path

    # provide file read functionality
    path = request.args.get('path')
    if not isinstance(path, str) or path == '':
        return render_template('index.html', msg='input the path!')

    if any(ch not in ALLOWED_CHARACTERS for ch in path):
        return render_template('index.html', msg='invalid path!')

    full_path = f'./{SECRETS_PATH}{path}'
    if not os.path.isfile(full_path):
        return render_template('index.html', msg='invalid path!')

    try:
        with open(full_path, 'r') as f:
            return render_template('index.html', msg=f.read())
    except:
        abort(500)
SECRETS_PATH = 'secrets/'
ALLOWED_CHARACTERS = string.ascii_letters + string.digits + '/'

경로명에 허용된 문자열로는 영문자 / 숫자만 가능 (path traversal 방지)

# create sample file
with open(f'{SECRETS_PATH}/sample', 'w') as f:
    f.write('Hello, world :)')
# secrets/sample 파일 열면 Hello, world :) 출력 

# create flag file
flag_dir = SECRETS_PATH + os.urandom(32).hex()
os.mkdir(flag_dir)
flag_path = flag_dir + '/flag'
with open('/flag', 'r') as f0, open(flag_path, 'w') as f1:
    f1.write(f0.read())

SECRETS_PATH는 secrets/라고 위에서 명시되어 있다.

Hello, world :)를 출력한다고 한다. (테스트 용 샘플 파일)

 

아래부터는 플래그 파일 생성 로직으로, 플래그 디렉토리 명은 랜덤 헥사값으로 구성되어있는걸 알 수 있다.

아마도, 올바른 플래그 파일 경로를 추측하는건 불가능에 가까울 듯하다.

session['secret'] = flag_path   # session['secret'] = flag_path? -> session에 flag_path 저장

    
path = request.args.get('path')
if not isinstance(path, str) or path == '':
    return render_template('index.html', msg='input the path!')

if any(ch not in ALLOWED_CHARACTERS for ch in path):
    return render_template('index.html', msg='invalid path!')

full_path = f'./{SECRETS_PATH}{path}'
if not os.path.isfile(full_path):
   return render_template('index.html', msg='invalid path!')

session['secret'] 에 플래그 경로가 저장되어 있고, path 변수는 url의 get request에서 path 쿼리 변수에서 가져와서 저장하는 듯 하다.

아래는 필터링 관련 리턴문으로 영문자/숫자 이외의 문자가 포함되어 있을 경우, 또는 파일이 유효하지 않을 경우 에러문을 리턴한다.

 

session에 flag_path를 저장하는 것을 보아, 세션 탈취를 통해 플래그 경로명을 알아내서 플래그를 탈취할 수 있을 듯 하다.

(ctf에서 safe하다고 자칭하는건 대개 취약점인거 아시죠.....?😉)

 

Exploit Tech

sample을 입력해보면, 코드에서 분석한 대로 Hello, world :)라는 문자열이 출력된다.

버프슈트를 통해 패킷을 한번 확인해보자.

session이 잡힌 걸 볼 수 있는데, 따로 session timeout을 설정을 안해놨음 -> 세션 탈취해서 알아낼 수 있을 듯 하다...!

flask의 session

플라스크에 제공되는 기본 세션의 경우, jwt와 유사한 형태이다.

플라스크에서의 세션 쿠키는 암호화된 것이 아니라 단순히 서명된 것이기 때문에 일반적으로 암호화된 내용이라 하면 읽을 수 없지만, 서명이 된 것이기 때문에 세션 쿠키의 secret key를 모르더라도 읽을 수 있다.
기본적으로 플라스크의 SecureCookieSessionInterface에서 쿠키를 서명하고, 플라스크 세션 형태로 만들어서 출력하는 구조이다.

session data

세션의 실질적 내용이 담겨 있다. 
Timestamp
서버에서 해당 데이터가 최근에 언제 변경되었는지 나타내는 부분이다.
Hash
Flask 의 쿠키의 보안을 책임지는 부분으로, 서버 측에서 해당 쿠키가 사용자로부터 위변조 없이 안전하게 보내진 것인지 확인하는 수단이다.
참고로, Hash는 SHA1 해쉬 알고리즘을 기반으로 세션 정보와 현재 타임스탬프 그리고 서버의 SECRET_KEY 를 기초로 계산해서 무결성 검증을 하여 위변조 검사를 하게 된다.

우리가 살펴볼 것은 session의 데이터이므로, .으로 구분된 cookie 값 중에서 첫번째 값인 session data를 복호화하면 session에 대한 정보가 나올 것이라고 추측해 볼 수 있겠다.

이제 base64 디코더 사이트에서 복호화해보자

???

뭔가 잘못된 듯 하다

※ 원인

플라스크의 세션 쿠키는 단순 base64로 인코딩된 문자열이 아니다....
플라스크의 세션 쿠키 페이로드의 데이터는 압축(zlib)된 다음 base64로 인코딩된다. 만약 단순 base64로 디코딩할 경우, 데이터가 압축이 되어있기 때문에 우리가 알아보지 못하는(?) 문자가 나오는 것...

그렇기 때문에 디코딩 후 압축 해제 과정까지 모두 거쳐야 한다.

import base64
import zlib

def decode_base64_url(encoded):
    encoded += '=' * (4 - len(encoded) % 4) 
    return base64.urlsafe_b64decode(encoded)

# payload
payload = "eJwtxrERgDAIAMBdskCQAAluQwixsVI7z921sPn7O53hR1xp_XNmBEADdJigbMNa8Tp7cQbVrvHpJIUxsAFVJNGw0XExEhlskOduW3peyLgaEg"

# decode
decoded = decode_base64_url(payload)
try:
    decompressed = zlib.decompress(decoded)
    print(decompressed.decode('utf-8'))
except Exception as e:
    print(str(e))
encoded += '='*(4-len(encoded)%4)
# 패딩 추가
return base64.urlsafe_b64decode(encoded)

우선 주어질 페이로드 값에 필요시 패딩을 추가하도록 한다. (base64 디코딩/인코딩 시 4로 나누어 떨어지지 않을 경우, 뒤에 패딩값으로 '='가 붙는다.)

payload = "eJwtxrERgDAIAMBdskCQAAluQwixsVI7z921sPn7O53hR1xp_XNmBEADdJigbMNa8Tp7cQbVrvHpJIUxsAFVJNGw0XExEhlskOduW3peyLgaEg"
decoded = decode_base64_url(payload)
try:
	decompressed = zlib.decompress(decoded)
	print(decompressed.decode('utf-8')

페이로드가 주어지면 base64 디코딩 후, 디코딩된 값을 python의 zlib 패키지를 이용해서 압축 해제한 후 해제한 값을 utf-8 형식으로 출력하도록 한다.

코드를 실행시켜보면

세션에 숨겨진 secret 키값을 발견할 수 있다.

여기서 2002a02c0f095ada83c7fb3c5099b9e099c46352e280472469eadb21a466d5a0/flag

플래그 발견

References

https://core-research-team.github.io/2020-07-01/Vulnerabilities-of-Flask

 

Vulnerabilities of Flask

라온화이트햇 핵심연구팀 황정식

core-research-team.github.io

https://domdom.tistory.com/295

 

[Research] Crack Flask Cookies (Secret Key)

상식적으로 웹서버에서 세션/쿠키를 암호화하는 데 쓰이는 Secret Key 라는 것이 노출되면 안된다고 노출되지 않게 잘 관리하도록 권유합니다. 개인적으로 필자는 웹서버에 대한 이해가 아직 부족

domdom.tistory.com