threats.pl > Bezpieczeństwo aplikacji internetowych > Lekcja 6: Home-brewed Crypto

Lekcja 6: Home-brewed Crypto

Wprowadzenie

Tworzenie algorytmów kryptograficznych nie jest zadaniem trywialnym. Temat kryptografii pojawia się zarówno na OWASP Top10 jako Insecure Cryptographic Storage, jak i w 2009 CWE/SANS Top 25 Most Dangerous Programming Errors w punkcie Use of a Broken or Risky Cryptographic Algorithm. Założenie, że jakiś algorytm jest bezpieczny (bezpieczniejszy), ponieważ nikt nie zna zasady jego działania, jest z zasady błędne. Należy postępować wprost odwrotnie, czyli wykorzystywać znane i sprawdzone algorytmy kryptograficzne, co więcej trzeba je wykorzystać w sposób prawidłowy. Nie należy wymyślać własnych algorytmów!

Przykład

W przykładzie dostępnym pod adresem http://bootcamp.threats.pl/lesson06/ zaimplementowany został domowej roboty algorytm hashowania haseł. Zakłada się, że atakujący ma konto w aplikacji, może zmieniać w niej hasła, jednocześnie posiada dostęp do hashy haseł. Celem atakującego może być złamanie/poznanie haseł innych użytkowników.

Jak wygląda hash

Pierwszym etapem może być przyjrzenie się jak wygląda hash hasła. Dla przykładu kilka hashy tego samego hasła:

    e10900ee6f5c04e99f1b49251fe079cb0e883c9a51a12be786c6fb85f87012e9
    0d59b65b462eb4e9c978a85104978acba97da72a6e039de7bba6d4352769b9e9
    0115316404c333e9dd413f00a76251cb4b1d26a610ca55e796d6b258022441e9

Jak widać poszczególne hashe różnią się między sobą. Oznacza to, że nie jest raczej wykorzystany żaden z algorytmów typu SHA1 czy MD5. W takim przpadku hash tego samego hasła wyglądałby w ten sposób. Możliwe jest jednak, że wykorzystywany jest jakiś salt, dzięki czemu nawet to samo hasło może dawać różne hashe. Można sprawdzić losowość generowanych hashy przy pomocy narzędzia Burp. W założeniu narzędzie to ma badać losowość identyfikatorów (np. identyfikatorów sesji), ale w tym zastosowaniu również może się sprawdzić.

W celu analizy należy wygenerować więcej próbek hashy. Im więcej, tym analiza jest bardziej wiarygodna. Minimalna ilość próbek wymaganych przez Burp to 100. Po poddaniu ich analizie pokazuje się następujący obraz:

Jak widać na pewnych pozycjach znaki ewidentnie nie są losowe. Jeśli sprawdzi się hasło o innej długości, otrzymuje się podobny rezultat:

Pierwsze z haseł miało długość 4 znaków, drugie - 8 znaków, trzecie - 2 znaki. Jak widać ilość nielosowych punktów w każdym z hashy jest równa długości hasła. Rozmieszczenie tych punktów jest zależne od długości hasła.

Długość hasha to 64 znaki. Ponieważ w każdym z "nielosowych" punktów znajdują się dwa znaki, można przypuszczać, że na tych dwóch bajtach reprezentowany jest znak z hasła.

Jak wyglądają hashe znanych haseł

Jak wyglądają hashe dla znanych haseł? Sprawdźmy kilka przypadków, na przykład aa, ab, ac, ad, ae. Ponieważ są to hasła o długości w hashu znaki im odpowiadające wystąpią na pozycjach 31, 32 oraz 63, 64 (patrz obrazek powyżej). Hashe tych haseł wyglądają w sposób następujący:

    be2fc198c0c8e5e27ebb3694fd7cf1c38de787f6121fdd88f3cbc7a0d57b7ec3
    5b9e91126e7a802741349ec0276947c3a7ba2c8228b51a06984b8751d0cce8c5
    40f646f4efb5e45730f04d8f1db069c3580ea2a526b773519ad4272cb98730c7
    a5a8b17b92c33b4c487ad24a9756fec30b92e58ff486543fa8133ae02516eac9

