Ponownie, skrypty ZAP, szyfrowanie żądań, i ostatecznie, podpisywanie.

W tym artykule ponownie zagłębię się w temat testowania API, które jest zabezpieczone przez mechanizmy szyfrowania i podpisywania żądań przy użyciu kluczy RSA. Jesteście już zaznajomieni z tymi mechanizmami z moich poprzednich postów. Jednak zdecydowałem się powrócić do tego tematu, aby przedstawić rozwiązania dostosowane do oprogramowania Zed Attack Proxy (ZAP), gdzie zachowanie wygląda inaczej. Co więcej, w tym artykule pokażę, jak wykorzystać dwa różne skrypty, uruchamiane w odpowiedniej kolejności, aby modyfikować żądania w locie w sposób oczekiwany przez stronę serwerową. Dzięki takim rozwiązaniom testowanie API staje się znacznie prostsze, ponieważ nie musimy już skupiać się na stosowanych mechanizmach bezpieczeństwa. Dodatkowo, podział skryptów według ich odpowiedzialności znacznie ułatwia ich użytkowanie i pozwala na ich ponowne użycie bez modyfikacji.

Ponownie, skrypty ZAP, szyfrowanie żądań, i ostatecznie, podpisywanie.

Wprowadzenie

W poprzednich artykułach wyjaśniłem, dlaczego zdecydowałem się opisać poniższe skrypty. Wyjaśniłem również implementację skryptu używanego do podpisywania żądań dla oprogramowania ZAP. Skrypt ten można również znaleźć w repozytorium community-scripts. W tym artykule chciałbym przedstawić kolejny skrypt zaimplementowany przeze mnie, który jest używany do szyfrowania żądań przy użyciu kluczy RSA. Gdy szyfrujemy treść żądania, sprawa staje się nieco skomplikowana, ponieważ nie tylko chcemy dodać coś “w locie” do wysyłanego przez nas żądania, ale także chcemy zastąpić ciało tego żądania w taki sposób, aby było poprawnie interpretowane przez serwer. Dodatkowo, chcemy mieć prosty sposób na identyfikację wysyłanych do serwera żądań, co według definicji nie powinno być możliwe po zaszyfrowaniu. Dlaczego identyfikacja wysłanych żądań jest dla nas kluczowa? Ponieważ, gdy wykryjemy podatność, chcemy być w stanie śledzić żądania wysyłane do serwera, co staje się praktycznie niemożliwe, gdy stosowane jest szyfrowanie.

Przypomnę, że tworzenie serii artykułów na temat szyfrowania i podpisywania żądań w API jest związane z projektem, który miałem przyjemność testować. Prezentowane tutaj mechanizmy są używane w prawdziwym API do komunikacji między backendem a aplikacją mobilną. Oprócz mechanizmu podpisywania żądań (nagłówek X-Signature) i ochrony przed atakami typu replay (nagłówki X-Nonce-Value i X-Nonce-Created-At), które opisałem w wcześniejszych artykułach, autor projektu zdecydował się również na użycie szyfrowania treści żądań przy użyciu kluczy RSA. Wszystkie te środki bezpieczeństwa znacząco utrudniają nam badania, szczególnie przy braku wewnętrznej wiedzy na temat implementacji.

W odpowiedzi dla osób szczególnie zainteresowanych tym tematem udostępniłem również na moim koncie GitHub PoC serwera Flask, który pozwala zweryfikować działanie przedstawionych tutaj skryptów.

Kolejną rzeczą, którą chciałbym wspomnieć, jest równoczesne wydanie biblioteki dla frameworka Flask, która umożliwia łatwą implementację szyfrowania i podpisywania żądań po stronie serwera. Kod biblioteki można znaleźć pod tym linkiem, jak również w menedżerze pakietów Pythona pypi.

Opis zaimplementowanego algorytmu

W tej części chciałbym przypomnieć czytelnikowi o różnicach między mechanizmem opisanym w tym artykule a tym opisanym w “Zed Attack Proxy (ZAP) Scripting and Request Signing with RSA Keys”. Obecny proces obejmuje dodatkowe kroki związane z szyfrowaniem i deszyfrowaniem treści żądania. W nowej wersji mechanizm wygląda następująco:

  1. Aplikacja używa publicznego klucza serwera do zaszyfrowania treści żądania.
  2. Aplikacja tworzy ustandaryzowany schemat żądania, który zawiera zaszyfrowaną wiadomość.
  3. Aplikacja dodaje do żądania unikalną wartość nonce, generowaną za pomocą uuid4 (nagłówek X-Nonce-Value).
  4. Aplikacja dołącza do żądania bieżącą datę (nagłówek X-Nonce-Created-At).
  5. Aplikacja tworzy podpis żądania, używając swojego prywatnego klucza, treści wiadomości, ścieżki, wartości nonce i bieżącej daty.
  6. Aplikacja dołącza wygenerowany podpis do żądania (nagłówek X-Signature).
  7. Serwer API weryfikuje poprawność podpisu, używając publicznego klucza klienta.
  8. Jeśli podpis jest prawidłowy, serwer API akceptuje żądanie. W przypadku nieprawidłowego podpisu żądanie jest odrzucane.
  9. Serwer API deszyfruje wiadomość, używając swojego prywatnego klucza.

