Strona główna » Device cookie – pokonaj ataki brute force używając ciasteczka

Device cookie – pokonaj ataki brute force używając ciasteczka

by Grzegorz Sowa

„Moja apka to tylko mały sklep internetowy, kto by chciał ją atakować” – słyszałeś kiedyś podobną sentencję? Brzmi, jak słynne ostatnie słowa. 😀 W dzisiejszym świecie zdominowanym przez wymianę informacji, żaden biznes online nie może być pewny, że nie stanie się celem ataku. Dlatego w tym artykule przyjrzymy się mechanizmowi device cookie, który pozwala znacznie spowolnić ataki brute force, słownikowe i inne, wymagające ciągłego odpytywania endpointu logowania.

Kod zawarty w tym artykule znajdziecie tutaj.

Zapobieganie atakom brute force 101

Zapobieganie atakom brute force można podzielić na dwa główne problemy:

  • Jak rozpoznać atak brute force?
  • Jak mu zapobiegać?

Oba problemy są mocno splecione i są adresowane przez różne rozwiązania, różniące się kosztem, bezpieczeństwem i wpływem na zwykłego użytkownika. Prawdopodobnie najczęściej stosuje się po prostu „wycinanie” adresów IP, z których przychodzi zbyt dużo requestów. Nie jest to idealna metoda, ponieważ bardzo często zdarza się, że wiele osób dzieli ten sam adres IP, na przykład przez konfigurację sieci domowej, VPNa lub ISP. To zabezpieczenie jest również dość łatwe do obejścia, przez używanie różnych serwerów proxy lub sieci zainfekowanych komputerów.

Inną często stosowaną metodą jest blokada konta użytkownika, na które przeprowadzany jest atak. Ma to jednak oczywistą wadę, w postaci odcięcia dostępu do konta użytkownikowi, który jest atakowany.

Jest jeszcze captcha, jednak nie zapewnia już zbyt dobrej ochrony – istnieją już nawet firmy, które udostępniają usługi tanich pracowników w krajach trzeciego świata do masowego rozwiązywania captcha. Ponadto wymaga dodatkowej czynności użytkownika, wpływając negatywnie na user experience.

Bardziej złożone systemy wykrywania incydentów opierają się o AI i potrafią wycinać ruch oraz alertować administratorów. Są one jednak zazwyczaj drogie, a ich wprowadzenie czasochłonne i trudne.

Device cookie na ratunek

Device cookie to alternatywne rozwiązanie tego problemu, które jest tanie, łatwe do wprowadzenia i transparentne z perspektywy użytkownika. W tym mechanizmie używamy „pre-autentykacji”, żeby już klienta który próbuje się zalogować, móc wstępnie zidentyfikować jako:

  1. klient niezaufany, z którego użytkownik jeszcze się nie logował (nie posiada device cookie),
  2. klient zaufany, z którego użytkownik już się logował (posiada zbieżne z loginem, ważne device cookie).

Dzięki temu podziałowi otrzymujemy dwa główne profity:

  • gdy blokujemy wszystkie klienty niezaufane, klienty zaufane mogą się logować,
  • gdy blokujemy klienta zaufanego, reszta klientów zaufanych może się logować.

W przeciwieństwie do captcha czy blokowania dostępu do konta, algorytm ten jest transparentny z punktu widzenia użytkownika i zapewnia środowisko, w którym użytkownik nie jest odcinany od atakowanego konta.

Jak działa device cookie

Spróbuję wyjaśnić krótko, na czym polega algorytm. Moje umiejętności narracyjne są kontrowersyjnej jakości, więc postarałem się wzbogacić post schematami. 🙂 Poza tym, pełna implementacja algorytmu w Spring Security dostępna jest w repo wspomnianym na wstępie.

Przed uwierzytelnieniem

device cookie przed uwierzytelnieniem

Na początku całego procesu, gdy system otrzymuje request uwierzytelniający, sprawdzamy obecność device cookie.

Jeśli go nie ma sprawdzamy, czy niezaufany klient z danym loginem nie jest jeszcze zablokowany. W przeciwnym razie przepuszczamy proces dalej.

Jeśli device cookie znajdziemy w requeście, login z device cookie porównujemy z loginem z request body. Jeśli są różne, traktujemy klienta jako niezaufanego. W przeciwnym razie z ciasteczka pobieramy wartość nonce, która jednoznacznie identyfikuje zaufanego klienta. Na tej podstawie sprawdzamy, czy zaufany klient jest zablokowany. W przeciwnym razie przepuszczamy proces dalej.

Jeśli okaże się, że klient jest zablokowany, powinniśmy przesłać odpowiedź zawierającą jak najmniej informacji (najlepiej tylko status 401) i identyczną z typową odpowiedzią na bad credentials.