Jak widać algorytm opiera się na jakimś prostym podstawieniu, które wygląda mniej więcej tak:

Jeśli popatrzy się na kody ASCII znaków a, b, c, d to następują one bezpośrednio po sobie:61, 62, 63, 64 (wartości w HEX).

W przypadku wartości podstawianych można zauważyć, że:

Znak występujący w hashu na określonej pozycji ewidentnie jest związany prostą zależnością z konkretnym znakiem w haśle. W tej chwili można empirycznie uzyskać "tabelę podstawień", można też zastanowić się jak wygląda wykorzystana funkcja. Jak uzyskać liczbę nieparzystą? Dodać jeden do liczby parzystej. A jak uzyskać liczbę parzystą z dowolnej liczby? Pomnożyć przez dwa.

Odwracając funkcję można zakładać (proste przekształcenie, przynajmniej za moich czasów, później była reforma edukacji...), że w celu uzyskania kodu ASCII należy najpierw odjąć jeden, a następnie wynik podzielić przez dwa (ułatwiając sobie życie przy pomocy IPython):

    In [7]: [chr((x-1)/2) for x in (0xc3, 0xc5, 0xc7)]
    
Out[7]: ['a', 'b', 'c']

Udało się ustalić, że:

W jaki sposób umieszczenie znaków w "hashu" jest uzależnione od długości hasła? Sam hash składa się z 64 znaków, na których zakodowane są 32 znaki. Zależność ta przedstawia się w sposób następujący (licząc od 1):

Sprawdzając hasła dłuższe od 16 znaków można zauważyć, że pozycje, na których występują znaki zależne od hasła nie ulegają zmianie, co z kolei oznacza, że jedynie pierwsze 16 znaków hasła jest wykorzystywanych/uwzględnianych w hashu.

Można więc pozycję znaków przedstawić prostą funkcją, w której pwdLen oznacza długość hasła:

    range(32/pwdLen, 32+1, 32/pwdLen)

Łamanie hashy haseł

Do osiągnięcia oczekiwanego efektu pozostaje więc tylko określenie algorytmu, który w efektywny sposób będzie dekodował hasła. Poniżej kilka hashy do spróbowania. Hasła są generowane losowo, więc na wyjściu nie należy oczekiwać czytelnych rezultatów. Pytanie dodatkowe: czy przekształcenie jest jednoznaczne? Czy może istnieje więcej niż jedno hasło, które prawidłowo zweryfikuje się z hashem?

    d83f02a6b53533c94a36fb8873c7a5e73f40c8b45cc42dcfb0a78258e590b48d
    cf40f9dd124263b17fe2ebf5d823ac6bb1632bc99032f9d3a11844cd136f43cf
    9883ebbe64ef1290a90d8b97b39871728edd1783abe1858318659fc2ac65c3ec
    e195d6a32cef6baf82611af1388194990c47057179a97cca37f461fb5ecacff0
    96f35a8327df45cbe58f26d1e4a353dbf1dd3fe500637e82d6f1aa70be6c6eac
    8fd7d9e97babd0cb05df8de5c985256f99633581528343a95cebd26773805085
    8d0e719fcbecd4a94c9f6861d09ef4633a11cc9367fe1671ad985acb4d8bf3d5
    9db1009fccff8647bd73c027227a1dcd19e027edb6b7aa2c05dcb1d134b348ed
    527f8d4e7563222b89cc888ff7b6c93f028b47e2a5f95bef4ae7adaa1146c127
    7142c3f5aa184fc3c6a44d8d29fb82499f0ebe9d3a65d5d93515fec9543f978c

Sam hash nie zawiera nigdzie informacji o długości hasła, które "zawiera". Można ją jednak uzyskać poprzez sprawdzenie na jakich pozycjach występują wartości, które mogą przedstawiać znaki hasła, a następnie sprawdzić jakiej długości hasła odpowiadają te pozycje.

