Solución al reto Sliver Forensics de #hc0n2023

El siguiente writeup corresponde a un reto de categoría forense que preparé para el CTF de nuestra conferencia h-c0n de 2023. Se basaba en uno que vi en Immersive Labs y concretamente en una infección con un implante de Sliver, un framework de Comando y Control (C2) de código abierto que imagino que ya conoceréis todos porque es bastante popular. El escenario planteado en el reto era el siguiente: 

En cristiano: "Un juaker se adelantó a nosotros y consiguió leer la flag del reto por medio de una víctima a la que infectó con un beacon de Sliver.
Sin embargo nuestro departamento forense hizo los deberes y capturó tráfico y dumpeo la memoria del proceso sospechoso. ¿Podrías obtener la flag a partir de ellos?"


Y bajo esta descripción adjuntábamos la captura de tráfico HTTP (sliver.pcapng) y un dump del proceso de la máquina comprometida (pid.10880.dmp.7z)...

El implante de Sliver, como muchas de estas herramientas, se conecta con la infra del atacante utilizando una variedad de protocolos, siempre intentando mimetizarse en el entorno para no ser detectado. En nuestro caso tráfico HTTP, que es el que veremos al abrir la captura de tráfico con Wireshark, Sliver hace esto de diferentes maneras.

En primer lugar, Sliver usa peticiones HTTP con rutas que parecen bastante normales. Estas son generadas aleatoriamente por el implante a partir de una lista de URLs que los atacantes pueden personalizar para que se mezclen aún más. Los mensajes de sesión y de intercambio de claves usan extensiones .html y .php de manera predeterminada, y los de polling utilizan extensiones .js y nombres como 'bootstrap' y 'jquery'. Lo vemos en detalle y exportamos todos los objetos HTTP (File \ Export Objects \ HTTP):

Luego otro poco de teoría: los implantes de Sliver seleccionan aleatoriamente un método de codificación para un conjunto particular de mensajes. Esto es importante porque es posible que un implante no contenga rutinas de codificación para todos los métodos admitidos por el servidor C2. Para evitar que esto cause problemas, el servidor C2 responderá con el mismo método de codificación utilizado por el implante.

En la actualidad Sliver implementa nueve métodos de codificación diferentes. El método viene identificado y se agrega a cada URL generada en un parámetro de consulta nombrado con un solo carácter y formado por entre ocho y diez caracteres, la mayoría de los cuales serán dígitos. Para identificar el método de codificación, el servidor elimina cualquier parte no numérica del llamado nonce, luego toma el módulo del número restante con 101. Para solicitudes legítimas, esto dará uno de los siguientes valores, conocido como la ID del codificador:

  •  13: indica codificación Base64
  •  22: indica codificación PNG
  •  31: indica codificación en inglés
  •  43: indica codificación Base58
  •  45: indica codificación en inglés comprimido con Gzip
  •  49: indica codificación Gzip
  •  64: indica codificación Base64 comprimida con Gzip
  •  65: indica codificación Base32
  •  92: indica codificación hexadecimal

Dicho esto ya sabéis lo que tenemos que hacer: el mod 101 para saber el encoder usado en cada post que el beacon randomiza:

¡13! ya sabemos que tenemos algunas peticiones encodeadas en b64

Otra cosa muy importante que debemos saber es que para los métodos de codificación Base64 y Base32, Sliver utiliza un alfabeto personalizado. Esto significa que las herramientas de línea de comandos normales de Linux y las bibliotecas predeterminadas para lenguajes de programación no funcionarán para decodificar estos datos sin hacer algunos reemplazos de caracteres.

def decode_b64(slv_data):
    """Uses the modifed alphabet from Sliver C2 to decode Base64"""
    decoded = b''
    table = slv_data.maketrans(base64_modified, base64_standard)
    std_data = slv_data.translate(table)
    decoded = base64.standard_b64decode(std_data + b'==')
    return decoded
Independientemente de cómo se codifiquen los datos, el resultado de la decodificación será un blob binario de datos cifrados. Las primeras peticiones de un implante serán intercambio de claves, haciendo uso de la clave ECC pública del servidor, la clave privada del implante y un código de contraseña de un solo uso (TOTP) basado en tiempo. El implante genera una clave de sesión mientras que el servidor verifica esto enviando una ID de sesión de regreso al implante, encriptado usando la clave de sesión. Los detalles completos del cifrado de transporte se pueden encontrar en https://github.com/BishopFox/sliver/wiki/Transport-Encryption.