Udane logowanie

Jeśli zastanawiacie się, skąd w ogóle miałoby się wziąć device cookie w przeglądarce użytkownika, to teraz właśnie to się dzieje. Po udanym uwierzytelnieniu, przesyłamy klientowi w odpowiedzi nowe device cookie z unikalną, wygenerowaną losowo wartością nonce.

device cookie po uwierzytelnieniu

Nieudane logowanie

W przypadku nieudanego uwierzytelnienia, dodajemy jego timestamp do listy nieudanych prób danego klienta. Tutaj ma już znaczenie, czy klient był zaufany, czy nie, bo niezaufanych klientów identyfikują loginy użytkowników, a zaufanych unikalne nonce zawarte w device cookie. Następnie przycinamy listę do okienka czasowego, które analizujemy i jeśli w tym czasie było więcej niż X requestów, dodajemy klienta do listy zablokowanych. Na przykład, jeśli zdefiniowaliśmy limit prób logowania do 5 w ciągu 30 minut, to szósty request w ciągu tych 30 minut będzie skutkował zablokowaniem klienta na założony okres czasu.

Jak zaimplementować device cookie w Spring Security

Zobaczmy teraz sposób wpięcia algorytmu we flow uwierzytelnienia Spring Security

Pre-authorization filter

Aby zweryfikować request musimy założyć filtr, który będzie działał jeszcze przed uwierzytelnieniem. W tym celu zdefiniujmy PreAuthFilter implementujący interfejs GenericFilterBean, a w metodzie doFilter() wprowadźmy pierwszą część algorytmu device cookie.  Najpierw upewniamy się, że w requeście jest username i czy idzie na path /login. Robimy to, ponieważ przez filtr idą wszystkie requesty a chcemy go aktywować tylko do uwierzytelnienia:

        final HttpServletRequest httpRequest = (HttpServletRequest) request;
        final String username = httpRequest.getParameter("username");
        if (username == null || username.isBlank()
                || !httpRequest.getRequestURI().contains("/login")) {
            // if it is not a login request, skip this filter
            filterChain.doFilter(request, response);
            return;
        }

Jeśli wiemy już, że to request uwierzytelnienia, wyciągamy device cookie:

        final Optional<String> deviceCookie = Arrays.stream(ArrayUtils.nullToEmpty(httpRequest.getCookies(), Cookie[].class))
                .filter(cookie -> cookie.getName().equals(DEVICE_COOKIE_NAME))
                .map(Cookie::getValue)
                .findFirst();

Teraz weryfikujemy, czy request jest od niezablokowanego klienta zaufanego. Jeśli tak, w metodzie proceedAsTrustedClient() oznaczamy request jako pochodzący od zaufanego klienta i wywołujemy resztę filtrów:

        // if request has a valid device cookie
        if (deviceCookie.isPresent() && deviceCookieService.isDeviceCookieValidFor(username, deviceCookie.get())) {
            final String nonce = deviceCookieService.extractNonce(deviceCookie.get());

            // if trusted client identified by cookie nonce is not locked
            if (!deviceCookieService.isTrustedClientLocked(nonce)) {
                // proceed with authentication as trusted client
                proceedAsTrustedClient(request, response, filterChain, nonce);
                return;
            }
        }
        // else treat as untrusted client
    private void proceedAsTrustedClient(ServletRequest request, ServletResponse response, FilterChain filterChain,
                                        String nonce) throws IOException, ServletException {
        request.setAttribute(AUTH_ALLOWED_ATTR_NAME, true);
        request.setAttribute(CLIENT_TRUSTED_ATTR_NAME, true);
        request.setAttribute(NONCE_ATTR_NAME, nonce);
        filterChain.doFilter(request, response);
    }

W przeciwnym razie sprawdzamy, czy klienty niezaufane nie zostały zablokowane. Jeśli nie, w metodzie proceedAsUntrustedClient() oznaczamy request od niezaufanego klienta i wywołujemy resztę filtrów:

        // if untrusted clients are not locked from that account
        if (!deviceCookieService.areUntrustedClientsLocked(username)) {
            // proceed with authentication as untrusted client
            proceedAsUntrustedClient(request, response, filterChain);
            return;
        }
    private void proceedAsUntrustedClient(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
        request.setAttribute(AUTH_ALLOWED_ATTR_NAME, true);
        request.setAttribute(CLIENT_TRUSTED_ATTR_NAME, false);
        filterChain.doFilter(request, response);
    }

Jeśli doszliśmy do tego momentu to znaczy, że request pochodzi od zablokowanego klienta, więc rzucamy wyjątek, blokując uwierzytelnienie:

        // else throw exception, blocking authentication
        request.setAttribute(AUTH_ALLOWED_ATTR_NAME, false);
        throw new AuthenticationAttemptsLockedException(
                String.format("Authentication attempts for login: %s are locked", username));

