The Summary Of Spring Security Authorization Bypass on Java

TutorialBoy
11 min readJul 12, 2023

--

Authorization Bypass

Basically, the name is very accurate. Say we have a webpage in our Spring Boot application that should only be accessible for users that are configured to have the admin role. An authorization bypass means that a non-admin user could access that page in certain use cases without having this admin role (or better). Obviously, this is unwanted and can lead to a number of things including data leaks and unauthorized changing, creating, or deleting data.

Spring Security

With Spring Security, it is possible to create a SecurityFilterChain to set permission for specific endpoints. In newer versions of Spring, it looks something like this:

@Configuration
@EnableWebSecurity //Enable web security features
public class AuthConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeRequests() // turn on HttpSecurity configuration
.antMatchers("/admin/**").hasRole("ADMIN") // admin/** Schema URL must have ADMIN role
.antMatchers("/user/**").access("hasAnyRole('ADMIN','USER')") // This mode requires ADMIN or USER role
.antMatchers("/db/**").access("hasRole('ADMIN') and hasRole('DBA')") // ADMIN and DBA roles are required
.anyRequest().authenticated() // Users must access other URLs after authentication (access after login)
.and().formLogin().loginProcessingUrl("/login").permitAll() // Enable form login and configure the login interface
.and().csrf().disable(); // close csrf
return http.build();}
method                                        describe
access(String) Allow access if the given SpEL expression evaluates to true
anonymous() Allow anonymous users to access
authenticated() Allow authenticated users to access
denyAll() Unconditionally deny all access
fullyAuthenticated() If the user is fully authenticated (not authenticated by the Remember-me function), access is allowed
hasAnyAuthority(String…) Allow access if the user has one of the given permissions
hasAnyRole(String…) Allow access if the user has one of the given roles
hasAuthority(String) Allow access if the user has the given permission
hasIpAddress(String) Allow access if the request comes from the given IP address
hasRole(String) Allow access if the user has the given role
not() negates the result of other access methods
permitAll() Allow access unconditionally
rememberMe() If the user is authenticated by the Remember-me function, access is allowed

WebSecurityConfigurerAdapter​ The configure() method can also be used to formulate the details of web security by integrating classes.

  • configure(WebSecurity): By overloading this method, the Filter chain of Spring Security can be configured.
  • configure(HttpSecurity): By overloading this method, you can configure how to protect requests through interceptors.

All SpEL expressions supported by Spring Security are as follows:

safe expression                      Calculation results
authentication user authentication object
denyAll The result is always false
hasAnyRole(list of roles) Returns true if the user is authorized for any of the specified permissions
hasRole(role) Evaluates to true if the user is granted the specified permission
hasIpAddress(IP Adress) user address
isAnonymous() Is it an anonymous user
isAuthenticated() not anonymous
isFullyAuthenticated Not anonymous nor remember-me authenticated
isRemberMe() remember-me certification
permitAll always true
principal User's main information object​

configure(AuthenticationManagerBuilder): By overloading this method, the user-detail (user details) service can be configured.

method                                Describe
accountExpired(boolean) Define whether the account has expired
accountLocked(boolean) Defines whether the account is locked
and() for connection configuration
authorities(GrantedAuthority…) Grant one or more permissions to a user
authorities(List) Grant one or more permissions to a user
authorities(String…) Grant one or more permissions to a user
credentialsExpired(boolean) Defines whether the credential has expired
disabled(boolean) Defines whether the account has been disabled
password(String) Define the user's password
roles(String…) Grant a user one or more roles

User Storage Method

  • Through the inMemoryAuthentication() method, we can enable, configure, and arbitrarily populate memory-based user storage. Also, we can call the withUser() method to add a new user to the in-memory user store. The parameter of this method is the username. The withUser() method returns UserDetailsManagerConfigurer.UserDetailsBuilder, which provides multiple methods for further configuring users, including the password() method for setting user passwords and the roles() method for granting one or more role permissions to a given user. Note that the roles() method is a shorthand for the authorities() method. The value given by the roles() method will be prefixed with a ROLE_ and granted to the user as permission. Therefore, the authority of the appeal code user is ROLE_USER, ROLE_ADMIN. With the help of the passwordEncoder() method to specify a password encoder (encoder), we can encrypt and store user passwords.