Dodatkowo, chciałbym przypomnieć, że procedury wymiany kluczy publicznych między stronami komunikacji nie zostały szczegółowo opisane w analizowanym mechanizmie. Zakłada się, że osoby wdrażające ten mechanizm już rozważyły wszystkie dostępne metody wymiany kluczy. Dla pentesterów jedynym pozostałym zadaniem jest potwierdzenie tego w praktyce.

W następnej części artykułu skupimy się na uproszczeniu całego procesu, używając tylko jednej pary kluczy RSA.

Implementacja

Tak jak poprzednio, aby korzystać z możliwości Pythona w ZAP, musimy mieć zainstalowane rozszerzenie Python Scripting. I podobnie jak w poprzednim przypadku, również wybrałem typ skryptu HTTP Sender. Podjąłem tę decyzję, ponieważ chciałem, aby skrypt pozwalał mi na szyfrowanie wiadomości w dowolnym momencie, niezależnie od tego, czy korzystam z funkcji Proxy, Attack, czy Requester. Więcej o typach skryptów możesz przeczytać pod tym linkiem. Po utworzeniu nowego skryptu otrzymamy szablon, który wygląda tak:

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())

Jednak, w przeciwieństwie do poprzedniej implementacji, tym razem interesuje nas modyfikacja obu metod. Pierwszą metodę, sendingRequest, wykorzystamy do zaimplementowania naszego szyfrowania, a drugą metodę, responseReceived, do przywrócenia treści żądania do jej pierwotnej formy. Dlaczego? Ponieważ jeśli tego nie zrobimy, przy każdym wysłanym żądaniu, ZAP zastąpi wartość ciała wartością faktycznie wysłaną do serwera, powodując, że stracimy informacje o faktycznej treści wysłanej do serwera, i będziemy musieli przygotować treść żądania od nowa (Rys. 1.).

Rys. 1. Brak implementacji responseReceived - utrata informacji o wysłanej wiadomości.
Rys. 1. Brak implementacji responseReceived - utrata informacji o wysłanej wiadomości.

Zacznijmy od początku i skupmy się najpierw na szyfrowaniu wiadomości. Z poprzedniego artykułu wiemy, że rozszerzenie Python Scripting daje nam możliwość pisania skryptów w Pythonie 2.7, który niestety nie jest już wspierany. Dodatkowo, nie mamy prostej drogi do instalowania dodatkowych bibliotek za pomocą menedżera pakietów Pythona, PyPI. Dlatego w tej implementacji wykorzystałem również możliwość wykonania poleceń systemowych za pomocą standardowej biblioteki subprocess.

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

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

Wykorzystałem funkcjonalność oferowaną przez bibliotekę OpenSSL do szyfrowania wiadomości przy użyciu publicznego klucza RSA.

cmd = 'printf %s "{}" | openssl pkeyutl -encrypt -pubin -inkey {} | openssl base64'.format(body_b64, PUBLIC_KEY)

Aby zebrać wszystko razem w jedną funkcję obsługującą szyfrowanie, potrzebujemy jeszcze jednego kroku. Autor implementacji API, aby poradzić sobie z problemami z nieoczekiwanymi znakami spowodowanymi przez różne formy kodowania, zastosował kodowanie base64. Dlatego, zanim zaszyfrujemy treść wiadomości, musimy zakodować ją do formatu base64.

body_b64 = base64.standard_b64encode(json.dumps(body, ensure_ascii=False).encode()).decode()

Teraz możemy to wszystko złożyć w całość:

def encrypt_body(body):
    body_b64 = base64.standard_b64encode(json.dumps(body, ensure_ascii=False).encode()).decode()

    cmd = 'printf %s "{}" | openssl pkeyutl -encrypt -pubin -inkey {} | openssl base64'.format(body_b64, PUBLIC_KEY)
    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", "")

W tym momencie wszystko, co nam pozostało, to przechwycić wiadomość, zaszyfrować ją i podmienić przed wysłaniem. W pierwszym kroku używamy metody getRequestBody, aby pobrać aktualną wartość żądania, a następnie ją deserializujemy. Proces ten ma na celu usunięcie wszystkich niechcianych białych znaków. Następnie szyfrujemy treść żądania, używając wcześniej przedstawionej funkcji szyfrującej.

