Podobnie jak inne systemy z rodziny UNIXów, FreeBSD umożliwia administratorowi ładowanie własnych modułów kernela. Pozwala to na elastyczne i niezwykle wygodne zarządzanie zasobami serwera oraz na rozszerzanie systemu o pewne funkcje czy urządzenia, bez konieczności rekompilowania jądra i restartu. Własnoręczne tworzenie modułów nie powinno sprawiać kłopotów żadnemu administratorowi programującemu w języku C i znającemu choć trochę strukturę systemu, tymczasem korzyści płynące z takiego rozwiązania są bardzo spektakularne.
Moduły jądra stanowią jeden z ważniejszych elementów, pomagających zabezpieczyć system przed niepowołanym dostępem, a także rozwinąć jego możliwości raportowania swojego stanu. Należy przy tym wspomnieć, że wiele tzw. tylnych drzwi (ang. backdoor), również częściowo opiera się na funkcjonalności modułów. Wszystkie te cechy powodują, że podczas planowania polityki bezpieczeństwa systemu, rozwiązania modularne powinny być wzięte pod uwagę.
Jako przykład wykorzystania modułów w polityce bezpieczeństwa, chciałbym opisać
rozwiązanie mojego autorstwa, rexec, czyli wrapper wywołania
systemowego execve(). Kod źródłowy dostępny jest pod adresem
http://www.frasunek.com/sources/security/rexec. W dalszej części
artykułu, chciałbym udokumentować możliwości aplikacji, przytaczając
jednocześnie przykładowe, skrócone fragmenty kodu źródłowego, co pozwoli
na przybliżenie Czytelnikowi techniki tworzenia własnych modułów.
Genezą powstania modułu okazała się potrzeba śledzenia poleceń wykonywanych przez użytkowników systemu. Brałem pod uwagę również inne możliwe rozwiązania, ale jedynie ingerencja na poziomie jądra zapewniała wystarczającą niskopoziomowość.
Rexec jest przeznaczony dla systemu FreeBSD 4.x, aczkolwiek po
drobnych modyfikacjach, widziałbym jego przydatność z wcześniejszymi wersjami
oraz innymi systemami z rodziny BSD. Działanie opiera się na
przechwyceniu
wywołania systemowego execve() oraz przeprowadzeniu odpowiednich
testów przed przekazaniem polecenia do wykonania.
static struct sysent n_execve_sysent =
{
3,
(void *)n_execve
};
[mod()]
case MOD_LOAD:
sysent[SYS_execve] = n_execve_sysent;
break;
case MOD_UNLOAD:
sysent[SYS_execve].sy_call = (sy_call_t *) execve;
break;
|
Jak widać na powyższym fragmencie kodu, załadowanie modułu powoduje
zastąpienie oryginalnej funkcji obsługi wywołania systemowego funkcją
n_execve, zadeklarowaną w module. W przypadku usunięcia modułu
z pamięci należy wykonać czynność odwrotną. Z punktu widzenia projektanta
zabezpieczeń, interesujące jest nie tylko wywołanie execve(), ale
również: open(), close(), chdir(),
mknod(), chmod(), setuid(),
setgid(), kill(), chroot(),
mkdir(), rmdir(), seteuid(),
setegid(). Monitorowanie ich może dostarczyć wielu ciekawych
informacji o stanie systemu. Również napastnikowi daje to bardzo duże
możliwości ukrywania swojej obecności, łącznie z zostawianiem tzw. tylnych
drzwi.
Pierwszym celem, dla którego został napisany rexec było monitorowanie wywołań komend przez użytkowników. Osiągnięto to przez następujący fragment kodu:
static int logme(p, ea)
struct proc *p;
struct execve_args *ea;
{
char **bp;
printf("rexec: %s ", ea->fname);
for (bp=ea->argv+1;*bp;bp++) printf("%s ", *bp);
printf("(called by %s [%d]) (uid=%d, gid=%d, euid=%d, egid=%d)\n",
p->p_comm,
p->p_pid, p->p_cred->p_ruid, p->p_cred->p_rgid,
p->p_ucred->cr_uid, p->p_ucred->cr_gid);
return 0;
}
static int n_execve(p, ea)
struct proc *p;
struct execve_args *ea;
{
[...]
logme(p, ea);
[...]
}
|
Jak widać, pierwszym efektem, który osiągamy w przechwyconym wywołaniu
systemowym jest przejście do funkcji logme() oraz przekazanie
jej struktur zawierających informacje o procesie wykonującym funkcję
execve(), a także argumentów tej funkcji (*fname,
**argv oraz **envv). Wywołanie funkcji
logme() powoduje wygenerowanie na konsoli (i w logach) wiadomości
zawierającej ścieżkę wykonywanego programu, argumenty, nazwę procesu
wykonującego program oraz jego (E)UID i (E)GID.
Kolejnym, niezwykle ważnym krokiem jest przetestowanie argumentów i środowiska. Odpowiada za to następujący fragment kodu:
[n_execve()] if (sanity(p, ea, NULL)) return EPERM; |
Uzależnia on wykonanie aplikacji od wyniku funkcji sanity().
Niepowodzenie testu powoduje zaniechanie wykonania programu oraz wygenerowanie
błędu Operation not permitted. Przyjrzyjmy się zatem, na czym polegają
owe testy:
static int sanity(p, ea, va)
struct proc *p;
struct execve_args *ea;
struct vattr *va;
{
[...]
root = suser(p);
if (!root || (p->p_ucred->cr_uid != p->p_cred->p_ruid) ||
(p->p_cred->p_rgid != p->p_ucred->cr_gid))
[...]
}
|
Jak widać, pierwszy test jest przeprowadzany jedynie wtedy, gdy: proces pracuje
z prawami superużytkownika (funkcja suser()) lub jego
effective [UG]ID różnią się od real [UG]ID, czyli jest on
S[UG]ID'em. Polega on na sprawdzeniu długości i ilości argumentów:
[sanity()]
for (bp=ea->argv;*bp;bp++,i++)
{
if (strlen(*bp)> MAXARGLEN)
{
printf("rexec: !!WARN!!: Argument too long.\n");
return 1;
}
}
if (i> MAXARGS)
{
printf("rexec: !!WARN!!: Too many arguments.\n");
return 1;
}
|
a także na sprawdzeniu, czy argumenty te nie zawierają niedozwolonych znaków:
static int validate(p)
char *p;
{
[...]
for(q=p,remap_count=0;*q;q++)
{
remap=allowed_char[(*q)&0xFF];
if (!remap)
{
*q = '\0';
return 1;
}
if (remap != *q)
{
*q = remap;
remap_count++;
}
}
if (remap_count> MAX_REMAP)
return 1;
return 0;
}
[sanity()]
if (validate(*bp))
{
printf("rexec: !!WARN!!: Suspicious argument.\n");
return 1;
}
|
Zawarta w programie tablica remapowania określa trzy możliwe scenariusze
dla każdego znaku: odrzucenie argumentu zawierającego taki znak, zastąpienie
znaku podkreślinikiem, przepuszczenie znaku. Jednocześnie sprawdzana jest
ilość dokonanych zastąpień. W przypadku, gdy jakikolwiek z testów zawiedzie,
generowana jest wiadomość na konsolę, a funkcja sanity() powraca z
niezerową wartością.
Kolejny test przeprowadzany jest jedynie wtedy, gdy uruchamiany lub uruchamiający program jest S[UG]IDem. Polega on na przeszukaniu i dopasowaniu zmiennych środowiskowych do wzorca, zawierającego listę przepuszczanych zmiennych, a także ich maksymalną długość. Dodatkowo, każda przepuszczona zmienna jest sprawdzana pod kątem występowania niedozwolonych znaków. Za dopasowanie do wzorca odpowiada następujący fragment kodu:
static TEnvInfo env_profile [] = {
{ "PATH=", 5, 512 },
{ "TERM=", 5, 16 },
{ "USER=", 5, 16 },
{ "LOGNAME=", 8, 16 },
{ "ROWS=", 5, 4 },
{ "COLUMNS=", 8, 4},
{ "SHELL=", 6, 32 },
{ "HOME=", 5, 32 },
{ "HOST=", 5, 128 },
{ "PAGER=", 6, 32 },
{ "DISPLAY=", 8, 32 },
{ NULL, 0, 0 }
};
[...]
[sanity()]
for (bp=ea->envv;*bp;bp++,i++)
{
for (match=k=0;env_profile[k].env!=NULL;k++)
{
if (0!=(match=!strncmp(*bp,
env_profile[k].env,
env_profile[k].name_len)))
break;
}
if (match)
{
if (strlen(*bp)> env_profile[k].max_len)
{
printf("rexec: !!WARN!!: Envp too long:"
" %.20s [...]\n", *bp);
return 1;
}
if (validate(*bp))
{
printf("rexec: !!WARN!!: Suspicious envp:"
" %.20s [...]\n", *bp);
return 1;
}
}
else
{
for(q=*bp;*q && *q != '=';q++)
*q++ = '\0';
}
}
|
Jak widać, również w tym wypadku, niespełnienie któregoś z warunków dla
przepuszczonych zmiennych powoduje wygenerowanie ostrzeżenia i zaniechanie
wykonywania programu. Ostatnią akcją, która jest podejmowana przez funkcję
sanity() jest usunięcie zmiennych środowiskowych
LD_*, dla użytkowników, którzy nie mają prawa uruchamiać własnych
programów. Pozwala to na ochronę przed zmianą konfiguracji
real time linkera, np. poprzez wykonanie następujących poleceń:
% cat mojprog.c
printf() { execl("/bin/sh", "sh", 0); }
% cc -shared -o mojprog.so mojprog.c
% setenv LD_PRELOAD /home/venglin/mojprog.so
% uname
$
|
co pozwalałoby na ominięcie mechanizmu noexec. Odpowiada za to krótki fragment kodu:
[sanity()]
for (bp=ea->envv;*bp;bp++)
{
if (!strncmp(*bp, "LD_", 3))
{
printf("rexec: !!WARN!!: Stripping LD_*\n");
for(q=*bp;*q && *q != '=';q++)
*q++ = '\0';
}
}
|
Podczas tworzenia funkcji sanity() miałem również pomysł na
uniemożliwienie S[UG]IDom wykonywania jakichkolwiek zewnętrznych aplikacji.
Jednakże okazało się, że takie postępowanie uniemożliwia pracę programowi
passwd, który do przebudowania bazy wykorzystuje zewnętrzną
aplikację mkpwd_db. Dlatego też pomysł ten został
zaimplementowany, ale jego wykorzystanie jest opcjonalne:
[sanity()]
if ((p->p_cred->p_ruid != p->p_ucred->cr_uid) || (p->p_cred->p_rgid
!= p->p_ucred->cr_gid))
{
p->p_ucred = crcopy(p->p_ucred);
change_euid(p, p->p_cred->p_ruid);
p->p_ucred->cr_gid = p->p_cred->p_rgid;
printf("rexec: NOTICE: Dropping set[ug]id privileges.\n");
}
|
Ostatnim mechanizmem zaimplemetowanym w module rexec jest selektywne zezwalanie na wykonywanie własnych aplikacji przez użytkowników systemu. Autorem tego rozwiązania jest Zbyszek Sobiecki, a po raz pierwszy pojawiło się ono w module jego autorstwa, realizującym tylko ten mechanizm.
Polega on na dopuszczaniu użytkowników tylko z określonej grupy do wykonywania własnych aplikacji. Realizuje to następujący fragment kodu:
static int noexec(p)
struct proc *p;
{
for (i=0;i<p->p_ucred->cr_ngroups;i++)
{
if (p->p_ucred->cr_groups[i] == NOT_RESTRICTED_GROUP)
return 1;
}
return 0;
}
[n_execve()]
allow = noexec(p);
ndptr = &nd;
NDINIT(ndptr, LOOKUP, FOLLOW | SAVENAME,
UIO_USERSPACE, ea->fname, p);
ret = namei(ndptr);
if (ret)
return (ret);
ret = VOP_GETATTR(ndptr->ni_vp, &va, p->p_ucred, p);
if (ret)
goto bad;
else if ((!allow && (va.va_uid> MAX_BIN_UID)) || sanity(p, ea, &va))
ret = EPERM;
bad:
VOP_UNLOCK(ndptr->ni_vp, 0, p);
if (ndptr->ni_vp)
{
NDFREE(ndptr, NDF_ONLY_PNBUF);
vrele(ndptr->ni_vp);
}
if (ret)
return (ret);
else
return execve(p, ea);
}
|
Jest to już ostatni element testów przeprowadzanych przez moduł. Nie jest on
oczywiście wykonywany dla superużytkownika. Polega na odczytaniu i-węzła
wykonywanej aplikacji oraz porównaniu właściciela pliku ze stała określającą
UIDy, od których test musi być przeprowadzony. Jeżeli warunek został spełniony,
wywoływana jest funkcja noexec(), sprawdzająca, czy użytkownik
znajduje się w grupie mającej prawa do wykonywania własnych aplikacji.
W sytuacji, kiedy wyniki wszystkich testów były pomyślne, wykonywanie jest
kontynuowane, a system przenosi się do oryginalnej funkcji
execve(). W przeciwnym wypadku zwracana jest wartość EPERM.
Na zakończenie chciałbym podkreślić, że programowanie systemowe różni się
nieco od programowania zwyczajnych aplikacji. Przede wszystkim należy
być czujnym na wszystkie błędy i obsługę pamięci -- na poziomie jądra,
każdy błąd kończy się kernel panic, nie ma też do dyspozycji
normalnego debuggera. Zmienne powinny być w miarę możliwości deklarowane
jako statyczne, co podniesie wydajność. Przy dynamicznej alokacji pamięci
należy pamiętać o jawnym zwalnianiu obszaru, gdyż każdy wyciek pamięci (ang.
memory leak powoduje przyrost pamięci zajmowanej przez jądro, a w
efekcie - zachwianie stabilności systemu. Przenoszenie danych pomiędzy
user space a kernel space może odbywać się tylko przy pomocy
funkcji copyin() i copyout().
Mam nadzieję, że artykuł przybliżył Czytelnikowi tajniki tworzenia ładowalnych modułów kernela. W przypadku FreeBSD technologia ta jest ciągle słabo udokumentowana i unikana przez programistów spoza core team'u.

Wszystkie materiały na mojej stronie dostępne są na licencji Creative Commons Uznanie autorstwa-Użycie niekomercyjne-Na tych samych warunkach 2.5 Polska.