Bug 65340

Summary: Hpack decode NegativeArraySizeException: -1
Product: Tomcat 9 Reporter: linking12 <297442500>
Component: ServletAssignee: Tomcat Developers Mailing List <dev>
Status: RESOLVED FIXED    
Severity: critical    
Priority: P1    
Version: 9.0.45   
Target Milestone: -----   
Hardware: PC   
OS: Mac OS X 10.1   

Description linking12 2021-05-28 12:51:27 UTC
Jetty version
9.4.41.v20210516
Java version
JDK1.8
Tomcat version
9.0.45

Question
1: when jetty cllient hpack index all of http2 header,found tomcat will throw some exception like this:

An exception has been raised by Name:http-nio-9315-exec-159,Id:440 java.lang.RuntimeException: java.lang.NegativeArraySizeException: -1
 at org.apache.coyote.http2.HpackDecoder.decode(HpackDecoder.java:130)
 at org.apache.coyote.http2.Http2Parser.readHeaderPayload(Http2Parser.java:486)
 at org.apache.coyote.http2.Http2Parser.readHeadersFrame(Http2Parser.java:270)
 at org.apache.coyote.http2.Http2AsyncParser$FrameCompletionHandler.completed(Http2AsyncParser.java:251)
 at org.apache.coyote.http2.Http2AsyncParser$FrameCompletionHandler.completed(Http2AsyncParser.java:164)
 at org.apache.tomcat.util.net.SocketWrapperBase$VectoredIOCompletionHandler.completed(SocketWrapperBase.java:1089)
 at org.apache.tomcat.util.net.NioEndpoint$NioSocketWrapper$NioOperationState.run(NioEndpoint.java:1621)
 at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1128)
 at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:628)
 at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
 at java.base/java.lang.Thread.run(Thread.java:829)
Caused by: java.lang.NegativeArraySizeException: -1
 at java.base/java.lang.AbstractStringBuilder.<init>(AbstractStringBuilder.java:86)
 at java.base/java.lang.StringBuilder.<init>(StringBuilder.java:112)
 at org.apache.coyote.http2.HpackDecoder.readHuffmanString(HpackDecoder.java:261)
 at org.apache.coyote.http2.HpackDecoder.readHpackString(HpackDecoder.java:251)
 at org.apache.coyote.http2.HpackDecoder.decode(HpackDecoder.java:126)
 ... 10 more

