Zaciemnianie kodu (także obfuskacja, z ang. obfuscation) to technika przekształcania programów, która zmienia składnię, ale zachowuje ich semantykę, co znacząco utrudnia ich zrozumienie[1]. Istnieją również narzędzia (obfuskatory) modyfikujące kod źródłowy, pośredni bądź binarny w celu utrudnienia inżynierii wstecznej programu. Wyróżniamy 3 typy transformacji obfuskacyjnych:

  • transformacja wyglądu (ang. Layout Transformation) – zmiany nazw identyfikatorów, zmiana formatowania, usuwanie komentarzy.
  • transformacja danych (ang. Data Transformation) – rozdzielenie zmiennych, konwersja statycznych danych do procedury, zmiana kodowania, zmiana długości życia zmiennej, łączenie zmiennych skalarnych, zmiana relacji dziedziczenia, rozłam/łączenie tablic, zmiana porządku instancji zmiennych / metod / tablic.
  • transformacja kontroli (ang. Control Transformation) – zmiana przebiegu, rozszerzenie warunków pętli, zmiana kolejności komend / pętli / wyrażeń, metody inline, ogólnikowe wyrażenia, klonowanie metod.

Zastosowania edytuj

Zarządzanie ryzykiem edytuj

Zaciemnianie kodu źródłowego w celu przeciwdziałania ewentualnym próbom analizy programu to jedna z technik zarządzania ryzykiem nieautoryzowanego dostępu. Następstwem takiego zdarzenia może być zagrożenie własności intelektualnej, ułatwienie znalezienia luk bezpieczeństwa lub obniżenie zysku w przypadku aplikacji, która została zmodyfikowana w celu obejścia jej zabezpieczeń chroniących przed kopiowaniem. Zaciemnianie kodu służy w takim przypadku łagodzeniu (kompensacji) strat związanych z tym ryzykiem. Chociaż inżynieria wsteczna nie jest niczym nowym, upowszechnienie się oprogramowania dystrybuowanego w postaci kodu pośredniego (np. Java lub .NET) znacząco zwiększa ryzyko wystąpienia negatywnych następstw nieautoryzowanego dostępu.

Optymalizacja edytuj

Zaciemnianie kodu binarnego programów (np. przez usuwanie wskaźnika rekordu aktywacji (ang. frame pointer) i informacji o symbolach) stosowane jest również do zmniejszania rozmiaru plików wynikowych i poprawiania ich wydajności. Z technik zaciemniania korzysta się również przy tworzeniu MIDletów na platformie Java ME w celu redukcji rozmiaru plików JAR wygenerowanych w procesie kompilacji.

Spam edytuj

Obfuskacja stosowana jest po obydwu stronach barykady w walce z niechcianymi wiadomościami. Spamerzy zaciemniają treść elektronicznej korespondencji w celu ominięcia filtrów np. w poniższy sposób:

<b>Via</b><b>gra</b>

Natomiast użytkownicy poczty elektronicznej, próbujący uniknąć spamu, podają swoje adresy e-poczty w niejawnej postaci, aby utrudnić harvesterom ich odczytanie.

Rozrywka edytuj

Czasami programy są zaciemniane wyłącznie w celach rozrywkowych. Organizowane są konkursy na najbardziej pomysłowo zaciemnione programy, a wśród nich: International Obfuscated C Code Contest, Obfuscated Perl Contest, International Obfuscated Ruby Code Contest oraz Obfuscated PostScript Contest.

Przykłady edytuj

The 12 Days of Christmas edytuj

Poniższy program to jeden z najsłynniejszych zaciemnionych kodów źródłowych w języku C. Jego wykonanie wypisuje do standardowego wyjścia dwanaście zwrotek tekstu angielskiej piosenki The 12 Days of Christmas.

#include <stdio.h>
main(t,_,a)char *a;{return!0<t?t<3?main(-79,-13,a+main(-87,1-_,
main(-86,0,a+1)+a)):1,t<_?main(t+1,_,a):3,main(-94,-27+t,a)&&t==2?_<13?
main(2,_+1,"%s %d %d\n"):9:16:t<0?t<-72?main(_,t,
"@n'+,#'/*{}w+/w#cdnr/+,{}r/*de}+,/*{*+,/w{%+,/w#q#n+,/#{l,+,/n{n+,/+#n+,/#\
;#q#n+,/+k#;*+,/'r :'d*'3,}{w+K w'K:'+}e#';dq#'l \
q#'+d'K#!/+k#;q#'r}eKK#}w'r}eKK{nl]'/#;#q#n'){)#}w'){){nl]'/+#n';d}rw' i;# \
){nl]!/n{n#'; r{#w'r nc{nl]'/#{l,+'K {rw' iK{;[{nl]'/w#q#n'wk nw' \
iwk{KK{nl]!/w{%'l##w#' i; :{nl]'/*{q#'ld;r'}{nlwb!/*de}'c \
;;{nl'-{}rw]'/+,}##'*}#nc,',#nw]'/+kd'+e}+;#'rdq#w! nr'/ ') }+}{rl#'{n' ')# \
}'+}##(!!/")
:t<-50?_==*a?putchar(31[a]):main(-65,_,a+1):main((*a=='/')+t,_,a+1)
  :0<t?main(2,2,"%s"):*a=='/'||main(0,main(-61,*a,
"!ek;dc i@bK'(q)-[w]*%n+r3#l,{}:\nuwloca-O;m .vpbks,fxntdCeghiry"),a+1);}

