Czy w 2022 roku, używając dojrzałych frameworków, wciąż możemy się „naciąć” na błędy bezpieczeństwa? Uważam, że bez odpowiedniej wiedzy – jak najbardziej. Mimo, że większość zabezpieczeń mamy out of the box, to wciąż możemy sami wprowadzić błędy bezpieczeństwa, nawet używając Spring.
SQL Injection
Pierwszy błąd to oczywiście wiecznie żywy SQL Injection. Ogromna większość funkcji, które udostępnia nam Spring Data, korzysta z zapytań parametryzowanych, które eliminują prawdopodobieństwo błędu bezpieczeństwa. Warto jednak, żebyśmy pamiętali, że wciąż możemy taki błąd wywołać, na przykład sklejając zapytania SQL samemu:
@RestController @RequestMapping("/tasks") @RequiredArgsConstructor public class TaskEndpoint { private final TaskRepository repository; private final DataSource dataSource; @GetMapping public List<String> searchTasks(@RequestParam String phrase) { String query = "select title from task where title like '%" + phrase + "%'"; List<String> result = new ArrayList<>(); try { Connection c = dataSource.getConnection(); ResultSet rs = c.createStatement().executeQuery(query); while (rs.next()) { result.add(rs.getString("title")); } } catch (SQLException e) { e.printStackTrace(); } return result; } }
Może mamy jakieś skomplikowane zapytanie, którego nie umiemy inaczej obsłużyć, a może w tym miejscu i tak nie podajemy inputu użytkownika. Niezależnie od wszystkiego, nigdy nie powinniśmy używać takich zapytań w produkcyjnych systemach! Nawet jeśli teraz nie wprowadzamy tam inputu usera, nie powinniśmy być pewni, że w którejś z przyszłych wersji aplikacji ktoś tego nie zmieni, wprowadzając podatność.
Test
Wykonajmy zapytanie GET /tasks?phrase=sys
– jeśli mamy jakieś taski z taką frazą w tytule, dostaniemy pewnie podobną odpowiedź:
200 OK [ "Poprawka konfiguracji systemu" ]
Spróbujmy jednak oszukać system tak, żeby „uciec” ze stringa select title from task where title like '%sys%'
i zamenić go na: select title from task where title like '%sys%' union select name from information_schema.users--
. To zapytanie specyficzne dla bazy H2. Wysyłamy więc zapytanie GET /tasks?phrase=sys%25%27%20union%20select%20name%20from%20information_schema.users--
i otrzymujemy odpowiedź:
200 OK [ "Poprawka konfiguracji systemu", "SA" ]
Do poprawnego wyniku zapytania dodał nam się username admina bazy danych, więc tak, właśnie wprowadziliśmy podatność SQL Injection w aplikacji opartej o Spring Boot.
Pamiętajmy też, że te same podatności możemy wprowadzić w analogiczny sposób, korzystając z HQL czy No-SQL.
Fix
Jednym ze sposobów rozwiązania tego rozwiązania jest sanityzacja inputu ze znaków specjalnych pozwalających na SQL Injection. Nie jest to jednak idealne, ponieważ pozostawia pole do pomyłki i ogranicza możliwości budowania zapytania. Jedyną tak naprawdę poprawną techniką jest używanie zapytań parametryzowanych, np. przez zdefiniowanie query method w naszym JpaRepository
:
@Repository public interface TaskRepository extends JpaRepository<Task, UUID> { List<Task> findByTitleContaining(String title); }
Login Bruteforce
Kolejnym miejscem, w którym łatwo możemy wprowadzić błędy bezpieczeństwa w Spring, jest uwierzytelnienie. Aby wprowadzić jeden z nich, wystarczy że dodamy do naszej aplikacji możliwość logowania użytkownika:
@Configuration @RequiredArgsConstructor public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter { private final PasswordEncoder passwordEncoder; @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth .inMemoryAuthentication() .passwordEncoder(passwordEncoder) .withUser("user").password(passwordEncoder.encode("pass")).authorities("USER"); } @Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers("/login").permitAll() .antMatchers("/users/**").permitAll() .anyRequest().authenticated() .and() .formLogin() .successHandler((req, resp, auth) -> resp.setStatus(OK.value())) .failureHandler((req, resp, auth) -> resp.setStatus(UNAUTHORIZED.value())) .and() .exceptionHandling() .authenticationEntryPoint((req, resp, ex) -> resp.setStatus(UNAUTHORIZED.value())) .and() .logout() .and() .csrf().disable(); } }
Możemy zalogować się w naszej aplikacji zapytaniem:
POST localhost:8080/login username=user&password=pass
Załóżmy jednak, że jesteśmy atakującym naszą aplikację hakerem, który pragnie przejąć konto użytkownika. Najprostszym sposobem, którego spróbuje będzie próba odgadnięcia hasła za pomocą ataku słownikowego lub ataku bruteforce. Przestępca szybko zapewne odkryje, że nawet przy częstym odpytywaniu aplikacja przetwarza jego kolejne żądania. Dzięki temu będzie w stanie zgadnąć w rozsądnym czasie większość haseł użytkowników. Niestety, Spring nie zapewnia domyślnych mechanizmów ochrony przed takimi atakami.
Fix
Jest kilka sposobów ochrony przed takim mechanizmem ataku:
- blokowanie użytkownika po X próbach,
- używanie CAPTCHA/reCAPTCHA,
- wprowadzenie mechanizmu Device Cookie
Słaby generator liczb losowych
Załóżmy teraz, że w naszej aplikacji wprowadzamy możliwość rejestrowania nowych użytkowników. Przy rejestracji generujemy token aktywacji, z którego potem budujemy link aktywacyjny. Ten link możemy dalej np. wysłać na mail podany przy rejestracji przez użytkownika, aby potwierdzić jego posiadanie.
@Service @Slf4j @RequiredArgsConstructor public class UserService implements UserDetailsService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final MailService mailService; private final Random random = new Random(); public String createUser(CreateUserDto dto) { final String token = RandomStringUtils.random(48, 0, 0, true, true, null, this.random); final User user = User.newInstance( dto.getUsername(), passwordEncoder.encode(dto.getPassword()), Set.of(Authority.USER), token); final User savedUser = userRepository.save(user); mailService.sendActivationMail(token); return savedUser.getId(). toString(); } public void activateUser(String token) { Optional<User> result = userRepository.findByActivationToken(token); if (result.isPresent()) { User user = result.get(); user.activate(); log.info("user " + user.getUsername() + " activated"); userRepository.save(user); } } @Override public User loadUserByUsername(String username) throws UsernameNotFoundException { return userRepository.findByUsername(username) .orElseThrow(() -> new UsernameNotFoundException("No user with such username!")); } }
Jaki jest problem z tą implementacją? Otóż używamy generatora liczb losowych – java.util.Random
. W miejscach, gdzie zależy nam na wartości, którą atakujący może próbować zgadnąć – ten generator powoduje ryzyko bezpieczeństwa. Wszystko dlatego, że java.util.Random
nie jest kryptograficznie bezpieczny – jego seed to tylko 48 bitów i jest ustalany na podstawie czasu, w którym jest wygenerowany. Poza tym do generowania kolejnych wartości używa algorytmu LCG, który nie jest uznawany za kryptograficznie bezpieczny. To wszystko otwiera przed atakującym kilka możliwości:
- Znając dokładnie czas wygenerowania seeda może przewidywać kolejne wartości.
- Mając serię kolejnych wartości, może próbować zgadnąć seed. W tym przypadku będzie potrzebował co najwyżej 2^48 prób – ilość, którą dzisiejsze procesory przetworzą w rozsądnym czasie.
Innym błędem może być użycie UUID zamiast losowego stringa – wartości UUID również nie są kryptograficznie bezpieczne.
Fix
Właściwym generatorem do użycia tutaj będzie java.security.SecureRandom
, ponieważ:
- Używa dłuższych, nawet 128-bitowych seedów
- Kolejne wartości wyznacza z użyciem bezpieczniejszego algorytmu (SHA1PRNG)
- Generowanie seedów pozostawia systemowi – pobiera je z /dev/ranom i /dev/urandom.
public class UserService implements UserDetailsService { private final Random random = new SecureRandom(); //… }
CSRF
Używając ataku CSRF złośliwa strona może wykonać żądanie w imieniu użytkownika naszej aplikacji. Może do tego użyć na przykład niewidocznego formularza HTML:
<body onload="document.forms['task_form'].submit()" > <form action="http://localhost:8080/tasks" method="POST" name="task_form" style="display:none"> <input type="text" name="title" value="trolololo"> </form> </body>
Jeśli zalogowany w naszej aplikacji użytkownik wejdzie na taką złośliwą stronę to wykona ona w jego imieniu request:
POST /tasks HTTP/1.1 Host: localhost:8080 Content-Type: application/x-www-form-urlencoded text=trolololo
Przeglądarka automatycznie załączy ciasteczko sesji i nasza aplikacja nie będzie miała szans odróżnić złośliwego żądania od pochodzącego z naszego frontendu.
Podatność CSRF ma kilka aspektów, dlatego najłatwiej omówić ją w punktach, po kolei:
- Używając Session Cookie – jesteśmy podatni na CSRF.
- Endpointy, które wymagają
@RequestBody
oczekująContent-Type: application/json
, więc na ogół nie są podatne na CSRF. Nie da się ich wywołać przez HTML<form>
, a tylko przez zapytania z JavaScript, a przed nimi chroni nas Same Origin Policy. Jednak przy wystąpieniu nieprawidłowej konfiguracji CORS – jesteśmy podatni. - Endpointy zmieniające stan bez
@RequestBody
, np. z użyciem metody DELETE lub PUT też na ogół nie są podatne, ponieważ przeglądarki nie pozwalają przesłać formularza HTML z metodą inną niż GET/POST. Jednak przy wystąpieniu dodatkowych okoliczności – interpretowania parametru _method lub nieprawidłowej konfiguracji CORS – jesteśmy podatni. - Endpointy zmieniające stan z metodą GET – jesteśmy podatni, nigdy nie powinniśmy zmieniać stanu aplikacji metodą GET.
Fix
Zabezpieczenie przed CSRF jest włączone w Spring Security by default, wystarczy upewnić się, że nie mamy w konfiguracji security .csrf().disable()
. Strategii ochrony jest kilka, w zależności od tego, jak emitujemy frontend:
- Możemy użyć headera JWT zamiast Session Cookie.
- Jeśli emitujemy frontend z aplikacji, np. za pomocą Thymeleaf – token CSRF zostanie automatycznie dodany do każdego
<tf:form>
. - Jeśli używamy SPA, np. z użyciem Angulara – możemy skonfigurować
CookieCsrfTokenRepository
, dzięki czemu aplikacja będzie zwracać cookie XSRF-TOKEN. Frontend musi wartość tego cookie przesyłać w każdym requeście w headerze X-XSRF-TOKEN, aby nie dostać odpowiedzi 401. Konfiguracja tego rozwiązania wygląda tak:
@Override public void configure(HttpSecurity http) throws { http .csrf() .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()); }
Podsumowanie
Tak wyglądają moim zdaniem ciekawsze błędy bezpieczeństwa, na które można się natknąć programując w Spring Boot. Starałem się wybrać błędy wynikające bardziej z niepoprawnej konfiguracji, niż designu systemu, chociaż na te też kiedyś przyjdzie pora 🙂