Intermezzo: Narzędzia wspomagające pisanie bezpiecznego kodu

Wprowadzenie

Jednym z najważniejszych pierwotnych źródeł problemów z bezpieczeństwem oprogramowania są błędy programistyczne. Organizacja o nazwie Common Weakness Enumeration zajmuje się śledzeniem wszelkiego rodzaju błędów programistycznych prowadzących do luk w bezpieczeństwie oraz promowaniem technik pozwalających ich uniknąć. Dodatkowo fundacja Open Web Application Security Project zajmuje się śledzeniem błędów występujących w aplikacjach WWW.

Organizacje te publikują swoje rankingi błędów powodujących najgroźniejsze luki w bezpieczeństwie:

Warto się im przyjrzeć.
Warto regularnie przeglądać, aby doskonalić swój warsztat programistyczny.

W związku z tym, że błędy przepełnienia bufora należą do najpoważniejszych, skupimy się nieco właśnie nad nimi.

Błędy przepełnienia bufora

Przyjrzyjmy się dokładniej jednemu z najczęściej pojawiających się błędów prowadzących do luk w bezpieczeństwie. Powaga tego błędu wynika szczególnie z faktu, że może on doprowadzić do nadpisania adresu powrotu z procedury, a to z kolei do wykonania kodu, który został wstawiony do programu jako dane.

Przykłady

Oto kilka przykładów kodu prowadzącego do luk w bezpieczeństwie (zainspirowane materiałami z CWE oraz SEI CERT).

Przykład 1

Pierwszy przykład to rzecz, która zdarza się w zasadzie jedynie nowicjuszom, ale też po wielu godzinach pracy i doświadczonemu programiście uda się taki błąd popełnić.

float grades[2];
 
/* Populate the array with initial grades. */
 
grades[0] = 1.0;
grades[1] = 2.0;
grades[2] = 3.5;

Tablica zadeklarowana jako dwuelementowa nie zmieści w swoim zakresie trzech elementów. W związku z tym, że tablice w C są indeksowane od 0, mamy nieintuicyjną sytuację, że indeks widoczny w deklaracji tablicy nie jest dostępny. Tego rodzaju błędy można wyłapać następującymi narzędziami:

  • cppcheck --enable=all <nazwa_pliku>.c
  • clang -Warray-bounds <nazwa_pliku>.c

Przykład 2

#include <stdlib.h>
#include <string.h>
 
int recordSize(void * p) {
 
   int i = 0;
   for (i=0; i < 100;i++) {
       if (*((char*)p+i) == 0) return i+1;
   }
   return -1;
}
 
int main() {
...
memcpy(destBuf, srcBuf, (recordSize(destBuf)-1));
...
}

Funkcja obliczająca długość bufora może dać w wyniku sygnał błędu (-1), który powinien być jawnie sprawdzany. Brak sprawdzenia może spowodować, że zainicjowane zostanie kopiowanie bardzo dużej ilości danych. Tego rodzaju błędy można wyłapać narzędziami, które sprawdzają zgodność typów w sposób ścisły:

  • gcc -Wconversion  <nazwa_pliku>.c
  • clang -Wconversion  <nazwa_pliku>.c

Przykład 3

#include <stdlib.h>
#include <string.h>
#include <stddef.h>
 
char * transform(char *input_string, size_t len){
   int i, j;
   char *buf = (char*)malloc(3*sizeof(char) * len + 1);
   if (buf==NULL) return NULL;
   j = 0;
   for ( i = 0; i < strlen(input_string); i++ ){
      if( '&' == input_string[i] ){
         buf[j++] = '&';
         buf[j++] = 'l';
         buf[j++] = 't';
         buf[j++] = ';';
      } else buf[j++] = input_string[i];
   }
   return buf;
}

W tym kodzie błąd polega na tym, że zaalokowana przestrzeń może nie być wystarczająca, ponieważ znak '&' jest zamieniany na cztery znaki, ale przy alokacji jest używany mnożnik 3 – duża liczba znaków '&' spowoduje przekroczenie zakresu zaalokowanej pamięci.

