# 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

### 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 的實現中,有漏洞的版本會將大小寫不同的參數視為不同的參數:

> [Github Commit - WW-5370 Makes HttpParameters case-insensitive](https://github.com/apache/struts/commit/13d972d6eeaf998f7199f0d39446aff72fd67423#diff-c3ff6723ce314bc163facd220f85264aa44661665942eaa24fd2da450a81e929)
因此,如果構造一個這樣的 FileUpload request:

> 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/)