W rzeczywistości ustalanie długości oryginalnego hasła nie jest niezbędne. Nie koniecznie trzeba podać dokładnie to hasło, które hash przedstawia, lecz to hasło, które zostanie poprawnie zweryfikowane. Weryfikacja hasła w tym przypadku będzie prawdopodobnie oznaczała sprawdzenie, czy na odpowiednich pozycjach w hashu pojawiają się wartości odpowiadające kolejnym znakom hasła.

Przykładowo w przypadku hasha cf40f9dd124263b17fe2ebf5d823ac6bb1632bc99032f9d3a11844cd136f43cf można zauważyć, że wartość cf znajduje się "w pobiliżu" wartości rozważanych w trakcie określenia w jaki sposób kodowane są znaki hasła w hashu. Ponownie korzystając z IPython:

    In [24]: [chr((x-1)/2) for x in (0xcf,)]
    
Out[24]: ['g']

Oznacza to, że hasło składające się z litery g zostanie prawidłowo zweryfikowane przez system, co wcale nie oznacza jednak, że oryginalna długość hasła wynosiła tylko jeden znak. Jednocześnie można odpowiedzieć na postawione wczesniej pytania - przekształcenie hasła na hash nie jest przekształceniem jednoznacznym, dla każdego hasha istnieje więcej niż jedna wartość, która zostanie prawidłowo zweryfikowana.

Podejście, które może być w tym przypadku podejściem efektywnym może być sprawdzenie dla każdego hasha haseł o długości od 1 do 16 znaków, a właściwie nie tyle samych haseł, co pozycji w hashu odpowiadających hasłom o takiej długości. Dla każdego z otrzymanych zestawów wartości należy spróbować "odkodować" hasło i sprawdzić, czy jest ono poprawne. Poprawność wyznacza w tym wypadku polityka haseł, która dopuszcza:

Na wyjściu otrzyma się dla każdego hasła jedno lub więcej możliwych haseł, z których każde powinno zostać poprawnie zweryfikowane przez system.

Przykład kodu:

Poniżej przykłdowy kod w języku Python, który realizuje opisany algorytm. Dla każdego przekazanego hasha wywołuje funkcjęGetPossibleChars z dwoma parametrami: hashem oraz długością hasła. Rezultatem wywołania funkcji jest możliwe hasło, lub None, jeśli dla danego hasha nie ma prawidłowego hasła o podanej długości.

Jeśli kod wydaje się nieco nieczytelny, to krótkie wyjaśnienie odnośnie tego, co dzieje się w funkcji GetPossibleChars.

W pierwszej linii tworzę listę wartości, które odpowiadają dopuszczalnym w haśle znakom. W zasadzie powinienem stworzyć to poza funkcją tak, by operacja ta wykonywała się tylko raz, a nie przy każdym wywołaniu funkcji, ale to PoC, więc nie przesadzajmy z optymalizacją.

W drugiej linii tworzę listę wartości liczbowych z hasha hasła, po prostu biorę po dwie litery i przekształcam je na wartość int.

W trzeciej linii wyliczam pozycje, na jakich wystąpią zakodowane znaki hasła, jeśli długość hasła będzie taka, jak podana w parametrze wywołania funkcji.

Czwarta linia to tworzenie listy znaków występujących na wyliczonych pozycjach i należących do wyliczonego w pierwszej linii zbioru. Dodatkowo przed sprawdzeniem, czy wartość znajduje się w liście sprawdzam, czy wartość jest nieparzysta. Jeśli nie, na pewno nie będzie jej w zbiorze. Do sprawdzenia/zbadania pozostaje fakt, czy ten dodatkowy warunek wpływa pozytywnie na czas wykonania funkcji.

Na zakończenie w zmiennej pwd mam listę wartości, które znajdują się na określonych pozycjach w hashu oraz reprezentują dopuszczalne w haśle znaki. Jeśli ilość tych elementów jest zgodna z długością poszukiwanego hasła, oznacza to, że znaleziona została poprawna wartość (a właśniwie jedna z poprawnych wartości). W takim przypadku wartości zostają odkodowane i zwrócone jako string. W przeciwnym wypadku funkcja zwróci None.