Una vez que se ha completado el intercambio de claves, todos los mensajes entre el servidor y el implante se cifran con la clave de sesión. Por necesidad, esta clave está presente en la memoria, por lo que puede extraerse de los volcados de memoria tomados de la máquina víctima.

Las claves de sesión son cadenas aleatorias de 32 bytes y, a veces, se pueden detectar buscando diferentes patrones asociados con ellas al examinar un volcado de memoria. Un ejemplo de patrón es la cadena WinHttpGetDefaultProxyConfiguration, que a menudo va seguida de otro patrón en el que la clave comienza con x00\n seguida de una cadena aleatoria de 32 bytes que termina en \x00. Traducido a expresión regular:

(.{32}[^\x00]{3}\x00\xc0\x00)

Es decir, 32 bytes de datos, seguidos de tres bytes de datos no nulos, seguidos de un byte nulo, un byte establecido en C0 y luego otro byte nulo. Cada implante Sliver específico tiene un conjunto distinto de tres bytes no nulos, pero todas las copias de la clave relacionadas con un implante específico tendrán el mismo conjunto.

Resumiendo, necesitamos un script que decodifique en base64 (con el alfabeto de Sliver), busque patrones en la memoria de esa manera obteniendo un montón de posibles claves e intente descifrarlo. Dado que no calcula la tag de autenticación, "descifrará" cualquier dato, pero en la mayoría de los casos, esto será incorrecto. Para manejar esto, el script intenta descomprimir los datos resultantes. Si el paso de descompresión funciona, se supone que se ha encontrado la clave correcta.

Al final todo este rollo es para explicaros el decryptor a continuación:
import argparse
import base64
import gzip
import re
import string

from binascii import hexlify, unhexlify

from chacha20poly1305 import ChaCha20Poly1305


encoders = {

    13: "b64",
    31: "words",
    22: "png",
    43: "b58",
    45: "gzip-words",
    49: "gzip",
    64: "gzip-b64",
    65: "b32",
    92: "hex"

}

base64_standard = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
base64_modified = b"a0b2c5def6hijklmnopqr_st-uvwxyzA1B3C4DEFGHIJKLM7NO9PQR8ST+UVWXYZ"

base32_standard = b'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
base32_modified = b'ab1c2d3e4f5g6h7j8k9m0npqrtuvwxyz'


def decode_nonce(nonce_value):
    """Takes a nonce value from a HTTP Request and returns the encoder that was used"""
    nonce_value = int(re.sub('[^0-9]','', nonce_value))
    encoder_id = nonce_value % 101
    return encoders[encoder_id]


def decode_b64(slv_data):
    """Uses the modifed alphabet from Sliver C2 to decode Base64"""
    decoded = b''
    table = slv_data.maketrans(base64_modified, base64_standard)
    std_data = slv_data.translate(table)
    decoded = base64.standard_b64decode(std_data + b'==')
    return decoded


def decode_b32(slv_data):
    """Uses the modifed alphabet from Sliver C2 to decode Base32"""
    decoded = b''
    table = slv_data.maketrans(base32_modified, base32_standard)
    std_data = slv_data.translate(table)
    decoded = base64.standard_b64decode(std_data + b'==')
    return decoded


def decode_words(word_list):
    """Decodes the sliver English Words Encoder without needing a wordlist"""
    decoded = []
    for word in word_list.split():

        value = 0
        for char in word.decode():
            value += ord(char)
        value = value %256
        decoded.append(value)

    return bytes(decoded)


def decrypt_chacha(key, data):
    cip = ChaCha20Poly1305(key)
    nonce = data[:12]
    ciphertext = data[12:]
    return cip.decrypt(nonce, ciphertext)


def parse_output(decrypted):
    print('[=] Raw Output')
    print(decrypted)
    clean_output = ''

    for char in decrypted:
        char = chr(char)
        if char in string.printable:
            clean_output += char
    print('[=] ASCII Output Only')
    print(clean_output)