@Configuration
@EnableWebSecurity //Enable web security features
public class AuthConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeRequests() // Open HttpSecurity configuration
.antMatchers("/admin/**").hasRole("ADMIN") // admin/** Schema URL must have ADMIN role
.antMatchers("/user/**").access("hasAnyRole('ADMIN','USER')") // This mode requires ADMIN or USER role
.antMatchers("/db/**").access("hasRole('ADMIN') and hasRole('DBA')") // ADMIN and DBA roles are required
.anyRequest().authenticated() // Users must access other URLs after authentication (access after login)
.and().formLogin().loginProcessingUrl("/login").permitAll() // Enable form login and configure the login interface
.and().csrf().disable(); // close csrf
        return http.build();@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("root").password("123").roles("ADMIN","DBA")
.and()
.withUser("admin").password("123").roles("ADMIN","USER")
.and()
.withUser("xxx").password("123").roles("USER");
}
}
  • Authentication based on database tables: User data is usually stored in a relational database and accessed through JDBC. In order to configure Spring Security to use JDBC-backed user storage, we can use the jdbcAuthentication() method and configure its DataSource so that we can access the relational database.
  • LDAP-based authentication: In order, for Spring Security to use LDAP-based authentication, we can use the ldapAuthentication() method.
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// Configure the user-detail service
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// Configure authentication based on LDAP
auth.ldapAuthentication()
.userSearchBase("ou=people")
.userSearchFilter("(uid={0})")
.groupSearchBase("ou=groups")
.groupSearchFilter("member={0}")
.passwordCompare()
.passwordAttribute("password")
.passwordEncoder(new BCryptPasswordEncoder());
}
}

use remote LDAP

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.ldapAuthentication()
.userSearchBase("ou=people")
.userSearchFilter("(uid={0})")
.groupSearchBase("ou=groups")
.groupSearchFilter("member={0}")
// Returns a ContextSourceBuilder object
.contextSource()
// Specify the address of the remote LDAP server
.url("ldap://xxx.com:389/dc=xxx,dc=com");

}
}
ldapAuthentication():Indicates LDAP-based authentication.
userSearchBase():Provides the base query for finding users
userSearchFilter():Provide filter criteria for searching users.
groupSearchBase():An underlying query is specified for the lookup group.
groupSearchFilter():Provides filter conditions for groups.
passwordCompare():Hope to pass password comparison for authentication.
passwordAttribute():Specifies the attribute name saved by password, default: userPassword.
passwordEncoder():Specifies a cipher converter.

hasRole and hasAuthority

http.authorizeRequests()
.antMatchers("/admin/**").hasAuthority("admin")
.antMatchers("/user/**").hasAuthority("user")
.anyRequest().authenticated()
http.authorizeRequests()
.antMatchers("/admin/**").hasRole("admin")
.antMatchers("/user/**").hasRole("user")
.anyRequest().authenticated()

Actually, both have the same effect

antMatchers configuration authentication bypass

package person.xu.vulEnv;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import static org.springframework.security.config.Customizer.withDefaults;
@Configuration
@EnableWebSecurity
public class AuthConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

http
.csrf().disable()
.authorizeRequests()
.antMatchers("/test").access("hasRole('ADMIN')")
.antMatchers("/**").permitAll();
//.antMatchers("/**").access("anonymous");
        // @formatter:on
return http.build();
}
// @formatter:off
@Bean
public InMemoryUserDetailsManager userDetailsService() {
UserDetails user = User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(user);
}
// @formatter:on
}

