# CVE-2023-50164 Research Apache Struts 2 上的路徑遍歷 & RCE 漏洞。 ## CVE Infos 透過操控檔案上傳參數觸發路徑遍歷漏洞,讓使用者能夠上傳惡意文件到未經授權的目錄中,進而訪問和執行上傳的惡意檔案來達到 RCE。 ### CVSS | Score | Vector | | ----- |:-------------------------------------------- | | 9.8 | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H | ### Affected Versions - Struts 2.0.0 - Struts 2.3.37 (EOL) - Struts 2.5.0 - Struts 2.5.32 - Struts 6.0.0 - Struts 6.3.0 ## Analysis ### Function Call Flow Graph ![apache_struts_cve_function_call](https://hackmd.io/_uploads/HJnkedFEkx.png) ### Update Parameters 當上傳一個檔案之後,PrepareOperations Class 會進行 Action 的前置作業,並將 `multipart/form-data` 中的每個部分的資料取出來,分別更新至 `fileInfos` 和 `parameters` 變數,而儲存至哪個變數則是由該 part 的 form data 是否存在 filename param 來判斷。 最後 `parameters` 會再由 Dispatcher 中的 `createContextMap()` 讀取並更新成 HttpParameters 的 instance。 #### Details `processUpload()` 會建立一個 FileItemIterator instance,透過這個 instance 去迭代 request (`multipart/form-data`) 中每個 part (FileItemStream) 的 form data,並透過 FileItemStream 的 method `isFormField()` 去檢查每個 part 是 FormField 還是 FileField: ```java // JakartaStreamMultiPartRequest.java protected void processUpload(HttpServletRequest request, String saveDir) throws Exception { if (ServletFileUpload.isMultipartContent(request)) { <SNIP> FileItemIterator i = servletFileUpload.getItemIterator(request); // Iterate the file items while (i.hasNext()) { try { FileItemStream itemStream = i.next(); if (itemStream.isFormField()) { processFileItemStreamAsFormField(itemStream); } else { <SNIP> processFileItemStreamAsFileField(itemStream, saveDir); } } catch (IOException e) { LOG.warn("Error occurred during process upload", e); } } } } ``` `isFormField()` 的回傳結果由 FileUploadBase.class 檔案裡面 FileItemStreamImpl class 的 `formField` 變數決定,而 `formField` 由傳入 constructor 的 `pFieldName` 參數控制: ```java // FileUploadBase.class class FileItemStreamImpl implements FileItemStream { private final String contentType; private final String fieldName; private final String name; private final boolean formField; private final InputStream stream; private boolean opened; private FileItemHeaders headers; FileItemStreamImpl(String pName, String pFieldName, String pContentType, boolean pFormField, long pContentLength) throws IOException { this.name = pName; this.fieldName = pFieldName; this.contentType = pContentType; this.formField = pFormField; <SNIP> } <SNIP> public boolean isFormField() { return this.formField; } <SNIP> } ``` 從前面提到的 FileItemIterator 的 implements FileItemIteratorImpl 可以看出 `hasNext()` 和 constructor 都會透過 `findNextItem()` 來更新 `currentItem` (FileItemStreamImpl)。 而 `findNextItem()` 則會透過 FileUploadBase 的 `getFileName()` 來讀取 form data 中 filename param 的值,並且透過 `getFileName()` 的結果是否為 null 來作為該 FileItemStreamImpl 是否為 FormField 的依據: ```java // FileUploadBase.class private class FileItemIteratorImpl implements FileItemIterator { private final MultipartStream multi; private final MultipartStream.ProgressNotifier notifier; private final byte[] boundary; private FileItemStreamImpl currentItem; private String currentFieldName; private boolean skipPreamble; private boolean itemValid; private boolean eof; FileItemIteratorImpl(RequestContext ctx) throws FileUploadException, IOException { <SNIP> this.findNextItem(); <SNIP> } private boolean findNextItem() throws IOException { <SNIP> -------> String fileName = FileUploadBase.this.getFileName(headers); -------> this.currentItem = new FileItemStreamImpl(fileName, fieldName, headers.getHeader("Content-type"), fileName == null, this.getContentLength(headers)); <SNIP> } public boolean hasNext() throws FileUploadException, IOException { if (this.eof) { return false; } else if (this.itemValid) { return true; } else { try { return this.findNextItem(); } catch (FileUploadIOException e) { throw (FileUploadException)e.getCause(); } } public FileItemStream next() throws FileUploadException, IOException { if (!this.eof && (this.itemValid || this.hasNext())) { this.itemValid = false; return this.currentItem; } else { throw new NoSuchElementException(); } } } ``` `getFileName()` 會嘗試讀取 form data 中的 filename param,若不存在該 param 則回傳 null: ```java // FileUploadBase.class protected String getFileName(FileItemHeaders headers) { return this.getFileName(headers.getHeader("Content-disposition")); } private String getFileName(String pContentDisposition) { String fileName = null; if (pContentDisposition != null) { String cdl = pContentDisposition.toLowerCase(Locale.ENGLISH); if (cdl.startsWith("form-data") || cdl.startsWith("attachment")) { ParameterParser parser = new ParameterParser(); parser.setLowerCaseNames(true); Map<String, String> params = parser.parse(pContentDisposition, ';'); -------> if (params.containsKey("filename")) { fileName = (String)params.get("filename"); if (fileName != null) { fileName = fileName.trim(); } else { fileName = ""; } } } } return fileName; } ``` 因此如果存在 filename param 就會被判斷為 FileField,否則則為 FormField。而 FormField 的值會被更新至變數 `parameters` 中,FileField 則是更新至 `fileInfos`。 最後經過 Dispatcher 的 `createContextMap()` 將 `parameters` 的值建立成 HttpParameters 的 instance,並更新到當前 Action 的 Context 中使用: ```java // Dispatcher.java public Map<String, Object> createContextMap(HttpServletRequest request, HttpServletResponse response, ActionMapping mapping) { <SNIP> // parameters map wrapping the http parameters. ActionMapping parameters are now handled and applied separately HttpParameters params = HttpParameters.create(request.getParameterMap()).build(); <SNIP> Map<String, Object> extraContext = createContextMap(requestMap, params, session, application, request, response); <SNIP> return extraContext; } ``` ### Execute Action PrepareOperations 完成 Action 的前置作業後,接著會透過 ExecuteOperations 的 `executeAction()` 執行 Action,最後程式會執行到 Interceptor 的 `intercept()`: ```java // FileUploadInterceptor.java public String intercept(ActionInvocation invocation) throws Exception { ActionContext ac = invocation.getInvocationContext(); HttpServletRequest request = ac.getServletRequest(); <SNIP> // bind allowed Files Enumeration fileParameterNames = multiWrapper.getFileParameterNames(); while (fileParameterNames != null && fileParameterNames.hasMoreElements()) { // get the value of this input tag String inputName = (String) fileParameterNames.nextElement(); // get the content type String[] contentType = multiWrapper.getContentTypes(inputName); if (isNonEmpty(contentType)) { // get the name of the file from the input tag String[] fileName = multiWrapper.getFileNames(inputName); if (isNonEmpty(fileName)) { // get a File object for the uploaded File UploadedFile[] files = multiWrapper.getFiles(inputName); if (files != null && files.length > 0) { List<UploadedFile> acceptedFiles = new ArrayList<>(files.length); List<String> acceptedContentTypes = new ArrayList<>(files.length); List<String> acceptedFileNames = new ArrayList<>(files.length); String contentTypeName = inputName + "ContentType"; String fileNameName = inputName + "FileName"; for (int index = 0; index < files.length; index++) { if (acceptFile(action, files[index], fileName[index], contentType[index], inputName, validation)) { acceptedFiles.add(files[index]); acceptedContentTypes.add(contentType[index]); acceptedFileNames.add(fileName[index]); } } if (!acceptedFiles.isEmpty()) { Map<String, Parameter> newParams = new HashMap<>(); newParams.put(inputName, new Parameter.File(inputName, acceptedFiles.toArray(new UploadedFile[acceptedFiles.size()]))); newParams.put(contentTypeName, new Parameter.File(contentTypeName, acceptedContentTypes.toArray(new String[acceptedContentTypes.size()]))); newParams.put(fileNameName, new Parameter.File(fileNameName, acceptedFileNames.toArray(new String[acceptedFileNames.size()]))); ac.getParameters().appendAll(newParams); } } } <SNIP> } } // invoke action return invocation.invoke(); } ``` 在檔案上傳相關的 Interceptor (FileUploadInterceptor) 中,程式會透過 MultiPartRequestWrapper 的 `getFileParameterNames()` 取得 `fileInfos` Map 的 keys,再將結果送入 `getFileNames()` 去存取對應的 filename value。 而在 `getFileNames()` 的執行過程中,程式會將 filename 送入 `getCanonicalName()` 中來將檔名中包含的路徑刪除,只返回原本的檔名: ```java // JakartaStreamMultiPartRequest.java public String[] getFileNames(String fieldName) { List<FileInfo> infos = fileInfos.get(fieldName); <SNIP> List<String> names = new ArrayList<>(infos.size()); for (FileInfo fileInfo : infos) { names.add(getCanonicalName(fileInfo.getOriginalName())); } return names.toArray(new String[0]); } <SNIP> protected String getCanonicalName(final String originalFileName) { String fileName = originalFileName; int forwardSlash = fileName.lastIndexOf('/'); int backwardSlash = fileName.lastIndexOf('\\'); if (forwardSlash != -1 && forwardSlash > backwardSlash) { fileName = fileName.substring(forwardSlash + 1); } else { fileName = fileName.substring(backwardSlash + 1); } return fileName; } ``` 接著會將 inputName (`getFileParameterNames()` 的回傳值,也就是 `multipart/form-data` 中每個 field 的 name value) 與 "FileName" 組合在一起,作為 key 與讀取出來的 filename value 更新到 Context 中原先儲存的 HttpParameters 變數中,並在最後程式儲存使用者上傳的檔案時作為檔案名稱的參數。 ```java // FileUploadInterceptor/intercept if (files != null && files.length > 0) { List<UploadedFile> acceptedFiles = new ArrayList<>(files.length); List<String> acceptedContentTypes = new ArrayList<>(files.length); List<String> acceptedFileNames = new ArrayList<>(files.length); String contentTypeName = inputName + "ContentType"; String fileNameName = inputName + "FileName"; for (int index = 0; index < files.length; index++) { if (acceptFile(action, files[index], fileName[index], contentType[index], inputName, validation)) { acceptedFiles.add(files[index]); acceptedContentTypes.add(contentType[index]); acceptedFileNames.add(fileName[index]); } } if (!acceptedFiles.isEmpty()) { Map<String, Parameter> newParams = new HashMap<>(); newParams.put(inputName, new Parameter.File(inputName, acceptedFiles.toArray(new UploadedFile[acceptedFiles.size()]))); newParams.put(contentTypeName, new Parameter.File(contentTypeName, acceptedContentTypes.toArray(new String[acceptedContentTypes.size()]))); newParams.put(fileNameName, new Parameter.File(fileNameName, acceptedFileNames.toArray(new String[acceptedFileNames.size()]))); ac.getParameters().appendAll(newParams); } } ``` ### Exploit 在 HttpParameters 的實現中,有漏洞的版本會將大小寫不同的參數視為不同的參數: ![image](https://hackmd.io/_uploads/HJaXYTK4ye.png) > [Github Commit - WW-5370 Makes HttpParameters case-insensitive](https://github.com/apache/struts/commit/13d972d6eeaf998f7199f0d39446aff72fd67423#diff-c3ff6723ce314bc163facd220f85264aa44661665942eaa24fd2da450a81e929) 因此,如果構造一個這樣的 FileUpload request: ![messageImage_1734101637258](https://hackmd.io/_uploads/rJ1p5TKV1e.jpg) > Picture Reference: `https://www.trendmicro.com/content/dam/trendmicro/global/en/research/23/l/decoding-cve-2023-50164--unveiling-the-apache-struts-file-upload-exploit/CVE202350164Figure5.png` 下面 field name 為 `uploadFileName` 的 form data 會被儲存至 `parameters` 中,而上面 field name 為 `Upload` 則是會被儲存到 `fileInfos` 中。 在最後執行到 FileUploadInterceptor 的 `intercept()` 時,`Upload` 會與 "FileName" 組合在一起作為 filename 參數的 key 放到 HttpParameters 中,此時儲存 HttpParameters 的變數會同時存在 `uploadFileName` (==../../shell.jsp==) 與 `UploadFileName` (==foo.txt==),而最後執行時 `uploadFileName` 會取代 `UploadFileName` 作為檔案名稱,因此可以繞過 `getCanonicalName()` 的檢查 (`parameters` 的儲存過程中不會經過 `getCanonicalName()`)。 所以最後路徑字元 `./` 等能夠透過 `uploadFileName` 參數留在檔案名稱中,造成路徑瀏覽的問題,使攻擊者可以上傳惡意檔案至 webroot 進而達到 RCE。 ## Reference - [CVE-2023-50164 Detail - NVD](https://nvd.nist.gov/vuln/detail/cve-2023-50164) - [Mirror of Apache Struts - GitHub](https://github.com/apache/struts/) - [CVE-2023-50164 解析:揭露 Apache Struts 檔案上傳漏洞攻擊手法](https://www.trendmicro.com/zh_tw/research/23/l/decoding-cve-2023-50164--unveiling-the-apache-struts-file-upload.html) - [Analyzing CVE-2023-50164: Apache Struts Path Traversal Vulnerability](https://www.paloaltonetworks.com/blog/prisma-cloud/cve-2023-50164-custom-rules/) - [Apache Struts RCE (CVE-2023-50164)- Vulnerability Analysis and Exploitation](https://www.cyfirma.com/research/apache-struts-rce-cve-2023-50164-vulnerability-analysis-and-exploitation/)