w00w00!
lkm : Kernel hacking simplifié
Par : w00w00 Security Development article
de Nicolas Dubee
Translation by eberkut - http://www.chez.com/keep
Ce qui suit s'applique à la série de kernel Linux i86 2.0.x. Il peut
également être efficace pour les versions précédentes, mais n'a pas été
testé. les kernel 2.1.x ont introduit beaucoup de modifications, notamment des
routines de managment de mémoire, et ne sont pas étudiées ici.
Merci à Halflife qui a eu la première fois l'idée d'utiliser les lkm à des
fins malveillants, et tiepilot, mon héros vivant.
Espace utilisateur vs. Espace kernel
Linux est un système d'exploitation protégé. Il est implémenté au-dessus du
mode protégé de la série de CPU i386.
La mémoire est divisée en approximativement 2 parties : l'espace kernel et
l'espace utilisateur. L'espace kernel est là où le code kernel vit, et
l'espace utilisateur est là où les programmes utilisateur vivent.
Naturellement, un programme utilisateur donné ne peut pas écrire dans la
mémoire kernel ou dans la zone de mémoire d'un autre programme.
Malheureusement, c'est également le cas du code kernel. Le code kernel ne peut
pas écrire dans l'espace utilisateur non plus. Qu'est ce que ça signifie ? Et
bien, quand un driver matériel veut écrire des octets de données sur un
programme dans la mémoire utilisateur, il ne peut pas la faire directement,
mais à la place il doit utiliser des fonctions du kernel spécifiques. En
outre, quand des paramaters sont passés par adresse vers une fonction kernel,
la fonction kernel ne peut pas lire les paramètres directement. Elle doit
employer d'autres fonctions kernel pour lire chaque octet des paramètres.
Voici quelques fonctions utiles à utiliser en mode kernel pour transféré des
octets de données vers ou depuis la mémoire utilisateur.
#include <asm/segment.h>
get_user(ptr)
Donne l'octet, word, ou long donné par la mémoire utilisateur. C'est une
macro, et elle se fonde sur le type de l'argument pour déterminer le nombre
d'octets à transférer. Vous alors devez utiliser les typecasts sagement.
put_user(ptr)
C'est pareil que get_user(), mais au lieu de lire, il écrit des octets de
données dans la mémoire utilisateur.
memcpy_fromfs(void *to, const void *from,unsigned long n)
Copie n octets depuis *from dans la mémoire utilisateur vers *to dans la
mémoire kernel.
memcpy_tofs(void *to,const *from,unsigned long n)
Copie n octets depuis *from dans la mémoire kernel vers *to dans lé mémoire
utilisateur.
System calls
La plupart des appels libc se fondent sur les appels système, qui sont les
fonctions kernel les plus simples qu'un programme utilisateur peut appeler. Ces
appels système sont implémenté au kernel lui-même ou dans des loadable
kernel modules, qui sont de petits morceaux de code kernel dynamiquement liable.
Comme MS-DOS et beaucoup d'autres, les appels système Linux sont implémenté
par un multiplexeur appelé avec une interruption masquable donnée. Dans Linux,
cette interruption est int 0x80. Quand on exécute l'instruction 'int 0x80', la
commande est donnée au kernel (ou, plus exactement, à la fonction _system_call()),
et le processus réel de démultiplexage se produit.
* Comment fonctionne _system_call() ?
D'abord, tous les registres sont sauvegardés et la teneur du registre %eax est
vérifiée avec la table globale d'appels système, qui énumère tous les
appels système et leurs adresses.
Cette table peut être consultée avec la variable externe vide *sys_call_table[
]. Une adresse mémoire et un nombre donnés dans cette table correspondent à
chaque appel système. Les numéros d'appel système peuvent être trouvé dans
/usr/include/sys/syscall.h. Ils sont de la forme SYS_systemcallname. Si l'appel
système n'est pas implémenté, la cellule correspondante dans sys_call_table
est 0, et une erreur est retournée. Autrement, l'appel système existe et
l'entrée correspondante dans la table est l'adresse de mémoire du code d'appel
système.
Voici un exemple d'un appel système invalide :
[root@plaguez kernel]# cat no1.c
#include <linux/errno.h>
#include <sys/syscall.h>
#include <errno.h>
extern void *sys_call_table[];
sc()
{ // L'appel système 165 n'existe pas à ce moment.
__asm__(
"movl $165,%eax
int $0x80");
}
main()
{
errno = -sc();
perror("test of invalid syscall");
}
[root@plaguez kernel]# gcc no1.c
[root@plaguez kernel]# ./a.out
test of invalid syscall: Function not implemented
[root@plaguez kernel]# exit
La commande est alors transférée à l'appel système
réel, qui exécute celui que vous avez requis et retouné. _system_call()
appelle alors ret_from_sys_call() pour contrôler diverse substance, et revient
finalement à la mémoire utilisateur.
* libc
Le int $0x80 n'est pas utilisé directement pour des appels système ; on
utilise plutôt des fonctions libc, qui sont souvent des wrappers pour
interrompre 0x80.
le libc comporte généralement les appels système utilisant les macros de
_syscallX(), où X est le nombre de paramètres pour l'appel système.
Par exemple, l'entrée de libc pour write(2) serait implémenté avec une macro
_syscall3, puisque le prototype write(2) réel exige 3 paramètres. Avant
d'appeler l'interruption 0x80, les macros _syscallX sont censés paramétrer
stack frame et la liste d'argument exigées pour l'appel système.En conclusion,
quand les retours de _system_call() (qui est déclenché avec int $0x80), les
macros _syscallX() vérifieront une valeur de retour négative (dans le %eax) et
placeront errno en conséquence.
Prenons un autre exemple avec write(2) et voyons comment il est prétraité.
[root@plaguez kernel]# cat no2.c
#include <linux/types.h>
#include <linux/fs.h>
#include <sys/syscall.h>
#include <asm/unistd.h>
#include <sys/types.h>
#include <stdio.h>
#include <errno.h>
#include <fcntl.h>
#include <ctype.h>
_syscall3(ssize_t,write,int,fd,const void *,buf,size_t,count);
main()
{
char *t = "this is a test.\n";
write(0, t, strlen(t));
}
[root@plaguez kernel]# gcc -E no2.c > no2.C
[root@plaguez kernel]# indent no2.C -kr
indent:no2.C:3304: Warning: old style assignment ambiguity in "=-".
Assuming "= -"
[root@plaguez kernel]# tail -n 50 no2.C
#9 "no2.c" 2
ssize_t write(int fd, const void *buf, size_t count)
{
long __res;
__asm__ __volatile("int $0x80":"=a"(__res):"0"(4),
"b"((long) (fd)), "c"((long) (buf)), "d"((long)
(count)));
if (__res >= 0)
return (ssize_t) __res;
errno = -__res;
return -1;
};
main()
{
char *t = "this is a test.\n";
write(0, t, strlen(t));
}
[root@plaguez kernel]# exit
Notez que le "0"(4) dans la fonction write()
ci-dessus correspond à la définition de SYS_write dans /usr/include/sys/syscall.h.
* Faites vos propres appels système.
Il y a quelques manière de faire vos propres appels système. Par exemple, vous
pouvez modifier les sources du kernel et ajouter votre propre code. Une méthode
bien plus facile, cependant, serait d'écrire loadable kernel module.
Un loadable kernel module n'est pas d'avantage qu'un fichier d'exécution
contenant le code qui sera dynamiquement incorporé dans le kernel quand il est
nécessaire.
Les buts principaux de ce dispositif sont d'avoir un petit kernel, et de charger
un gestionnaire donné quand il est nécessaire avec la commande insmod(1). Il
est également plus facile d'écrire un lkm que d'écrire le code dans
l'arborescence des source du kernel.
* Écrire un lkm
Un lkm est facile à faire en C.
Il contient un gros morceau de #define, quelques fonctions, une fonction
d'initialisation appelée init_module(), et une fonction de déchargement
appelée cleanup_module().
Voici une structure typique de source de lkm :
#define MODULE
#define __KERNEL__
#define __KERNE_SYSCALLS__
#include <linux/config.h>
#ifdef MODULE
#include <linux/module.h>
#include <linux/version.h>
#else
#define MOD_INC_USE_COUNT
#define MOD_DEC_USE_COUNT
#endif
#include <linux/types.h>
#include <linux/fs.h>
#include <linux/mm.h>
#include <linux/errno.h>
#include <asm/segment.h>
#include <sys/syscall.h>
#include <linux/dirent.h>
#include <asm/unistd.h>
#include <sys/types.h>
#include <stdio.h>
#include <errno.h>
#include <fcntl.h>
#include <ctype.h>
int errno;
char tmp[64];
/* for example, we may need to use ioctl */
_syscall3(int, ioctl, int, d, int, request, unsigned long, arg);
int myfunction(int parm1,char *parm2)
{
int i,j,k;
/* ... */
}
int init_module(void)
{
/* ... */
printk("\nModule loaded.\n");
return 0;
}
void cleanup_module(void)
{
/* ... */
}
Contrôlez les #define obligatoires (#define MODULE, #define
__KERNEL__) et les #incldes (#include <linux/config.h >...)
Notez également que comme notre lkm fonctionnera en mode kernel, nous ne
pouvons pas utiliser des fonctions libc, mais nous pouvons utiliser des appels
système avec les macros précédemment étudiées de _syscallX().
Vous compilerez ce module avec 'gcc -c -O3 module.c' et l'insérerez dans le
kernel avec 'insmod module.o' (l'optimisation doit être enclenchée).
Comme le titre le suggère, le lkm peut également être employé pour modifier
le code kernel sans devoir le reconstruire entièrement. Par exemple, vous
pourvez patcher l'appel système write(2) pour cacher des parties d'un fichier
donné. Ca semble un bon endroit pour des backdoors, également : que
feriez-vous si vous ne pouvez pas faire confiance à votre propre kernel ?
* Backdoors kernel et d'appels système
L'idée principale derrière tout ça est assez simple. Nous redirigerons ces
fichus appels système vers les notres dans un lkm, qui nous permettra de forcer
le kernel à réagir quand nous le voulons. Par exemple, nous pourrions cacher
un sniffer en patchant l'appel système IOCTL et en masquant le bit PROMISC.
Lame mais efficace.
Modifier un appel système donné, ajouter juste la définition du void externe
*sys_call_table[] dans votre lkm, et prenez la fonction init_module() et
modifiez l'entrée correspondante dans le sys_call_table pour pointer vers votre
propre code. L'appel modifié peut alors faire tout ce que vous souhaitez,
appellez l'appel système initial par la modification sys_call_table une fois de
plus, et...