2: so replace tomcat HpackDecoder code in project class to catch error like this:

 void decode(ByteBuffer buffer) throws HpackException {
        while (buffer.hasRemaining()) {
            int originalPos = buffer.position();
            byte b = buffer.get();
            if ((b & 0b10000000) != 0) {
                //if the first bit is set it is an indexed header field
                buffer.position(buffer.position() - 1); //unget the byte
                int index = Hpack.decodeInteger(buffer, 7); //prefix is 7
                if (index == -1) {
                    buffer.position(originalPos);
                    return;
                } else if(index == 0) {
                    throw new HpackException(
                            sm.getString("hpackdecoder.zeroNotValidHeaderTableIndex"));
                }
                handleIndex(index);
            } else if ((b & 0b01000000) != 0) {
                //Literal Header Field with Incremental Indexing
                String headerName = readHeaderName(buffer, 6);
                if (headerName == null) {
                    buffer.position(originalPos);
                    return;
                }
                String headerValue = null;
                ByteBuffer bufferReplica = buffer.duplicate();
                try {
                    headerValue = readHpackString(buffer);
                } catch (Throwable e) {
                    CharBuffer originalValue = StandardCharsets.UTF_8.decode(bufferReplica);
                    // **log decode error header** 
                    log.warn("hpack decode error, headerName: {}, value: {}", headerName, originalValue);
                    throw new RuntimeException(e);
                }
                if (headerValue == null) {
                    buffer.position(originalPos);
                    return;
                }
                emitHeader(headerName, headerValue);
                addEntryToHeaderTable(new Hpack.HeaderField(headerName, headerValue));
            } else if ((b & 0b11110000) == 0) {
                //Literal Header Field without Indexing
                String headerName = readHeaderName(buffer, 4);
                if (headerName == null) {
                    buffer.position(originalPos);
                    return;
                }
                String headerValue = null;
                ByteBuffer bufferReplica = buffer.duplicate();
                try {
                    headerValue = readHpackString(buffer);
                } catch (Throwable e) {
                    CharBuffer originalValue = StandardCharsets.UTF_8.decode(bufferReplica);
                    **log.warn("hpack decode error, headerName: {}, value: {}", headerName, originalValue);**
                    throw new RuntimeException(e);
                }
                if (headerValue == null) {
                    buffer.position(originalPos);
                    return;
                }
                emitHeader(headerName, headerValue);
            } else if ((b & 0b11110000) == 0b00010000) {
                //Literal Header Field never indexed
                String headerName = readHeaderName(buffer, 4);
                if (headerName == null) {
                    buffer.position(originalPos);
                    return;
                }
                String headerValue = null;
                ByteBuffer bufferReplica = buffer.duplicate();
                try {
                    headerValue = readHpackString(buffer);
                } catch (Throwable e) {
                    CharBuffer originalValue = StandardCharsets.UTF_8.decode(bufferReplica);
                    log.warn("hpack decode error, headerName: {}, value: {}", headerName, originalValue);
                    throw new RuntimeException(e);
                }
                if (headerValue == null) {
                    buffer.position(originalPos);
                    return;
                }
                emitHeader(headerName, headerValue);
            } else if ((b & 0b11100000) == 0b00100000) {
                //context update max table size change
                if (!handleMaxMemorySizeChange(buffer, originalPos)) {
                    return;
                }
            } else {
                throw new RuntimeException(sm.getString("hpackdecoder.notImplemented"));
            }
        }
    }



https://github.com/eclipse/jetty.project/issues/6333
Comment 1 linking12 2021-05-28 12:53:30 UTC
3: hpack decode error, headerName: device-info, value: � 
   device-info is a base64 string
Comment 2 Thomas 2021-05-28 13:04:57 UTC
The original exception stack trace is as below in Tomcat source:

java.lang.NegativeArraySizeException: -1
	at java.base/java.lang.AbstractStringBuilder.<init>(AbstractStringBuilder.java:86)
	at java.base/java.lang.StringBuilder.<init>(StringBuilder.java:112)
	at org.apache.coyote.http2.HpackDecoder.readHuffmanString(HpackDecoder.java:231)
	at org.apache.coyote.http2.HpackDecoder.readHpackString(HpackDecoder.java:221)
	at org.apache.coyote.http2.HpackDecoder.decode(HpackDecoder.java:131)
	at org.apache.coyote.http2.Http2Parser.readHeaderPayload(Http2Parser.java:486)
	at org.apache.coyote.http2.Http2Parser.readHeadersFrame(Http2Parser.java:270)
	at org.apache.coyote.http2.Http2AsyncParser$FrameCompletionHandler.completed(Http2AsyncParser.java:251)
	at org.apache.coyote.http2.Http2AsyncParser$FrameCompletionHandler.completed(Http2AsyncParser.java:164)
	at org.apache.tomcat.util.net.SocketWrapperBase$VectoredIOCompletionHandler.completed(SocketWrapperBase.java:1089)
	at org.apache.tomcat.util.net.NioEndpoint$NioSocketWrapper$NioOperationState.run(NioEndpoint.java:1621)
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1128)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:628)
	at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
	at java.base/java.lang.Thread.run(Thread.java:829)
Comment 3 Mark Thomas 2021-05-28 13:55:51 UTC
I'd like to know if this is a Tomcat decoding bug or a Jetty encoding bug. Please re-create the issue over h2c and provide a tcpdump of the failed request. Until there is evidence this is a Tomcat bug, I am marking this as an enhancement.

Additional debug logging is always useful. Proposed changes should be provided in diff -u format.

