Index

0. Introduction


안녕하세요, KnockOn 교육과정 1기 수료생 아주대학교 사이버보안학과에 재학중인 석정원입니다. 저는 Spring Framework의 취약점을 대상으로 선정하여 분석을 진행하였습니다.

기존에 Spring을 사용한 웹 프로젝트을 준비하고 있었으나, JAVA에 대한 지식이 부족했기 때문에 이를 보충하는 겸 해당 주제의 취약점을 선정하였습니다.

Spring 관련 CVE들은 DOS, reDOS 관련 취약점이 주를 이루었으나, CVE-2024-22243, 22259, 22263 는 이와 달리 open redirect, SSRF를 trigger하며 같은 취약점에 대해 다른 input으로 3번 연달아 발생하였습니다.

따라서 Spring처럼 유명한 Framework에서 여러 차례 발생한 Critical한 취약점을 분석하면 얻어갈 수 있는 것이 많을 것이라 생각해 해당 CVE를 선정하였습니다.


1. UriComponentsBuilder


이 취약점은 Spring FrameworkUriComponentsBuilder class를 사용하여 host 무결성 검사를 수행할 때 발생합니다.


UriComponentsBuilder Source code

UriComponentsBuilderSpring Framework에서 URI를 구성하는 components들을 효과적으로 다룰 수 있도록 해주는 UriComponents class를 build하는 역할을 수행합니다.

따라서 UriComponentsBuilder 가 URI의 components들을 어떻게 구분하는지 알아보았습니다.

	private static final Pattern QUERY_PARAM_PATTERN = Pattern.compile("([^&=]+)(=?)([^&]+)?");

	private static final String SCHEME_PATTERN = "([^:/?#\\\\]+):";

	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 = "(" + HOST_IPV6_PATTERN + "|" + HOST_IPV4_PATTERN + ")";

	private static final String PORT_PATTERN = "(\\{[^}]+\\}?|[^/?#\\\\]*)";

	private static final String PATH_PATTERN = "([^?#]*)";

	private static final String QUERY_PATTERN = "([^#]*)";

	private static final String LAST_PATTERN = "(.*)";

	// Regex patterns that matches URIs. See RFC 3986, appendix B
	private static final Pattern URI_PATTERN = Pattern.compile(
			"^(" + SCHEME_PATTERN + ")?" + "(//(" + USERINFO_PATTERN + "@)?" + HOST_PATTERN + "(:" + PORT_PATTERN +
					")?" + ")?" + PATH_PATTERN + "(\\?" + QUERY_PATTERN + ")?" + "(#" + LAST_PATTERN + ")?");

해당 class는 pattern class를 사용하여 URI의 각 components들을 정규 표현식으로 정의해 compile method로 제공받은 URI를 pattern 객체로 바꿔줍니다.

	public static UriComponentsBuilder fromOriginHeader(String origin) {
		Matcher matcher = URI_PATTERN.matcher(origin);
		if (matcher.matches()) {
			UriComponentsBuilder builder = new UriComponentsBuilder();
			String scheme = matcher.group(2);
			String host = matcher.group(6);
			String port = matcher.group(8);
			if (StringUtils.hasLength(scheme)) {
				builder.scheme(scheme);
			}
			builder.host(host);
			if (StringUtils.hasLength(port)) {
				builder.port(port);
			}
			checkSchemeAndHost(origin, scheme, host);
			return builder;
		}
		else {
			throw new IllegalArgumentException("[" + origin + "] is not a valid \"Origin\" header value");
		}
	}

그리고, matcher class의 group method를 사용하여 각각의 components들을 나누어 저장합니다.


정리하면, UriComponentsBuilder 는 URI의 각 components들을 정규 표현식으로 정의하고 pattern 객체로 바꿔 matcher class를 통해 build합니다.


2. CVE-2024-22243


CVE-2024-22243 Info


2-1. Patch Diff

// Spring Framework 6.1.3 → 6.1.4 {120ea0a}
-	private static final String USERINFO_PATTERN = "([^@\\[/?#]*)";
+	private static final String USERINFO_PATTERN = "([^@/?#]*)";

