DreamHack/CTF

chockshop

G_OM 2024. 12. 10. 16:32

 

 

 

 

 

 

 

 

 

 

 

 

 

문제 페이지를 들어가면 액세스를 위해서는 세션이 필요하다고 한다.

 

Acquire Session을 눌러 들어가 보자.

 

 

 

 

 

 

 

 

 

 

 

들어가는 순간 SHOP, MYPAGE 가 나오는데 둘러보자.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

flag는 2000 파운드로 판매되고 있다.

 

MYPAGE에 들어가 보자.

 

 

 

 

 

 

 

 

 

Coupon Claim을 누르면 쿠폰 값을 주고 그 밑에다가 제출하는 식인거 같다.

 

 

 

 

 

 

 

 

 

 

 

Claim 을 누르면 쿠폰 값이 뜨고

 

 

 

 

 

 

 

 

 

 

 

 

submit 하면 바로 1000파운드가 생긴다.

 

flag는 2000파운드다.

 

힌트에서는 사용된 쿠폰을 검사하는 로직이 취약하다고 하니 그걸 중점으로 한번 보자.

 

 

 

 

 

 

 

 

 

 

# Redis와 Flask 라이브러리를 사용하여 웹 애플리케이션 구현
from flask import Flask, request, jsonify, current_app, send_from_directory
import jwt  # JSON Web Token 처리
import redis  # Redis 데이터베이스 연결
from datetime import timedelta  # 시간 계산용 유틸리티
from time import time  # 현재 시간을 초 단위로 가져옴
from werkzeug.exceptions import default_exceptions, BadRequest, Unauthorized  # HTTP 예외 처리
from functools import wraps  # 데코레이터 함수 작성에 사용
from json import dumps, loads  # JSON 직렬화/역직렬화
from uuid import uuid4  # UUID 생성

# Redis 클라이언트 초기화
r = redis.Redis()

# Flask 애플리케이션 초기화
app = Flask(__name__)

# 비밀 상수 가져오기
from secret import JWT_SECRET, FLAG  # JWT 서명 비밀 키와 플래그

# 공개 상수 설정
COUPON_EXPIRATION_DELTA = 45  # 쿠폰 만료 시간 (초)
RATE_LIMIT_DELTA = 10  # 쿠폰 제출 속도 제한 시간 (초)
FLAG_PRICE = 2000  # FLAG 구매 가격
PEPERO_PRICE = 1500  # Pepero 구매 가격


# 전역 에러 핸들러 정의
def handle_errors(error):
    return jsonify({'status': 'error', 'message': str(error)}), error.code


# 모든 기본 HTTP 예외에 대해 에러 핸들러 등록
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):
            # 클라이언트에서 Authorization 헤더로 전달된 UUID 확인
            uuid = request.headers.get('Authorization', None)
            if uuid is None:
                raise BadRequest("Missing Authorization")  # 인증 정보 누락 시 예외 발생

            # Redis에서 세션 정보 가져오기
            data = r.get(f'SESSION:{uuid}')
            if data is None:
                raise Unauthorized("Unauthorized")  # 세션 정보가 없으면 인증 실패

            # 세션 데이터를 역직렬화하여 `user`로 전달
            kwargs['user'] = loads(data)
            return function(*args, **kwargs)
        return wrapper
    return decorator


# FLAG 구매 엔드포인트
@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})  # 성공적으로 FLAG 반환


# Pepero 구매 엔드포인트
@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:
        # JWT를 디코딩하여 쿠폰 데이터 가져오기
        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):  # 쿠폰이 사용되지 않았으면 처리
        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:
        raise BadRequest('Your coupon is already 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')  # 쿠폰을 JWT로 인코딩
    r.setex(f'SESSION:{uuid}', timedelta(minutes=10), dumps(user))
    return jsonify({'coupon': coupon})  # 쿠폰 반환


# 세션 생성 엔드포인트
@app.route('/session')
def make_session():
    uuid = uuid4().hex  # 고유 UUID 생성
    # 초기 세션 데이터 Redis에 저장
    r.setex(f'SESSION:{uuid}', timedelta(minutes=10), dumps(
        {'uuid': uuid, 'coupon_claimed': False, 'money': 0}))
    return jsonify({'session': uuid})  # 세션 UUID 반환