import string

hashes = ("d83f02a6b53533c94a36fb8873c7a5e73f40c8b45cc42dcfb0a78258e590b48d", 
"cf40f9dd124263b17fe2ebf5d823ac6bb1632bc99032f9d3a11844cd136f43cf",
"9883ebbe64ef1290a90d8b97b39871728edd1783abe1858318659fc2ac65c3ec",
"e195d6a32cef6baf82611af1388194990c47057179a97cca37f461fb5ecacff0",
"96f35a8327df45cbe58f26d1e4a353dbf1dd3fe500637e82d6f1aa70be6c6eac",
"8fd7d9e97babd0cb05df8de5c985256f99633581528343a95cebd26773805085",
"8d0e719fcbecd4a94c9f6861d09ef4633a11cc9367fe1671ad985acb4d8bf3d5",
"9db1009fccff8647bd73c027227a1dcd19e027edb6b7aa2c05dcb1d134b348ed",
"527f8d4e7563222b89cc888ff7b6c93f028b47e2a5f95bef4ae7adaa1146c127",
"7142c3f5aa184fc3c6a44d8d29fb82499f0ebe9d3a65d5d93515fec9543f978c")


def GetPossibleChars(pwdHash, pwdLen):
  pwdChars = [ord(char)*2+1 for char in string.letters + string.digits + "@$#"]
  chars = [int(pwdHash[i:i+2],16) for i in range(0,len(pwdHash),2)]
  charPos = range(32/pwdLen-1, 32, 32/pwdLen)
  pwd = [chars[i] for i in charPos if chars[i] & 0x1 and chars[i] in pwdChars]
  if len(pwd) == pwdLen:
    return "".join([chr((char-1)/2) for char in pwd])
  return None

def CrackHash(pwdHash):
  print pwdHash
  for pwdLen in range(16):
    pwd = GetPossibleChars(pwdHash, pwdLen+1)
    if pwd:
      print "\t%s" % pwd
  print 
    
if __name__ == "__main__":
  for hash in hashes:
    CrackHash(hash)         
        

Poniżej rezultat (fragment) jego wykonania. Jak widać dla przykładowych hashy znalezione zostało więcej niż jedno hasło, które zostanie prawidłowo zweryfikowane przez system.

d83f02a6b53533c94a36fb8873c7a5e73f40c8b45cc42dcfb0a78258e590b48d
        F
        sF
        dsgF

cf40f9dd124263b17fe2ebf5d823ac6bb1632bc99032f9d3a11844cd136f43cf
        g
        5g
        X5ig
        nXz5difg
(...)
        

Oryginalne hasła wykorzystane do wygenrowania hashy:

dsgF
nXz5difg
uwTK8nUAO
JQwW0x@L#8T
yAoeGhQmnr1
ktUeorB71@ATu3
OT01I8ej
9vY
F1DGdERwV
zaF$Nld         
        

Podsumowanie

Jak widać tworzenie algorytmów kryptograficznych, nawet tak pozornie prostych, jak "zaciemnianie hasła", nie jest zadaniem trywialnym. Zostawmy kryptografię ekspertom, korzystajmy z tego co jest znane i sprawdzone. Zamiast odkrywać koło na nowo, poświęćmy chwilę czasu by poszukać informacji jak to należy zrobić poprawnie. Wbrew pozorom wykorzystanie home brewed crypto zdarza się częsciej, niż można przypuszczać. Jeszcze raz odsyłam wszystkich do CWE-327: Use of a Broken or Risky Cryptographic Algorithm, w szczególności do fragmentu:

Do not develop your own cryptographic algorithms. They will likely be exposed to attacks that are well-understood by cryptographers. Reverse engineering techniques are mature. If your algorithm can be compromised if attackers find out how it works, then it is especially weak.

Gratulacje

Gratulacje dla Kacpra za dehashera. Jego rozwiązanie nie działa co prawda do końca poprawnie, jednak szedł w dobrą stronę i za to należy się uznanie.