一、问题背景与现象
在实际生产环境中,运维团队经常会在服务端日志中发现一类让人头疼的异常:BufferUnderflowException。这个异常看起来有些晦涩,但它实际上反映的是一个非常具体的问题——当程序试图从一个缓冲数据区中读取数据时,发现剩下的数据量不足以满足读取需求。想象一下你正在喝一瓶果汁,你伸手想倒出一杯的量,结果瓶子里只剩下一口,这时候就发生了"缓冲区下溢"。
在 Jetty 这个老牌 Java Web 服务器中,这个问题尤其值得关注。Jetty 作为许多企业级应用的核心组件,承担着处理大量 HTTP 请求的重任。当客户端使用 chunked(分块)传输方式发送请求体时,Jetty 内部的解析逻辑在特定条件下就会出现这个异常,导致请求处理失败,甚至可能引发服务不稳定。
1.1 什么是 BufferUnderflowException
BufferUnderflowException 是 Java NIO(New I/O)包中的一个异常类型,它继承自 java.nio 包下的 Exception。这个异常的核心含义是:你正在使用的 ByteBuffer 中已经没有足够的数据可以被读取了。
import java.nio.ByteBuffer;
public class BufferUnderflowDemo {
public static void main(String[] args) {
// 创建一个容量为4字节的缓冲区
ByteBuffer buffer = ByteBuffer.allocate(4);
// 只写入2个字节的数据
buffer.put((byte) 0x01);
buffer.put((byte) 0x02);
// 切换为读取模式
buffer.flip();
// 尝试读取4个字节,但缓冲区只有2个字节可用
try {
byte[] dest = new byte[4];
buffer.get(dest); // 这里会抛出 BufferUnderflowException
System.out.println("读取成功: " + new String(dest));
} catch (java.nio.BufferUnderflowException e) {
System.out.println("异常捕获:缓冲区数据不足");
System.out.println("当前剩余可读字节数: " + buffer.remaining());
}
}
}
1.2 为什么会在 Jetty 中出现
Jetty 在处理 HTTP 请求时,使用了一套基于缓冲区的数据处理机制。当客户端发送一个 chunked 编码的请求体时,Jetty 需要按照 chunked 协议逐块读取数据,直到遇到长度为 0 的 chunk 表示传输结束。问题就出在这个"逐块读取"的过程中——如果请求数据的实际内容与 chunked 头部声明的大小不一致,Jetty 就会在尝试读取超出实际数据量的字节时抛出 BufferUnderflowException。
这种情况通常不是客户端的恶意攻击,而是由于网络中间件(如反向代理、负载均衡器)对请求体的不当处理、客户端库的 bug、或者是不完整的 TCP 连接断开等原因造成的。
二、Jetty 的 Chunked 请求体解析机制
要理解这个缺陷,我们首先需要搞懂 HTTP chunked 传输编码的基本原理,以及 Jetty 是如何处理这种编码的。
2.1 HTTP Chunked 传输编码基础
Chunked 传输编码是 HTTP 协议中一种特殊的传输方式。当客户端发送请求时,如果事先不知道请求体的总大小(比如在流式上传场景),就可以使用 chunked 编码。每个数据块前面会标注该块的大小(十六进制格式),最后以大小为 0 的块来标识传输结束。
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
public class ChunkedRequestDemo {
public static void main(String[] args) throws Exception {
// 模拟发送一个 chunked 编码的 HTTP POST 请求
Socket socket = new Socket("localhost", 8080);
OutputStream out = socket.getOutputStream();
// 构造 HTTP 请求头和 chunked 体
StringBuilder request = new StringBuilder();
request.append("POST /api/data HTTP/1.1\r\n");
request.append("Host: localhost\r\n");
request.append("Transfer-Encoding: chunked\r\n");
request.append("Content-Type: application/json\r\n");
request.append("\r\n");
// 第一个 chunk:5个字节的 "Hello"
String chunk1 = "Hello";
request.append(Integer.toHexString(chunk1.length())).append("\r\n");
request.append(chunk1).append("\r\n");
// 第二个 chunk:5个字节的 "World"
String chunk2 = "World";
request.append(Integer.toHexString(chunk2.length())).append("\r\n");
request.append(chunk2).append("\r\n");
// 结束 chunk(长度为0)
request.append("0\r\n\r\n");
// 发送请求
out.write(request.toString().getBytes(StandardCharsets.ISO_8859_1));
out.flush();
out.close();
socket.close();
System.out.println("Chunked 请求发送完成");
}
}
2.2 Jetty 对 Chunked 请求的解析流程
Jetty 在解析 chunked 请求体时,大致经历以下几个步骤:
import java.nio.ByteBuffer;
/**
* 简化版:模拟 Jetty 解析 chunked 请求体的核心逻辑
* 注意:这是教学示例,并非 Jetty 真实源码
*/
public class JettyChunkedParserSimulator {
private ByteBuffer buffer;
public JettyChunkedParserSimulator() {
this.buffer = ByteBuffer.allocate(8192);
}
public void simulateParse() throws Exception {
// 模拟从 socket 读取的数据写入缓冲区
String rawData = "3\r\nabc\r\n2\r\nxy"; // 故意构造不完整数据
byte[] bytes = rawData.getBytes("ISO-8859-1");
buffer.put(bytes);
buffer.flip(); // 切换为读取模式
System.out.println("缓冲区总容量: " + buffer.capacity());
System.out.println("缓冲区可用数据: " + buffer.remaining());
while (buffer.hasRemaining()) {
// 步骤1:读取 chunk 大小(十六进制字符串)
StringBuilder sizeStr = new StringBuilder();
while (buffer.hasRemaining()) {
char c = (char) buffer.get();
if (c == '\r') break;
sizeStr.append(c);
}
if (sizeStr.length() == 0) {
throw new RuntimeException("无法读取 chunk 大小");
}
int chunkSize = Integer.parseInt(sizeStr.toString(), 16);
System.out.println("声明的 chunk 大小: " + chunkSize);
// 读取 \r\n
buffer.get(); // \n
buffer.get(); // \r 实际上应该是 \r\n,这里简化处理
// 步骤2:尝试读取 chunk 数据
byte[] chunkData = new byte[chunkSize];
// 问题出在这里:如果缓冲区数据不足,就会抛出 BufferUnderflowException
if (buffer.remaining() < chunkSize) {
throw new java.nio.BufferUnderflowException();
}
buffer.get(chunkData);
System.out.println("成功读取 chunk 数据: " + new String(chunkData));
}
}
public static void main(String[] args) {
JettyChunkedParserSimulator parser = new JettyChunkedParserSimulator();
try {
parser.simulateParse();
} catch (Exception e) {
System.out.println("解析异常: " + e.getClass().getSimpleName());
System.out.println("异常信息: " + e.getMessage());
}
}
}
2.3 解析缺陷的根因分析
Jetty 出现 BufferUnderflowException 的根因可以归结为以下几点:
第一,Jetty 在某些版本中对 chunked 编码的校验不够严格。当它读取到 chunk 大小时,会直接信任这个值,然后尝试从缓冲区中读取对应数量的数据。如果由于网络截断、代理干扰等原因导致实际数据少于声明的大小,就会触发异常。
第二,Jetty 在处理 chunked 请求体时,其内部的缓冲区管理策略在特定边界条件下存在缺陷。当多个小 chunk 连续到达,且恰好跨缓冲区边界时,某些版本的 Jetty 可能出现缓冲区指针计算错误。
第三,当 HTTP 请求被 HTTP/2 或 HTTP/1.1 的混合配置处理时,Jetty 在不同协议栈之间的切换逻辑可能引入额外的解析问题,使得 chunked 编码的处理路径变得更加脆弱。
三、复现 BufferUnderflowException 的场景
3.1 最小化复现代码
下面我们通过一个完整的示例来复现这个问题。我们将搭建一个 Jetty 服务器,然后用一个自定义的客户端发送构造好的异常 chunked 请求。
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.server.handler.AbstractHandler;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
/**
* Jetty 服务器端:接收 chunked 请求
* 技术栈:Java + Jetty 9.4.x
*/
public class JettyServerDemo {
public static void main(String[] args) throws Exception {
Server server = new Server(8080);
server.setHandler(new AbstractHandler() {
@Override
public void handle(String target, Request baseRequest,
HttpServletRequest request, HttpServletResponse response)
throws IOException {
System.out.println("收到请求: " + request.getMethod() + " " + target);
System.out.println("Transfer-Encoding: " + request.getHeader("Transfer-Encoding"));
// 尝试读取请求体(这会触发 chunked 解析)
byte[] body = new byte[8192];
int bytesRead = request.getInputStream().read(body);
System.out.println("请求体读取字节数: " + bytesRead);
response.setStatus(200);
response.setContentType("text/plain");
response.getWriter().write("OK");
baseRequest.setHandled(true);
}
});
server.start();
System.out.println("Jetty 服务器已启动,监听 8080 端口");
server.join();
}
}
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
/**
* 客户端:发送故意构造的不完整 chunked 请求
* 技术栈:Java + Socket(手动构造 HTTP 协议)
*/
public class ChunkedAttackClient {
public static void main(String[] args) throws Exception {
Socket socket = new Socket("localhost", 8080);
OutputStream out = socket.getOutputStream();
// 场景1:声明 chunk 大小为 100,但实际只发送 5 个字节的数据
StringBuilder request = new StringBuilder();
request.append("POST /api/upload HTTP/1.1\r\n");
request.append("Host: localhost:8080\r\n");
request.append("Transfer-Encoding: chunked\r\n");
request.append("\r\n");
// 声明有 100 字节的数据(0x64 = 100)
request.append("64\r\n");
// 但实际只发送 "hello" 这 5 个字节
request.append("hello");
// 发送构造好的异常请求
byte[] requestData = request.toString().getBytes(StandardCharsets.ISO_8859_1);
out.write(requestData);
out.flush();
System.out.println("已发送恶意 chunked 请求,等待服务器响应...");
// 等待一段时间观察服务器日志
Thread.sleep(3000);
out.close();
socket.close();
System.out.println("请求发送完毕");
}
}
3.2 异常触发条件分析
通过上述复现代码,我们可以总结出触发 BufferUnderflowException 的几个关键条件:
第一,请求必须使用 chunked 传输编码,并且 header 中必须包含 Transfer-Encoding: chunked。
第二,chunked 头部声明的数据大小必须大于实际传输的数据大小。这种不一致可能由多种原因造成,包括网络中间设备的修改、客户端实现 bug、或故意的异常注入。
第三,Jetty 服务器端必须配置为允许接收 chunked 编码的请求体,并且没有额外的请求大小限制来提前拦截异常请求。
四、绕过手段与解决方案
4.1 服务端防护方案
了解缺陷的根因之后,我们可以从多个层面来保护 Jetty 服务器免受此类异常的影响。
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.handler.AbstractHandler;
import org.eclipse.jetty.server.handler.HandlerWrapper;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 服务端防护:添加全局异常处理器
* 技术栈:Java + Jetty 9.4.x
*/
public class JettyProtectionServer {
// 自定义 Handler 包装器,用于捕获 chunked 解析异常
static class ProtectionHandler extends HandlerWrapper {
@Override
public void handle(String target, org.eclipse.jetty.server.Request baseRequest,
HttpServletRequest request, HttpServletResponse response)
throws IOException, ServletException {
try {
// 委托给下一个 handler 处理
super.handle(target, baseRequest, request, response);
} catch (Exception e) {
// 捕获所有异常,防止服务器崩溃
System.err.println("请求处理异常: " + e.getClass().getSimpleName());
System.err.println("异常详情: " + e.getMessage());
// 如果是 BufferUnderflowException,记录安全日志
if (e instanceof java.nio.BufferUnderflowException) {
System.err.println("安全告警:检测到异常的 chunked 请求体");
System.err.println("来源 IP: " + request.getRemoteAddr());
System.err.println("请求路径: " + target);
// 返回 400 状态码,告知客户端请求格式错误
response.setStatus(400);
response.setContentType("application/json");
response.getWriter().write("{\"error\": \"Invalid request body\"}");
}
baseRequest.setHandled(true);
}
}
}
public static void main(String[] args) throws Exception {
Server server = new Server(8080);
// 使用防护 Handler 包装实际的业务 Handler
ProtectionHandler protectionHandler = new ProtectionHandler();
protectionHandler.setHandler(new AbstractHandler() {
@Override
public void handle(String target, org.eclipse.jetty.server.Request baseRequest,
HttpServletRequest request, HttpServletResponse response)
throws IOException, ServletException {
// 正常业务逻辑
response.setStatus(200);
response.getWriter().write("Hello from protected server");
baseRequest.setHandled(true);
}
});
server.setHandler(protectionHandler);
server.start();
System.out.println("带防护的 Jetty 服务器已启动");
server.join();
}
}
4.2 使用 Jetty 内置的约束机制
Jetty 提供了内置的请求大小限制机制,我们可以在服务器启动时配置这些参数,从根本上避免过大的或异常的请求体被处理。
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.ServerConnector;
import org.eclipse.jetty.server.handler.DefaultHandler;
import org.eclipse.jetty.server.handler.HandlerList;
import org.eclipse.jetty.server.handler.ResourceHandler;
import java.io.File;
/**
* 使用 Jetty 内置约束保护服务器
* 技术栈:Java + Jetty 9.4.x
*/
public class JettyConfiguredServer {
public static void main(String[] args) throws Exception {
Server server = new Server();
// 创建连接器并配置约束参数
ServerConnector connector = new ServerConnector(server);
connector.setPort(8080);
// 限制单个请求的头部大小(默认 8192 字节)
connector.setRequestHeaderSize(8192);
// 限制请求体大小(以字节为单位)
connector.setRequestBufferSize(4096);
server.addConnector(connector);
// 配置静态资源处理器
ResourceHandler resourceHandler = new ResourceHandler();
resourceHandler.setDirectoriesListed(true);
resourceHandler.setResourceBase(new File("webapp").getAbsolutePath());
HandlerList handlerList = new HandlerList();
handlerList.addHandler(resourceHandler);
handlerList.addHandler(new DefaultHandler());
server.setHandler(handlerList);
// 设置处理超时时间
server.setStopTimeout(30000);
server.start();
System.out.println("配置了约束的 Jetty 服务器已启动");
System.out.println("请求头部大小限制: 8192 bytes");
System.out.println("请求缓冲区大小: 4096 bytes");
server.join();
}
}
4.3 基于 Filter 的请求体校验方案
另一种更精细的控制方式是通过 Servlet Filter 在请求到达业务逻辑之前,对 chunked 请求体进行前置校验。
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.*;
import java.nio.BufferUnderflowException;
/**
* 自定义 Filter:在请求到达之前校验 chunked 请求体
* 技术栈:Java + Servlet API + Jetty
*/
public class ChunkedValidationFilter implements Filter {
// 允许的最大请求体大小(1MB)
private static final long MAX_BODY_SIZE = 1024 * 1024;
@Override
public void init(FilterConfig filterConfig) throws ServletException {
System.out.println("ChunkedValidationFilter 初始化完成");
}
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
// 只拦截带有 chunked 编码的请求
String transferEncoding = httpRequest.getHeader("Transfer-Encoding");
if ("chunked".equalsIgnoreCase(transferEncoding)) {
System.out.println("检测到 chunked 编码请求,开始校验...");
try {
// 读取请求体并验证完整性
validateRequestBody(httpRequest);
chain.doFilter(request, response);
} catch (BufferUnderflowException e) {
System.err.println("校验失败:请求体不完整");
HttpServletResponse httpResponse = (HttpServletResponse) response;
httpResponse.setStatus(400);
httpResponse.setContentType("application/json");
httpResponse.getWriter().write(
"{\"code\": 400, \"message\": \"Malformed chunked request body\"}"
);
} catch (IOException e) {
System.err.println("校验过程中发生 IO 异常: " + e.getMessage());
HttpServletResponse httpResponse = (HttpServletResponse) response;
httpResponse.setStatus(400);
httpResponse.setContentType("application/json");
httpResponse.getWriter().write(
"{\"code\": 400, \"message\": \"Request processing failed\"}"
);
}
return;
}
// 非 chunked 请求直接放行
chain.doFilter(request, response);
}
/**
* 验证请求体是否完整
*/
private void validateRequestBody(HttpServletRequest request) throws IOException {
InputStream inputStream = request.getInputStream();
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
byte[] temp = new byte[4096];
int bytesRead;
long totalBytes = 0;
while ((bytesRead = inputStream.read(temp)) != -1) {
totalBytes += bytesRead;
// 检查请求体是否超出限制
if (totalBytes > MAX_BODY_SIZE) {
throw new IOException("请求体过大,超过 " + MAX_BODY_SIZE + " 字节限制");
}
buffer.write(temp, 0, bytesRead);
}
System.out.println("请求体验证通过,总大小: " + totalBytes + " 字节");
}
@Override
public void destroy() {
System.out.println("ChunkedValidationFilter 已销毁");
}
}
五、应用场景分析
这个问题的应用场景主要集中在几个方面。首先是大型互联网公司的 API 网关场景,每天都有海量的请求涌入,其中不乏异常格式的请求体。如果网关基于 Jetty 构建,就需要特别关注 chunked 编码的解析安全性。
其次是微服务架构中的服务间通信。在复杂的调用链中,某个中间服务如果错误地修改了请求体但忘记更新 chunked 头部的大小声明,就会导致下游的 Jetty 服务抛出 BufferUnderflowException。
第三是物联网场景。IoT 设备通常使用较简单的网络库发送数据,这些库在实现 chunked 编码时可能不够规范,容易出现数据不完整的情况。作为 IoT 平台的核心接入层,Jetty 需要妥善应对这些异常。
第四是安全测试场景。安全工程师在进行渗透测试时,经常使用构造异常的 chunked 请求来探测服务器是否存在解析漏洞。了解这个缺陷的机制,有助于安全团队更好地评估系统的安全水位。
六、技术优缺点
从服务器端防护的角度来看,各种方案都有各自的优缺点。全局异常捕获方案的优点是实现简单,不需要改动业务代码,缺点是无法区分不同的异常类型,可能掩盖其他重要问题。内置约束方案的优点是配置方便,Jetty 原生支持,缺点是粒度较粗,可能影响正常的业务请求。基于 Filter 的方案优点是灵活性最高,可以实现精细的校验逻辑,缺点是需要额外的代码维护成本。
从客户端角度来看,使用规范的标准库发送 chunked 请求可以避免很多问题,但标准库可能在某些边界情况下仍会产生不规范的输出。手动构造请求在测试场景下非常有用,但在生产环境中应该尽量避免。
七、注意事项
在处理这类问题时,有几个重要的注意事项需要牢记。第一,不要在生产环境中直接禁用 chunked 编码支持,因为许多合法的流式上传场景依赖这种编码方式。第二,添加防护逻辑后,务必进行充分的性能测试,确保新增的校验步骤不会成为性能瓶颈。第三,日志记录非常重要,应该对每次异常 chunked 请求都进行详细记录,包括来源 IP、请求路径、异常类型等信息,以便后续分析和溯源。
第四,注意 Jetty 版本的升级。Jetty 团队在后续版本中对 chunked 解析进行了多次修复和优化,升级到较新的稳定版本可能直接解决这个问题。第五,如果使用了反向代理(如 Nginx),需要在代理层也配置相应的请求大小限制,形成多层防御。
第六,在进行安全加固时,要确保不会误伤正常的业务流量。建议在 staging 环境充分测试后再推送到生产环境,并且上线后密切关注错误日志的变化。
八、文章总结
BufferUnderflowException 在 Jetty 中的出现,本质上是一个输入验证不充分的问题。客户端发送的 chunked 请求体中声明的数据大小与实际传输的数据不匹配,而 Jetty 在尝试读取声明大小的数据时发现了缓冲区中数据不足,从而抛出了这个异常。
解决这个问题需要从多个层面入手。从代码层面,可以添加全局异常处理器来防止服务崩溃;从配置层面,可以设置请求大小限制来提前拦截异常请求;从架构层面,可以在反向代理等中间设备层添加额外的防护。三者结合,可以构建一个比较完整的防御体系。
同时,保持对 Jetty 版本的关注和及时升级也是重要的防护措施。Jetty 团队在持续修复已知的解析缺陷,新版本通常具有更好的健壮性和安全性。
在实际工作中,遇到日志中频繁出现这个异常时,建议首先确认是否确实存在异常请求来源(可能是网络问题、客户端 bug 或安全攻击),然后根据具体场景选择合适的防护方案,最后通过监控和日志持续观察防护效果。
评论
围绕“日志中频繁出现BufferUnderflowException,Jetty对chunked请求体解析缺陷与绕过手段”参与讨论