def main(key, mode, data, force):

    if mode == 'hex':
        cipher_text = unhexlify(data)
    elif mode == 'words':
        cipher_text = decode_words(data)
    elif mode == 'b64':
        cipher_text = decode_b64(data)
    elif mode == 'b32':
        cipher_text = decode_b32(data)

    if force:
        print(f'[+] Finding all possible keys in {force}')
        with open(args.force, 'rb') as input_file:
            file_data = input_file.read()
            /** This is going to be a large list **/
            possible_keys = re.findall(b'(.{32}[^\x00]{3}\x00\xc0\x00)',file_data, re.DOTALL)

        /** Dedup **/
        possible_keys = list(dict.fromkeys(possible_keys))

        print(f'  [-] Found {len(possible_keys)} possible keys')

    else:
        possible_keys = [unhexlify(key)]

    success = False
    for chacha_key in possible_keys:
        chacha_key = chacha_key[:32]

        if b'\x00\x00\x00' in chacha_key:
            /** Statistcly unlikely to be a valid key **/
            continue
        try:
            decrypted = decrypt_chacha(chacha_key, cipher_text)
            uncompressed = gzip.decompress(decrypted)

            if uncompressed:
                print(f'  [-] Found key: {hexlify(chacha_key)}')
                /** Dont process any more keys **/
                success = True
                parse_output(uncompressed)
                break

        except Exception as err:
            pass
    if not success:
        print('[!] Unable to find a valid key for encoded data!')



if __name__ == '__main__':

    parser = argparse.ArgumentParser(description='Sliver C2 Decryptor')

    parser.add_argument(
        '--key',
        help='Session Key extracted from memory as hex',
        required=False)

    parser.add_argument(
        '--encoder',
        help='Encoding Mode',
        choices=['hex', 'words', 'b64', 'b32'],
        required=True
    )

    parser.add_argument(
        '--file_path',
        help='path to file with encoded data',
        required=True
    )

    parser.add_argument(
        '--verbose',
        help='show all decoding steps (Noisy)',
        required=False
    )

    parser.add_argument(
        '--force',
        help='Brute Force Key given a procdump file',
        required=False,
        default=False
    )

    args = parser.parse_args()


    with open(args.file_path, 'rb') as input_file:
        file_data = input_file.read()


    main(args.key, args.encoder, file_data, args.force)

Ya entendiendo cómo funciona y con el script bruteforceamos con los post http (anteriormente exportados) y el dump del proceso. Lanzamos desde el directorio correspondiente:

# 7z x pid.10880.dmp.7z 

# for f in *; do echo ${f} && python3 ../silver-decrypt.py --file_path ${f} --encoder b64 --force ../pid.10880.dmp; done;
api.php%3fa=52778270
[+] Finding all possible keys in ../pid.10880.dmp
  [-] Found 10511 possible keys
  [-] Found key: b'2e11303ca4d4c03fe7a829a0164be69927d325e59c0a87472bb63c12b8b3c2d0'
[=] Raw Output
b'\x10^\x1a(\n$e8d91221-b612-4a98-96e2-800871ff8596\x18P'
[=] ASCII Output Only
^(
$e8d91221-b612-4a98-96e2-800871ff8596P
api.php%3fk=82740728
[+] Finding all possible keys in ../pid.10880.dmp
  [-] Found 10511 possible keys
  [-] Found key: b'2e11303ca4d4c03fe7a829a0164be69927d325e59c0a87472bb63c12b8b3c2d0'
[=] Raw Output
b'\x10^\x1a(\n$e8d91221-b612-4a98-96e2-800871ff8596\x18@'
[=] ASCII Output Only
^(
$e8d91221-b612-4a98-96e2-800871ff8596@
api.php%3fl=4828250l8
[+] Finding all possible keys in ../pid.10880.dmp
^C^Z
[2]+  Stopped                

¡Bingo!! pues ya tenemos la clave. Ahora conseguimos en claro las peticiones con la key obtenida
# for f in *; do echo ${f} && python3 ../silver-decrypt.py --file_path ${f} --encoder b64 --key 2e11303ca4d4c03fe7a829a0164be69927d325e59c0a87472bb63c12b8b3c2d0; done | grep hc0nCTF

y ¡voilà! ya vemos la flag entre las mismas:


Espero que os haya gustado el reto. Para mí bastante complicado y difícil si no lo maneja un forense experimentado que se haya peleado anteriormente con Sliver....

¡Hasta el próximo!

Comentarios