The proposed change is hard to read because it isn't provided as a patch but I note the following:
- patch should use Tomcat's logging framework
- patch should use Tomcat's StringManager
- no need to wrap caught exception - just catch RuntimeException and re-throw
- I'm not sure (I'd need to check the HPACK spec) if conversion to string is appropriate in all cases. It may be better to log the bytes (See HexUtils.toHexString(byte[])
Comment 4 linking12 2021-05-28 14:19:16 UTC
this is issue happen on prod env, It diffcult to reproduce.
some request is ok, but some request is error;
it is very difficult to reproduce, and tcpdump also difficult.

the purpose of submitting this question is want to get some help for tomcat dev team, some suggestion is fine 😄
Comment 5 gregw@webtide.com 2021-05-29 23:08:08 UTC
Mark,  I can't tell either if this is Jetty encoding or Tomcat decoding.

If you want to write a test to do some jetty encodes and tomcate decodes, then if you have a maven dependency on org.eclipse.jetty.http2:http2-hpack:jar
the following code shows how to do jetty encoding/decoding:

    @Test
    public void encodeDecodeTest() throws Exception
    {
        HpackEncoder encoder = new HpackEncoder();
        HpackDecoder decoder = new HpackDecoder(4096, 8192);

        HttpFields fields = new HttpFields();
        fields.add(HttpHeader.CONTENT_TYPE, "text/html");
        fields.add(HttpHeader.CONTENT_LENGTH, "1024");

        MetaData.Request request = new MetaData.Request("POST", new HttpURI("/test"), HttpVersion.HTTP_2, fields);

        ByteBuffer buffer = BufferUtil.allocateDirect(16 * 1024);
        BufferUtil.clearToFill(buffer);
        encoder.encode(buffer, request);
        BufferUtil.flipToFlush(buffer, 0);

        MetaData.Request requestReceived = (MetaData.Request)decoder.decode(buffer);

        System.err.println(requestReceived);
        requestReceived.getFields().stream().forEach(System.err::println);

        MetaData.Response response = new MetaData.Response(HttpVersion.HTTP_2, 200, fields);

        BufferUtil.clearToFill(buffer);
        encoder.encode(buffer, response);
        BufferUtil.flipToFlush(buffer, 0);

        MetaData.Response responseReceived = (Response)decoder.decode(buffer);

        System.err.println(responseReceived);
        responseReceived.getFields().stream().forEach(System.err::println);
    }
Comment 6 Thomas 2021-05-31 16:29:47 UTC
I found some information. Can you give me some answers?
1. If my header size is very big. Its length is bigger than 1024 after huffman encoding, the header will not be got.
2. The value of the variable "length" is -1 in org.apache.coyote.http2.HpackDecoder#readHpackString, why the following logic don't process it?
Comment 7 Thomas 2021-05-31 16:55:29 UTC
(In reply to Thomas from comment #6)
> I found some information. Can you give me some answers?
> 1. If my header size is very big. Its length is bigger than 1024 after
> huffman encoding, the header will not be got.  The value of headerReadBuffer.remaining() is not 0, when the header is not the first one.
    protected void readHeaderPayload(int streamId, int payloadSize, ByteBuffer buffer)
            throws Http2Exception, IOException {

        if (log.isDebugEnabled()) {
            log.debug(sm.getString("http2Parser.processFrameHeaders.payload", connectionId,
                    Integer.valueOf(streamId), Integer.valueOf(payloadSize)));
        }

        int remaining = payloadSize;

        while (remaining > 0) {
            if (headerReadBuffer.remaining() == 0) {
                // Buffer needs expansion
                int newSize;
                if (headerReadBuffer.capacity() < payloadSize) {
                    // First step, expand to the current payload. That should
                    // cover most cases.
                    newSize = payloadSize;
                } else {
                    // Header must be spread over multiple frames. Keep doubling
                    // buffer size until the header can be read.
                    newSize = headerReadBuffer.capacity() * 2;
                }
                headerReadBuffer = ByteBufferUtils.expand(headerReadBuffer, newSize);
            }
            int toRead = Math.min(headerReadBuffer.remaining(), remaining);
            // headerReadBuffer in write mode
            if (buffer == null) {
                input.fill(true, headerReadBuffer, toRead);
            } else {
                int oldLimit = buffer.limit();
                buffer.limit(buffer.position() + toRead);
                headerReadBuffer.put(buffer);
                buffer.limit(oldLimit);
            }
            // switch to read mode
            headerReadBuffer.flip();
            try {
                hpackDecoder.decode(headerReadBuffer);
            } catch (HpackException hpe) {
                throw new ConnectionException(
                        sm.getString("http2Parser.processFrameHeaders.decodingFailed"),
                        Http2Error.COMPRESSION_ERROR, hpe);
            }

            // switches to write mode
            headerReadBuffer.compact();
            remaining -= toRead;

            if (hpackDecoder.isHeaderCountExceeded()) {
                StreamException headerException = new StreamException(sm.getString(
                        "http2Parser.headerLimitCount", connectionId, Integer.valueOf(streamId)),
                        Http2Error.ENHANCE_YOUR_CALM, streamId);
                hpackDecoder.getHeaderEmitter().setHeaderException(headerException);
            }

            if (hpackDecoder.isHeaderSizeExceeded(headerReadBuffer.position())) {
                StreamException headerException = new StreamException(sm.getString(
                        "http2Parser.headerLimitSize", connectionId, Integer.valueOf(streamId)),
                        Http2Error.ENHANCE_YOUR_CALM, streamId);
                hpackDecoder.getHeaderEmitter().setHeaderException(headerException);
            }

            if (hpackDecoder.isHeaderSwallowSizeExceeded(headerReadBuffer.position())) {
                throw new ConnectionException(sm.getString("http2Parser.headerLimitSize",
                        connectionId, Integer.valueOf(streamId)), Http2Error.ENHANCE_YOUR_CALM);
            }
        }
    }

> 2. The value of the variable "length" is -1 in
> org.apache.coyote.http2.HpackDecoder#readHpackString, why the following
> logic don't process it?

    private String readHpackString(ByteBuffer buffer) throws HpackException {
        if (!buffer.hasRemaining()) {
            return null;
        }
        byte data = buffer.get(buffer.position());

        int length = Hpack.decodeInteger(buffer, 7);
        if (buffer.remaining() < length) {
            return null;
        }
        boolean huffman = (data & 0b10000000) != 0;
        if (huffman) {
            return readHuffmanString(length, buffer);
        }
        StringBuilder stringBuilder = new StringBuilder(length);
        for (int i = 0; i < length; ++i) {
            stringBuilder.append((char) buffer.get());
        }
        return stringBuilder.toString();
    }
Comment 8 linking12 2021-05-31 17:03:35 UTC
(In reply to gregw@webtide.com from comment #5)
> Mark,  I can't tell either if this is Jetty encoding or Tomcat decoding.
> 
> If you want to write a test to do some jetty encodes and tomcate decodes,
> then if you have a maven dependency on
> org.eclipse.jetty.http2:http2-hpack:jar
> the following code shows how to do jetty encoding/decoding:
> 
>     @Test
>     public void encodeDecodeTest() throws Exception
>     {
>         HpackEncoder encoder = new HpackEncoder();
>         HpackDecoder decoder = new HpackDecoder(4096, 8192);
> 
>         HttpFields fields = new HttpFields();
>         fields.add(HttpHeader.CONTENT_TYPE, "text/html");
>         fields.add(HttpHeader.CONTENT_LENGTH, "1024");
> 
>         MetaData.Request request = new MetaData.Request("POST", new
> HttpURI("/test"), HttpVersion.HTTP_2, fields);
> 
>         ByteBuffer buffer = BufferUtil.allocateDirect(16 * 1024);
>         BufferUtil.clearToFill(buffer);
>         encoder.encode(buffer, request);
>         BufferUtil.flipToFlush(buffer, 0);
> 
>         MetaData.Request requestReceived =
> (MetaData.Request)decoder.decode(buffer);
> 
>         System.err.println(requestReceived);
>         requestReceived.getFields().stream().forEach(System.err::println);
> 
>         MetaData.Response response = new
> MetaData.Response(HttpVersion.HTTP_2, 200, fields);
> 
>         BufferUtil.clearToFill(buffer);
>         encoder.encode(buffer, response);
>         BufferUtil.flipToFlush(buffer, 0);
> 
>         MetaData.Response responseReceived =
> (Response)decoder.decode(buffer);
> 
>         System.err.println(responseReceived);
>         responseReceived.getFields().stream().forEach(System.err::println);
>     }


I have test your code, It is ok

but we do something,and found more information,when the header is larger, it appear more frequently;

we have done something:

1: when we not index and not hufman header in jetty and the header is larger than 1k, tomcat can not processs the header;

2: as thomas's reply, in javadoc in org.apache.coyote.http2.Hpack.decodeInteger(ByteBuffer, int), it can return -1, why tomcat do not process -1 to throw some more detail exception, i think it is not make sense
Comment 9 linking12 2021-06-01 02:54:13 UTC
public class Constants {
    static final int DEFAULT_HEADER_READ_BUFFER_SIZE = 1024;
}


we found some bug for DEFAULT_HEADER_READ_BUFFER_SIZE, when one header is larger than 1024, the headerReadBuffer can not expand;

i confirm jetty encode right; we confirm from this order:
1: disable hpack index in jetty and Huffman, force header encode by Ascii
2: force header larger than 1k
3: debug tomcat decode and found can not process the header(larger than 1k)
Comment 10 Thomas 2021-06-03 06:39:15 UTC
I have reproduced the issue, please see the detail as below.
Reason description:
When the length of one header value is greater than 127 and its first prefix byte is the last one in org.apache.coyote.http2.Http2Parser#headerReadBuffer, then the return value of org.apache.coyote.http2.Hpack#decodeInteger will be -1. And then the exception "java.lang.NegativeArraySizeException: -1" is thrown in org.apache.coyote.http2.HpackDecoder#readHpackString, because the length is -1.

Reproduce steps:
example project: https://github.com/qingdaoheze/tomcat-hpack-error-reproduce
1.Start up the tomcat server through tomcat-server-gzip/src/main/java/org/sample/http2/tomcat/TomcatHttp2Main.java
2.Run the code in jetty9-client-sample/src/main/java/org/sample/jetty/client/http2/HttpClientWithHttp2Transport.java
3.The exception java.lang.NegativeArraySizeException will be thrown.

the hex string of the byte array in org.apache.coyote.http2.Http2Parser#headerReadBuffer is:
40fffb068e46638e566469c91d68653c9e2495a1b3196312c3090be0b2a249505f1297c224951297c4a89252f897c1632b4c8da7e575f68fda4eb28e4ae5e883492a28eb24bd11d68925e88925107a78f03eb47493ad08e92d1d51d3ad7e0a7322497a76acd24bd04fb3274349d251d6535bd2c0acf099b0b28fec6892529fc19124b251d472c8897a483e9594de25133d1e7e57ad7de174968474944fb32f413f5cbd04cbef4bd052f447495f132f7495fe8ff443fd257c4cf4947d24e80133d2467a4bd0e6376f1d257fa044fb2f3dd257de643d1f97a3e1e92859ec6f12747c2f0a4e881c87ede0dd2590de0fd25ec63f494e6ede0c7e92f63f829ba4a7f06ec7e92f63f837494fd8de2539bb7831e5ec63f494d234e48eb4d3258c4309232b4c8da7e575f68fda4eb28e4ae5e883492a28eb24bd11d68925e88925e83eb47493ad08e92d1d51d3ad7e0a7322497a76acd24bd04fb3274349d251d6535bd2c0acf099b0b28fec6892529fc19124b251d472c8897a483e9594de25133d1e7e57ad7de174968474944fb32f413f5cbd04cbef4bd052f447495f132f7495fe8ff443fd257c4cf4947d24e80133d2467a4bd0e6376f1d257fa044fb2f3dd257de643d1f97a3e1e92859ec6f12747c2f0a4e881c87ede0dd2590de0fd25ec63f494e6ede0c7e92f63f829ba4a7f06ec7e92f63f837494fd8de2539bb7831e5ec63f494d234e48eb4d3258c4309232b4c8da7e575f68fda4eb28e4ae5e883492a28eb24bd11d68925e88925e83eb47493ad08e92d1d51d3ad7e0a7322497a76acd24bd04fb3274349d251d6535bd2c0acf099b0b28fec6892529fc19124b251d472c8897a483e9594de25133d1e7e57ad7de174968474944fb32f413f5cbd04cbef4bd052f447495f132f7495fe8ff443fd257c4cf4947d24e80133d2467a4bd0e6376f1d257fa044fb2f3dd257de643d1f97a3e1e92859ec6f12747c2f0a4e881c87ede0dd2590de0fd25ec63f494e6ede0c7e92f63f829ba4a7f06ec7e92f63f837494fd8de2539bb7831e5ec63f494c7ede3a044fb3d25e881f4394dd3c4a7376e9298fd1979f0cbf63e9257d2514af00afbcc87a3f2f47c3d250b3d8de24e8f85e149d10390fdbc1ba4b21bc1fa4bd8c7e929cddbc18fd25ec7f0537494fe0dd8fd25ec7f06e929fb1bc4a7376f063cbd8c7e9298fdbc74089f67a4bd103e8729ba7894e6edd2531fa32f3e197ec7d24afa4a295e015f7990f47e5e8f87a4a167b1bc49d1f0bc293a20721fb783749643783f497b18fd2539bb7831fa4bd8fe0a6e929fc1bb1fa4bd8fe0dd253f637894e6ede0c797b18fd2531fb78e8113ecf497a207d0e5374f129cddba4a63f465e7c32fd8fa495f49452bc02bef321e8fcbd1f0f4942cf637893a3e178527440e43f6f06e92c86f07e92f631fa4a7fffd

Suggested solution(modify org.apache.coyote.http2.HpackDecoder#readHpackString):
    private String readHpackString(ByteBuffer buffer) throws HpackException {
        if (!buffer.hasRemaining()) {
            return null;
        }
        byte data = buffer.get(buffer.position());

        int length = Hpack.decodeInteger(buffer, 7);
        if (buffer.remaining() < length || length == -1) {
            return null;
        }
        boolean huffman = (data & 0b10000000) != 0;
        if (huffman) {
            return readHuffmanString(length, buffer);
        }
        StringBuilder stringBuilder = new StringBuilder(length);
        for (int i = 0; i < length; ++i) {
            stringBuilder.append((char) buffer.get());
        }
        return stringBuilder.toString();
    }
the changed content is adding " || length == -1" in the following line.
if (buffer.remaining() < length || length == -1)


Summary, I suggest that:
1.Fix the bug using the alike solution mentioned above.
2.Expand the default size of org.apache.coyote.http2.Http2Parser#headerReadBuffer, for example, maxHeaderSize of the server.

@Mark Thomas
Comment 11 Remy Maucherat 2021-06-03 08:47:42 UTC
This is correct, the original code has the fix now. I'll do that.
Comment 12 Remy Maucherat 2021-06-03 09:08:06 UTC
The fix will be in 10.0.7, 9.0.47 and 8.5.67. Thanks.
Comment 13 Joakim Erdfelt 2021-06-03 13:01:35 UTC
Does this HPACK fix also address the HPACK index issue reported (at the Jetty issue tracker) against Tomcat?

https://github.com/eclipse/jetty.project/issues/6341
Comment 14 Thomas 2021-06-09 07:33:59 UTC
I think they are not the same issue. The issue you mentioned above is also submitted by me. The same time, I had submitted the same one in tomcat as follow:
https://bz.apache.org/bugzilla/show_bug.cgi?id=65350

Please help address it!