Blog
Obfuskacja kodu
Stanisław Kubiak 07.06.2019
Czy zastanawiałeś się kiedykolwiek, droga czytelniczko oraz drogi czytelniku, nad tym w jaki sposób uczynić swoją aplikację bezpieczniejszą? I nie będzie to unikanie nieprzewidzianych crashy, niepoprawnego parsowania danych czy też <tutaj wstaw dowolny rodzaj błędu>. Mam na myśli raczej uczynienie swojej aplikacji odpornej na ataki z zewnątrz. Przed tymi strasznymi crackerami. Tak! Idzie o obfuskację kodu. Bądź też, bardziej po polsku, zaciemnianie kodu.
Niedawno pisaliśmy to i owo o bezpieczeństwie – a konkretniej o tym, w jaki sposób sprawdzić czy nasze hasło nie wyciekło w odmęty internetów.
W tym wpisie zastanowimy się nad tym co należy uczynić, aby to kod źródłowy naszej aplikacji nie trafił w odmęty internetów.
Na samym początku zdefiniujmy, czym tak właściwie jest zaciemnianie kodu. Definicji jest zapewnie kilka. Generalnie zaciemnianie kodu – czyli obfuskacja kodu – polega na przekształceniu aplikacji w taki taki sposób, aby było trudniej zrozumieć (w skrajnym przypadku: złamać) kolejne kroki kodu asemblera.
Zmiany te aplikować można w różnych stadiach. I tak, można je wprowadzać w kodzie:
- źródłowym,
- bajtowym,
- maszynowym.
Należy pamiętać, że obfuskacja kodu – jak i każde inne zabezpieczenie – nie daje 100% gwarancji bezpieczeństwa. Wprowadza jedynie przeszkodę do pokonania przez atakującego, co w praktyce może jedynie opóźnić moment złamania aplikacji.
Wracając do meritum – wspomniałem już, że zmiany możemy wprowadzać na różnych etapach powstawania programu. Skupię się na technikach związanych stricte z kodem źródłowym. Będę modyfikować prosty program, który postaram się uczynić minimalnie bezpieczniejszym w kilku krokach.
Poniżej wspomniany kod aplikacji:
#include <iostream>
#include <string>
std::string encrypt(std::string s)
{
std::string out;
for (auto i = 0u; i < s.size(); ++i)
out += static_cast<char>(s[i] ^ 0xDD);
return out;
}
static auto pass = encrypt("okon");
int main()
{
std::string s;
std::cout << "Password, please: ";
std::cin >> s;
if (encrypt(s) == pass)
{
std::cout << "Hello, Sir!\n";
return 0;
}
else
{
std::cout << "Go away, peasant!\n";
return 1;
}
}
„bezpieczeństwo” programu będziemy oceniać za pomocą IDA (Interactive DissAselbmer) – narzędzia służącego do zabaw przy inżynierii wstecznej.
Uwaga! – tak naprawdę skupię się tylko na tym, aby ocenić złożoność złożoność bloków kodu wygenerowanych przez IDA.
Pozwoli to nam, bardzo luźno, ocenić czy dane metody obfuskacji faktycznie działają. Dlatego, pamiętaj proszę, że w tym wpisie nie będziemy pochylać się nad praktycznym weryfikowaniem zabezpieczeń konkretnych technik zaciemniania kodu.
Podmiana nazw
Na początek najłatwiejsza z technik – zmienianie nazw (zmiennych bądź metod). Transformacja ta nie ma zbyt dużego przełożenia w językach kompilowanych (jak np. C++), natomiast chętniej wykorzystywana jest w językach skryptowych (np. JavaScript). Polega ona na zmianie nazwy, np. zweryfikujPin() na gdkfglndv030uigujnd() – prawda, że proste?
Pochodną przekształcania na zasadzie podmiany nazw może być również korzystanie z szyfrowanych nazw zmiennych bądź metod.
Niestety, metoda ta o ile jest banalna do zaaplikowania, tak niestety nie daje dużo w kwestii bezpieczeństwa.
Sterowanie przepływem
Dość enigmatycznie brzmiąca transformacja, nieprawdaż? Zasadniczo, każdy program można rozbić na pewne moduły, w ramach których wykonywane są kolejne operacje, jedna po drugiej, bez instrukcji skoku.
Coś na kształt segmentu, w którym zawieramy daną część funkcjonalności (np. logiczne odwzorowanie procesu wpisywania hasła i jego walidacji).
Analizując program, uzyskać można graf obrazujący przejścia między tymi modułami – to już może doprowadzić do odgadnięcia tego, w jaki sposób działa dana aplikacja (czyli np. to jak walidacja hasła wpływa na autoryzację użytkownika).
Powinniśmy zadać sobie pytanie, w jaki sposób możemy zmodyfikować nasz kod źródłowy, aby wygenerowane bloki nie ujawniły naszych intencji?
Wyżej wspomniana analiza z reguły opiera się o wyszukiwanie konkretnych wzorców, sekwencji. Dlatego naszym zadaniem powinno być ich zaburzanie.
Np. bloki kodu, które następują kolejno po sobie umiejscawiamy obok siebie (stąd spłaszczanie przepływu – control flow flattening).
Idealnie nadają się ku temu konstrukcje, przed którymi przestrzega uncle Bob, przestrzegają dobrzy harcerze oraz Zbigniew Stonoga – czyli np. goto bądź niezrozumiałe akrobacje przy użyciu switch.
Przyjrzyjmy się zmianom w metodzie encrypt, z naszego przykładu
std::string encrypt(std::string s)
{
std::string out;
std::size_t i;
int dummy = 1;
while (true)
{
switch (dummy)
{
case 1:
{
i = 0;
dummy = 2;
break;
}
case 2:
{
if (i < s.size())
{
out += static_cast<char>(s[i] ^ 0xDD);
++i;
}
else
{
dummy = 3;
}
break;
}
case 3:
{
return out;
}
}
}
}
Funkcjonalnie metoda robi nadal to samo. Przeprowadza nieskomplikowaną operację kodowania zadanych danych na wejściu.
Z tym, że diagram przepływu znacząco się różni od tego początkowego.
Powyżej zawarłem bazową wersję metody encrypt. Jej schemat przepływu kontroli jest trywialny. Sprawdźmy jak prezentuje się ta sama metoda po drobnych zmianach.
Efekt jest zauważalny: przepływ jest obecnie rozbity pomiędzy kilka bloków. Faktem jest, że nasze zmiany nadal nie stanowią poważnego zabezpieczenia dla atakujących, zatem psujmy kod działamy dalej!
Dalsze zaciemnianie
Przyjrzyjmy się najzwyklejszemu dodawaniu martwego kodu. Kodu, który nic nie robi i nigdy nie zostanie wywołany. Bądź też, który funkcjonalnie nie robi niczego.
Dodawanie takich fragmentów ma za zadanie zmusić atakującego do spędzenia większej ilości czasu nad naszym programem, aby odrzucić bloki assemblera, które ostatecznie nie mają żadnego wpływu na całokształt.
Ponadto, można dodawać bloki kodu, które zostaną wykonane i nie wprowadzą żadnej zmiany w działaniu aplikacji, np. potencjalnie wykonują skomplikowane operacje matematyczne na danych zmiennych, finalnie pozostawiając je w oryginalnej postaci. Możemy również korzystać z operacji asemblerowych, które dosłownie nic nie robią – nop.
Niestety ta konkretna technika ma pewne wady.
Primo – musimy walczyć z optymalizacjami kompilatora. Współczesne kompilatory potrafią przeprowadzać zaawansowane optymalizacje, potrafią zreorganizować strukturę kodu (w zależności na jak agresywne optymalizacje pozwalamy) potrafią wreszcie usuwać kawałki kodu, które niczego nie robią (ale! – jest ratunek: z odsieczą przyjść może volatile). A wszystko to, abyśmy otrzymali w wyniku mniejszy plik binarny i/lub szybciej działającą aplikację.
Secundo – im więcej kodu tym (z reguły) większy plik binarny. W pewnych konkretnych warunkach może to być naprawdę poważny problem.
Zatem, załóżmy że dodaliśmy kolejne bloki kodu. Poradziliśmy sobie z optymalizacjami kompilatora. Rozmiar pliku binarnego nie jest nam straszny. W takim razie pójdźmy jeszcze o krok dalej – wprowadźmy pewną losowość wśród bloków assemblera naszej aplikacji.
Przyjrzyjmy się zmianom dokonanym w metodzie szyfrującej – dla przypomnienia, dodaliśmy: bloki kodu które się nie wykonają, bloki kodu które niczego nie zmieniają oraz losowe umiejscowienie segmentów między sobą.
std::string encrypt(std::string s)
{
std::string out;
std::size_t i;
int dummy = 1;
while (true)
{
switch (dummy)
{
case 3:
{
return out;
}
case 2:
{
if (i < s.size())
{
out += static_cast<char>(s[i] ^ 0xDD);
++i;
}
else
{
dummy = 3;
}
break;
}
case 1:
{
i = 0;
dummy = 2;
}
case 42:
{
i *= 150;
break;
}
case 1337:
{
return "dfjdjlfd*#&$@*#0832740238742";
}
case 0:
{
dummy = 1;
break;
}
}
}
}
Ponownie, zweryfikujmy zmiany naszą zaawansowaną metodą oceniania bezpieczeństwa w aplikacji.
Pozorne warunki
Kolejną techniką, którą warto znać w przypadku zaciemniania kodu to stosowanie pozornie skomplikowanych wyrażeń, warunkujących wykonanie programu. Wyrażenia te zwracają wartość znaną z góry; w połączeniu z instrukcjami if-else otrzymujemy pozorne rozgałęzienie wykonania naszej aplikacji. Jest to oczywiście opaque predicate (niestety nie jestem w stanie doszukać się oficjalnego tłumaczenia na język polski, dlatego pozostaję przy angielskiej nazwie).
Postarajmy się zastosować ją w naszym przykładzie. Oto nowa metoda, którą wprowadzimy do naszego programu wraz z jej późniejszym użyciem w main().
bool verify(int a)
{
volatile int b = a;
a = 150 * 28;
volatile int c = a;
a ^= 1336;
return b;
}
int main()
{
std::string s;
std::cout << "Password, please: ";
std::cin >> s;
if (verify(150))
{
if (encrypt(s) == pass)
{
std::cout << "Hello, Sir!\n";
return 0;
}
else
{
std::cout << "Go away, peasant!\n";
return 1;
}
}
else
return 0xABCD;
}
Co jeszcze
Można pokusić się o kolejne techniki zaciemniania kodu, niektóre z nich to:
- rekurencja,
- modyfikowanie układu przetwarzanych danych w taki sposób, aby ich typ nie był od razu oczywisty,
- kompozycja danych w większe struktury ukrywające rzeczywisty typ ,
- wykorzystywanie zewnętrznych źródeł (entropii) do sterowania przepływem w aplikacji,
- korzystanie z „god object” czyli obiektu, który realizuje wiele – jeśli nie wszystkie – funkcjonalności danego komponentu,
- tworzenie bardzo długich metod, zrywając z regułami pisania czystego kodu (metoda nie powinna być dłuższa niż 10 LOC),
- wiele innych.
Konkluzja
Zagadnienie zaciemniania kodu jest zdecydowanie złożonym procesem. Wymaga dużej kreatywności, znajomości zasad działania kompilatora, często zaawansowanych konstrukcji danego języka programowania, konkretnych cech systemów operacyjnych, znajomości schematów ataków, etc.
Przystępując do zaciemniania kodu mieć należy na uwadze również kilka problemów wynikających z jego zastosowania.
Mianowicie:
- wydajność aplikacji: stosując techniki obfuskacji często świadomie implementujemy rozwiązania nieoptymalne, bądź utrudniamy kompilatorowi wdrożenie konkretnych zmian w kodzie mających na celu przyspieszenie jego działania; z drugiej strony tworząc np. aplikacje z GUI zależy nam na wysokiej liczbie FPSów, dlatego warto rozważyć np. zaciemnianie kodu tylko w newralgicznych obszarach aplikacji, a te które są krytyczne (pod kątem wydajności) pozostawić bez zmian,
- debugowanie naszego własnego kodu może stać się problematyczne,
- rozmiar pliku binarnego może ulec zwiekszeniu, co np. przy systemach wbudowanych może mieć kolosalne znaczenie,
- utrzymanie kodu poddanego wymienionym przekształceniom będzie na pewno trudniejsze.
Gorąco zachęcam do zgłębienia zaciemniania kodu. Niniejszy wpis służy tylko zasygnalizowaniu istnienia tego tematu, nie skupiałem się na detalach, nie korzystałem w sposób zaawansowany z IDA, nie zagłębiałem się w kod assemblerowy, nie poruszyłem metod obfuskacji na etapie kodu pośredniego lub maszynowego.