Skryptowanie w Zed Attack Proxy (ZAP) i podpisywanie żądań przy użyciu kluczy RSA

Jeden z członków organizacji OWASP zapytał mnie, czy chciałbym przedstawić metodę testowania API zabezpieczonego kluczami RSA za pomocą ZAP. Początkowo nie byłem pewien, czy sobie poradzę, ponieważ na co dzień używam Burpa, ale jak to często bywa w naszym zawodzie, trzeba ciągle ewoluować i eksplorować nowe możliwości. Dlatego zgodziłem się i w tym artykule opisuję, jak można testować API zabezpieczone mechanizmem podpisywania żądań za pomocą kluczy RSA, tym razem wykorzystując skryptowanie w Zed Attack Proxy (ZAP).

Skryptowanie w Zed Attack Proxy (ZAP) i podpisywanie żądań przy użyciu kluczy RSA

Opis problemu

Jak wspomniałem w poprzednim artykule, projekt, który miałem przyjemność testować, wykorzystywał API do komunikacji, między innymi, z aplikacją mobilną. Aplikacja mobilna przesyła wrażliwe dane, takie jak informacje osobiste czy dane karty kredytowej, do backendu. Z oczywistych względów, deweloper aplikacji, dążąc do dodatkowej ochrony danych przed modyfikacją w trakcie transmisji, wdrożył mechanizm komplikujący próby manipulacji żądaniami przez atakujących i dalsze wykorzystanie systemu. Jednocześnie mechanizm ten czyni testy bezpieczeństwa bardziej wymagającymi. W badanym oprogramowaniu deweloper zastosował dwa dodatkowe nagłówki, X-Nonce-Value i X-Nonce-Created-At, zaprojektowane do obrony przed atakami typu replay. Dodatkowo, istnieje trzeci nagłówek, X-Signature, zabezpieczający integralność przesyłanej wiadomości poprzez podpis RSA.

W ramach rzeczywistych testów w omawianym projekcie, użyłem implementacji przygotowanej dla Burp Suite, która została omówiona tutaj. Jednak, zachęcony przez jednego z członków organizacji OWASP, mianowicie Simona Bennettsa, lidera projektu ZAP, odważyłem się stworzyć podobną implementację dla Zed Attack Proxy (ZAP). Ku mojemu zaskoczeniu, sposób implementacji wtyczki był równie prosty co w przypadku Burp Python Scripts. Wtyczka opisana w tym poście jest również dostępna w repozytorium community-scripts, które zawiera zbiór różnych innych dodatków stworzonych przez społeczność ZAP, wszystkie mogą być przydatne w naszej codziennej pracy.

Opis zastosowanego mechanizmu bezpieczeństwa

Dla przypomnienia, mechanizm wdrożony przez autorów badanej aplikacji działa następująco:

  1. Aplikacja dodaje do żądania wartość nonce, używając uuid4.
  2. Aplikacja dodaje bieżącą datę do żądania.
  3. Aplikacja mobilna generuje podpis dla żądania, używając klucza prywatnego, metody, ścieżki, ciała wiadomości, wartości nonce oraz bieżącej daty.
  4. Aplikacja dodaje podpis do żądania.
  5. Serwer API weryfikuje podpis za pomocą klucza publicznego.
  6. Jeśli podpis jest ważny, serwer API akceptuje żądanie. Jeśli podpis jest nieprawidłowy, serwer API odrzuca żądanie.

Implementacja

Po pierwsze, aby wykorzystać język Python do implementowania dodatkowych skryptów w ZAP, musimy zainstalować rozszerzenie o nazwie Python Scripting. Podobnie jak w przypadku Burp, to rozszerzenie jest implementacją Pythona w języku Java, powszechnie znaną jako Jython. Tak jak w przypadku Burp, jest to wersja 2.7, która nie jest już wspierana. W obu implementacjach wtyczki napotkałem ten sam problem, mianowicie brak możliwości instalacji dodatkowych bibliotek Pythona. Ponieważ miałem już działające rozwiązanie dla Burp, postanowiłem je wykorzystać.

Po zainstalowaniu rozszerzenia Python Scripting w ZAP, następnym krokiem jest utworzenie skryptu wybranego typu (Rys. 1). Wybór typu skryptu, na który się decydujemy, jest kluczowy. Każdy dostępny typ w oknie Skryptowania zachowuje się inaczej, co oznacza, że skrypt danego typu jest wykonywany w różnych warunkach. Więcej informacji o typach skryptów można przeczytać tutaj. W naszym rozważanym przypadku chcemy, aby podpis był dodawany do każdego żądania, w tym tych generowanych przez moduł Ataku ZAP lub przechodzących przez Proxy ZAP. Dlatego wybieramy moduł HTTP Sender.

Rys 1. Zdefiniowana lista typów w oknie Skryptów.
Rys. 1. Zdefiniowana lista typów w oknie Skryptów.