취약점은 USERINFO_PATTERN의 정규 표현식에서 사용할 수 있는 문자로 [ 를 추가하여 patch되었습니다.


2-2. Analyze

	private static final Pattern URI_PATTERN = Pattern.compile(
			"^(" + SCHEME_PATTERN + ")?" + "(//(" + USERINFO_PATTERN + "@)?" + HOST_PATTERN + "(:" + PORT_PATTERN +
					")?" + ")?" + PATH_PATTERN + "(\\?" + QUERY_PATTERN + ")?" + "(#" + LAST_PATTERN + ")?");

USERINFO_PATTERN 전후를 확인해 보았을 때, USERINO_PATTERN, HOST_PATTERN, PORT_PATTERN은 크게 하나로 묶여있습니다. 이는 기본적으로 HOST_PATTERN으로 인식되지만, 조건에 따라 USERINFO_PATTERN, PORT_PATTERN이 추가될 수 있습니다.

USERINFO_PATTERN[ 이 사용되었을 때를 가정해 보겠습니다 (http://foo[@bar) . 이 경우 실제 URI는 userinfo가 foo[ 로, host가 bar 로 인식됩니다.

그러나 이 class에서는 [USERINFO_PATTERN 에서 filtering되기 때문에 USERINFO_PATTERN 에서 foo까지 저장되고, @ 이전에 [ 를 만나 HOST_PATTERN 로 저장됩니다.

따라서 실제 URI와 UriComponentsBuilder가 인식하는 components 간에 괴리가 발생합니다. 그러므로, UriComponentsBuilder 를 사용해 host 무결성 검사를 진행한다면 웹 서비스의 Whitelisting, Blacklisting이 수행되지 않습니다.

결과적으로, 웹 서버가 URL을 외부에서 입력받을 때, host 무결성 검사로 UriComponentsBuilder 를 사용한다면 검사를 우회할 수 있습니다.


2-3. PoC

Spring framework 6.1.3 기준

Open Redirect

@Controller
public class RedirectController {

    @GetMapping("/redirect")
    public void redirect(@RequestParam("url") String url, HttpServletResponse response) throws IOException {
        UriComponents uriComponents = UriComponentsBuilder.fromUriString(url).build();
        if (uriComponents.getHost().equals("allow.com"))
            response.sendRedirect(url);
    }
}

해당 코드는 host가 allow.com일 때만 redirect를 진행합니다.

Untitled

그러므로 http://localhost:8080/redirect?url=http://naver.com 의 경우 redirect하지 않습니다.

Untitled

Untitled

그러나 위 취약점을 trigger하는 http://localhost:8080/redirect?url=http://allow.com%5b@naver.com 의 경우 UricomponentsBuilder 가 allow.com를 host로 인식하여 open redirect 취약점이 발생합니다.

SSRF

@Controller
public class SSRFController {
    private final RestTemplate restTemplate;

    public SSRFController() {
        Proxy proxy = new Proxy(Type.HTTP, new InetSocketAddress("127.0.0.1", 80));
        SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
        factory.setProxy(proxy);

        this.restTemplate = new RestTemplate(factory);
    }

    @GetMapping("/fetch")
    public ResponseEntity<String> fetchUrl(@RequestParam("url") String url) {
        try {
            String decodedUrl = URLDecoder.decode(url, StandardCharsets.UTF_8.name());

            UriComponents uriComponents = UriComponentsBuilder.fromUriString(url).build();
            if (!uriComponents.getHost().equals("allow.com"))
                return ResponseEntity.status(400).body(uriComponents.getHost() + "Denied URL");

            String adjustedUrl = adjustUrl(decodedUrl);
            String response = restTemplate.getForObject(adjustedUrl, String.class);

            return ResponseEntity.ok(response);
        } catch (UnsupportedEncodingException e) {
            return ResponseEntity.status(500).body("Error decoding URL");
        } catch (MalformedURLException e) {
            return ResponseEntity.status(400).body("Malformed URL");
        }
    }
    
    private String adjustUrl(String url) throws MalformedURLException {
        Pattern pattern = Pattern.compile("^(http[s]?://)([^@]+)@(.+)$");
        Matcher matcher = pattern.matcher(url);

        if (matcher.matches()) {
            String protocol = matcher.group(1);
            String userInfo = matcher.group(2);
            String hostAndPath = matcher.group(3);
            return protocol + hostAndPath;
        } else {
            return url;
        }
    }
}

해당 코드는 host가 allow.com일 때만 http 요청을 보냅니다.

Untitled

그러므로 http://localhost:8080/fetch?url=http://naver.com 의 경우 요청을 보내지 않습니다.

Untitled

Untitled

그러나 위 취약점을 trigger하는 http://localhost:8080/fetch?url=http://allow.com%5b@example.com 의 경우 UricomponentsBuilder 가 allow.com를 host로 인식하여 SSRF 취약점이 발생합니다.

  • 해당 코드는 [ 에 대해 예외처리를 수행하고 진행하였는데, 기본적으로 spring의 restTemplate에서 userinfo에 [ , %5b 가 포함될 경우 MalformedURLException 을 발생시키기 때문입니다. 따라서 SSRF 취약점은 실현 가능성이 낮습니다.

3. CVE-2024-22259


CVE-2024-22259 Info


3-1. Patch Diff

// Spring Framework 6.1.4 → 6.1.5 {381f790}
-	private static final String USERINFO_PATTERN = "([^@/?#]*)";
+	private static final String USERINFO_PATTERN = "([^/?#]*)";

-	private static final String HOST_IPV4_PATTERN = "[^\\[/?#:]*";
+	private static final String HOST_IPV4_PATTERN = "[^/?#:]*";

*// public static UriComponentsBuilder fromUriString(String uri)*
- if (StringUtils.hasLength(scheme) && scheme.startsWith("http") && !StringUtils.hasLength(host)) {
- throw new IllegalArgumentException("[" + uri + "] is not a valid HTTP URL");
-	}
+ checkSchemeAndHost(uri, scheme, host);

*// public static UriComponentsBuilder fromHttpUrl(String httpUrl)*
- if (StringUtils.hasLength(scheme) && !StringUtils.hasLength(host)) {
-				throw new IllegalArgumentException("[" + httpUrl + "] is not a valid HTTP URL");
-			}
+			checkSchemeAndHost(httpUrl, scheme, host);

+	private static void checkSchemeAndHost(String uri, @Nullable String scheme, @Nullable String host) {
+		if (StringUtils.hasLength(scheme) && scheme.startsWith("http") && !StringUtils.hasLength(host)) {
+			throw new IllegalArgumentException("[" + uri + "] is not a valid HTTP URL");
+		}
+		if (StringUtils.hasLength(host) && host.startsWith("[") && !host.endsWith("]")) {
+			throw new IllegalArgumentException("Invalid IPV6 host in [" + uri + "]");
+		}
+	}

USERINFO_PATTERN의 정규 표현식에서 사용할 수 있는 문자로 @ 를 추가하여 patch되었습니다.

그리고 UriString에 대해 scheme이 http로 시작되며 host가 null일 경우 예외되는 조건과 HttpUrl에 대해 scheme이 존재하고, host가 null일 경우 예외되는 조건이 checkSchemeAndHost method로 조정되었습니다.

해당 method는 UriString, HttpUrl 모두에 대하여 scheme가 http로 시작하고, host가 null일 경우와 host가 [ 로 시작하고, ] 로 끝나지 않을 경우 예외가 발생합니다.


3-2. Analyze

	private static final Pattern URI_PATTERN = Pattern.compile(
			"^(" + SCHEME_PATTERN + ")?" + "(//(" + USERINFO_PATTERN + "@)?" + HOST_PATTERN + "(:" + PORT_PATTERN +
					")?" + ")?" + PATH_PATTERN + "(\\?" + QUERY_PATTERN + ")?" + "(#" + LAST_PATTERN + ")?");				

scheme 이후 uri가 @ 로 시작한다면 userinfo에 null 예외조건이 없으므로 uriComponentsBuilder 가 userinfo를 null, host가 @ 이후부터 저장이 됩니다.

private static final String HOST_IPV4_PATTERN = "[^\\[/?#:]*";

그리고 HOST_IPV4_PATTERN 에서 [를 filtering하기 때문에 host에 [ 가 들어갈 경우 해당 문자 이후가 path로 저장됩니다.

그러므로 http://@foo[@bar 의 경우 실제 uri는 userinfo를 @foo[ , host를 bar 로 인식하지만, uriComponentsBuilder 는 userinfo를 null , host를 foo 로 인식하기 때문에 괴리가 발생합니다.

결과적으로, 해당 경우에 대해 웹 서버가 URL을 외부에서 입력받을 때, host 무결성 검사로 UriComponentsBuilder 를 사용한다면 위 취약점을 통해 검사를 우회할 수 있습니다.


3-3. PoC

Spring framework 6.1.4 기준

Open Redirect

※ source code는 CVE-2024-22243 PoC와 동일합니다.

Untitled

패치로 인해 CVE-2024-22243 의 payload가 동작하지 않습니다.

Untitled

그러나 해당 취약점을 trigger하는 http://localhost:8080/redirect?url=http://@allow.com%5b@naver.com 의 경우 실제 uri는 @allow.com[를 userinfo, naver.com을 host로 인식하지만, UriComponentsBuilderallow.com을 host, [이후를 path로 인식하여 open redirect가 발생합니다.

SSRF

@Controller
public class SSRFController {
    private final RestTemplate restTemplate;

    public SSRFController() {
        Proxy proxy = new Proxy(Type.HTTP, new InetSocketAddress("127.0.0.1", 80));
        SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
        factory.setProxy(proxy);

        this.restTemplate = new RestTemplate(factory);
    }

    @GetMapping("/fetch")
    public ResponseEntity<String> fetchUrl(@RequestParam("url") String url) {
        try {
            String decodedUrl = URLDecoder.decode(url, StandardCharsets.UTF_8.name());

            UriComponents uriComponents = UriComponentsBuilder.fromUriString(url).build();
            if (!uriComponents.getHost().equals("allow.com"))
                return ResponseEntity.status(400).body(uriComponents.getHost() + "Denied URL");

            String adjustedUrl = adjustUrl(decodedUrl);
            String response = restTemplate.getForObject(adjustedUrl, String.class);

            return ResponseEntity.ok(response);
        } catch (UnsupportedEncodingException e) {
            return ResponseEntity.status(500).body("Error decoding URL");
        } catch (MalformedURLException e) {
            return ResponseEntity.status(400).body("Malformed URL");
        }
    }

    private static String adjustUrl(String url) throws MalformedURLException {
        Pattern pattern = Pattern.compile("^(http[s]?://)([^@]*@?[^@]*)@(.+)$");
        Matcher matcher = pattern.matcher(url);

        if (matcher.matches()) {
            String protocol = matcher.group(1);
            String userInfo = matcher.group(2);
            String hostAndPath = matcher.group(3);
            return protocol + hostAndPath;
        } else {
            return url;
        }
    }
}

기존 CVE-2024-22243 의 SSRF source code에서 userinfo에 @ 가 사용될 수 있도록 수정하였습니다.

Untitled

patch로 인해 CVE-2024-22243 의 payload가 동작하지 않습니다.

Untitled

Untitled

그러나 해당 취약점을 trigger하는 http://localhost:8080/fetch?url=http://@allow.com%5b@naver.com 의 경우 ssrf가 발생합니다. 하지만 여전히 userinfo에 [ 이 사용되며. userinfo에 @ 도 들어가도록 임의로 설정해 주었기 때문에 실현 가능성이 낮습니다.


4. CVE-2024-22262


CVE-2024-22262 Info


4-1. Patch Diff

// Spring Framework 6.1.5 → 6.1.6 {494ed4e}
-	private static final String SCHEME_PATTERN = "([^:/?#]+):";
+	private static final String SCHEME_PATTERN = "([^:/?#\\\\]+):";

- private static final String USERINFO_PATTERN = "([^/?#]*)";
+	private static final String USERINFO_PATTERN = "([^/?#\\\\]*)";

-	private static final String HOST_IPV4_PATTERN = "[^/?#:]*";
+	private static final String HOST_IPV4_PATTERN = "[^/?#:\\\\]*";

-	private static final String PORT_PATTERN = "(\\{[^}]+\\}?|[^/?#]*)";
+	private static final String PORT_PATTERN = "(\\{[^}]+\\}?|[^/?#\\\\]*)";

SCHEME_PATTERN, USERINFO_PATTERN, HOST_IPV4_PATTERN, PORT_PATTERN 에 모두 필터링으로 \ 문자가 추가되도록 patch되었습니다.


4-2. Analyze

여러 URI components에 대해 \ 가 포함되지 않도록 patch되었으므로, 이를 중점으로 두고 분석해 보았습니다.

http://foo@bar\ , http:\//foo@barURL은 대부분의 웹 브라우저가 \/로 변환하기 때문에 bar host로 redirect됩니다.

	private static final Pattern URI_PATTERN = Pattern.compile(
			"^(" + SCHEME_PATTERN + ")?" + "(//(" + USERINFO_PATTERN + "@)?" + HOST_PATTERN + "(:" + PORT_PATTERN +
					")?" + ")?" + PATH_PATTERN + "(\\?" + QUERY_PATTERN + ")?" + "(#" + LAST_PATTERN + ")?");				

예시로 http:\\foo@bar"(//(" 부분이 충족되지 않아 scheme 이외 모든 components들이 null 값으로 저장됩니다. 따라서 실제 URL의 components들과 UriComponentsBuilder 가 인식하는 components들 간의 괴리가 발생하여 host 검사 시 취약점이 trigger될 수 있습니다.

같은 원리로 scheme, userinfo, host, port에서도 취약점 trigger가 가능합니다.


4-3. PoC

Spring framework 6.1.5 기준

Open Redirect

@Controller
public class RedirectController {

    @GetMapping("/redirect")
    public void redirect(@RequestParam("url") String url, HttpServletResponse response) throws IOException {
        UriComponents uriComponents = UriComponentsBuilder.fromUriString(url).build();
        if (!uriComponents.getHost().equals("naver.com"))
            response.sendRedirect(url);
    }
}

이전 CVE들과 다르게 검사가 blacklisting을 사용하는 것을 가정하였고, host가 naver.com인 경우 redirect하지 않습니다.

Untitled

Untitled

따라서 %5c (\) 를 URL의 뒤에 붙여주면 UriComponentsBuilder가 인식하는 host가 naver.com\이 되고, 실제 URL은 http://naver.com/이 되어 Blacklisting을 우회함으로써 open redirect 취약점이 발생합니다.

SSRF

@Controller
public class SSRFController {
    private final RestTemplate restTemplate;

    public SSRFController() {
        Proxy proxy = new Proxy(Type.HTTP, new InetSocketAddress("127.0.0.1", 80));
        SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
        factory.setProxy(proxy);

        this.restTemplate = new RestTemplate(factory);
    }

    @GetMapping("/fetch")
    public ResponseEntity<String> fetchUrl(@RequestParam("url") String url) {
        try {
            String decodedUrl = URLDecoder.decode(url, StandardCharsets.UTF_8.name());

            UriComponents uriComponents = UriComponentsBuilder.fromUriString(url).build();
            if (uriComponents.getHost().equals("naver.com"))
                return ResponseEntity.status(400).body(uriComponents.getHost() + "Denied URL");

            String adjustedUrl = adjustUrl(decodedUrl);
            String response = restTemplate.getForObject(adjustedUrl, String.class);

            return ResponseEntity.ok(response);
        } catch (UnsupportedEncodingException e) {
            return ResponseEntity.status(500).body("Error decoding URL");
        } catch (MalformedURLException e) {
            return ResponseEntity.status(400).body("Malformed URL");
        }
    }

    private static String adjustUrl(String url) throws MalformedURLException {
        Pattern pattern = Pattern.compile("^(http[s]?://)([^\\\\%]+)(.*)$");ㅡㅡ
        Matcher matcher = pattern.matcher(url);

        if (matcher.matches()) {
            String protocol = matcher.group(1);
            String host = matcher.group(2);
            String path = matcher.group(3);
            return protocol + host;
        } else {
            return url;
        }
    }
}

기존 CVE-2024-22243 의 SSRF source code에서 host에 \ 가 사용될 수 있도록 수정하였습니다.

Untitled

Untitled

취약점은 open redirect와 동일한 원리로 발생하였고, 위 CVE와 마찬가지로 restTemplate 에서 components에 허용되지 않은 문자를 강제로 허용해 주었기 때문에 실현 가능성이 낮습니다.


5. Conculusion


Summary

요약하면, CVE-2024-22243, 22259, 22263은 Spring Framework의 UriComponentsBuilder class가 URI의 각 components들을 정규 표현식을 사용한 pattern , matcher class를 사용하여 구분하기 때문에 정규 표현식과 compile method 간의 허점이 사용되어 발생하는 취약점입니다.

따라서, Spring Framework 에서 외부에서 입력되는 URL을 검증할 때, 낮은 버전의 UriComponentsBuilder 만을 사용하는 것은 보안 상 취약합니다. 이는 해당 class에서 같은 취약점이 여러 번 발생하였고, 완전한 patch가 수행되었다고 판단하기 어렵기에 더욱 중요합니다.

Untitled

그러나 최신 버전은 해당 취약점을 patch하기 위해 정규 표현식을 대체하는 fromUriString method를 도입하였습니다.

이는 UrlParser class를 새로 추가하면서 함께 도입한 것인데,

Spring Framework UrlParser source code

위 링크를 통해 소스코드를 확인해보면, 정규 표현식보다 더 강하고 많은 조건으로 이를 해결하였음을 확인할 수 있습니다.

따라서, 정규 표현식의 사용이 코드 작성에 있어 편리할 수는 있지만 많은 변수가 발생할 수 있으므로, 중요한 취약점이 발생할 수 있는 영역에서는 사용을 지양해야 함을 알게 되었습니다.


Limitation

Spring Framework에서는 RestTemplate 에서 exception을 통해 HTTP 요청에 있어 자체 filtering을 거칩니다. 따라서 해당 CVE로 SSRF 취약점을 trigger하기 위해서는, 대상 서비스가 다른 방식을 사용해야 합니다.

PoC에서는 adjustUrl method를 사용하여 강제로 SSRF를 유발했지만, 실제 환경에서는 위처럼 HTTP 요청에서 각 components들에 임의의 특수문자를 첨가하여 보내기 어렵습니다. 즉, 특정 조건을 추가적으로 만족해야 하므로 프로그래머가 어떻게 구현하는지에 따라 실현 가능성이 달라집니다.

그리고 Open Redirect 취약점 또한 프로그래머의 구현 방식에 따라 실현 여부가 달라집니다. 이는 프레임워크가 프로그래머마다 구현하는 방식이 다르기 때문이며, generic 하지 않다고 표현할 수 있습니다.


Thoughts

첫 One-day 취약점 분석을 진행하면서 자신이 없는 분야인 만큼 많은 삽질을 수행했습니다. 그러나 그 과정에서 JAVA와 Spring Framework, MAVEN 등 다양한 지식을 습득할 수 있었습니다. 하지만, PoC 작성에 있어 코드가 간결하지 않고, 기타 조건을 억지로 첨가한 점 등 부족한 부분이 많았습니다. 이번 기회를 토대로 여러 CVE들을 분석해보고, Zero-day 취약점까지 찾을 수 있도록 노력해야겠다고 생각했습니다.


#. Reference

Stealien 윤석찬 연구원 One-day Research 게시글

Spring Framework Github

9eek CVE-2024-22243 PoC 게시글