Bezpieczeństwo systemu a moduły jądra na FreeBSD

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.

Ogólna charakterystyka

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.

Logowanie wywołań komend

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.

Poprawność argumentów i środowiska

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");
	}
Selektywny noexec

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.


Creative Commons License
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.