Po utworzeniu skryptu wybranego typu (na Rys. 1: RsaSigningForZap.py), otrzymujemy fragment kodu przedstawiony poniżej. W dalszej implementacji będziemy korzystać tylko z pierwszej funkcji, tej odpowiedzialnej za przetwarzanie wysyłanych żądań (sendingRequests). Druga funkcja, responseReceived, nie jest potrzebna, i aby uniknąć dodawania niepotrzebnych logów podczas wykonania skryptu, usuniemy z niej funkcję print.

def sendingRequest(msg, initiator, helper):
    # Debugging can be done using print like this
    print('sendingRequest called for url=' +
          msg.getRequestHeader().getURI().toString())


def responseReceived(msg, initiator, helper):
    # Debugging can be done using print like this
    print('responseReceived called for url=' +
          msg.getRequestHeader().getURI().toString())

Zacznijmy od czegoś prostego. Poniższy fragment kodu aktualizuje wartość nagłówka X-Nonce-Value przy każdym wysłanym żądaniu. Nie bez powodu zaczynam od losowej wartości dla nagłówka nonce; ten skrypt może być przydatny w dowolnych testach penetracyjnych, gdzie chcemy śledzić wykonanie naszych żądań. Na przykład, podczas uruchamiania ataku ZAP i chcąc znaleźć w logach żądanie, które wyzwala podatność, lub śledzić wykonanie naszego żądania w logach aplikacji. Unikalna wartość nagłówka znacznie ułatwia to zadanie.

import uuid


NONCE_HEADER = 'X-Nonce-Value'


def sendingRequest(msg, initiator, helper):
    nonce_value = str(uuid.uuid4())
    print('Adding new {}: {}'.format(SIGNATURE_HEADER, signature))
    msg.getRequestHeader().setHeader(SIGNATURE_HEADER, signature)

W następnym kroku dodamy do skryptu znacznik czasu.

import uuid
import datetime


NONCE_HEADER = 'X-Nonce-Value'
NONCE_CREATED_AT_HEADER = 'X-Nonce-Created-At'


def sendingRequest(msg, initiator, helper):
    nonce_value = str(uuid.uuid4())
    print('Adding new {}: {}'.format(SIGNATURE_HEADER, signature))
    msg.getRequestHeader().setHeader(SIGNATURE_HEADER, signature)

    nonce_created_at = '{}+00:00'.format(datetime.datetime.utcnow().isoformat())
   print('Adding new {}: {}'.format(NONCE_CREATED_AT_HEADER, nonce_created_at))
   msg.getRequestHeader().setHeader(NONCE_CREATED_AT_HEADER, nonce_created_at)

Gdy już zaimplementowaliśmy dwa podstawowe nagłówki, warto przyjrzeć się samej sygnaturze. Obecnie, w Python ZAP, nie ma możliwości rozszerzenia go o dodatkowe biblioteki. Kolejnym ograniczeniem jest to, że dostępna jest tylko wersja Jython 2.7, która nie jest już wspierana. Aby ominąć te ograniczenia, możemy użyć biblioteki subprocess do wywołań systemowych. Na przykład:

process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)

output, err = process.communicate()
if err.decode() != "":
    raise Exception(err)

To pozwala nam na wykonanie dowolnego polecenia w systemie z poziomu Pythona. Na tym etapie mamy już prawie wszystkie niezbędne elementy, potrzebujemy jedynie informacji o tym, jak zbudować dane wejściowe podpisu. Aby przekazać całą wartość danych wejściowych podpisu do systemu, nie martwiąc się o dodatkowe spacje w treści wiadomości, należy je zakodować za pomocą base64. Kod budujący dane wejściowe podpisu dla opisanego przypadku będzie wyglądał tak:

 msg = helpers.bytesToString(requestBody)
 signature_input = "{}{}{}{}{}".format(method, path, nonce_value, nonce_created_at, msg)
 signature_input_b64 = base64.standard_b64encode(signature_input.encode()).decode()

Poniżej znajduje się funkcja, która implementuje opisany mechanizm podpisywania żądań:

def sign(signature_input):
   print('signature_input', signature_input)
   signature_input_b64 = base64.standard_b64encode(signature_input.encode()).decode()
   print('signature_input_b64', signature_input_b64)

   cmd = """printf %s "{}" | openssl dgst -sha256 -sign {}| openssl base64""".format(signature_input_b64, PRIVATE_KEY)
   print(cmd)
   process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)

   output, err = process.communicate()
   if err.decode() != "":
       raise Exception(err)

   return output.decode().replace("\n", "")

Na koniec przedstawiam rozwiązanie, które implementuje opisany mechanizm podpisywania żądań:

# RSA Signing Script for Zed Attack Proxy - ZAP
# HelpAddOn Script - HTTPSender
# Michal Walkowski - https://mwalkowski.com/
# https://github.com/mwalkowski
#
# Tested with Jython 14 beta and ZAP 2.14.0
# For RSA Signing Process: https://httpwg.org/http-extensions/draft-ietf-httpbis-message-signatures.html#name-rsassa-pkcs1-v1_5-using-sha
# Based On: https://mwalkowski.com/post/using-burp-python-scripts-to-sign-requests-with-rsa-keys/

import urlparse
import uuid
import datetime
import base64
import subprocess

