Giới thiệu chung
Trên trang chủ Apache Shiro, nó được mô tả là một framework bảo mật mã nguồn mở Java mạnh mẽ và dễ sử dụng, thực hiện quản lý xác thực, kiểm soát quyền truy cập, mật khẩu và phiên người dùng. Sử dụng Shiro, ta có thể nhanh chóng và dễ dàng triển khai bất kỳ ứng dụng nào, từ ứng dụng di động nhỏ nhất đến ứng dụng web và doanh nghiệp lớn nhất. Tuy vậy, framework bảo mật này cũng có khá nhiều CVE (Common Vulnerabilities and Exposures) liên quan đến việc bypass xác thực. Trong bài viết này, ta sẽ đề cập và tìm hiểu 1 số CVE bypass authentication trên nền tảng này.Cài đặt
Ta dựng 1 project spring boot tương tự như sau: https://github.com/l3yx/springboot-shiro Thêm các dependency sau vào file pom.xml, ta có thể thay đổi các version shiro tương ứng và thực hiện load lại maven:<dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-web</artifactId> <version>1.3.2</version> </dependency> <dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring</artifactId> <version>1.3.2</version> </dependency>
Phân tích
CVE-2016-6802
Trước hết ta sẽ đi vào flow xử lí khi 1 request được gửi đi, bắt đầu luôn vào với CVE-2016-6802:

/admin/page
đã được cấu hình cần xác thực và role admin, bật debug và gửi request:


doFilter()
trên ApplicationFilterChain
, như có thể thấy trong phần debugger, Filter trên Tomcat sẽ được đi qua trước.
Lần lượt các filter tương ứng sẽ được gọi ra như sau:

setFilterChainDefinitionMap()
ta add thêm các key-value như bên dưới:


ShiroFilterFactoryBean
sẽ sử dụng các thông tin này để tạo ra các FilterChainManager
và FilterChainResolver
.
Sau đó lần lượt nó sẽ thực hiện khởi tạo filter và sẽ put các giá trị được cấu hình bao gồm cả các mapping vào các phần tương ứng vào filterConfigs, nên filters ở trên ta sẽ thấy có 1 filter là shiroFilterFactoryBean, sẽ còn phần thêm filter vào các filters nữa.



doFilter()
ở đoạn trên, nó sẽ đi lần lượt vào từng filter, ta lướt nhanh F8 đến shiroFilter:

doFilterInternal()
, ta để ý method executeChain()
có nhiệm vụ thực thi các filterChain:


getExecuteChain()
nó gọi tiếp tới method getChain()
, đầu tiên xem phần xử lí request lấy uri của nó:

PathMatchingFilterChainResolver#getPathWithinApplication()
thực hiện xử lí lấy contextPath và requestUri từ request ta gửi xuống:
String contextPath = getContextPath(request); String requestUri = getRequestUri(request);Trong mỗi method, nó sẽ thực hiện xử lí decode request và thực hiện normalize. Phần normalize được xử lí như sau:
private static String normalize(String path, boolean replaceBackSlash) { if (path == null) { return null; } else { String normalized = path; if (replaceBackSlash && path.indexOf(92) >= 0) { normalized = path.replace('\\', '/'); } if (normalized.equals("/.")) { return "/"; } else { if (!normalized.startsWith("/")) { normalized = "/" + normalized; } while(true) { int index = normalized.indexOf("//"); if (index < 0) { while(true) { index = normalized.indexOf("/./"); if (index < 0) { while(true) { index = normalized.indexOf("/../"); if (index < 0) { return normalized; } if (index == 0) { return null; } int index2 = normalized.lastIndexOf(47, index - 1); normalized = normalized.substring(0, index2) + normalized.substring(index + 3); } } normalized = normalized.substring(0, index) + normalized.substring(index + 2); } } normalized = normalized.substring(0, index) + normalized.substring(index + 1); } } } }Phần xử lí không có gì đặc biệt, loại bỏ
./
, xử lí /../
(tìm /
gần nhất trước nó - thực hiện tương tự giống path traversal), ta chỉ cần chú ý đến đoạn nếu request có //
nó sẽ thực hiện lọc cho đến khi chỉ còn 1 /
, nếu có 1 /
thì nó sẽ giữ nguyên.
Tương tự với requestURI, nhưng có 1 phần đáng chú ý, method decodeAndCleanUriString()
ở đây được xử lí khác 1 chút, nó vẫn thực hiện decode nhưng nếu có kí tự ;
trong request, uri sẽ chỉ còn từ đoạn bắt đầu của uri → đến trước ;
, vậy nếu request là /;/admin/page
→ thì uri
lúc này sẽ chỉ còn là /
:


AntPathMatcher#doMatch()
để kiểm tra:

AntPathMatcher#doMatch()
khi xử lí việc kiểm tra xem pattern (được lấy từ phần mapping của shirofilter) với requestURI vừa được xử lí xem có khớp với nhau không - so từng phần được chia bởi dấu /
→ nếu khớp tất thì sẽ trả về true còn kh thì là false
.

/admin/page
y hệt với phần cấu hình trong shiroconfig nên nó sẽ trả về true
.
Khi request xử lí xong, trả về true
(tức khớp hết, xử lý trong vòng while
trả về false
), sẽ đi tiếp xuống method filterChainManager.proxy()
thực hiện kiểm tra tiếp:


preHandle()
( method này được thực hiện trước khi controller được gọi để xử lí request. Giá trị trả về sẽ xác định xem liệu có tiếp tục thực hiện các hoạt động tiếp theo hay không, true - tiếp tục, false - dừng) :

PathMatchingFilter#pathsMatch
→ trả về true
→ đi tiếp xuống method isFilterChainContinued()
:

onPreHandle
→ isAccessAllowed
|| onAccessDenied
, gọi tới isAuthenticated()
và isLoginRequest()
→ isLoginSubmission()
, ở đây ta truy cập thẳng mà không login đúng nên auto return về false
. Và sẽ redirect về trang login.


true
, tức method pathMatches
return về false
(không khớp), và tiếp tục vòng lặp, nó sẽ không phải nhảy vào method filterChainManager.proxy()
và thực hiện 1 loạt đoạn kiểm tra của shiroFilter nữa:

;
thì nó sẽ lấy từ /
→ ;
, vậy với các url như /;/admin/page
thì nó sẽ chỉ còn /
, hoặc với request /admin/page/
thì sẽ giữ nguyên và phần check từng phần ngăn cách bởi dấu /
thì sẽ không khớp hoàn toàn với phần mapping được cấu hình trong shirofilter nữa. Chẳng hạn với request ;/admin/page
thì uri sẽ còn là /
như sau:

filterChainManager.proxy()
, thì sẽ không còn check đoạn xác thực nữa. Tiếp, vậy khi gửi 1 trong 2 request trên đi thì nó sẽ được xử lí tiếp như nào?
Thỏa mãn không bị chặn bởi các filter chain → nó sẽ gọi tiếp tới method DispatcherServlet#doService()
để thực hiện tiếp xử lí các request. Class DispatcherServlet
ở đây nó đóng vai trò như 1 FrontController giúp tiếp nhận và phân phối các request, response, quản lý các HandlerMapping … Mô hình đơn giản như sau:

doDispatch()
→ DispatcherServlet#getHandler
→ AbstractHandlerMapping#getHandler
→ RequestMappingInfoHandlerMapping#getHandlerInternal
:

getLookupPathForRequest()
ta sẽ cần chú ý đến method này được gọi trong nó:

getRequestUri(request)
, nó sẽ gọi tới method decodeAndCleanUriString()
để xử lí request:

private String decodeAndCleanUriString(HttpServletRequest request, String uri) { uri = this.removeSemicolonContent(uri); uri = this.decodeRequestString(request, uri); uri = this.getSanitizedPath(uri); return uri; }Thứ nhất sẽ loại bỏ
;
ra khỏi uri → uri bây giờ sẽ là //admin/page
, thực hiện decode ở đây vẫn giữ nguyên, tiếp tục vào method getSanitizedPath(uri)
, hàm này nếu gặp //
thì sẽ cắt bỏ đi cho đến khi chỉ còn 1 dấu /
, nó được xử lí cụ thể như sau:

/admin/page
. Và đến đây nó sẽ mapping với request tương ứng ở controller và cho phép ta truy cập vào trang của admin. Thực hiện bypass thành công.
Tương tự đối với request /admin/page/
→ sau khi thực hiện bypass thành công chỗ kiểm tra của filter chain thì urirequest sau khi đi qua method decodeAndCleanUriString()
sẽ giữ nguyên vẫn là /admin/page/
:


/admin/page
, vậy tại sao nó vẫn khớp và bypass được, đi tiếp tới method PatternsRequestCondition#getMatchingPattern
→ pathMatcher.match()
, nếu true
thì sẽ trả về pattern là cái ta đang muốn để truy cập vào:

doMatch()
ở phía trên, thì vẫn sẽ là không khớp, tuy vậy ở vòng else , nếu pattern chính là /admin/page
không kết thúc với /
và nếu thêm /
vào cuối pattern và so với cả lookupPath ( tương ứng với uri ta truyền vào) thì nó sẽ return true
.

/admin/page
như sau:

/admin/page/
cũng sẽ tương ứng với /admin/page
và cho phép truy cập tới, từ đây dẫn tới truy cập mà không cần xác thực.
Đối với CVE 2016 6802 thì phần fix được update trên shiro 1.5.0 như sau:

/
thì requestURI sẽ bỏ đi dấu /
ở cuối, tất nhiên là dùng //
hay ///
… cũng không được vì trước khi tới hàm này, nó đã đi qua method normalize()
được đề cập ở phía trên rồi.
Ta có thể thấy nó chỉ loại bỏ dùng //
ở cuối, còn dùng ;
thì vẫn thoải mái không vấn đề. Đối với CVE mới hơn là CVE-2020-1957
version ảnh hưởng < 1.5.2 sẽ dùng //;//
như ở trên để bypass.
CVE-2020-1957
Đầu tiên chuyển version shiro về 1.5.1 trong pom.xml, ta sẽ xem nhanh cách nó xử lí loại bỏ chỗ;
như nào khi vào method removeSemicolonConten()
như đã đề cập ở trên:
public String removeSemicolonContent(String requestUri) { return this.removeSemicolonContent ? this.removeSemicolonContentInternal(requestUri) : this.removeJsessionid(requestUri); } private String removeSemicolonContentInternal(String requestUri) { for(int semicolonIndex = requestUri.indexOf(59); semicolonIndex != -1; semicolonIndex = requestUri.indexOf(59, semicolonIndex)) { int slashIndex = requestUri.indexOf(47, semicolonIndex); String start = requestUri.substring(0, semicolonIndex); requestUri = slashIndex != -1 ? start + requestUri.substring(slashIndex) : start; } return requestUri; }Ta gửi đi request như sau:
GET /aaaaa/;../../admin/pageNhảy debug nhanh đến method trên, nó xử lí thế này:

requestURI
lúc này sẽ là /aaaaaaa//../admin/page
Đi qua method getSanitizedPath()
sẽ còn là /aaaaaaa/../admin/page
→ sau đó đi qua 1 số hàm xử lí trả về servletPath tương ứng là /admin/page
:

/admin/page
và thực hiện tương tự truy cập được thành công trang admin.
Tuy vậy ở đây có 1 lưu ý, với version spring boot sử dụng, từ 2.3.0 đổ xuống thì alwaysUseFullPath
sẽ mặc định là false
, còn version từ 2.3.0 đổ lên thì nó sẽ mặc định là true
. Tức ở đây nếu dùng với version trên 2.3.0 ta sẽ không nhảy vào vòng else nữa, mà nó sẽ return pathWithinApp
luôn, nên nếu dùng GET /aaaaa/;../../admin/page
thì nó sẽ không xử lí đoạn /aaaaaaa/../admin/page
và sẽ 404

pathWithinApp
rồi:

/;aaaaaaa/;....../admin/page
, /;//////admin/page
,…..
Cụ thể với CVE-2020-1957 này thì bản fix họ sửa lại uri trong class WebUtils như sau:

uri = valueOrEmpty(request.getContextPath()) + "/" + valueOrEmpty(request.getServletPath()) + valueOrEmpty(request.getPathInfo());Vẫn request
/;/admin/page
gửi đi, ta sẽ debug xem cách nó xử lí khác như thế nào:

contextPath
= "", getServletPath
sẽ trả về đúng với /admin/page
, còn ở đây không có pathInfo()
nên sẽ trả về null, request sẽ trở thành //admin/page
Ở chỗ trên, tại sao getServletPath
trả về /admin/page
, khi gửi request đi, trước khi đi vào 1 số hàm xử lí ta đã đề cập phía trên, tại method CoyoteAdapter#postParseRequest()
nó sẽ xử lí với request truyền vào như sau:

;...abc
sẽ được normalize và bị filter đi, và với nhiều ///
cũng sẽ bị lọc lần lượt và chỉ còn lại 1 cái là /
, xóa bỏ các chuỗi /./
và xử lý các chuỗi /../
để ghép nối các thư mục cha/con.
Đối với method getServletPath()
sau đó sẽ lấy đường dẫn servlet của request sau khi parse, tính từ sau contextPath.


//admin/page
Do đó, khi đi vào method normalize(decodeAndCleanUriString(request, uri))
ở phía dưới, nó sẽ thực hiện thay thế thành /admin/page
và sẽ lại đi vào ShiroFilter:

CVE-2020-11989
Version bị ảnh hưởng: Shiro < 1.5.3 Với cấu hìnhmap.put("/admin/*", "authc, roles[admin]");
.
Cần nhắc lại 1 chút về AntPathMatcher
, nó có xử lí so khớp url request như sau:
? : Khớp chính xác một ký tự bất kỳ *: Khớp với 0 hoặc nhiều ký tự giữa hai dấu gạch chéo (/) **: Khớp với 0 hoặc nhiều thư mục , bao gồm cả dấu gạch chéo (/)Và nếu trong controller có sử dụng 1 phần xử lí tương tự như sau:
@GetMapping("/admin/{test}") public void list(@PathVariable String test, HttpServletResponse response) throws IOException { response.getWriter().println("aaa"+ test); }Ta có thể dùng double encoding để bypass:

encodedSolidusHandling
sẽ có giá trị Reject
và trả về 400, nguyên do có thể từ đây:


/
có dạng sau:

/admin/*
chỉ so khớp với với 0 hoặc nhiều ký tự trong một phân đoạn đường dẫn nên sẽ không thỏa mãn và bypass.

/;/
để bypass như thường, flow vẫn tương tự như trên. Cụ thể, cấu hình trong file application.properties
:



getRequestURI()
chứ không còn lấy cả getcontextPath()
+ getServletPath()
+ getPathInfo()
như version trước. Đồng thời sử dụng method normalize()
và removeSemicolon()
( loại bỏ dấu ;
) để lọc kết quả trả về từ getServletPath()
và getPathInfo()
, không còn xử lí contextPath
nữa.
Nó cũng không xử lí decode lần 2 như trước khi vào tới method pathMatches()
kiểm tra -> tại method pathMatches(pathPattern, requestURI)
sau khi xử lí xong, giờ đã khớp và đi vào method filterChainManager.proxy()
-> redirect về trang login:

Tổng kết
Apache Shiro là một framework bảo mật khá thuận tiện, cung cấp nhiều tính năng xác thực và ủy quyền linh hoạt, tuy vậy vẫn có nhiều vấn đề gặp phải trong việc cung cấp an toàn cho việc xác thực, phân quyền. Bài viết tổng hợp và làm rõ 1 số luồng xử lí với ứng dụng tích hợp với apache shiro qua 1 số CVE trên nền tảng này, đồng thời phân tích thêm 1 chút về flow xử lí trên Tomcat và Spring Boot.Tham khảo
- https://xz.aliyun.com/t/10799
- https://su18.org/post/shiro-5/
- https://threedr3am.github.io/2021/09/22/Spring%20Security%E7%9A%84%E4%B8%80%E4%B8%AA%E7%AE%80%E5%8D%95auth%20bypass%E5%92%8C%E4%B8%80%E4%BA%9B%E5%B0%8F%E7%AC%94%E8%AE%B0/
- http://rui0.cn/archives/1643
- https://stackoverflow.com/questions/13482020/encoded-slash-2f-with-spring-requestmapping-path-param-gives-http-400
815 lượt xem