Bypass: http://127.0.0.1:8012/test/

mvcMatchers(“/test”).access(“hasRole(‘ADMIN’)”)Or use antMatchers(“/test/**”).access(“hasRole(‘ADMIN’)”)the writing method to prevent authentication bypass.

regexMatchers configuration authentication bypass

public class AuthConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeRequests()
.regexMatchers("/test").access("hasRole('ADMIN')")
.antMatchers("/**").access("anonymous");
        // @formatter:on
return http.build();
}

http://127.0.0.1:8012/test?、http://127.0.0.1:8012/test/

Matchers do not use a similar /test.*method. When passing in /test?, the regex will not match and will not hit /test the rules. The safe writing

.regexMatchers("/test.*?").access("hasRole('ADMIN')")

useSuffixPatternMatch bypass

The lower version of spring-webmvc and its related components, including:

spring-webmvc <= 5.2.4.RELEASE
spring-framework <= 5.2.6.RELEASE
spring-boot-starter-parent <= 2.2.5.RELEASE

useSuffixPatternMatchThe configuration default value defined in the code true means to use the suffix matching pattern to match the path.

For example, /path/abcrouting will also allow /path/abcd.ef, /path/abcde.fetc. Adding a path in the form of .xxxa suffix matches successfully.

Bugfixes:

Using a higher version of spring-web MVC can effectively avoid problems.

CVE-2022–22978

Affected version

  • Spring Security 5.5.x < 5.5.7
  • Spring Security 5.6.x < 5.6.4

Vulnerability Analysis

When Spring is loaded DelegatingFilterProxy, DelegatingFilterProxy will get the Filter implementation class injected into the Spring container from the Spring container according to the targetBeanName. When configuring the DelegatingFilterProxy, you generally need to configure the attribute targetBeanName. DelegatingFilterProxy is a proxy for the servlet filter. The benefit of using this class is mainly to manage the life cycle of the servlet filter through the Spring container.

In addition, if some instances of Spring containers are needed in the filter, they can be injected directly through the spring,

and these convenient operations such as reading some configuration files can be configured and realized through Spring.

@Override
protected void initFilterBean() throws ServletException {
synchronized (this.delegateMonitor) {
if (this.delegate == null) {
// If no target bean name specified, use filter name.
//If the targetBeanName attribute is not set when the Filter is configured, it will be searched directly based on the Filter name
if (this.targetBeanName == null) {
this.targetBeanName = getFilterName();
}
WebApplicationContext wac = findWebApplicationContext();
if (wac != null) {
//Get the implementation of the injected Filter from the Spring container
this.delegate = initDelegate(wac);
}
}
}
}

protected Filter initDelegate(WebApplicationContext wac) throws ServletException {
//Obtain the implementation class of the injected Filter from the Spring container
Filter delegate = wac.getBean(getTargetBeanName(), Filter.class);
if (isTargetFilterLifecycle()) {
delegate.init(getFilterConfig());
}
return delegate;
}
@Override
protected void initFilterBean() throws ServletException {
synchronized (this.delegateMonitor) {
if (this.delegate == null) {
// If no target bean name specified, use filter name.
//If the targetBeanName attribute is not set when the Filter is configured, it will be searched directly based on the Filter name
if (this.targetBeanName == null) {
this.targetBeanName = getFilterName();
}
WebApplicationContext wac = findWebApplicationContext();
if (wac != null) {
//Obtain the implementation class of the injected Filter from the Spring container
this.delegate = initDelegate(wac);
}
}
}
}

protected Filter initDelegate(WebApplicationContext wac) throws ServletException {
//Obtain the implementation class of the injected Filter from the Spring container
Filter delegate = wac.getBean(getTargetBeanName(), Filter.class);
if (isTargetFilterLifecycle()) {
delegate.init(getFilterConfig());
}
return delegate;
}

Get the injected Filter implementation class from the Spring container, and then call org.springframework.web.filter.DelegatingFilterProxy#invokeDelegatethe method

comeorg.springframework.security.web.FilterChainProxy#doFilterInternal

private void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
FirewalledRequest firewallRequest = this.firewall.getFirewalledRequest((HttpServletRequest)request);
HttpServletResponse firewallResponse = this.firewall.getFirewalledResponse((HttpServletResponse)response);
List<Filter> filters = this.getFilters((HttpServletRequest)firewallRequest);
if (filters != null && filters.size() != 0) {
if (logger.isDebugEnabled()) {
logger.debug(LogMessage.of(() -> {
return "Securing " + requestLine(firewallRequest);
}));
}
VirtualFilterChain virtualFilterChain = new VirtualFilterChain(firewallRequest, chain, filters);
virtualFilterChain.doFilter(firewallRequest, firewallResponse);
} else {
if (logger.isTraceEnabled()) {
logger.trace(LogMessage.of(() -> {
return "No security for " + requestLine(firewallRequest);
}));
}
firewallRequest.reset();
chain.doFilter(firewallRequest, firewallResponse);
}
}

a firewall is loaded by default StrictHttpFirewall, instead of DefaultHttpFirewall. On the contrary DefaultHttpFirewall, the verification is not so strict

FirewalledRequestIt is an encapsulated request class, but in fact, this class just HttpServletRequestWrapperadds a reset method on the basis of. When the spring security filter chain is executed, it is FilterChainProxyresponsible for calling this method in order to reset all or part of the properties.

FirewalledResponseIt is an encapsulated response class. This class mainly rewrites the four methods of sendRedirect, setHeader, addHeader, and addCookie, and checks its parameters in each method to ensure that the parameters do not contain \r and \n.

In the FilterChainProxy property definition, the HttpFirewall instance created by default is StrictHttpFirewall.

FilterChainProxy is built in the WebSecurity#performBuild method, and WebSecurity implements the ApplicationContextAware interface and implements the setApplicationContext method in the interface. In this method, the HttpFirewall pair is found from the spring container and assigned to the httpFirewall attribute. Finally, in the performBuild method, after the FilterChainProxy object is built successfully, if httpFirewall is not empty, configure httpFirewall to the FilterChainProxy object.

Therefore, if there is an HttpFirewall instance in the spring container, the instance provided by the spring container will be used; if not, the StrictHttpFirewall defined by default in FilterChainProxy will be used.

org.springframework.security.web.firewall.StrictHttpFirewall#getFirewalledRequest

public FirewalledRequest getFirewalledRequest(HttpServletRequest request) throws RequestRejectedException {
this.rejectForbiddenHttpMethod(request);
this.rejectedBlocklistedUrls(request);
this.rejectedUntrustedHosts(request);
if (!isNormalized(request)) {
throw new RequestRejectedException("The request was rejected because the URL was not normalized.");
} else {
String requestUri = request.getRequestURI();
if (!containsOnlyPrintableAsciiCharacters(requestUri)) {
throw new RequestRejectedException("The requestURI was rejected because it can only contain printable ASCII characters.");
} else {
return new StrictFirewalledRequest(request);
}
}
}

The method will determine whether the requested method is allowed

​ org.springframework.security.web.firewall.StrictHttpFirewall#rejectForbiddenHttpMethod

private void rejectForbiddenHttpMethod(HttpServletRequest request) {
if (this.allowedHttpMethods != ALLOW_ANY_HTTP_METHOD) {
if (!this.allowedHttpMethods.contains(request.getMethod())) {
throw new RequestRejectedException("The request was rejected because the HTTP method \"" + request.getMethod() + "\" was not included within the list of allowed HTTP methods " + this.allowedHttpMethods);
}
}
}
private static Set<String> createDefaultAllowedHttpMethods() {
Set<String> result = new HashSet();
result.add(HttpMethod.DELETE.name());
result.add(HttpMethod.GET.name());
result.add(HttpMethod.HEAD.name());
result.add(HttpMethod.OPTIONS.name());
result.add(HttpMethod.PATCH.name());
result.add(HttpMethod.POST.name());
result.add(HttpMethod.PUT.name());
return result;
}

org.springframework.security.web.firewall.StrictHttpFirewall#rejectedBlocklistedUrls

private void rejectedBlocklistedUrls(HttpServletRequest request) {
Iterator var2 = this.encodedUrlBlocklist.iterator();
String forbidden;
do {
if (!var2.hasNext()) {
var2 = this.decodedUrlBlocklist.iterator();
do {
if (!var2.hasNext()) {
return;
}
forbidden = (String)var2.next();
} while(!decodedUrlContains(request, forbidden));
throw new RequestRejectedException("The request was rejected because the URL contained a potentially malicious String \"" + forbidden + "\"");
}
forbidden = (String)var2.next();
} while(!encodedUrlContains(request, forbidden));
throw new RequestRejectedException("The request was rejected because the URL contained a potentially malicious String \"" + forbidden + "\"");
}
encodedUrlBlocklist = {HashSet@7373}  size = 18
0 = "//"
1 = ""
2 = "%2F%2f"
3 = "%2F%2F"
4 = "%00"
5 = "%25"
6 = "%2f%2f"
7 = "%2f%2F"
8 = "%5c"
9 = "%5C"
10 = "%3b"
11 = "%3B"
12 = "%2e"
13 = "%2E"
14 = "%2f"
15 = "%2F"
16 = ";"
17 = "\"
decodedUrlBlocklist = {HashSet@7374} size = 16
0 = "//"
1 = ""
2 = "%2F%2f"
3 = "%2F%2F"
4 = "%00"
5 = "%"
6 = "%2f%2f"
7 = "%2f%2F"
8 = "%5c"
9 = "%5C"
10 = "%3b"
11 = "%3B"
12 = "%2f"
13 = "%2F"
14 = ";"
15 = "\"
private static boolean encodedUrlContains(HttpServletRequest request, String value) {
return valueContains(request.getContextPath(), value) ? true : valueContains(request.getRequestURI(), value);
}
private static boolean decodedUrlContains(HttpServletRequest request, String value) {
if (valueContains(request.getServletPath(), value)) {
return true;
} else {
return valueContains(request.getPathInfo(), value);
}
}
private static boolean valueContains(String value, String contains) {
return value != null && value.contains(contains);
}

Prioritize the value of the request.getContextPath()the inside, if there is a blacklist, it will return false and throw an exception.

org.springframework.security.web.firewall.StrictHttpFirewall#rejectedUntrustedHosts

private void rejectedUntrustedHosts(HttpServletRequest request) {
String serverName = request.getServerName();
if (serverName != null && !this.allowedHostnames.test(serverName)) {
throw new RequestRejectedException("The request was rejected because the domain " + serverName + " is untrusted.");
}
}

org.springframework.security.web.firewall.StrictHttpFirewall#isNormalized(java.lang.String)

private static boolean isNormalized(String path) {
if (path == null) {
return true;
} else {
int slashIndex;
for(int i = path.length(); i > 0; i = slashIndex) {
slashIndex = path.lastIndexOf(47, i - 1);
int gap = i - slashIndex;
if (gap == 2 && path.charAt(slashIndex + 1) == '.') {
return false;
}
if (gap == 3 && path.charAt(slashIndex + 1) == '.' && path.charAt(slashIndex + 2) == '.') {
return false;
}
}
return true;
}
}

Check that request.getRequestURI() request.getContextPath() request.getServletPath() request.getPathInfo() is not allowed ., /./or/. to request.getRequestURI();callorg.springframework.security.web.firewall.StrictHttpFirewall#containsOnlyPrintableAsciiCharacters

private static boolean containsOnlyPrintableAsciiCharacters(String uri) {
int length = uri.length();
for(int i = 0; i < length; ++i) {
char ch = uri.charAt(i);
if (ch < ' ' || ch > '~') {
return false;
}
}
return true;
}

special characters not allowed

!
"
#
$
%
&
'
(
)
*
+
,
-
.
/
:
;
<
=
>
?
@
[
\
]
^
_
`
{
|
}
~

Get filters, call virtualFilterChain.doFilterinto the following will traverse call doFilter, into the Filter execution chain

public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException {
if (this.currentPosition == this.size) {
if (FilterChainProxy.logger.isDebugEnabled()) {
FilterChainProxy.logger.debug(LogMessage.of(() -> {
return "Secured " + FilterChainProxy.requestLine(this.firewalledRequest);
}));
}
this.firewalledRequest.reset();
this.originalChain.doFilter(request, response);
} else {
++this.currentPosition;
Filter nextFilter = (Filter)this.additionalFilters.get(this.currentPosition - 1);
if (FilterChainProxy.logger.isTraceEnabled()) {
FilterChainProxy.logger.trace(LogMessage.format("Invoking %s (%d/%d)", nextFilter.getClass().getSimpleName(), this.currentPosition, this.size));
}
nextFilter.doFilter(request, response, this);
}
}

org.springframework.security.web.access.intercept.FilterSecurityInterceptor#invoke

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
this.invoke(new FilterInvocation(request, response, chain));
}
public void invoke(FilterInvocation filterInvocation) throws IOException, ServletException {
if (this.isApplied(filterInvocation) && this.observeOncePerRequest) {
filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse());
} else {
if (filterInvocation.getRequest() != null && this.observeOncePerRequest) {
filterInvocation.getRequest().setAttribute("__spring_security_filterSecurityInterceptor_filterApplied", Boolean.TRUE);
}
InterceptorStatusToken token = super.beforeInvocation(filterInvocation);
try {
filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse());
} finally {
super.finallyInvocation(token);
}
super.afterInvocation(token, (Object)null);
}
}

transfersuper.beforeInvocation(filterInvocation);

Do a regular match. Here first replace the configuration of the vulnerability

@Configuration
@EnableWebSecurity
public class AuthConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorize) -> authorize
.regexMatchers("/admin/.*").authenticated()
)
.httpBasic(withDefaults())
.formLogin(withDefaults());
return http.build();
}

Using regexMatchers will use org.springframework.security.web.util.matcher.RegexRequestMatcher#matchesthe class to process the rules written

Access /admin/123is subject to this rule, which requires authentication in the configuration.

However, when visiting ``/admin/123%0d`, the regular match here is flase, which does not hit this rule, so it goes to the next rule to achieve bypass.

The problem here is that .*the regular pattern is used to match, but the incoming data %0d cannot be matched. The default rule of Pattern is no match \r\nand so on.

public class test {
public static void main(String[] args) {
String regex = "a.*b";
//Output true, specify the Pattern.DOTALL mode, which can match newline characters.
Pattern pattern1 = Pattern.compile(regex,Pattern.DOTALL);
boolean matches1 = pattern1.matcher("aaabbb").matches();
System.out.println(matches1);
boolean matches2 = pattern1.matcher("aa\nbb").matches();
System.out.println(matches2);
//Output false, the default dot (.) does not match the newline character
Pattern pattern2 = Pattern.compile(regex);
boolean matches3 = pattern2.matcher("aaabbb").matches();
boolean matches4 = pattern2.matcher("aa\nbb").matches();
System.out.println(matches3);
System.out.println(matches4);
}
}
//true
//true
//true
//false

But if you add a Pattern.DOTALLparameters, even if there is \n, it will match. Therefore, the later version fixes the use ofPattern.DOTALL

References

--

--

TutorialBoy
TutorialBoy

Written by TutorialBoy

Our mission is to get you into information security. We'll introduce you to penetration testing and Red Teaming. We cover network testing, Active Directory.

Responses (1)