body = msg.getRequestBody().toString()
body = json.loads(body)
encrypted_body = encrypt_body(body)
new_payload = PAYLOAD.replace(PAYLOAD_PLACEHOLDER, encrypted_body)
msg.setRequestBody(new_payload)

Uważny czytelnik powinien zauważyć następującą linię kodu:

new_payload = PAYLOAD.replace(PAYLOAD_PLACEHOLDER, encrypted_body)

Dlaczego zastępujemy PAYLOAD_PLACEHOLDER zamiast używać słownika i deserializacji JSON? Różnice w serializacji między Pythonem 2 i Pythonem 3, jak wspomniałem w poprzednich artykułach. Jeśli używamy zarówno szyfrowania, jak i podpisywania, różnice w deserializacji mogą powodować, że podpisy nie będą się zgadzać.

Na tym etapie pojawiła się pierwsza różnica między implementacją skryptu w ZAP i Burp. Burp automatycznie aktualizuje wartość nagłówka Content-Length, więc nie musimy się tym martwić. Jednak w ZAP musimy zająć się tym sami. To nie jest trudne, ale dość znaczące, szczególnie gdy początkowo zastanawiamy się, dlaczego wiadomość wysłana do serwera jest ucięta. Brak aktualizacji Content-Length spowodował, że wiadomość wysyłana przez ZAP i przechwycona przez druge proxy wyglądała jak pokazano na Rys 2.

Rys. 2. Wiadomość wysłana za pomocą ZAP, przechwycona przy użyciu Burp.
Rys. 2. Wiadomość wysłana za pomocą ZAP, przechwycona przy użyciu Burp.

Rozwiązałem problem, ręcznie aktualizując wartość nagłówka Content-Length w następujący sposób:

msg.getRequestHeader().setContentLength(msg.getRequestBody().length())

Na tym etapie mamy do rozwiązania jeden ostatni problem, mianowicie przywrócenie żądania do jego pierwotnej formy, jak wspomniałem na początku tej sekcji. Tutaj użyłem prostego triku: zapisuję wartość niezaszyfrowanego żądania w notatce dostępnej dla każdego wysłanego żądania. Następnie, używając funkcji responseReceived, przywracam żądanie do jego oryginalnego stanu.

Zapisywanie notatki:

msg.setNote(body)

Przywracanie wartości żądania:

def responseReceived(msg, initiator, helper):
    body = msg.getNote()
    msg.setRequestBody(body)

Dodatkową zaletą tego rozwiązania jest to, że w historii wykonanych żądań zobaczymy zaszyfrowane żądanie wysłane do serwera, natomiast w notatkach jego wartość będzie w postaci zwykłego tekstu (Rys 3).

Rys. 3. Rzeczywista wartość wysłanego żądania dostępna w notatkach.
Rys. 3. Rzeczywista wartość wysłanego żądania dostępna w notatkach.

Całe proponowane przeze mnie rozwiązanie wygląda następująco:

# RSA Encrypt Payload Script for Zed Attack Proxy - ZAP
# HelpAddOn Script - HTTPSender
# Michal Walkowski - https://mwalkowski.com/
#
# Tested with Jython 14 beta and ZAP 2.14.0
# Based On: https://mwalkowski.com/post/using-burp-python-scripts-to-encrypt-requests-with-rsa-keys/
# You can test the script's functionality using https://github.com/mwalkowski/api-request-security-poc



import json
import base64
import subprocess

# path to public.pem
PUBLIC_KEY = "public.pem"

PAYLOAD_PLACEHOLDER = "PAYLOAD"
PAYLOAD = '{\"keyId\": \"init\", \"encryptedPayload\": \"' + PAYLOAD_PLACEHOLDER + '\"}'


def encrypt_body(body):
    body_b64 = base64.standard_b64encode(json.dumps(body, ensure_ascii=False).encode()).decode()

    cmd = 'printf %s "{}" | openssl pkeyutl -encrypt -pubin -inkey {} | openssl base64'.format(body_b64, PUBLIC_KEY)
    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):
    body = msg.getRequestBody().toString()
    msg.setNote(body)
    body = json.loads(body)
    encrypted_body = encrypt_body(body)
    new_payload = PAYLOAD.replace(PAYLOAD_PLACEHOLDER, encrypted_body)
    msg.setRequestBody(new_payload)
    msg.getRequestHeader().setContentLength(msg.getRequestBody().length())


def responseReceived(msg, initiator, helper):
    body = msg.getNote()
    msg.setRequestBody(body)

Przykład użycia

Poniżej prezentuję działanie opisanego skrypu, dołączam również wideo

