Tagi:  •  

Wpis ten przedstawia w jaki sposób można pozwolić programowi pisanego w Javie na modyfikowanie własnego kodu. Nie jest to do końca to samo co SMC rozpopularyzowane przez autorów zabezpieczeń na Windowsach, ale w połączeniu z kilkoma genialnymi pomysłami na które każdy z was prawdopodobnie trafi pozwoli na zbudowanie lepszego zabezpieczenia niż te zwykłe, oparte na zwykłym porównaniu dwóch stringów. Postaram się opisać w jaki sposób można zaimplementować deszyfrowanie plików .class podczas działania programu robiąc to w samej pamięci, bez tworzenia plików tymczasowych.

W przeciwieństwie do zwykłych plików wykonywalnych, Java jest trochę na przegranej pozycji, jeżeli chodzi o zabezpieczanie software przed złamaniem. Którąkolwiek formę dystrybucji by wybrać to i tak można się dostać do plików .class, w których jest interesujący atakującego kod. Pliki JAR to zwykłe ZIPy ze zmienionym rozszerzeniem, więc rozpakować je można za pomocą programu unzip, a same pliki .class można zdekompilować do pliku .java za pomocą choćby takiego programu jak jad. Różne wymysły takie jak obfuscatory mogą tylko utrudnić śledzenie kodu, ale w żadnym wypadku nie mogą zatrzymać atakującego, bo przecież kod programu to jest nadal kod programu - w jakimkolwiek stopniu zaciemniony by nie był, to działać nadal musi. Można go śledzić i analizować, stawiając małe kroki można w końcu dojść do rozwiązania i poznać algorytm na tyle dobrze, że napisanie generatora kluczy nie będzie stanowiło większego problemu.

Programiści Javy mają do dyspozycji maksymalnie kod bajtowy, który mogą wykorzystać w celu sklejenia dobrego zabezpieczenia. Można się pokusić o stwierdzenie, że przecież wiele języków posiada kompilatory tworzące kod pod JVM, ale suma sumarum i tak wykonuje się sam bytecode, a ten bytecode po prostu nie ma instrukcji które pozwoliłyby na tworzenie takich rzeczy jak samomodyfikujący się kod, operacje na wskaźnikach czy inne, popularne rozwiązania kodu x86 (packery, protectory, etc). Sposób uruchamiania programów przez maszynę wirtualną jest jednak na tyle odmienny od konwencjonalnego odpalania exeków, że pojawiają się też nowe możliwości obrony przed atakami, i jeden z tych sposobów opiszę w tym poście. Będzie to samomodyfikujący się kod w Javie :). Tak, napisałem przed chwilą, że taki wynalazek w JVM nie jest możliwy, i to jest prawda, ale ta metoda działa trochę inaczej od zwykłego SMC. Ale najpierw trochę teorii ;).

Przede wszystkim, każdy program w Javie składa się z klas. Każda klasa umieszczona jest w osobnym pliku .class, bez względu na to, czy jest to klasa prywatna, publiczna, anonimowa, czy wewnętrzna, jeden plik zawiera informacje o tylko jednej klasie. Jeśli np. w pliku SimpleClass.java znajduje się klasa publiczna SimpleClass oraz klasa prywatna o nazwie np. SimpleClassInformationHolder, to wynikiem kompilacji pliku SimpleClass.java będą dwa pliki: SimpleClass.class i SimpleClassInformationHolder.class. Podobnie rzecz ma się przy plikach .class dla klas wewnętrznych lub anonimowych, zmienia się tylko nazwa wygenerowanego pliku na np. SimpleClass$SimpleClassInformationHolder.class lub SimpleClass$1.class dla klas anonimowych. Wiedząc już, że każda klasa jest zapisywana w oddzielnym pliku, łatwiej jest sobie wyobrazić powiązania między klasami – tzn. jeśli klasa A zawiera jakieś pola o typach B, to wtedy alokacja obiektu o typie B w klasie A implikuje załadowanie też i klasy B. Programy zwykle składają się z setek różnych klas w różnych pakietach, dlatego JVM nie ładuje wszystkich od razu; najpierw lokalizuje klasę, która posiada metodę main(String[] args), następnie ładuje ją. Jeśli JVM podczas interpretowania kodu klasy napotka alokację obiektu o typie który jeszcze nie został załadowany, wtedy ładuje klasę i tworzy obiekt, jeśli natomiast klasa została już załadowana, JVM korzysta z już załadowanej definicji. Powoduje to sytuację, że klasy doładowywane są dynamicznie tylko wtedy, kiedy są potrzebne. Jeśli kiedykolwiek zastanawiałeś się „dlaczego do !@#$ to okienko ładuje się aż 5 sekund?!” to jest całkiem pokaźna szansa, że takie zachowanie właśnie było tego powodem. ;)