Błędy takie, jak w Przykładzie 3 można wykrywać za pomocą narzędzi takich jak ElectricFence. Narzędzie to działa w ramach testowania działającego programu zawierającego błędny kod. W trakcie działania programu podmieniany jest alokator pamięci tak, że dynamicznie alokowane bufory są umieszczane tak, iż koniec (lub początek) bufora wypada na końcu strony, zaś strona jest ulokowana tak, że następna strona nie jest przydzielona do procesu. Dzięki temu każde wyjście poza zakres bufora kończy się błędem segmentacji.

Badanie, czy występuje tego typu problem wykonujemy za pomocą pakietu ElectricFence przez wywołanie polecenia:

ef <nazwa programu> <parametry wywołania>

gdzie <nazwa programu> to nazwa programu ze skompilowanym kodem z parametrami tak dobranymi, aby doprowadzić do wykonania wadliwego kodu.

O wpisach poza zarezerwowany bufor ostrzeże nas też program valgrind, gdy go wywołamy tak:

valgrind <nazwa programu> <parametry wywołania>

Działa on na podobnej zasadzie, co ElectriFence, ale do wykrywania błędów wykorzystuje nie alokowanie na granicy strony, a zapisywanie na brzegach bufora specjalnych wartości zwanych kanarkami. Powyższe wywołanie da m.in taki wynik

...
==81332== Invalid write of size 1
==81332==    at 0x4011AF: transform (in /somewhere/compiled_code)
==81332==    by 0x401265: main (in /somewhere/compiled_code)
==81332==  Address 0x4a81060 is 1 bytes after a block of size 31 alloc'd
==81332==    at 0x484186F: malloc (vg_replace_malloc.c:381)
==81332==    by 0x40115F: transform (in /somewhere/compiled_code)
==81332==    by 0x401265: main (in /somewhere/compiled_code)
...

Przykład 4

#include <stdlib.h>
#include <string.h>
 
void provide_name(char *);
 
char* give_name(){
  char name[64];
 
  provide_name(name);
  if (name[0] == '.') return NULL;
  char* res = (char*)malloc(64);
  if (res==NULL) return NULL;
  strcpy(name, res);
  return res;
}

Tutaj funkcja give_name pobiera z funkcji provide_name nazwę name, ale nigdzie nie jest sprawdzane, czy uzyskany wynik mieści się w 64 bajtach. Funkcja provide_name może w trakcie swojego wykonania spowodować nadpisanie adresu powrotu z funkcji give_name.

Tutaj problem można wykryć za pomocą techniki kanarków. Obecnie jest ona bez problemu wspierana przez współczesne kompilatory. Wystarczy skompilować program tak:

  • gcc -fstack-protector <nazwa_pliku>.c
  • clang -fstack-protector <nazwa_pliku>.c

a następnie go uruchomić. Jeśli nastąpi przekroczenie zakresu bufora dostępnego na stosie (name), to przy wychodzeniu z funkcji (give_name) mechanizm wykonawczy sprawdzi, że naruszona została zawartość specjalnie spreparowanych kilku bajtów umieszczonych między buforem a adresem powrotu z procedury. Program zakończy się w tym momencie błędem segmentacji poprzedzonym komunikatem w stylu:

*** stack smashing detected ***: terminated

Warto przyjrzeć się dokumentacji gcc, żeby zobaczyć, jak można wpłynąć na zawartość wspomnianego kanarka.

Nota bene powyższy kod to przykład źle zaprojektowanego interfejsu programistycznego, bo skąd niby funkcja provide_name ma się w swojej treści dowiedzieć, jakiej wielkości bufor może wypełnić danymi?

Przykład 5

#include <string.h>
#include <stdio.h>
 
void truncend(char *input_string){
   int i = strlen(input_string);
   while (input_string[--i] != ';');
   input_string[i+1] = 0;
}
 
int main() {
	char  a[30];
        strcpy(a,";ala ma kota");
	truncend(a+2);
	printf("%s\n", a);
}

W kodzie tym, jeśli założymy, że bufor input_string jest prawidłowym napisem zaterminowanym znakiem o kodzie 0, znajduje się błąd polegający na tym, że można wyjść poza bufor wejściowy na jego początku.

