一、问题背景与现象

在实际生产环境中,运维团队经常会在服务端日志中发现一类让人头疼的异常: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 或安全攻击),然后根据具体场景选择合适的防护方案,最后通过监控和日志持续观察防护效果。