Na Rysunku 4 przedstawiono wysyłanie żądania w postaci zwykłego tekstu – niezaszyfrowanego, które zgodnie z implementacją jest przetwarzane przez serwer. Jak widać w odpowiedzi serwera, przetwarzanie żądania nie powiodło się, ponieważ serwer oczekuje wiadomości z polem encryptedPayload.

Rys. 4. Wysłane niezaszyfrowane żądanie, serwer odrzucił przetwarzanie wiadomości.
Rys. 4. Wysłane niezaszyfrowane żądanie, serwer odrzucił przetwarzanie wiadomości.

Na Rysunku 5 przedstawiono to samo żądanie, tym razem z wykorzystaniem napisanego skryptu. Jak wynika z odpowiedzi serwera, żądanie zostało poprawnie przetworzone.

Rys. 5. Wysłane zaszyfrowane żądanie, serwer poprawnie przetworzył wiadomość.
Rys. 5. Wysłane zaszyfrowane żądanie, serwer poprawnie przetworzył wiadomość.

Na Rysunku 6 pokazano faktyczne żądanie wysłane do serwera, które zostało zalogowane w historii. Można zaobserwować, że treść wiadomości została całkowicie zmieniona na format oczekiwany przez nasz serwer.

Rys. 6.  Zaszyfrowana wiadomość wysłana do serwera i zalogowana w historii ZAP.
Rys. 6. Zaszyfrowana wiadomość wysłana do serwera i zalogowana w historii ZAP.

Jak wspomniano wcześniej, jako ostatni szlif, do wysłanej wiadomości zostanie dodany podpis przy użyciu klucza RSA. Do tego celu wykorzystamy skrypt napisany w poprzednim artykule. Aby zapewnić prawidłowe działanie w ZAP, należy zachować kolejność wykonywania skryptów, co oznacza, że skrypt szyfrujący powinien być włączony jako pierwszy, a następnie skrypt podpisujący.

Na Rysunku 7 pokazano wysłane żądanie, brakujące wcześniej wspomnianych nagłówków odpowiedzialnych za nasz podpis: X-Nonce-Value i X-Nonce-Created-At, jak również X-Signature.

Rys. 7. Wysłane żądanie, które nie zawiera wymaganych nagłówków i podpisu, skutkuje odrzuceniem wiadomości przez serwer.
Rys. 7. Wysłane żądanie, które nie zawiera wymaganych nagłówków i podpisu, skutkuje odrzuceniem wiadomości przez serwer.

Na Rysunku 8 przedstawiono wysłane żądanie, które jest poprawnie interpretowane przez serwer.

Rys. 8. Wysłane żądanie, poprawnie przetworzone przez serwer.
Rys. 8. Wysłane żądanie, poprawnie przetworzone przez serwer.

Na Rysunku 9 pokazano wysłane żądanie zalogowane w historii. Jak możemy zaobserwować, wiadomość wysłana do serwera została zaszyfrowana. Dodatkowo, dodano nagłówki X-Nonce-Value i X-Nonce-Created-At, a wiadomość została podpisana.

Rys. 9. Wysłane żądanie zalogowane w historii ZAP, zawierające zaszyfrowaną treść i podpis.
Rys. 9. Wysłane żądanie zalogowane w historii ZAP, zawierające zaszyfrowaną treść i podpis.

Podsumowanie

W tym artykule ponownie poruszyłem temat testowania API zabezpieczonych mechanizmami szyfrowania i podpisywania żądań przy użyciu kluczy RSA. Postanowiłem powrócić do tego tematu, aby dostosować rozwiązania do oprogramowania Zed Attack Proxy (ZAP), gdzie zachowanie różni się od Burp Suite. Dodatkowo przedstawiłem, jak użyć dwóch różnych skryptów do modyfikowania żądań w locie w sposób oczekiwany przez serwer, co znacznie ułatwia testowanie API.

Bibliografia

  1. https://en.wikipedia.org/wiki/Replay_attack
  2. https://www.zimuel.it/blog/sign-and-verify-a-file-using-openssl
  3. https://sereysethy.github.io/encryption/2017/10/23/encryption-decryption.html
  4. https://httpwg.org/http-extensions/draft-ietf-httpbis-message-signatures.html#name-rsassa-pkcs1-v1_5-using-sha
  5. https://www.zaproxy.org/docs/desktop/addons/python-scripting/
  6. https://github.com/zaproxy/community-scripts
  7. https://github.com/mwalkowski/api-request-security-poc/
  8. https://stackoverflow.com/questions/51769239/why-json-dumps-in-python-3-return-a-different-value-of-python-2
  9. https://mwalkowski.com/pl/post/zed-attack-proxy-zap-scripting-and-request-signing-with-rsa-keys/