# 현재 사용자 정보 반환
@app.route('/me')
@get_session()
def me(user):
    return jsonify(user)


# 정적 파일 (index.html) 제공
@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)

 

 

전체 코드이다.

 

중복쿠폰을 계산하는 로직을 보면.....

 

 

 

 

 

 

 

 

# 쿠폰 제출 엔드포인트
@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:
        # JWT를 디코딩하여 쿠폰 데이터 가져오기
        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):  # 쿠폰이 사용되지 않았으면 처리
        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:
        raise BadRequest('Your coupon is already submitted!')  # 이미 제출된 쿠폰

 

 

더 자세히 보면

 

coupon = request.headers.get('coupon', None)
if coupon is None:
    raise BadRequest('Missing Coupon')

 

/coupon/submit 경로로 들어오는 요청에서 coupon 헤더를 가져오고 쿠폰 헤더가 없는 경우 BadRequest를 발생시킨다.

 

 

 

 

 

try:
    coupon = jwt.decode(coupon, JWT_SECRET, algorithms='HS256')
except:
    raise BadRequest('Invalid coupon')

 

쿠폰은 JWT 형식으로 인코딩 되어있어 jwt.decode()를 사용해서 디코딩시킨다.

 

마찬가지로 디코딩에 실패하면 BadRequest를 발생시킨다.

 

 

 

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

 

쿠폰 만료 확인이다.

 

expiration 필드를 확인해서 쿠폰이 만료되는 시간에 해당된다면 BadRequest 를 발생시킨다.

 

 

 

 

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.")

 

여러 번 쿠폰을 제출하지 못하게 하는 리미트이다.

 

쿠폰 제출이 너무 빠르면 BadRequest를 출력시키고 속도 제한에 걸리게 한다.

 

(여러 번 제출하면 10초간 제한)

 

 

 

 

used_coupon = f'COUPON:{coupon["uuid"]}'
if r.setnx(used_coupon, 1):  # 쿠폰이 사용되지 않았으면 처리
    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:
    raise BadRequest('Your coupon is already submitted!')

 

 

이중 사용 방지에 대한 코드이다.

 

쿠폰의 uuid를 이용해 이 쿠폰이 이미 사용했는지 Redis에 저장된 값을 통해서 확인한다.

 

이미 제출된 쿠폰이라면 BadRequest를 출력시킨다.

 

 

 

 

 

 

 

 

 

일단 Python으로 요청하기 앞서 UUID 값을 통해 구분을 한다고 하니 개발자 도구(Network)를 통해서 한번 살펴보자.

 

 

 

 

 

 

아까 처음 들어갈 때 세션 값을 등록해야 한다고 나와있다.

 

 

 

 

 

세션 값하고 uuid 가 똑같은 걸 확인할 수 있다.

 

세션 == UUID를 통해서 요청에 대한 식별자(?)를 이용할 수 있다.

 

먼저 해야 할 거는 중복검사에 대한 로직이 취약하다고 했으니 중복검사를 계속 반복적으로 실행해 이상한 점이 있는지 확인해야 한다.

 

 

 

 

 

 

 

 

 

 

 

 

총 60초간 1초 간격으로 요청을 한 결과 44~46초 사이에서 쿠폰이 만료되는 걸 볼 수 있다.

 

몇 번 실험을 해본 결과

 

1. 쿠폰 발급 기준으로 시간이 흐름

2. 쿠폰 만료 기준은 Submit 기준으로는  존재하지 않음

 

그렇다면 처음 쿠폰 실행을 하고 연속적으로 쿠폰을 제출하면 10초간 제한되니...

 

44~46초 사이쯤에 한번 더 중복 쿠폰을 제출해 보자.

 

 

 

 

 

 

 

 

 

 

 

시간차가 있긴 하지만 만료 직전에 쿠폰을 제출하면 success를 해준 모습이 보인다.