Construire un binaire Rust "Two-Face" pour Linux
Dans cet article nous aborderons une technique pour créer facilement un binaire Rust "Two-Face" pour Linux : c'est à dire un exécutable qui lance un programme inoffensif dans la plupart des cas, mais lance un programme caché différent s'il est déployé sur un hôte particulier. Nous détaillerons aussi comment rendre cet exécutable "caché" plus difficile à inspecter en mémoire.
Vous souhaitez améliorer vos compétences ? Découvrez nos sessions de formation ! En savoir plus
Énoncé du problème
Supposons que vous vouliez exécuter un programme malveillant sur une machine cible précise. Une façon de procéder est de distribuer le programme très largement et d’espérer que la cible finira par l’exécuter. Le vecteur de distribution spécifique sort du cadre de cet article, mais vous pouvez imaginer par exemple un fichier binaire pré-compilé, comme les développeurs en téléchargent souvent sur la page GitHub de leur projet favori.
Cependant, si vous voulez maximiser les chances d’atteindre la cible, vous voudrez probablement imiter le comportement d’un programme inoffensif et éviter tout ce qui peut paraître suspect (ex. : se connecter à un serveur C&C) et déclencher la détection par diverses solutions (sandboxs, LSM, auditd, etc.).
Jusqu’ici, cela semble assez simple, voyons donc comment nous pourrions construire ça.
Concevoir notre binaire schizophrène
Dans le reste de cet article, nous appelons « caché » le programme que nous voulons exécuter sur la machine cible, et « normal » le programme inoffensif que nous exécuterons sur les autres machines.
Une manière naïve de construire un tel programme est de prendre la décision dès le démarrage sur quel code exécuter, avec par ex. :
if is_running_on_target_host() {
hidden_program();
} else {
normal_program();
}
Cela fonctionnerait pour contourner la détection basique à l’exécution, mais n’est pas idéal :
- le programme « caché » restera présent et observable en mémoire
- pire, le fichier binaire peut être analysé et désassemblé, et le programme « caché » exposé
- encore pire, la fonction
is_running_on_target_hostexpose qui nous ciblons
Que faire pour améliorer cela ? Le problème fondamental est que le binaire expose tout ce que nous voulons cacher. Alors, cachons tout et chiffrons le programme cible et même les données de l'hôte que nous cherchons, cela devrait résoudre le problème, non ? Bien sûr, ce n’est pas si simple : ces données chiffrées devront être déchiffrées à l’exécution, donc la clé devrait être intégrée dans le binaire avec les données chiffrées, ce qui ne fait qu’ajouter une couche d’obfuscation par rapport à notre solution précédente.
Cependant, et si nous gardions l’idée du chiffrement mais sans stocker directement la clé à côté des données chiffrées ? Et si nous la dérivions à partir des données uniques de la machine que nous ciblons ?
Les étapes au démarrage du programme seraient :
- Extraire des données de la machine hôte qui identifient de manière unique la cible (plus de détails plus loin)
- Dériver une clé intégrée au binaire à partir des données hôte précédentes en utilisant HKDF, produisant une nouvelle clé
- Déchiffrer les données du binaire « caché » intégré, en utilisant la clé dérivée
- Si le déchiffrement réussit, exécuter le programme « caché », sinon exécuter le programme « normal »
Maintenant, cela commence à être intéressant. Par construction, un tel binaire serait incapable de déchiffrer le programme « caché » s’il n’est pas exécuté sur la machine cible, parce que les données hôte extraites seraient différentes et conduiraient à une clé de déchiffrement invalide.
Pour cela, il est nécessaire de choisir un algorithme de chiffrement symétrique par blocs qui fournit aussi l’authentification, de sorte que nous détecterons une clé invalide si nous ne sommes pas sur la machine cible, au lieu d’exécuter un programme corrompu. AES‑GCM est un choix d’algorithme commun pour cela.
Choix des données de dérivation
Les données utilisées pour identifier la machine cible, et dériver la clé comme décrit précédemment, doivent être choisies avec soin. Elles doivent être :
- Suffisamment uniques, sinon notre programme « caché » pourrait s’exécuter sur la mauvaise cible
- Stables dans le temps, sinon notre programme « caché » pourrait ne jamais s’exécuter, même sur la bonne cible
- Difficiles à deviner pour quelqu’un n’ayant pas accès à la machine cible, afin qu’il soit impossible pour des tiers ne connaissant pas le système ciblé d’extraire le programme « caché »
Notez que « difficile à deviner » ici diffère d’un secret classique comme un mot de passe. Par exemple, le numéro de série de votre carte mère serait difficile à deviner pour moi, mais n’est pas vraiment secret car il peut être lu facilement depuis/sys/class/dmi/id/, ou peut être indiqué son emballage.
Quelques candidats sont :
- UID utilisateur : pas assez unique (la plupart des utilisateurs de postes de travail ont la valeur 1000), manque sérieusement d’entropie
- IPv6 interface WAN : peut ne pas être stable, peut être deviné depuis d’autres canaux
- numéros de série matériels dans
/sys/class/dmi/id/: nécessite des privilègesrootpour être lus, peut ne pas être présent sur tous les appareils, peu d’entropie - modèle CPU tel que donné par
grep ^model /proc/cpuinfo: peut ne pas être assez unique, par exemple dans les machines virtuelles, flottes de PC portables d’entreprise, etc. - UUIDs de partitions de disque tels que donné par
ls /dev/disk/by-uuid: fait de valeurs aléatoires générées lors de la création des partitions, donc bonne entropie et unicité, ceci répond à tous nos besoins !
Code build-time
Pour faciliter l’utilisation pour les développeurs, nous allons intégrer toute cette logique dans une unique crate Rust twoface. Heureusement pour nous, Rust supporte très bien le code exécuté au moment de la compilation (build-time), en plus d’être un langage moderne de bas niveau. Notre bibliothèque aura deux parties principales activées par des feature flags : une partie au moment de la compilation qui contrôle le chiffrement du binaire « caché » et génère des données à intégrer pour la seconde partie à l’exécution, qui fera le déchiffrement et aiguillera l’exécution soit vers le binaire « normal », soit vers le binaire « caché ».
Intégrer nos deux binaires « normal » et « caché » dans un nouveau binaire « Two-Face », incluant toute la crypto et les opérations d’intégration, peut être fait depuis un fichier build.rs. Le code final du binaire a simplement besoin de :
build.rs:
use std::io;
fn main() -> io::Result<()> {
twoface::build::build::<twoface::host::HostPartitionUuids>()
}
Ici HostPartitionUuids est un type générique utilisé pour personnaliser la manière d’extraire les données hôte, qui implémente le trait HostData.
/// System partition UUIDs, as shown in `ls /dev/disk/by-uuid | LANG=C sort`
#[derive(serde::Serialize, serde::Deserialize)]
pub struct HostPartitionUuids {
part_uuids: Vec<String>,
}
impl HostData for HostPartitionUuids {
fn from_host() -> io::Result<Self> {
let mut part_uuids: Vec<_> = fs::read_dir("/dev/disk/by-uuid")?
.filter_map(Result::ok)
.filter_map(|e| e.file_name().into_string().ok())
.collect();
part_uuids.sort_unstable();
Ok(Self { part_uuids })
}
}
Son code est très court, et il serait facile de le personnaliser ou d’implémenter une autre source de données.
Ensuite, nous pouvons écrire un fichier JSON qui contient les données que nous nous attendons à trouver sur notre machine cible, par exemple :
{
"part_uuids": [
"02e989c5-32dc-45ad-98f8-f284e9ac23c0",
"0e2fcda2-5ca1-4e38-841d-68e5d3a46f93",
"f99b45d8-d76d-48a3-94a2-3b0c6316d899"
]
}
Le code final a aussi besoin de quelques variables d’environnement pour la compilation, afin de passer les chemins des deux binaires et du JSON précédent :
export TWOFACE_HOST_INFO="/path/to/host_partition_uuids.json"
export TWOFACE_NORMAL_EXE="/path/to/normal_exe"
export TWOFACE_HIDDEN_EXE="/path/to/hidden_exe"
cargo build
Pendant le build, ceci va :
- charger l’exécutable « normal », et générer un tableau
constà partir de celui-ci pour être utilisé à l'exécution - charger l’exécutable « caché », et le compresser
- charger les données hôte depuis le fichier passé via
TWOFACE_HOST_INFO - générer une clé aléatoire, et générer un tableau
constà partir de celle-ci pour être utilisé à l'exécution - dériver la clé avec les données hôte de l'étape 3
- chiffrer les données compressées de l’exécutable « caché » avec la clé dérivée, et générer un tableau
constà partir de cela pour l'exécution
Puis, dans main.rs, le code à l’exécution a simplement besoin d’inclure le fichier .rs généré au moment du build, et de passer les tableaux const générés à la fonction run qui exécutera soit le binaire « normal », soit le binaire « caché » :
use std::io;
include!(concat!(env!("OUT_DIR"), "/target_exe.rs"));
fn main() -> io::Result<!> {
twoface::run::run::<twoface::host::HostPartitionUuids>(
NORMAL_EXE,
HIDDEN_EXE_BLACK,
HIDDEN_EXE_KEY,
&HIDDEN_EXE_DERIVATION_SALT,
)
}
Exécution en mémoire
Les lecteurs attentifs auront remarqué que nous prenons des fichiers ELF binaires en entrée au moment du build, et que nous les lançons tels quels à l’exécution, ce qui peut être délicat à faire depuis un ELF déjà en cours d’exécution. Une façon possible serait d’écrire le programme à exécuter sur le système de fichiers, puis d’appeler l’appel système exec. Cependant, pour le programme « caché », cela obligerait à écrire le binaire déchiffré sous une forme facilement isolable/observable, ce que nous voulons éviter. D’autres approches possibles seraient de créer un fichier avec le flag O_TMPFILE (fichier invisible pour les autres processus), ou de mapper toutes les pages ELF cible en mémoire (fastidieux, et nécessiterait de mapper des pages exécutables, ce qui pourrait déclencher des détections à l’exécution ou des problèmes de hardening).
À la place, nous optons pour l’appel système memfd_create qui crée un descripteur de fichier non associé à un fichier. Une fois que le binaire cible y est écrit, l’appel fexecve remplacera l’image du processus courant par la nouvelle et notre travail est terminé.
Challenge supplémentaire
Nous avons maintenant une solution fonctionnelle pour empaqueter deux binaires dans un seul à la compilation, extraire des données hôte pour identifier notre cible à l’exécution, et exécuter notre binaire « normal » ou « caché » depuis la mémoire selon le résultat.
À ce stade, le binaire « caché » déchiffré n’est jamais présent dans son intégralité dans la mémoire du processus, parce que lorsque nous déchiffrons les blocs AES, nous pouvons les écrire à la volée sur le descripteur de fichier que nous exécuterons ensuite. C’est une propriété intéressante, cependant l’opération d’écriture est triviale à observer, même pour un utilisateur non privilégié.
Par exemple, si nous prenons un petit programme Python d’une ligne qui crée un memfd et écrit dedans, on peut voir les données écrites facilement avec strace :
$ strace -e write python3 -c 'import os; fd = os.memfd_create(""); f = open(fd, "wb"); f.write(b"secret data")'
write(3, "secret data", 11) = 11
+++ exited with 0 +++
Chaque bloc AES déchiffré pourrait être observé de la même manière, et notre binaire « caché » complet reconstruit. Bien sûr, cela supposerait d’exécuter l’analyse sur la machine cible, mais il serait souhaitable d’éviter cela.
Pour améliorer ce point, nous utiliserons différentes façons d’écrire les données ELF déchiffrées sur le descripteur de fichier cible, chacune ayant ses avantages et inconvénients :
- avec io_uring : aucun appel système
writen’est émis, donc par exemplestracene verra pas les données écrites, toutefois il peut ne pas être supporté ou être désactivé sur le système - en faisant un
mmapdes segments mémoire : pas d’écriture traçable non plus, mais cela nécessite de nombreux appels système pour map/unmap chaque segment (impact sur les performances), de sorte que le fichier déchiffré complet n’est pas visible en mémoire à un instant donné - fallback sur l’écriture classique : les données complètes du fichier déchiffré ne seront toujours pas dans la mémoire du processus, mais les appels
writepeuvent être facilement tracés.
Notez que dans tous les cas, cela ne résisterait pas à une analyse à l’exécution plus avancée faite par un utilisateur privilégié. Bien que les données du descripteur de fichier en mémoire ne soient pas mappées dans l’espace utilisateur, elles peuvent être accédées et extraites depuis le noyau.
Résultat
L’ensemble du code est disponible sur https://github.com/synacktiv/twoface, et contient un exemple de binaire « inoffensif »/« normal », un autre « caché »/« malveillant », la bibliothèque twoface, et un exemple pour tester le tout :
test-example
harmless_binary
├── Cargo.toml
└── src
└── main.rs
evil_binary
├── Cargo.toml
└── src
└── main.rs
example
├── build.rs
├── Cargo.toml
├── host.json
└── src
└── main.rs
twoface
├── Cargo.toml
└── src
├── build.rs
├── crypto
│ ├── dec.rs
│ ├── enc.rs
│ └── mod.rs
├── exe_writer
│ ├── io_uring.rs
│ ├── mmap.rs
│ └── mod.rs
├── host.rs
├── lib.rs
└── run.rs
Lancer test-example va :
- compiler le binaire « inoffensif »
- compiler le binaire « malveillant »
- charger les UUID de partitions depuis
example/host.json - compiler un binaire d’exemple qui intègre les deux ELF (« inoffensif » et « malveillant » chiffré)
- le lancer afin que vous puissiez voir lequel s’exécute
Conclusion
Ce proof of concept montre comment on peut tirer parti du code build-time en Rust pour créer des mécanismes avancés mais conviviaux pour les développeurs, et implémenter notre binaire « Two‑Face ».
Ce n’est cependant qu’un aperçu de ce qui pourrait être fait, pour aller plus loin, on pourrait :
- ajouter de l’obfuscation au moment du build, par exemple pour masquer le fait que nous lisons les UUID des partitions dans
/dev/disk/by-uuid - ajouter des techniques anti‑debug à l’exécution
- utiliser des données hôte déjà en mémoire pour dériver la clé, par exemple en hachant des pages de bibliothèques partagées
- chaîner plusieurs niveaux de loaders, chacun utilisant une source différente de données de dérivation
- déchiffrer dynamiquement les pages ELF en mémoire à la volée en utilisant par exemple
userfaultfd
…ceci fera peut‑être l’objet d’un autre article.