Samo ładowanie dokonywane jest przez obiekt ClassLoader. W skrócie, JVM po wykonaniu bootstrapu tworzy obiekt ClassLoader, który ładuje klasę główną (czyli tą, którą podaliśmy w argumencie programu java). To, co jest najbardziej interesujące w tym obiekcie to metoda loadClass, która w argumencie pobiera ciąg znaków String oznaczający nazwę klasy do załadowania, a zwraca obiekt Class, czyli obiektową reprezentację klasy języka Java, gotową do wykorzystania przez takie mechanizmy jak refleksja. Obiekt ten opisuje takie rzeczy jak lista metod w klasie, lista pól, klasy nadrzędne, implementowane interfejsy, etc. (polecam przeczytać dokumentacje na ten temat, bo refleksja to jeden z ciekawszych aspektów programów uruchamianych na VM), pozwala też na wykonywanie metod, których nazw do końca nie znamy w chwili pisania programu, ale będziemy znali w fazie runtime. Ogólnie, dość ciekawy mechanizm ;). Jakoże cała mechanika klasy ClassLoader jest już zrobiona przez Sun'a, więc można tą klasę wykorzystać sobie do naszych niecnych celów... To, co będzie nam potrzebne, to przede wszystkim:

  • - Obiekt dziedziczący klasę ClassLoader (on będzie zajmował się deszyfrowaniem plików .class i zamianą ich na obiekt Class, pozwalający już na uruchamianie metod z kodu),
  • - Obiekt reprezentujący nasz program (czyli po prostu klasę główną z kodem programu),
  • - Jakieś narzędzie do szyfrowania plików (chyba, że lubisz siedzieć z długopisem i kalkulatorem, wtedy możesz szyfrować klasy ręcznie).

Zacznijmy standardowo, czyli od końca :). Szyfrowanie klas będzie odbywało się przez prosty i znany XOR z uwagi na swoją prostotę i dobry przykład szyfrowania. Nasze narzędzie będzie po prostu szyfrowało plik podany w argumencie (a będzie to plik .class) i tworzyło plik wynikowy. Możesz napisać to narzędzie w czym chcesz, ja napisałem je w języku C (choć w erze pytonów i rubinów może się wydawać trochę dziwnym posunięciem). Tak wygląda szyfrowanie i deszyfrowanie plików .class:

  1.     while((b = fgetc(fp)) != EOF)
  2.      {
  3.          b &= 0xff;
  4.  
  5.          if(encode)
  6.          {
  7.              b ^= key; // szyfrowanie
  8.              b++;
  9.          }
  10.          else
  11.          {
  12.              b--; // deszyfrowanie
  13.              b ^= key;
  14.          }
  15.  
  16.          fputc(b, fpw);
  17.      }

Link do pełnego źródła znajduje się na końcu posta. Użycie programu zdradza informacja Usage, czyli podając argument -c plik zostanie zaszyfrowany i zaszyfrowana postąć powędruje do pliku „z” (czyli ostatnia litera nazwy pliku zostaje zamieniona na „z”), natomiast używając argumentu -d plik zostanie rozszyfrowany. Tej drugiej opcji nie będziemy musieli jednak używać.

Kolejny punkt, to przykładowy program, który może być czymkolwiek. Dla celów prostoty przykładu posłużę się tutaj najpopularniejszym programem, mianowicie Hello World.

  1. package cx.ath.antonone.hl;
  2.  
  3.  public class SampleProgram
  4.  {
  5.      public static void main(String[] args)
  6.      {
  7.           (new SampleProgram()).run(args);
  8.      }
  9.  
  10.      public void run(String[] args)
  11.      {
  12.      if(args.length > 0)
  13.          System.out.println("Hello, " + args[0] + ", from " + System.getProperty("os.name") + "!");
  14.      else
  15.          System.out.println("Podaj argument.");
  16.      }
  17.  }