Znaleźć błąd w tym kodzie pomoże nam program frama-c. Wywołanie:

frama-c -eva -eva-precision 1 <nazwa programu>.c

da między innymi taki wynik:

[eva:final-states] Values at end of function truncend:
  i ∈ {-2; -1; 0; 1; 2; 3; 4; 5; 6; 7; 8; 9}
  a[0..12] ∈ [--..--] or UNINITIALIZED
   [13..29] ∈ UNINITIALIZED
[eva:final-states] Values at end of function main:
  a[0..12] ∈ [--..--] or UNINITIALIZED
   [13..29] ∈ UNINITIALIZED
  __retres ∈ {0}
  S___fc_stdout[0..1] ∈ [--..--]

Łatwo wyczytamy z niego, że niespodziewanie zmienna i może tutaj przyjąć wartości ujemne.

Przykład 6

#include <sys/socket.h>
#include <netinet/in.h>
 
#define MAX_NO_ADDR 100
 
size_t get_no_addresses();
 
int main() {
 
  size_t no_addr;
  int i;
 
  no_addr = get_no_addresses();
  if ((no_addr == 0) || (no_addr > MAX_NO_ADDR)) {
     exit(-1);
  }
  in_addr_t *addrs = (in_addr_t *)malloc(no_addr * sizeof(in_addr_t));
 
  for(i=0; i<no_addr; i++) {
    addrs[i] = 0xFFFFFFFF;
  }
  addrs[no_addr] = 0;
}

Tutaj w intencji tablica addrs ma być wypełniona niezerowymi adresami internetowymi, a zerowy adres ma oznaczać koniec tablicy. Błąd polega na tym, że alokacja uwzględnia miejsce na właściwe adresy, ale nie uwzględnia miejsca na terminujący adres zerowy.

W tym i następnym przykładzie zachęcamy Państwa do spróbowania znalezienia błędu za pomocą narzędzi samemu.

Przykład 7

int main() {
  ...
  scanf("%d", &srcBuf); 
  strncpy(destBuf, &srcBuf[find(srcBuf, ch)], 1024);
  ...
}

Tutaj problematyczne jest użycie funkcji find, która może dać w wyniku -1, gdy znak ch nie występuje w buforze srcBuf. Dodatkowo, jeśli na wartość wskaźnika w srcBuf może wpływać użytkownik, to uzyskuje on narzędzie do wstawienia dowolnej wartości pod adres destBuf.

Standardy kodowania

Oprócz list groźnych błędów wspomniane organizacje publikują także poradniki dotyczące zasad dobrego kodowania:

To ostatnie źródło nie pochodzi od omawianych tutaj organizacji, ale jest najbogatszym źródłem porad, które też zawiera informacje, jakie narzędzia wspomagają unikanie poszczególnych rodzajów błędów. Dodatkowo źródło to jest bezpłatne.

Ćwiczenie

Znajdź za pomocą opisanych powyżej narzędzi trzy błędy związane z operacjami na pamięci w kodzie załączonego pliku blad.c (błędy związane z typami całkowitoliczbowymi się nie liczą). Wywołania, które wskazują na błąd, oraz wypisywane przez nich komunikaty zapisz w pliku tekstowym i wyślij go za pomocą Moodle. Uwaga: znalezione błędy niekoniecznie muszą należeć do omówionych powyżej.

blad.c

#include <string.h>
#include <stdio.h>
#include <stdlib.h>
 
#define ERROR  '@'
 
int palindrome_with_error(char* buf, size_t size) {
 
  char* tmp;
  size_t len;
  size_t i;
 
  buf[size] = '\0';
  tmp = (char*) malloc(size);
  if (tmp==NULL) return -1;
  len = strlen(buf);
  for (i = len; i>=0; i--) {
    if (buf[i] == ERROR) return -1;
    tmp[len-i]=buf[i];
  }
  int res = strcmp(buf, tmp);
  free(tmp);
  return res;
}
 
 
int main() {
  char a[10];
  strcpy(a, "aba");
  if (palindrome_with_error(a, 3))
    printf( "OK\n");
}