SpringMVC的一个目录穿越漏洞,或者说目录遍历漏洞。SpringMVC是Java开发界最常用的框架之一,当静态资源存放在Windows系统上时,攻击可以通过构造特殊URL导致目录遍历漏洞
漏洞有限制条件
下载官方示例:https://github.com/spring-projects/spring-mvc-showcase
修改spring的版本
xxxxxxxxxx
<properties>
<java-version>1.8</java-version>
<org.springframework-version>5.0.0.RELEASE</org.springframework-version>
<org.aspectj-version>1.8.1</org.aspectj-version>
</properties>
修改配置文件:org\springframework\samples\mvc\config\WebMvcConfig.java 这里的作用主要是配置资源映射,将url的/resources/**映射到file协议的resources下,其实真实开发场景一般不这样配
xxxxxxxxxx
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/resources/**").addResourceLocations("file:./src/main/resources/","/resources/");
}
使用mvn命令启动:mvn jetty:run(可在IDEA中配置启动方式为Maven后加入参数)
访问特殊url后成功读到文件:http://localhost:8080/spring-mvc-showcase/resources/%255c%255c..%255c/..%255c/..%255c/..%255c/..%255c/..%255c/..%255c/..%255c/..%255c/key.txt
(由于我项目不在c盘,就没有读win.ini文件,自己新建了一个文件)
org\springframework\web\servlet\resource\ResourceHttpRequestHandler.java
这里是处理请求的类,方法是handleRequest,一开始调用了getResource
方法,跟入
xxxxxxxxxx
Resource resource = getResource(request);
protected Resource getResource(HttpServletRequest request) throws IOException {
String path = (String) request.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE);
......
path = processPath(path);
if (!StringUtils.hasText(path) || isInvalidPath(path)) {
......
}
进入request.getAttribute有多种实现,不方便继续跟入分析,尝试动态调试,发现这里得到的path是进行一次url解码后的结果
后续有processPath和isInvalidPath方法对path进行处理和校验,其中processPath
方法作用是处理斜杠的问题,比如多个斜杠或者丢失斜杠的问题,和这里无关系,程序经过它是path也没有变化。isInvalidPath是需要重点关注的
首先禁止了WEB-INF和META-INF等关键路径,然后处理是url(带有:/)的情况,最后是处理带有..
的情况,而..
正是目录遍历漏洞需要用到的
xxxxxxxxxx
protected boolean isInvalidPath(String path) {
if (logger.isTraceEnabled()) {
logger.trace("Applying \"invalid path\" checks to path: " + path);
}
if (path.contains("WEB-INF") || path.contains("META-INF")) {
if (logger.isTraceEnabled()) {
logger.trace("Path contains \"WEB-INF\" or \"META-INF\".");
}
return true;
}
if (path.contains(":/")) {
String relativePath = (path.charAt(0) == '/' ? path.substring(1) : path);
if (ResourceUtils.isUrl(relativePath) || relativePath.startsWith("url:")) {
if (logger.isTraceEnabled()) {
logger.trace("Path represents URL or has \"url:\" prefix.");
}
return true;
}
}
if (path.contains("..")) {
path = StringUtils.cleanPath(path);
if (path.contains("../")) {
if (logger.isTraceEnabled()) {
logger.trace("Path contains \"../\" after call to StringUtils#cleanPath.");
}
return true;
}
}
return false;
}
跟入cleanPath
方法,稍显复杂,实现的功能是将/foo/bar/../
这样的路径转换为/foo/
,其中delimitedListToStringArray
方法会返回一个目录数组,比如上面的foo
,bar
,..
。如果传入的是/foo/bar//../
,数组中会有一个空值,cleanPath
会解析为/foo/bar/
,但系统中认为是/foo/
。我们的payload%5c%5c..%5c/..%5c/..%5c/..%5c/..%5c/
在这里按照/
分割后,没有等于..
的,所以cleanPath
后原样返回
xxxxxxxxxx
public static String cleanPath(String path) {
if (!hasLength(path)) {
return path;
}
String pathToUse = replace(path, WINDOWS_FOLDER_SEPARATOR, FOLDER_SEPARATOR);
// Strip prefix from path to analyze, to not treat it as part of the
// first path element. This is necessary to correctly parse paths like
// "file:core/../core/io/Resource.class", where the ".." should just
// strip the first "core" directory while keeping the "file:" prefix.
int prefixIndex = pathToUse.indexOf(":");
String prefix = "";
if (prefixIndex != -1) {
prefix = pathToUse.substring(0, prefixIndex + 1);
if (prefix.contains("/")) {
prefix = "";
}
else {
pathToUse = pathToUse.substring(prefixIndex + 1);
}
}
if (pathToUse.startsWith(FOLDER_SEPARATOR)) {
prefix = prefix + FOLDER_SEPARATOR;
pathToUse = pathToUse.substring(1);
}
String[] pathArray = delimitedListToStringArray(pathToUse, FOLDER_SEPARATOR);
List<String> pathElements = new LinkedList<>();
int tops = 0;
for (int i = pathArray.length - 1; i >= 0; i--) {
String element = pathArray[i];
if (CURRENT_PATH.equals(element)) {
// Points to current directory - drop it.
}
else if (TOP_PATH.equals(element)) {
// Registering top path found.
tops++;
}
else {
if (tops > 0) {
// Merging path element with element corresponding to top path.
tops--;
}
else {
// Normal path element found.
pathElements.add(0, element);
}
}
}
// Remaining top paths need to be retained.
for (int i = 0; i < tops; i++) {
pathElements.add(0, TOP_PATH);
}
return prefix + collectionToDelimitedString(pathElements, FOLDER_SEPARATOR);
}
而上文的getResource
方法的后半部分,又调用了一次isInvalidPath
方法。此时path中还是包含%,所以成功进入了if,进行url解码后变成\\..\/..\/..\/..\/..\/..\/
这种格式,经过cleanPath
处理后,变成了//key.txt
,返回false,通过了校验
xxxxxxxxxx
if (path.contains("%")) {
try {
// Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars
if (isInvalidPath(URLDecoder.decode(path, "UTF-8"))) {
if (logger.isTraceEnabled()) {
logger.trace("Ignoring invalid resource path with escape sequences [" + path + "].");
}
return null;
}
}
catch (IllegalArgumentException ex) {
// ignore
}
}
经过以上一系列复杂的处理后得到一个resource,path其实并没有变化,只是经过一次url解码后的结果:%5c%5c..%5c/..%5c/..%5c/..%5c/..%5c/..%5c/..%5c/..%5c/..%5c/key.txt
xxxxxxxxxx
ResourceResolverChain resolveChain = new DefaultResourceResolverChain(getResourceResolvers());
Resource resource = resolveChain.resolveResource(request, path, getLocations());
if (resource == null || getResourceTransformers().isEmpty()) {
return resource;
}
ResourceTransformerChain transformChain =
new DefaultResourceTransformerChain(resolveChain, getResourceTransformers());
resource = transformChain.transform(request, resource);
return resource;
其中getLocations
是我们之前配置的file:./src/main/resources/
,跟入resolveResource
,一条很长的调用链,最终到PathResourceResolver
的getResource
xxxxxxxxxx
protected String resolveUrlPathInternal(String resourcePath, List<? extends Resource> locations,
ResourceResolverChain chain) {
return (StringUtils.hasText(resourcePath) && getResource(resourcePath, locations) != null ? resourcePath : null);
}
private Resource getResource(String resourcePath, List<? extends Resource> locations) {
for (Resource location : locations) {
try {
if (logger.isTraceEnabled()) {
logger.trace("Checking location: " + location);
}
Resource resource = getResource(resourcePath, location);
if (resource != null) {
if (logger.isTraceEnabled()) {
logger.trace("Found match: " + resource);
}
return resource;
}
else if (logger.isTraceEnabled()) {
logger.trace("No match for location: " + location);
}
}
catch (IOException ex) {
logger.trace("Failure checking for relative resource - trying next location", ex);
}
}
return null;
}
继续跟入getResource
方法,
xxxxxxxxxx
protected Resource getResource(String resourcePath, Resource location) throws IOException {
Resource resource = location.createRelative(resourcePath);
if (resource.exists() && resource.isReadable()) {
if (checkResource(resource, location)) {
return resource;
}
else if (logger.isTraceEnabled()) {
Resource[] allowedLocations = getAllowedLocations();
logger.trace("Resource path=\"" + resourcePath + "\" was successfully resolved " +
"but resource=\"" + resource.getURL() + "\" is neither under the " +
"current location=\"" + location.getURL() + "\" nor under any of the " +
"allowed locations=" + (allowedLocations != null ? Arrays.asList(allowedLocations) : "[]"));
}
}
return null;
}
public Resource createRelative(String relativePath) throws MalformedURLException {
if (relativePath.startsWith("/")) {
relativePath = relativePath.substring(1);
}
return new UrlResource(new URL(this.url, relativePath));
}
经过createRelative
方法拼接后得到file:src/main/resources/%5c%5c..%5c/..%5c/..%5c/..%5c/..%5c/..%5c/..%5c/..%5c/..%5c/key.txt
xxxxxxxxxx
public boolean exists() {
try {
URL url = getURL();
if (ResourceUtils.isFileURL(url)) {
// Proceed with file system resolution
return getFile().exists();
}
......
在配置文件中修改为file:
是因为isFileURL
xxxxxxxxxx
public static boolean isFileURL(URL url) {
String protocol = url.getProtocol();
return (URL_PROTOCOL_FILE.equals(protocol) || URL_PROTOCOL_VFSFILE.equals(protocol) ||
URL_PROTOCOL_VFS.equals(protocol));
}
跟入getFile
xxxxxxxxxx
public File getFile() throws IOException {
URL url = getURL();
if (url.getProtocol().startsWith(ResourceUtils.URL_PROTOCOL_VFS)) {
return VfsResourceDelegate.getResource(url).getFile();
}
return ResourceUtils.getFile(url, getDescription());
}
public static File getFile(URL resourceUrl, String description) throws FileNotFoundException {
Assert.notNull(resourceUrl, "Resource URL must not be null");
if (!URL_PROTOCOL_FILE.equals(resourceUrl.getProtocol())) {
throw new FileNotFoundException(
description + " cannot be resolved to absolute file path " +
"because it does not reside in the file system: " + resourceUrl);
}
try {
return new File(toURI(resourceUrl).getSchemeSpecificPart());
}
catch (URISyntaxException ex) {
// Fallback for URLs that are not valid URIs (should hardly ever happen).
return new File(resourceUrl.getFile());
}
}
在getSchemeSpecificPart
方法中,有一次url的解码
xxxxxxxxxx
public String getSchemeSpecificPart() {
if (decodedSchemeSpecificPart == null)
decodedSchemeSpecificPart = decode(getRawSchemeSpecificPart());
return decodedSchemeSpecificPart;
}
层层返回,验证通过,但路径还是没有变:/%5c%5c..%5c/..%5c/..%5c/..%5c/..%5c/..%5c/..%5c/..%5c/..%5c/key.txt
handleRequest
方法后续,验证通过,准备工作结束后,开始往response里面写入
x
this.resourceHttpMessageConverter.write(resource, mediaType, outputMessage);
protected void writeInternal(Resource resource, HttpOutputMessage outputMessage)
throws IOException, HttpMessageNotWritableException {
writeContent(resource, outputMessage);
}
protected void writeContent(Resource resource, HttpOutputMessage outputMessage)
throws IOException, HttpMessageNotWritableException {
try {
InputStream in = resource.getInputStream();
程序会在openConnection
方法处进行URL解码
xxxxxxxxxx
public InputStream getInputStream() throws IOException {
URLConnection con = this.url.openConnection();
ResourceUtils.useCachesIfNecessary(con);
try {
return con.getInputStream();
}