# path to private.key
PRIVATE_KEY = "private.key"
SIGNATURE_HEADER = 'X-Signature'
NONCE_HEADER = 'X-Nonce-Value'
NONCE_CREATED_AT_HEADER = 'X-Nonce-Created-At'


def sign(signature_input):
    print('signature_input', signature_input)
    signature_input_b64 = base64.standard_b64encode(signature_input.encode()).decode()
    print('signature_input_b64', signature_input_b64)

    cmd = """printf %s "{}" | openssl dgst -sha256 -sign {}| openssl base64""".format(signature_input_b64, PRIVATE_KEY)
    print(cmd)
    process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)

    output, err = process.communicate()
    if err.decode() != "":
        raise Exception(err)

    return output.decode().replace("\n", "")

def sendingRequest(msg, initiator, helper):
    method = msg.getRequestHeader().getMethod() 
    path = urlparse.urlparse(msg.getRequestHeader().getURI().toString()).path
    body = msg.getRequestBody().toString()
    print(msg.getRequestBody().toString())

    nonce_value = str(uuid.uuid4())
    nonce_created_at = '{}+00:00'.format(datetime.datetime.utcnow().isoformat())
    signature = sign("{}{}{}{}{}".format(method, path, nonce_value, nonce_created_at, body))

    print('Adding new {}: {}'.format(SIGNATURE_HEADER, signature))
    msg.getRequestHeader().setHeader(SIGNATURE_HEADER, signature)

    print('Adding new {}: {}'.format(NONCE_HEADER, nonce_value))
    msg.getRequestHeader().setHeader(NONCE_HEADER, nonce_value)

    print('Adding new {}: {}'.format(NONCE_CREATED_AT_HEADER, nonce_created_at))
    msg.getRequestHeader().setHeader(NONCE_CREATED_AT_HEADER, nonce_created_at)


def responseReceived(msg, initiator, helper):
    pass

Na Rysunku 2 przedstawiono próbę wysłania żądania, które nie zawierało dodatkowych nagłówków. Jak widać, serwer wskazał na ich brak i nie przetworzył wiadomości.

Rys 2. Wysłane żądanie i otrzymana odpowiedź, brak wymaganych dodatkowych nagłówków bezpieczeństwa.
Rys. 2. Wysłane żądanie i otrzymana odpowiedź, brak wymaganych dodatkowych nagłówków bezpieczeństwa.


Na Rysunku 3 pokazano kolejną próbę wysłania żądania; tym razem dodano wymagane nagłówki. Jednak zmiana w treści wiadomości spowodowała niezgodność z podpisem, co również skłoniło serwer do odrzucenia wiadomości.

Rys 3. Wysłane żądanie i otrzymana odpowiedź, brak kompatybilności z podpisem wiadomości.
Rys. 3. Wysłane żądanie i otrzymana odpowiedź, brak kompatybilności z podpisem wiadomości.


Na Rysunku 4 przedstawiono użycie napisanego kodu. Nagłówki nonce i signature są poprawne, co potwierdza odpowiedź otrzymana od serwera.

Rys 4. Wysłane żądanie i otrzymana odpowiedź, poprawnie wygenerowane nagłówki bezpieczeństwa.
Rys. 4. Wysłane żądanie i otrzymana odpowiedź, poprawnie wygenerowane nagłówki bezpieczeństwa.

Podsumowanie

Mechanizm nonce i podpisywania żądań może być skutecznym sposobem ochrony żądań API przed modyfikacją. Bez dodatkowych informacji dostarczonych przez autora aplikacji byłoby trudno rozpoznać, jak zbudowany jest wdrożony podpis cyfrowy. Przedstawiony fragment kodu znacznie ułatwia dalsze testowanie API, ponieważ nie ma już potrzeby skupiania się na zapewnieniu poprawności wszystkich wartości nagłówków.

Ten artykuł ma również na celu wprowadzenie innych mechanizmów zabezpieczających integrację wysyłanych żądań, innych niż HMAC, oraz możliwy sposób ich testowania. Jeśli ktoś jest zainteresowany przetestowaniem wtyczki, pod tym linkiem znajdzie prosty kod serwera implementujący weryfikację zastosowanego podpisu.

Dodatkowo, wtyczkę omówioną w tym poście można znaleźć w repozytorium community-scripts dla projektu ZAP.

Bibliografia

  1. https://en.wikipedia.org/wiki/Replay_attack
  2. https://www.zaproxy.org/docs/desktop/addons/python-scripting/
  3. https://github.com/zaproxy/community-scripts
  4. https://www.zimuel.it/blog/sign-and-verify-a-file-using-openssl
  5. https://sereysethy.github.io/encryption/2017/10/23/encryption-decryption.html
  6. https://httpwg.org/http-extensions/draft-ietf-httpbis-message-signatures.1.html#name-rsassa-pkcs1-v1_5-using-sha
  7. https://github.com/mwalkowski/api-request-security-poc/
  8. https://mwalkowski.com/post/using-burp-python-scripts-to-sign-requests-with-rsa-keys