
إعادة إنتاج ثغرة أمنية في Spring CVE-2020-5421
تتيح CVE-2020-5421 تجاوز الحماية ضد هجمات RFD من خلال معامل المسار jsessionid. وقد أُضيفت الحماية السابقة ضد RFD لمواجهة CVE-2015-5211.
ما هي RFD
ثغرة التنزيل الانعكاسي للملفات (RFD) هي أسلوب هجوم يتم من خلاله تنزيل ملف افتراضيًا من نطاق موثوق، مما يمنح المهاجم وصولًا كاملًا إلى جهاز الضحية.
Spring Framework 5.2.0 - 5.2.8
Spring Framework 5.1.0 - 5.1.17
Spring Framework 5.0.0 - 5.0.18
Spring Framework 4.3.0 - 4.3.28
رابط GitHub: https://github.com/pandaMingx/CVE-2020-5421
تم إجراء الاختبار بناءً على SpringBoot-2.1.7.RELEASE و Spring-xxx-5.1.9.RELEASE.
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.7.RELEASE</version>
<relativePath/>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
@Controller
@RequestMapping(value = "spring")
public class cve20205421 {
// localhost:8080/spring/input?input=hello
@RequestMapping("input")
@ResponseBody
public String input(String input){
return input;
}
}
إعدادات إضافية
spring.mvc.pathmatch.use-suffix-pattern=true
spring.mvc.contentnegotiation.favor-path-extension=true
أضف ;jsessionid= إلى عنوان URL، مثل http://localhost:8080/spring/;jsessionid=/input.bat?input=calc، وسيتم تنزيل ملف قابل للتنفيذ باسم input.bat.
CVE-2020-5421 هي تجاوز لطريقة إصلاح CVE-2015-5211. يقع كود الإصلاح الخاص بـ CVE-2015-5211 في org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodProcessor.addContentDispositionHeader.
/**
* Check if the path has a file extension and whether the extension is
* either {@link #WHITELISTED_EXTENSIONS whitelisted} or explicitly
* {@link ContentNegotiationManager#getAllFileExtensions() registered}.
* If not, and the status is in the 2xx range, a 'Content-Disposition'
* header with a safe attachment file name ("f.txt") is added to prevent
* RFD exploits.
*/
private void addContentDispositionHeader(ServletServerHttpRequest request, ServletServerHttpResponse response) {
HttpHeaders headers = response.getHeaders();
if (headers.containsKey(HttpHeaders.CONTENT_DISPOSITION)) {
return;
}
try {
int status = response.getServletResponse().getStatus();
if (status < 200 || status > 299) {
return;
}
}
catch (Throwable ex) {
// ignore
}
HttpServletRequest servletRequest = request.getServletRequest();
String requestUri = rawUrlPathHelper.getOriginatingRequestUri(servletRequest);
int index = requestUri.lastIndexOf('/') + 1;
String filename = requestUri.substring(index);
String pathParams = "";
index = filename.indexOf(';');
if (index != -1) {
pathParams = filename.substring(index);
filename = filename.substring(0, index);
}
filename = decodingUrlPathHelper.decodeRequestString(servletRequest, filename);
String ext = StringUtils.getFilenameExtension(filename);
pathParams = decodingUrlPathHelper.decodeRequestString(servletRequest, pathParams);
String extInPathParams = StringUtils.getFilenameExtension(pathParams);
if (!safeExtension(servletRequest, ext) || !safeExtension(servletRequest, extInPathParams)) {
headers.add(HttpHeaders.CONTENT_DISPOSITION, "inline;filename=f.txt");
}
}
بمتابعة طريقة rawUrlPathHelper.getOriginatingRequestUri، نصل بالتدريج إلى طريقة org.springframework.web.util.UrlPathHelper.removeJsessionid، والتي تقوم بقطع السلسلة التي تبدأ بـ ;jsessionid= من عنوان URL للطلب (أو قبل الفاصلة المنقوطة التالية).
private String removeJsessionid(String requestUri) {
int startIndex = requestUri.toLowerCase().indexOf(";jsessionid=");
if (startIndex != -1) {
int endIndex = requestUri.indexOf(59, startIndex + 12);
String start = requestUri.substring(0, startIndex);
requestUri = endIndex != -1 ? start + requestUri.substring(endIndex) : start;
}
return requestUri;
}
بسبب كود حذف ;jsessionid= هذا، فإن كود الدفاع اللاحق الخاص بـ CVE-2015-5211 لن يتمكن بعد حذف ;jsessionid= من الحصول على اسم الامتداد الحقيقي للطلب، وبالتالي يتم تجاوز كود الدفاع ضد RFD.
أثناء إعادة إنتاج الثغرة، تمت إضافة معاملين في ملف applcation.properties: spring.mvc.pathmatch.use-suffix-pattern=true و spring.mvc.contentnegotiation.favor-path-extension=true (القيمة الافتراضية في SpringBoot هي false). يتضح أن شرط استغلال CVE-2020-5421 هو تفعيل نمط مطابقة الامتداد وآلية التفاوض على المحتوى (Content Negotiation). إذا لم يتم تمكين هذين النمطين في مشروع SpringBoot، فلا توجد شروط لاستغلال الثغرة ولا حاجة لاتخاذ إجراء. إذا توافرت شروط الاستغلال، نقدم هنا حلّين، حيث يناسب الحل الثاني المشاريع التي قد يشكّل ترقية إصدار Spring فيها مخاطرة كبيرة.
Spring Framework 5.2.9
Spring Framework 5.1.18
Spring Framework 5.0.19
Spring Framework 4.3.29
يتحقق الحل الثاني مما إذا كان امتداد عنوان URL الذي يحتوي على ;jsessionid= امتدادًا آمنًا، وإذا لم يكن كذلك، يتم تعيين Content-Disposition=inline;filename=f.txt لفرض تنزيل محتوى الاستجابة في ملف باسم f.txt. (النهج مطابق لآلية دفاع RFD في Spring).
public class SpringJsessionidRdfFilter implements Filter {
private final Set<String> safeExtensions = new HashSet<>();
/* Extensions associated with the built-in message converters */
private static final Set<String> WHITELISTED_EXTENSIONS = new HashSet<>(Arrays.asList(
"txt", "text", "yml", "properties", "csv",
"json", "xml", "atom", "rss",
"png", "jpe", "jpeg", "jpg", "gif", "wbmp", "bmp"));
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest)servletRequest;
HttpServletResponse response = (HttpServletResponse)servletResponse;
String contentDisposition = response.getHeader(HttpHeaders.CONTENT_DISPOSITION);
if (!"".equals(contentDisposition)&&null != contentDisposition) {
return;
}
try {
int status = response.getStatus();
if (status < 200 || status > 299) {
return;
}
}
catch (Throwable ex) {
// ignore
}
String requestUri = request.getRequestURI();
System.out.println(requestUri);
if(requestUri.contains(";jsessionid=")){
int index = requestUri.lastIndexOf('/') + 1;
String filename = requestUri.substring(index);
String pathParams = "";
index = filename.indexOf(';');
if (index != -1) {
pathParams = filename.substring(index);
filename = filename.substring(0, index);
}
UrlPathHelper decodingUrlPathHelper = new UrlPathHelper();
filename = decodingUrlPathHelper.decodeRequestString(request, filename);
String ext = StringUtils.getFilenameExtension(filename);
pathParams = decodingUrlPathHelper.decodeRequestString(request, pathParams);
String extInPathParams = StringUtils.getFilenameExtension(pathParams);
if (!safeExtension(request, ext) || !safeExtension(request, extInPathParams)) {
response.addHeader(HttpHeaders.CONTENT_DISPOSITION, "inline;filename=f.txt");
}
}
filterChain.doFilter(servletRequest,servletResponse);
}
private boolean safeExtension(HttpServletRequest request, @Nullable String extension) {
if (!StringUtils.hasText(extension)) {
return true;
}
extension = extension.toLowerCase(Locale.ENGLISH);
this.safeExtensions.addAll(WHITELISTED_EXTENSIONS);
if (this.safeExtensions.contains(extension)) {
return true;
}
String pattern = (String) request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
if (pattern != null && pattern.endsWith("." + extension)) {
return true;
}
if (extension.equals("html")) {
String name = HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE;
Set<MediaType> mediaTypes = (Set<MediaType>) request.getAttribute(name);
if (!CollectionUtils.isEmpty(mediaTypes) && mediaTypes.contains(MediaType.TEXT_HTML)) {
return true;
}
}
return false;
}
}