Po uruchomieniu klasy cx.ath.antonone.hl.SampleProgram przywita nas napis „Podaj argument.”. Po uruchomieniu klasy z argumentem, pojawi się napis „Hello, a1, from Linux!” (w moim przypadku). Teraz, gdy już posiadamy wygenerowany plik .class (w katalogu cx/ath/antonone/hl), trzeba go zaszyfrować.

  1. $ ./cryptor -c SampleProgram.class && mv SampleProgram.clasz SampleProgram.class

Tak zaszyfrowany plik zostawiamy na swoim miejscu.

Ostatnia część, czyli pisanie ClassLoadera, to główna część tego posta i przy okazji najbardziej skomplikowana, jednak nie chcę przez to powiedzieć, że jest w jakimkolwiek stopniu skomplikowana ;). Zasada jest prosta. Tworzymy obiekt ClassLoader, zasłaniamy w nim metodę loadClass(String). W tej metodzie staramy się określić, czy klasa którą mamy załadować jest zaszyfrowana, czy nie; jeśli tak, to odszyfrowujemy klasę i ją ładujemy do postaci obiektu Class, jeśli jednak nie jest zaszyfrowana, to i tak ją ładujemy, ale przu użyciu któregoś z nadrzędnych ClassLoaderów. Początek klasy to jej deklaracja, w której precyzujemy klasę nadrzędną i deklarujemy jedno pole prywatne:

  1. public class Loader extends ClassLoader
  2.  {
  3.      private ClassLoader orig_loader;

Pierwsza metoda to konstruktor, w którym zapisujemy nadrzędny classloader. Będziemy go wykorzystywać do załadowania klasy, jeśli nie jest ona zaszyfrowana.

  1. public Loader()
  2.  {
  3.      orig_loader = this.getClass().getClassLoader();
  4.  }

Pora na metodę główną loadera, czyli metodę main. Ogranicza się ona do stworzenia obiektu Loader i załadowania konkretnej klasy („cx.ath.antonone.hl.SampleProgram”) w linii 25. Po załadowaniu klasy wywołujemy jej metodę main() z argumentami takimi samymi jakie przekazaliśmy do klasy Loader (linia 26).

  1.     public static void main(String[] args) throws IllegalAccessException, InvocationTargetException
  2.      {
  3.             Loader ldr = new Loader();
  4. ...
  5.                 Class cls = ldr.loadClass("cx.ath.antonone.hl.SampleProgram");
  6.                 cls.getMethod("main", args.getClass()).invoke(null, (Object) args);
  7. ...
  8.      }

Taki jest ogólny schemat. Teraz przejdźmy do nieco większych szczegółów. Oto metoda zasłaniająca standardową metodę ładowania klas. Pierwszą rzeczą jest ustalenie nazwy pliku .class z nazwy klasy do załadowania. Nazwa klasy określana jest zawsze w długiej postaci razem ze wszystkimi pakietami, do których ta klasa należy tzn. w naszym przypadku będzie to cx.ath.antonone.hl.SampleProgram. Aby określić ścieżkę do pliku .class w systemie plików, wystarczy kropki zamienić na slashe (lub backslashe w Windows) i na koniec dodać ciąg znaków „.class”. Oczywiście to jest tylko przykład – definiując własną klasę ładującą mamy pełną swobodę w decydowaniu jaka będzie jej nazwa pliku i równie dobrze ta nazwa może być generowana przy pomocy MD5 czy SHA1, a jej treść rozkodowana przy pomocy krzywych eliptycznych kluczem pobranym z zewnętrznego serwera przy pomocy gniazd sieciowych – dla JVM nie ma to znaczenia. W przykładzie jednak, aby nie wprowadzać nadmiernych i niepotrzebnych komplikacji, posłużę się bardziej tradycyjnym sposobem nazewnictwa klas (oczywiście, jeśli klasa siedziałaby w pliku JAR wtedy trzeba dopisać kod obsługujący odczytywanie archiw JAR).

  1.     public Class loadClass(String name) throws ClassNotFoundException
  2.      {
  3.         try
  4.         {
  5.                 String class_name = name.replace('.', '/') + ".class";
  6.                 FileInputStream data = new FileInputStream(class_name);

Tak więc mając już nazwę pliku, a nawet więcej – już otwarty plik, przystępujemy do sprawdzania, czy klasa jest zaszyfrowana, czy nie. Dzieje się to przy pomocy sprawdzania wartości magicznej magic value plików .class: 0xCA, 0xFE, 0xBA, 0xBE. Mój kod sprawdza tylko dwa początkowe bajty, ale nie ma to już większego znaczenia.

  1.                int b1 = data.read(); // odczytaj bajt 1
  2.                 int b2 = data.read(); // odczytaj bajt 2
  3.  
  4.                 if(b1 != 0xca || b2 != 0xfe)
  5.                 {
  6.                      byte[] bytes = decode(data);
  7.                      return defineClass(name, bytes, 0, bytes.length);
  8.                 }

Jeśli warunek jest spełniony, to znaczy że klasa jest zaszyfrowana i używamy metody decode() aby ją odszyfrować, a następnie ładujemy ją przy pomocy metody defineClass() - aby uzyskać obiekt Class. Warto też dodać, że to właśnie te dwie linijki stanowią całą istotę tego podejścia. Zauważ, że metoda defineClass w argumentach pobiera nazwę klasy i kod bajtowy, który jest ładowany, weryfikowany i kompilowany przez kompilator JIT maszyny wirtualnej. Mamy pełną swobodę co do tego co ten kod bajtowy będzie zawierał, wraz z dowolnością modyfikacji całych struktur we wnętrzu plików class. To pozwala nam nie tylko na „deszyfrowanie plików class”, ale też bardziej subtelne operacje, takie jak rekonstruowanie tablicy zmiennych lokalnych (czyli informacji, które należą do typu informacji od debugowania, wycinanych przy kompilowaniu z przełącznikiem -g:none, ale bez których nie da się podejrzeć zmiennych lokalnych w metodzie przy użyciu debugera jdb), modyfikacja puli stałych („Constant Pool”), czy nawet mutacja kodu (w celu personalizacji kopii programu, watermarking), równie dobrze kod bajtowy może być pobierany z zewnętrznego serwera po udanej autoryzacji przy użyciu systemu kluczy jednorazowych – możliwości jest wiele.

Możesz też zadać pytanie: gdzie tu jest miejsce dla samomodyfikującego się kodu? Otóż, klasa może równie dobrze załadować „swój” plik .class; tzn. klasa o nazwie A ładuje plik o nazwie A.class, modyfikuje kod bajtowy i tworzy obiekt Class, następnie wywołuje konkretne, zmodyfikowane już, metody. Dekompilator będzie przedstawiał ciało metod jakie odczytał z pliku, jednak JVM będzie wykonywała taki kod, jaki zmieniliśmy po załadowaniu klasy dynamicznie. Idąc dalej...

  1.        else
  2.                 throw new Exception();
  3.         }
  4.         catch(Exception e)
  5.         {
  6.                 return orig_loader.loadClass(name);
  7.         }
  8.   }

Jeśli dwa pierwsze bajty tego pliku będą równe 0xCA, 0xFE to znaczy, że klasa jest odkodowana. Dlatego wyrzucamy wyjątek, a zaraz poniżej go łapiemy. W bloku obsługującym łapanie wyjątków ładujemy tą klasę przy pomocy nadrzędnego classloadera, do którego obiekt pobraliśmy w konstruktorze klasy wcześniej. Takie żonglowanie wyjątkami tłumaczy fakt, że metoda defineClass sama może wyrzucić wyjątek; wtedy zostanie złapany w bloku powyżej i swoich sił spróbuje nadrzędny classloader.

Kod dekodujący pliki .class jest prosty i raczej taki sam jak zapewne większość z was się domyśla:

  1.     private byte[] decode(FileInputStream input) throws IOException
  2.      {
  3.         int b;
  4.         ByteArrayOutputStream output = new ByteArrayOutputStream();
  5.  
  6.         // zapisz wczesniej pominiete bajty
  7.         output.write(0xca);
  8.         output.write(0xfe);
  9.  
  10.         while((b = input.read()) != -1)
  11.         {
  12.                 byte bb = (byte) b;
  13.                 bb--;
  14.                 bb ^= 0xca;
  15.                 output.write(bb);
  16.         }

Czyli jest to kod z cryptor.c przerobiony na język Java, z drobną modyfikacją. Na początku z obiektu 'input' odczytaliśmy dwa bajty (w metodzie loadClass) i porównywaliśmy te bajty z 0xCA, 0xFE. Teraz te pominięte bajty zapisujemy do bufora 'output' (w odkodowanej postaci). Po uruchomieniu programu pojawia się wynik:

  1. $ java cx.ath.antonone.hl.Loader a1
  2. Hello, a1, from Linux!
Natomiast wygląd klasy...
  1. 0000000: 0135 7175 cbcb cbf9 cbf5 c1cb dccb ecce  .5qu............
  2. 0000010: cbe9 c1cb c9cb ecc1 cbc9 cbea c4cb efcb  ................
  3. 0000020: f0ce cbed c1cb cdcb ecc3 cbee c1cb cdcb  ................
  4. 0000030: e3c3 cbe4 c3cb e1c1 cbef cbe2 c3cb e7c1  ................
  5. 0000040: cbcd cbe8 c1cb e5cb e6c3 cbfb cecb fccc  ................
  6. 0000050: cbcd f7a4 a5a4 bff5 cccb cae3 e49d cccb  ................

... nie przypomina klasy w ogóle. :) Co prawda, w tym przypadku, doświadczone oko zauważyłoby tutaj pewne schematy i wzory i być może udałoby się jej nawet uzyskać klucz XOR którym się posłużyliśmy. Standardowy wygląd plików class wygląda tak (Loader.class):

  1. 0000000: cafe babe 0000 0032 009d 0a00 2400 5a0a  .......2....$.Z.

Więc na początku znajdujemy 4 wysokie bajty (tzn. o wysokiej wartości), zaraz potem seria niskich bajtów. Wracając do struktury naszej zakodowanej klasy można zauważyć, że sytuacja razem z proporcjami jest po prostu odwrotna, a zwracając uwagę na to, że w miejscu gdzie w klasie powinny być bajty 0x00 w klasie zakodowanej są bajty 0xCB, to jest wysokie prawdopodobieństwo, że szyfrowanie to zwykły XOR ze stałym kluczem. Dlatego, jak pisałem wcześniej, XOR służy tylko jako przykład szyfrowania, nie jako pełnoprawny „algorytm” szyfrujący.

Podsumowując, oczywiście to zabezpieczenie, jak i wszystkie inne, ma swoją wadę. Atakujący może przechwycić wywołania metody defineClass i zdumpować tablicę byte[] opisującą prawidłowy wygląd klasy. Przed tym też można się obronić, jednak ta obrona prawdopodobnie też będzie miała swój czuły punkt, i tak dalej. Można się też łudzić, że na platformie Javy nie ma jeszcze takich zaawansowanych narzędzi do łamania, jakie są na platformie x86, tzn. debuggery wymagają informacji do debugowania (przełącznik -g), dekompilatory są oczywiście podatne choćby na metodę opisaną wyżej (SMC), jednak to jest tylko stan przejściowy. Prędzej czy później pojawią się odpowiednie narzędzia stworzone do odpowiednich celów i wypełnią pusty koszyk czerwonego zakapturzonego crackera zmierzającego przez las zabezpieczeń do swojej babci. Pytanie tylko czy będziemy groźnym wilkiem czy nie :).

Kod źródłowy: tutaj.

Komentuj

Zawartość tego pola nie będzie udostępniana publicznie.
  • Adresy internetowe są automatycznie zamieniane w klikalne odnośniki.
  • Use <!--pagebreak--> to create page breaks.
  • You may post block code using <blockcode [type="language"]>...</blockcode> tags. You may also post inline code using <code [type="language"]>...</code> tags.
  • Use <fn>...</fn> to insert automatically numbered footnotes.

Więcej informacji na temat formatowania