CVE-2024-21733 请求走私
前言
最近看到这个漏洞,真的很有意思,因为之前一直没有接触过请求走私,在这里顺便学习了一下,然后看到网上几乎只是告诉你如何去打,几乎没有分析,在这里也来分析一下
走私漏洞原理
漏洞原理
Web应用程序经常在用户和最终应用程序逻辑之间使用HTTP服务器链,用户将请求发送到前端服务器(有时称为"负载均衡器"或"反向代理"),然后该服务器将请求转发到一台或多台后端服务器,这种类型的架构在现代基于云的应用程序中越来越常见并且在某些情况下是不可避免的,而当前端服务器将HTTP请求转发到后端服务器时,它通常会通过同一后端网络连接发送多个请求,因为这样的效率和性能要高得多,HTTP请求被一个接一个地发送,接收服务器必须确定一个请求在哪里结束以及下一个请求从哪里开始
在这种情况下前端和后端系统就请求之间的边界达成一致至关重要,否则攻击者可能能够发送不明确的请求,前端和后端系统会以不同的方式解释该请求,在下面的示例图中攻击者通过更改请求数据包导致其前端请求的一部分被后端服务器解释为下一个请求的开始,它有效地添加到下一个请求之前,因此可能会干扰应用程序处理该请求的方式,这便是请求走私攻击,可能会造成灾难性的后果
环境搭建
这里使用spring搭建的,内置tomcat为
9.0.43
然后随便写一个路由用于测试
public class tomcat {
@RequestMapping({"/cve"})
public String cve(HttpServletRequest request) {
String name = request.getParameter("name");
return "Your name is:" + name;
}
}
漏洞复现
首先访问路由,随便输入一些东西
发送请求
然后再次发送一个请求
可以发现我们上次的数据已经被爆出来了
需要content—length设置比原文大于1
漏洞分析
正常处理流程
这里先简单分析一个正常的流程
我们关注到处理请求的地方
parseParameters:3216, Request (org.apache.catalina.connector)
getParameter:1142, Request (org.apache.catalina.connector)
getParameter:381, RequestFacade (org.apache.catalina.connector)
cve:20, tomcat (com.example.headerstealer.controller)
可以看到是访问我们路由后处理参数的时候
具体逻辑就在parseParameters方法
这里重点关注处理content-length的部分
int len = getContentLength();
byte[] formData = null;
if (len < CACHED_POST_LEN) {
if (postData == null) {
postData = new byte[CACHED_POST_LEN];
}
formData = postData;
} else {
formData = new byte[len];
}
try {
if (readPostBody(formData, len) != len) {
parameters.setParseFailedReason(FailReason.REQUEST_BODY_INCOMPLETE);
return;
}
}
先获取我们的长度,然后formData是来自
postData = new byte[CACHED_POST_LEN];
CACHED_POST_LEN就是储存的意思,下面也会分析到,也就是上次请求的值
进入readPostBody方法
protected int readPostBody(byte[] body, int len)
throws IOException {
int offset = 0;
do {
int inputLen = getStream().read(body, offset, len - offset);
if (inputLen <= 0) {
return offset;
}
offset += inputLen;
} while ((len - offset) > 0);
return len;
}
首先读取输入数据的长度,然后和content的长度做比较,如果比实际长度长那么就继续循环
如果一样就返回
然后解析完后就回到
public String cve(HttpServletRequest request) {
String name = request.getParameter("name");
return "Your name is:" + name;
}
并返回
之后来到service方法
if (!isAsync() || getErrorState().isError()) {
request.updateCounters();
if (getErrorState().isIoAllowed()) {
inputBuffer.nextRequest();
outputBuffer.nextRequest();
}
}
跟进nextRequest
void nextRequest() {
request.recycle();
if (byteBuffer.position() > 0) {
if (byteBuffer.remaining() > 0) {
// Copy leftover bytes to the beginning of the buffer
byteBuffer.compact();
byteBuffer.flip();
} else {
// Reset position and limit to 0
byteBuffer.position(0).limit(0);
}
}
// Recycle filters
for (int i = 0; i <= lastActiveFilter; i++) {
activeFilters[i].recycle();
}
// Reset pointers
lastActiveFilter = -1;
parsingHeader = true;
swallowInput = true;
headerParsePos = HeaderParsePosition.HEADER_START;
parsingRequestLine = true;
parsingRequestLinePhase = 0;
parsingRequestLineEol = false;
parsingRequestLineStart = 0;
parsingRequestLineQPos = -1;
headerData.recycle();
}
nextRequest
方法简单来说,确保在处理下一个请求之前,当前请求的所有资源都被正确回收和重置。它通过清理缓冲区、过滤器和解析状态,确保系统能够高效地处理多个请求。
之后来到parseRequestLine方法
逻辑有点复杂,分为不同的阶段
这里主要是二阶段
if (parsingRequestLinePhase == 2) {
//
// Reading the method name
// Method name is a token
//
boolean space = false;
while (!space) {
// Read new bytes if needed
if (byteBuffer.position() >= byteBuffer.limit()) {
if (!fill(false)) // request line parsing
return false;
}
// Spec says method name is a token followed by a single SP but
// also be tolerant of multiple SP and/or HT.
int pos = byteBuffer.position();
chr = byteBuffer.get();
if (chr == Constants.SP || chr == Constants.HT) {
space = true;
request.method().setBytes(byteBuffer.array(), parsingRequestLineStart,
pos - parsingRequestLineStart);
} else if (!HttpParser.isToken(chr)) {
// Avoid unknown protocol triggering an additional error
request.protocol().setString(Constants.HTTP_11);
String invalidMethodValue = parseInvalid(parsingRequestLineStart, byteBuffer);
throw new IllegalArgumentException(sm.getString("iib.invalidmethod", invalidMethodValue));
}
}
parsingRequestLinePhase = 3;
}
**检查缓冲区**
if (byteBuffer.position() >= byteBuffer.limit()) {
if (!fill(false)) // request line parsing
return false;
}
- 如果当前缓冲区的位置已经到达限制,调用 `fill(false)` 方法填充更多数据。如果这次填充失败,则返回 `false`,表示解析尚未完成。
2. **读取字符和分析**
int pos = byteBuffer.position();
chr = byteBuffer.get();
- 记录当前缓冲区的位置,然后读取下一个字符。
3. **判断字符类型**
if (chr == Constants.SP || chr == Constants.HT) {
space = true;
request.method().setBytes(byteBuffer.array(), parsingRequestLineStart, pos - parsingRequestLineStart);
} else if (!HttpParser.isToken(chr)) {
request.protocol().setString(Constants.HTTP_11);
String invalidMethodValue = parseInvalid(parsingRequestLineStart, byteBuffer);
throw new IllegalArgumentException(sm.getString("iib.invalidmethod", invalidMethodValue));
}
- 第一部分:检查读取到的字符是否为空格(
Constants.SP
)或水平制表符(
Constants.HT
)。
- 如果是,设置 `space` 为 `true`,并将解析的请求方法名的字节存储到 `request.method()` 中。方法名的字节内容来源于 `byteBuffer`,从 `parsingRequestLineStart` 开始,长度为当前位置和开始位置之间的差值。
- 第二部分:如果读取的字符不是有效的标记(确认是否是支持的 HTTP 方法),则:
- 默认将请求的协议设置为 `HTTP/1.1`。
- 解析获取到的无效方法名,并抛出 `IllegalArgumentException`,附上无效方法的值。
当然我这里其实主要分析的是漏洞部分相关的
回显之报错原因
首先需要明白一个问题,为什么会报错
HTTP method names must be tokens
at org.apache.coyote.http11.Http11InputBuffer.parseRequestLine(Http11InputBuffer.java:417) ~[tomcat-embed-core-9.0.43.jar:9.0.43]
也就是我们分析的parseRequestLine方法的第二阶段的时候,每个阶段都是解析不同的字符
if (!HttpParser.isToken(chr)) {
// Avoid unknown protocol triggering an additional error
request.protocol().setString(Constants.HTTP_11);
String invalidMethodValue = parseInvalid(parsingRequestLineStart, byteBuffer);
throw new IllegalArgumentException(sm.getString("iib.invalidmethod", invalidMethodValue));
也就是当数据没有正常的token的时候会抛出异常,把不合法的invalidMethodValue抛了出来,而这个就是我们数据的value值
String invalidMethodValue = parseInvalid(parsingRequestLineStart, byteBuffer);
value就是我们的请求数据
那为什么会不合法呢?合法的又是什么呢?首先第二阶段是解析请求头的阶段
判断依据主要是第一个字符的if
if (chr == Constants.SP || chr == Constants.HT)
这个表示结束,因为我们的请求包一般都是POST空格啥的
public static final byte SP = (byte) ' ';
/**
* HT.
*/
public static final byte HT = (byte) '\t';
然后istoken就是限制请求头方法不能有的值,比如=,@等这些
而我们读取的数据是
其中有=号,所以报错错误抛出异常
那我们的数据为什么是这样的呢?
content-length对读取数据的影响
按照上面分析的正常流程,我们会走到parseParameters方法
重点是
byte[] formData = null;
if (len < CACHED_POST_LEN) {
if (postData == null) {
postData = new byte[CACHED_POST_LEN];
}
formData = postData;
} else {
formData = new byte[len];
}
try {
if (readPostBody(formData, len) != len) {
parameters.setParseFailedReason(FailReason.REQUEST_BODY_INCOMPLETE);
return;
}
}
其中的formData就是上次正常请求的数据
进入readPostBody方法
protected int readPostBody(byte[] body, int len)
throws IOException {
int offset = 0;
do {
int inputLen = getStream().read(body, offset, len - offset);
if (inputLen <= 0) {
return offset;
}
offset += inputLen;
} while ((len - offset) > 0);
return len;
}
其中inputLen就是真实的长度,然后这里就会一直死循环,超出时长,造成异常,但是没有抛出
最后又会来到parseRequestLine处理部分
所以这里读取的数据我们重点观察
少了一个n,应该是name=...这里是ame=...是因为我们输入和content长度不符合,然后把缓冲区设置为了1,导致读取的时候少读了一位
师傅的示意图
len(leakage) = len(previous body) + len(actually length) + 1 -len(Content-Length)
最后
想问师傅们一个问题,为什么我找不到在最后来到parseRequestLine方法的时候,byteBuffer的值就变成了不合理的值,是在哪里完成赋值的呢?具体赋值又是如何根据content长度去计算的呢?