W przeddzień Wigilii 1998 roku Thomas Ball opublikował analizę powyższego programu[2], przedstawiając jego czytelną formę wraz z objaśnieniem zasady działania oraz szczegółowym opisem procesu jego analizy.

Liczby pierwsze edytuj

Wykonanie poniższego programu wypisuje na ekran listę wszystkich liczb pierwszych mniejszych od 100.

_(__,___,____){___/__<=1?_(__,___+1,____):!(___%__)?_(__,___+1,0):___%__==___/
__&&!____?(printf("%d\t",___/__),_(__,___+1,0)):___%__>1&&___%__<___/__?_(__,1+
___,____+!(___/__%(___%__))):___<__*__?_(__,___+1,____):0;}main(){_(100,0,0);}

Chociaż powyższy fragment może być trudny do zrozumienia, powstał on przez przekształcenie poniższego programu:

void primes(int cap) {
  int i, j, composite;
  for(i = 2; i < cap; i++) {
    composite = 0;
    for(j = 2; j < i; j++) 
      composite += !(i % j);
    if(!composite)
      printf("%d\t", i);
  }
}

int main() { 
  primes(100);
}

Obfuskacja powyższego programu została przeprowadzona przez wykonanie następujących kroków:

  1. Przekształcenie ciała funkcji primes do jednej pętli while, wewnątrz której znajduje się tylko sekwencja instrukcji warunkowych.
  2. Zamiana iteracji na rekurencję.
  3. Zamiana sekwencji instrukcji warunkowych na pojedyncze wyrażenie warunkowe (z zagnieżdżonymi podwyrażeniami).
  4. Usunięcie pomocniczych zmiennych lokalnych.
  5. Zamiana nazw funkcji i jej parametrów na ciągi podkreślników.
  6. Usunięcie nadmiarowych białych znaków i nawiasów.

Pierwsza transformacja wykorzystuje fakt, że każdy program można wyrazić przy pomocy pojedynczej pętli, wewnątrz której zagnieżdżona jest sekwencja instrukcji warunkowych. Oryginalny program, przekształcony w ten sposób, prezentuje się w następujący sposób:

void primes(int cap) { 
  int i, j, composite, t = 0;
  while(t < cap * cap) {
    i = t / cap;
    j = t++ % cap;
    if(i <= 1);
    else if(j == 0)
      composite = 0;
    else if(j == i && !composite)
      printf("%d\t",i);
    else if(j > 1 && j < i)
      composite += !(i % j);  
  }
}

int main() {
  primes(100);
}

Zamiana iteracji na rekurencję wymaga zastąpienia zmiennych t i composite (niezależnie modyfikowanych w pętli) parametrami formalnymi. Ponadto, wykonanie sekwencyjne funkcji printf i primes zostało zapisane przy pomocy pojedynczego wyrażenia przecinkowego. Warto również zwrócić uwagę na dodatkowy warunek, który obsługuje sytuację, gdy żaden z warunków poprzedniej formy programu nie był prawdziwy i gdy pętla kontynuuje wykonanie, zwiększając jedynie wartość zmiennej t.

void primes(int cap, int t, int composite) {
  int i,j;
  i = t / cap;
  j = t % cap;
  if(i <= 1)
    primes(cap,t+1,composite);
  else if(j == 0)
    primes(cap,t+1,0);
  else if(j == i && !composite)
    (printf("%d\t",i), primes(cap,t+1,0));
  else if(j > 1 && j < i)
    primes(cap,t+1, composite + !(i % j));
  else if(t < cap * cap)
    primes(cap,t+1,composite);
}

int main() {
  primes(100,0,0);
}

Nazwy zmiennych obcinane są do pierwszych liter, a instrukcje warunkowe if(A) B else if(C) D else E zamieniane są na wyrażenia A ? B : C ? D : E.

void primes(int m, int t, int c) {
  int i,j;
  i = t / m;
  j = t % m;
  (i <= 1) ? primes(m,t+1,c) : (j == 0) ? primes(m,t+1,0) : (j == i && !c) ? 
  (printf("%d\t",i), primes(m,t+1,0)) : (j > 1 && j < i) ? 
  primes(m,t+1,c + !(i % j)) : (t < m * m) ? primes(m,t+1,c) : 0;
}

