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:

 

Lỗ hổng cho phép ta bypass các servlet filters để truy cập các tài nguyên 1 cách trái phép, nó được fix ở version 1.5.0, nên các version trước đó đều ảnh hưởng

Ta biết là trong tomcat khi xử lí 1 request đến server xử lí, nó sẽ được xử lí qua nhiều giai đoạn, trong đó sẽ đi qua 1 hoặc 1 chuỗi các Filter (hay Filter chain):

Các filter này sẽ có nhiệm vụ thực hiện việc kiểm tra, xác thực, phân quyền, log,… tùy thuộc vào việc ta cấu hình, nếu request thỏa mãn, nó sẽ được đi qua còn không sẽ bị chặn lại.

Trong shiro cũng có các filter chain của riêng nó, chẳng hạn khi được tích hợp vào Tomcat, các filter của Tomcat sẽ được thực hiện trước, lần lượt đến các filter của shiro đã được cấu hình. Phần này sẽ được thể hiện rõ hơn trong quá trình debug.

Ta sẽ đi vào flow xử lí khi gửi 1 request đến /admin/page đã được cấu hình cần xác thực và role admin, bật debug và gửi request: 

Flow sẽ đi như sau:

Ta đặt debug tại method 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:

Có thể thấy các filter lần lượt sẽ được gọi, trong đó có shiroFactoryBean – định nghĩa 1 chuỗi filter chain, ở phần config ta đã thêm các filter cấu hình vào, tại method setFilterChainDefinitionMap() ta add thêm các key-value như bên dưới:

Sau khi cấu hình các filter chain mappings trên,ShiroFilterFactoryBean sẽ sử dụng các thông tin này để tạo ra các FilterChainManagerFilterChainResolver.

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.

Vào phần xử lí chính của request, sau khi tới method doFilter() ở đoạn trên, nó sẽ đi lần lượt vào từng filter, ta lướt nhanh F8 đến shiroFilter:

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

Flow xử lí tiếp tục như sau:

Sau khi nhảy vào method 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ó:

Method 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à / :

Còn đoạn normolized khác thì vẫn tương tự, tiếp tục:

Gọi tới AntPathMatcher#doMatch() để kiểm tra:

Để ý với 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 .

Ở đây ta truyền request /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:

Vào đến đây coi như là xong, thỏa mãn filter, và không đi vào được page của admin nữa:

→ Gọi tiếp đến method 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() :

Gọi tiếp vào method onPreHandleisAccessAllowed ||  onAccessDenied, gọi tới isAuthenticated()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.

Flow là như vậy, giờ cần bypass, giờ để ý đoạn này, nếu điều kiện trong vòng while trả về 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:

Mấu chốt ở đây là ta cần kiểm soát requestURI, như phân tích ở trên, ở đây, nếu trong request URI có dấu ; 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:

Mục đích ở đây là để bypass không cho đi vào 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:

Tiếp tục nó gọi lần lượt tới các method doDispatch()DispatcherServlet#getHandlerAbstractHandlerMapping#getHandlerRequestMappingInfoHandlerMapping#getHandlerInternal :

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

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

Cụ thể method này sẽ có chức năng như sau:

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:

→ Vậy là đến đây request được gửi xuống, uri sẽ chỉ còn là /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/ :

Flow tiếp là đi tìm urlPath tương ứng xem có khớp với phần mapping được khai báo trong controller hay không:

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

Nó xử lí gần giống lúc khi gọi method 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 .

Tức sẽ thỏa mãn điều kiện mapping → và cho phép truy cập tới /admin/page như sau:

 

Ở đây ta có thể thấy do cách xử lí của Spring mà /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:

Nó fix nếu uri request được kết thúc với / 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/page

Nhả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 :

→ trả về là /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

 

Còn dùng với request này thì vẫn được do đã được xử lí thỏa mãn khi trả về pathWithinApp rồi:

Hoặc tương ứng cùng kiểu /;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:

Thay vì lấy uri thẳng từ method getRequestURI() , thì URI giờ được lấy 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:

Hiện tại web đang được cấu hình không dùng contextPath, với request gửi đi, các giá trị lần lượt khi đi qua xử lí sẽ là:

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:

Đại khái thì khi đi vào hàm này ;...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.

Tiếp nối đoạn trên, ta có thể thấy, ở sau bản vá, khi truyền request exploit như version trước thì đến đoạn lấy uri nó sẽ trả về //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:

 

Ở đây, khi họ fix thế này, có thể bypass dựa trên cấu hình web tùy thuộc vào tình huống cụ thể như sau (CVE-2020-11989)

CVE-2020-11989

Version bị ảnh hưởng: Shiro < 1.5.3

Với cấu hình map.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:

 

Ở đây nếu dùng %2f, khi xử lí request trong class CoyoteAdapter, encodedSolidusHandling sẽ có giá trị Reject và trả về 400, nguyên do có thể từ đây:

 

Còn khi gửi request với double encode, thì lần đầu tiên khi gửi request , flow sẽ như sau:

Sau khi đi vào normalize nó thực hiện decode và loại bỏ kí tự / có dạng sau:

Lúc này, với đoạn check /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.

 

Ngoài ra ở đây, nếu ứng dụng sử dụng contextPath – chẳng hạn là aaa thì có thể dùng /;/ để bypass như thường, flow vẫn tương tự như trên. Cụ thể, cấu hình trong file application.properties:

Gửi request đi:

 

Và bypass thành công.

Bản vá của CVE này được cập nhật như sau:

Ta có thể thấy, Shiro đã quay trở lại sử dụng method getRequestURI() chứ không còn lấy cả getcontextPath() + getServletPath()  + getPathInfo() như version trước. Đồng thời sử dụng method normalize()removeSemicolon() ( loại bỏ dấu ;) để lọc kết quả trả về từ getServletPath() 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

447 lượt xem