Login success handler

Po udanym zalogowaniu musimy wygenerować nowe device cookie i zwrócić je klientowi, musimy więc zaimplementować AuthenticationSuccessHandler:

@RequiredArgsConstructor
class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    private final DeviceCookieService deviceCookieService;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        response.addCookie(new Cookie("device", deviceCookieService.generateDeviceCookieFor(authentication.getName())));
        response.setStatus(OK.value());
    }
}

Login failure handler

Po nieudanym uwierzytelnieniu musimy odnotować tę próbę, więc potrzebujemy zaimplementować AuthenticationFailureHandler:

@RequiredArgsConstructor
class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {

    private final DeviceCookieService deviceCookieService;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) {
        if ((Boolean) request.getAttribute(AUTH_ALLOWED_ATTR_NAME)) {
            if ((Boolean) request.getAttribute(CLIENT_TRUSTED_ATTR_NAME)) {
                deviceCookieService.reportTrustedClientLoginFailure((String) request.getAttribute(NONCE_ATTR_NAME));
            } else {
                deviceCookieService.reportUntrustedClientLoginFailure(request.getParameter("username"));
            }
        }
        response.setStatus(UNAUTHORIZED.value());
    }
}

Wpięcie w Spring Security

Filtr i dwa handlery musimy jeszcze wpiąć we flow uwierzytelnienia. Możemy to zrobić w konfiguracji web security:

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    private final DeviceCookieService deviceCookieService;

    // …

    @Override
    protected void configure(final HttpSecurity http) throws Exception {
        http
                .addFilterBefore(new PreAuthFilter(deviceCookieService), UsernamePasswordAuthenticationFilter.class)
                .failureHandler(new CustomAuthenticationFailureHandler(deviceCookieService))
                .successHandler(new CustomAuthenticationSuccessHandler(deviceCookieService))
        // …
    }
}

Device cookie service

Serwis obsługujący device cookie jest już dość prosty, orkiestruje tylko encjami klientów i dwoma providerami, NonceProvider i CookieProvider:

@Service
@RequiredArgsConstructor
class DeviceCookieServiceImpl implements DeviceCookieService {

    private final DeviceCookieProperties properties;
    private final TrustedClientRepository trustedClientRepository;
    private final UntrustedClientRepository untrustedClientRepository;
    private final CookieProvider cookieProvider;
    private final NonceProvider nonceProvider;

    @Override
    public boolean isDeviceCookieValidFor(String login, String deviceCookie) {
        if (!cookieProvider.isCookieValid(deviceCookie)) {
            return false;
        }
        DeviceCookie cookie = cookieProvider.decodeCookie(deviceCookie);
        return cookie.getLogin().equals(login);
    }

    @Override
    public String generateDeviceCookieFor(String login) {
        String nonce = nonceProvider.generate(properties.getNonceLength());
        return cookieProvider.encodeCookie(new DeviceCookie(login, nonce));
    }

    @Override
    public String extractNonce(String deviceCookie) {
        return cookieProvider.decodeCookie(deviceCookie).getNonce();
    }

    @Override
    public boolean isTrustedClientLocked(String nonce) {
        Optional<TrustedClient> trustedClient = trustedClientRepository.findById(nonce);
        return trustedClient.isPresent() && trustedClient.get().isLocked();
    }

    @Override
    public boolean areUntrustedClientsLocked(String login) {
        Optional<UntrustedClient> untrustedClient = untrustedClientRepository.findById(login);
        return untrustedClient.isPresent() && untrustedClient.get().isLocked();
    }

    @Override
    public void reportTrustedClientLoginFailure(String nonce) {
        TrustedClient trustedClient = trustedClientRepository.findById(nonce)
                .orElse(TrustedClient.newInstance(nonce));
        trustedClient.registerFailedLoginAttempt(properties);
        trustedClientRepository.save(trustedClient);
    }

    @Override
    public void reportUntrustedClientLoginFailure(String login) {
        UntrustedClient untrustedClient = untrustedClientRepository.findById(login)
                .orElse(UntrustedClient.newInstance(login));
        untrustedClient.registerFailedLoginAttempt(properties);
        untrustedClientRepository.save(untrustedClient);
    }

}

Za długo byłoby omawiać tutaj szczegóły implementacyjne providerów czy encji klientów, ich implementację znajdziecie w repo podlinkowanym we wstępie.

Podsumowanie

Mechanizm device cookie jest na pewno ciekawą i przydatną rzeczą, zwłaszcza, jeśli zdarzy się Wam tworzyć lub rozwijać własny system uwierzytelnienia. Więcej znajdziecie w originalnym poście o device cookie na stronie OWASP.

You may also like

Leave a Comment