int main() {
  primes(100,0,0);
}

Następnie wszystkie wystąpienia zmiennych i oraz j zamieniane są odpowiednio na (t / m) oraz (t % m).

void primes(int m, int t, int c) {
  ((t / m) <= 1) ? primes(m,t+1,c) : !(t % m) ? primes(m,t+1,0) : 
  ((t % m)==(t / m) && !c) ? (printf("%d\t",(t / m)), primes(m,t+1,0)) : 
  ((t % m)> 1 && (t % m) < (t / m)) ? primes(m,t+1,c + !((t / m) % (t % m))) : 
  (t < m * m) ? primes(m,t+1,c) : 0;
}

int main() {
  primes(100,0,0); 
}

W kolejnym kroku wszystkie pozostałe nazwy: primes, m, t oraz c zastępowane są identyfikatorami _, __, ___ oraz ____.

void _(int __, int ___, int ____) {
  ((___ / __) <= 1) ? _(__,___+1,____) : !(___ % __) ? _(__,___+1,0) : 
  ((___ % __)==(___ / __) && !____) ? (printf("%d\t",(___ / __)), 
  _(__,___+1,0)) : ((___ % __) > 1 && (___ % __) < (___ / __)) ? 
  _(__,___+1,____ + !((___ / __) % (___ % __))) : (___ < __ * __) ? 
  _(__,___+1,____) : 0;
} 

int main() {
  _(100,0,0); 
}

Po usunięciu wszystkich zbędnych białych znaków, nadmiarowych nawiasów i deklaracji typów powstaje ostateczna postać zaciemnionego programu.

_(__,___,____){___/__<=1?_(__,___+1,____):!(___%__)?_(__,___+1,0):___%__==___/
__&&!____?(printf("%d\t",___/__),_(__,___+1,0)):___%__>1&&___%__<___/__?_(__,1+
___,____+!(___/__%(___%__))):___<__*__?_(__,___+1,____):0;}main(){_(100,0,0);}

Program ten można skompilować i uruchomić na większości systemów. Należy przy tym pamiętać, że choć teoretycznie zaciemniona forma programu jest równoważna z oryginałem, kompilator wygeneruje dla obydwu wersji różne kody binarne, których wykonania mogą zwracać różne wyniki. Na przykład, wersja rekurencyjna powyższego programu, w przeciwieństwie do oryginalnej postaci iteracyjnej, podatna jest na błąd przepełnienia stosu wykonania.

Wady edytuj

Jedyne zabezpieczenie edytuj

Żadna ze znanych technik obfuskacji nie daje gwarancji znacznego utrudnienia analizy zaciemnionego programu[3]. Obfuskacja nie zapewnia bezpieczeństwa porównywalnego ze współczesnymi systemami kryptograficznymi, powinna więc być stosowana jako ich dopełnienie.

Microsoft zalecał zaciemnianie plików ASP przy pomocy programu Script Encoder, aby, w przypadku włamania na serwer, cracker nie miał dostępu do ich treści. W dokumentacji narzędzia[4] zaznaczono jednak, że nie jest ono w stanie powstrzymać zdeterminowanego włamywacza od analizy programu.

Debugowanie edytuj

Wykonywanie zaciemnionych programów pod kontrolą debugera jest niezwykle trudne. Nazwy zmiennych nie mają sensu, a struktura programu może być zmieniona nie do poznania. Zmusza to programistów do utrzymywania przynajmniej dwóch osobnych kompilacji oraz do porównywania ich zachowania. Ponadto, istnienie niezaciemnionej wersji programu zwiększa ryzyko jego rozpowszechnienia np. na skutek kradzieży.

Przenośność edytuj

Zaciemnianie bardzo często zależy od specyficznych cech kompilatora bądź środowiska wykonania programu, co utrudnia obfuskację, jeśli jedno z nich się zmieni.

Mechanizm refleksji edytuj

Mechanizm refleksji umożliwia inspekcję klas i wywoływanie ich metod wyłącznie na podstawie ich nazw. Stosowanie obfuskacji w systemach, których działanie wykorzystuje ten mechanizm, znacznie ogranicza swobodę i wymaga od programistów oznaczenia wszystkich identyfikatorów, których nazwy nie mogą zostać zmienione przez obfuskator.

Zobacz też edytuj

Przypisy edytuj

  1. Sebastian Kotuła: Wstęp do Open Source. Warszawa: Wydawnictwo Stowarzyszenia Bibliotekarzy Polskich, 2014, s. 17-18. ISBN 978-83-64203-33-6. OCLC 898274558.
  2. Code Obfuscation: The 12 Days of Christmas [online], Paul Selles, 24 grudnia 2013 [dostęp 2017-05-10].
  3. Boaz Barak, Can We Obfuscate Programs?
  4. Microsoft Corp., Script Encoder