---
# System prepended metadata

title: CVE-2023-50164 Research
tags: [CVE Research]

---

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