Wykorzystanie Burp Python Scripts do podpisywania żądań za pomocą kluczy RSA
W tym artykule chciałbym opisać mechanizm bezpieczeństwa żądań API, z którym spotkałem się w jednym z moich projektów. Mechanizm ten wykorzystuje klucze RSA do weryfikacji integralności żądań oraz wprowadza dodatkową funkcję zabezpieczającą przed atakami typu replay.
Opis problemu
W wymienionym projekcie API służyło do komunikacji, między innymi, z aplikacją mobilną. Aplikacja mobilna wysyła wrażliwe dane, takie jak informacje osobiste i dane karty kredytowej, do backendu. Z oczywistych względów dane te muszą być chronione przed manipulacją. Wdrożony mechanizm ma na celu utrudnienie atakującym manipulowanie żądaniami i dalsze wykorzystanie. Jednocześnie zastosowane środki bezpieczeństwa utrudniają przeprowadzanie testów penetracyjnych. W badanym oprogramowaniu autor zaimplementował dwa mechanizmy bezpieczeństwa. Po pierwsze, w żądaniach zaobserwować można dwa dodatkowe nagłówki: X-Nonce-Value
i X-Nonce-Created-At
, zaprojektowane do ochrony przed atakami typu replay. Po drugie, istnieje trzeci nagłówek, X-Signature
, który zapewnia integralność przekazywanej wiadomości przez podpis RSA.
Aby rozwiązać problem i przeprowadzić testy bezpieczeństwa, moją początkową rozważaną opcją było użycie biblioteki dostarczonej przez klienta (przeznaczonej do ułatwienia testowania). Jednak to rozwiązanie miało pewne wady. Po pierwsze, wymagało częstych zmian w kodzie biblioteki, co mogło być czasochłonne. Po drugie, ograniczało liczbę i zakres testów, które mogłem przeprowadzić.
Ostatecznie zdecydowałem się na rozwiązanie polegające na użyciu dodatkowej wtyczki do Burp o nazwie Python Scripter (więcej o wtyczce można znaleźć tutaj, a dodatkowe przykłady użycia można znaleźć tutaj). Napisałem również własny skrypt, który przedstawię w dalszej części artykułu.
Opis zastosowanego mechanizmu bezpieczeństwa
Prezentowany mechanizm bezpieczeństwa żądań działa w następujący sposób:
- Aplikacja dodaje unikalną wartość nonce do żądania za pomocą uuid4 (nagłówek
X-Nonce-Value
). - Aplikacja dodaje bieżącą datę do żądania (nagłówek
X-Nonce-Created-At
). - Aplikacja mobilna generuje cyfrowy podpis dla żądania, używając klucza prywatnego, treści wiadomości, wartości nonce i bieżącej daty.
- Aplikacja dodaje cyfrowy podpis do żądania (nagłówek
X-Signature
). - Serwer API weryfikuje cyfrowy podpis za pomocą klucza publicznego.
- Jeśli cyfrowy podpis jest ważny, serwer API akceptuje żądanie. Jeśli cyfrowy podpis jest nieprawidłowy, serwer API odrzuca żądanie.
Implementacja
Zanim przedstawię kompleksowe rozwiązanie opisanego problemu, zacznijmy od czegoś prostego. Poniższy fragment kodu aktualizuje nagłówek X-Nonce-Value
za każdym razem, gdy wysyłane jest żądanie. Nie bez powodu zaczynam od losowej wartości dla nagłówka nonce; skrypt ten może być użyteczny w dowolnych scenariuszach testów penetracyjnych, gdy chcemy śledzić wykonanie naszych żądań. Na przykład możemy uruchomić Burp Scanner i dążyć do znalezienia żądania wyzwalającego podatność w logach lub śledzić wykonanie naszego żądania w logach aplikacji. Unikalna wartość nagłówka znacznie ułatwi nam zadanie.
import uuid
NONCE_HEADER = 'X-Nonce-Value'
if messageIsRequest:
requestInfo = helpers.analyzeRequest(messageInfo.getRequest())
headers = requestInfo.getHeaders()
requestBody = messageInfo.getRequest()[requestInfo.getBodyOffset():]
for h in headers:
if header.startswith(NONCE_HEADER):
headers.remove(h)
nonce_value = str(uuid.uuid4())
nonce_value = '{}: {}'.format(NONCE_HEADER, nonce_value)
print('Adding new', nonce_value)
headers.append(nonce_value)
request = helpers.buildHttpMessage(headers, requestBody)
messageInfo.setRequest(request)
W następnym kroku dodamy znacznik czasu i kilka informacji debugowych do skryptu.
import uuid
import datetime
NONCE_HEADER = 'X-Nonce-Value'
NONCE_CREATED_AT_HEADER = 'X-Nonce-Created-At'
if messageIsRequest:
requestInfo = helpers.analyzeRequest(messageInfo.getRequest())
headers = requestInfo.getHeaders()
requestBody = messageInfo.getRequest()[requestInfo.getBodyOffset():]
newHeaders = []
for h in headers:
if NONCE_HEADER not in h and NONCE_CREATED_AT_HEADER not in h:
newHeaders.append(h)
else:
print('Header exist, removing: ', h)
nonce_value = str(uuid.uuid4())
nonce_created_at = '{}+00:00'.format(datetime.datetime.utcnow().isoformat())
nonce_value = '{}: {}'.format(NONCE_HEADER, nonce_value)
print('Adding new', nonce_value)
headers.append(nonce_value)
nonce_created_at = '{}: {}'.format(NONCE_CREATED_AT_HEADER, nonce_created_at)
print('Adding new', nonce_created_at)
newHeaders.append(nonce_created_at)
request = helpers.buildHttpMessage(headers, requestBody)
messageInfo.setRequest(request)
Teraz, kiedy zaimplementowaliśmy dwa podstawowe nagłówki, warto przyjrzeć się bliżej samemu podpisowi. Obecnie we wtyczce Burp Python Scripts nie ma prostej metody dodawania dodatkowych bibliotek z języka Python dostępnych w repozytorium pypi. Innym ograniczeniem jest to, że mamy dostęp tylko do Pythona 2.7, który, jak wiemy, nie jest już wspierany. Aby obejść te ograniczenia, możemy użyć wywołań systemowych za pomocą biblioteki subprocess. Na przykład:
import subprocess
process = subprocess.Popen("<cmd>”,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
shell=True
)
output, err = process.communicate()
if err.decode() != "":
raise Exception(err)
To pozwoli nam wykonywać dowolne polecenie w systemie za pomocą języka Python. Na tym etapie mamy prawie wszystkie niezbędne elementy, potrzebujemy tylko informacji, jak skonstruować dane wejściowe podpisu. Tutaj napotkałem kolejny niuans. Zazwyczaj, pracując z oprogramowaniem Burp i Repeater, używamy trybu Pretty dla prezentacji żądań. Jest to dość powszechna i zrozumiała praktyka, ponieważ sprawia, że nasze żądania są schludnie formatowane i jest to domyślne ustawienie w Burp. Problem z domyślnym wyświetlaniem w zakładce Pretty polega na tym, że nie pokazuje ona białych znaków. Dlatego, podpisując i edytując żądania, zaleca się używanie zakładki RAW, aby usunąć wszystkie niepotrzebne białe znaki, które mogą być uwzględniane podczas generowania podpisu. Dodatkowo, aby przekazać całe dane wejściowe podpisu do systemu bez obaw 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ł następująco:
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()
Uważny czytelnik może zauważyć w tym momencie, że zamiast martwić się o dodatkowe białe znaki w wiadomości, moglibyśmy zdeserializować wiadomość JSON, a następnie ponownie ją zserializować. Jednak tutaj pojawia się nowy problem. Istnieje różnica między serializacją JSON w Pythonie 2.7 a Pythonie 3. Najważniejsza różnica polega na tym, że kolejność kluczy w zserializowanym słowniku się zmienia. Powoduje to, że komponent po drugiej stronie, weryfikujący nasz podpis, ma inne dane wejściowe podpisu, i cała operacja weryfikacji zawiedzie. Więcej o różnicach w deserializacji JSON między Pythonem 2.7 a Pythonem 3 można przeczytać tutaj.
Poniżej znajduje się kod, który implementuje opisany mechanizm podpisywania żądań:
import uuid
import datetime
import base64
import subprocess
PRIVATE_KEY = "private.key"
SIGNATURE_HEADER = 'X-Signature'
NONCE_HEADER = 'X-Nonce-Value'
NONCE_CREATED_AT_HEADER = 'X-Nonce-Created-At'
if messageIsRequest:
requestInfo = helpers.analyzeRequest(messageInfo.getRequest())
headers = requestInfo.getHeaders()
requestBody = messageInfo.getRequest()[requestInfo.getBodyOffset():]
path = messageInfo.getUrl().getPath()
method = requestInfo.getMethod()
newHeaders = []
for h in headers:
if SIGNATURE_HEADER not in h and NONCE_HEADER not in h and NONCE_CREATED_AT_HEADER not in h:
newHeaders.append(h)
else:
print('Header exist, removing: ', h)
nonce_value = str(uuid.uuid4())
nonce_created_at = '{}+00:00'.format(datetime.datetime.utcnow().isoformat())
msg = helpers.bytesToString(requestBody)
signature_input = "{}{}{}{}{}".format(method, path, nonce_value, nonce_created_at, msg)
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)
signature = output.decode().replace("\n", "")
new_sign = '{}: {}'.format(SIGNATURE_HEADER, signature)
print('Adding new', new_sign)
newHeaders.append(new_sign)
nonce_value = '{}: {}'.format(NONCE_HEADER, nonce_value)
print('Adding new', nonce_value)
newHeaders.append(nonce_value)
nonce_created_at = '{}: {}'.format(NONCE_CREATED_AT_HEADER, nonce_created_at)
print('Adding new', nonce_created_at)
newHeaders.append(nonce_created_at)
request = helpers.buildHttpMessage(newHeaders, requestBody)
messageInfo.setRequest(request)
Na rysunku 1 zilustrowano próbę wysłania żądania bez dodatkowych nagłówków. Jak można zaobserwować, serwer wskazał na ich brak i nie przetworzył wiadomości.
Na rysunku 2 przedstawiono kolejną próbę wysłania żądania, tym razem z dodanymi wymaganymi nagłówkami. Jednak zmiana w treści wiadomości spowodowała brak kompatybilności z podpisem, co skutkowało również odrzuceniem wiadomości przez serwer.
Na rysunku 3 zilustrowano użycie napisanego kodu. Nagłówki X-Nonce
i X-Signature
są poprawne, co potwierdza odpowiedź otrzymana od serwera.
Na rysunku 4 przedstawiono zmodyfikowane żądanie zalogowane przez moduł Burp Logger, które zostało wysłane do serwera. Różnica w stosunku do rysunku 3 polega na wartościach nagłówków X-Signature
, X-Nonce-Value
i X-Nonce-Created-At
. Przy modyfikacji treści wiadomości nie ma już potrzeby martwić się o poprawne wartości tych nagłówków; zostaną one automatycznie dostosowane przed wysłaniem do serwera.
Podsumowanie
Mechanizm użycia nonce i podpisywania żądań może być skutecznym sposobem ochrony żądań API przed manipulacją. 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 potrzeby skupiania się na zapewnieniu poprawności wszystkich wartości nagłówków.
Ten artykuł ma również na celu wprowadzenie alternatywnych mechanizmów zabezpieczających integrację przesyłanych żądań, inne niż HMAC, oraz możliwe podejście do ich testowania. Jeśli ktoś jest zainteresowany sprawdzeniem funkcjonalności wtyczki, może znaleźć prosty kod serwera pod tym linkiem, który implementuje weryfikację zastosowanego podpisu.
W następnym artykule przedstawię kolejny mechanizm bezpieczeństwa komunikacji, z którym spotkałem się w projekcie. Podobnie jak omawiany w tym artykule, jego celem jest utrudnienie dalszego wykorzystywania systemu przez atakujących, i muszę przyznać, że może to robić niezwykle skutecznie.
Bibliografia
- https://en.wikipedia.org/wiki/Replay_attack
- https://www.zimuel.it/blog/sign-and-verify-a-file-using-openssl
- https://sereysethy.github.io/encryption/2017/10/23/encryption-decryption.html
- https://learn.microsoft.com/pl-pl/azure/communication-services/tutorials/hmac-header-tutorial
- https://httpwg.org/http-extensions/draft-ietf-httpbis-message-signatures.html#name-rsassa-pkcs1-v1_5-using-sha
- https://github.com/PortSwigger/python-scripter
- https://github.com/lanmaster53/pyscripter-er/tree/master/snippets
- https://github.com/mwalkowski/api-request-security-poc/
- https://stackoverflow.com/questions/51769239/why-json-dumps-in-python-3-return-a-different-value-of-python-2