A malformed message can escape normal error handling
Wire’s protobuf decoder contains a signed 32-bit integer overflow that lets a small malformed message bypass a length check and throw an unexpected runtime exception, disrupting application request handling. Protobuf is a binary format for structured data organized into messages; Wire decodes those messages into application objects. The directly observed path is ProtoAdapter.decode(ByteArray) on the Java Virtual Machine (JVM), which reaches Wire’s ByteArrayProtoReader32 reader.
Remote exposure requires an application to accept attacker-controlled bytes over a network and pass them to the affected decoder. Wire is a library; the application supplies any HTTP endpoint and authentication policy. An attacker needs to pass authentication if that input is protected. No user interaction is required once the attacker can submit the bytes.
How a positive length becomes a negative boundary
A protobuf message contains numbered fields. A length-delimited field, such as a string, declares how many bytes its value occupies. The reader should reject a declared value that extends past its containing message. In Wire 6.4.1, pos tracks the current byte position and limit marks the message’s end; both use signed 32-bit Int values. In the upstream source below, internalNextLengthDelimited() rejects negative lengths but adds a nonnegative length to pos before checking whether the result exceeds the enclosing boundary.
Wire 6.4.1 adds lengths before proving they fit
internal class ByteArrayProtoReader32( |
Excerpt from the recorded material.
For the recorded six-byte string input, the reader has consumed the field tag, which identifies the field and its encoding, and the encoded length when pos reaches 6. The declared length is 2147483647, the largest positive signed 32-bit integer. Adding 6 exceeds that integer range and wraps to -2147483643. The reader saves the enclosing boundary, 6, in pushedLimit, then accepts the negative result because limit > pushedLimit is false. This arithmetic explains the failure in the versioned source; it does not by itself establish how a host application handles the resulting exception.
The string path compounds the problem. beforeLengthDelimitedScalar() subtracts pos from the corrupted boundary, wrapping the byte count back to 2147483647, and restores the enclosing limit. readUtf8() adds that count to pos again, producing -2147483643. Its upper-bound check also passes because the negative position is below the restored limit. decodeToString() then receives a start index of 6 and a negative end index, causing IllegalArgumentException. This is an unchecked runtime exception: it falls outside the decoder’s declared IOException error path for rejecting malformed input. A handler catching only IOException will miss it.
Related malformed structures reach two other decoding paths. An unknown field is a field absent from the message definition used by the decoder; reading its bytes through ByteString.of can encounter the corrupted length. A protobuf group encloses a set of fields between start and end tags; skipping such a group can encounter the corrupted cursor. Separate Wire 6.4.1 observations of the full malformed inputs for these paths produced ArrayIndexOutOfBoundsException, an error caused by an invalid array index.
What the recorded requests demonstrate
The HTTP examples use a custom Java test wrapper that passes request bodies to Person.ADAPTER, a message adapter backed by Wire. The adapter knows the fields of the Person message type and turns encoded bytes into a Person object. The wrapper returns HTTP 400 for checked IOException failures, but explicitly terminates the JVM with status 1 for any other escaping Throwable, Java’s general type for exceptions and errors. The following excerpt shows this wrapper policy.
The test harness turns unchecked exceptions into JVM exit
static final String BANNER = |
Excerpt from the recorded material.
The explicit halt(1) accounts for process termination in these examples. Wire itself does not supply this endpoint or require this termination policy. The wrapper also hardcodes its version banner, so that banner cannot establish which runtime is loaded.
With Wire 6.4.1 and its required Okio and Kotlin libraries available to the wrapper, the recorded controls submit a valid Person message and a truncated string. The truncated field declares five bytes but supplies only three. The transcripts below preserve historical interactions: <TARGET> and <PATH> substitute for the test host and local paths, and <path> in the wrapper banner denotes a request path. The commands depend on that custom test setup.
Valid and truncated inputs establish normal behavior
printf '\x0a\x05hello\x10\x2a' | curl -s -m 5 --data-binary @- http://<TARGET>:8080/decode; echo "code=$?"; echo "---truncated benign---"; printf '\x0a\x05hel' | curl -s -m 5 -w '\nHTTP=%{http_code}\n' --data-binary @- http://<TARGET>:8080/decode; curl -s -o /dev/null -w 'still-alive GET=%{http_code}\n' -m 5 http://<TARGET>:8080/ |
Excerpt; placeholders replace lab-specific values without changing code logic or results.
The valid input decodes to Person{name="hello", birthYear=42}. The truncated string produces checked EOFException, an IOException indicating that the expected data is unavailable, and HTTP 400; a subsequent GET returns 200. These controls establish normal decoding and ordinary malformed-input rejection in that wrapper; they do not test the overflowing length. The printed code=0 is the first curl command’s exit status.
A separate recorded interaction uses a fresh Wire 6.4.1 wrapper instance with the same termination policy. It submits the six-byte string field declaring length 2147483647. The exception indices and reader stack below connect the failed request to the overflowing calculation.
Six-byte input causes an unchecked decoding exception
printf '\x0a\xff\xff\xff\xff\x07' > <PATH>/poc.bin && xxd <PATH>/poc.bin && echo "--- POSTING EXPLOIT ---" && curl -s --max-time 8 -w "\nHTTP %{http_code} exit=%{exitcode}\n" --data-binary @<PATH>/poc.bin http://<TARGET>:8080/decode; echo "curl done rc=$?"; sleep 1; echo "--- server process state ---"; ps aux | grep -v grep | grep WirePocServer || echo "PROCESS DEAD (good)"; echo "--- server.log ---"; cat <PATH>/server.log |
Excerpt; placeholders replace lab-specific values without changing code logic or results.
Curl reports exit 52 and HTTP 000, meaning no HTTP response was received; 000 is not a server status code. The log records IllegalArgumentException with start index 6 and end index -2147483643, and the process check finds the wrapper dead. The library exception is directly observed, while process death follows the wrapper’s explicit halt policy. Other applications’ outcomes depend on their exception handling, and sustained unavailability also depends on recovery and restart behavior.
Fix the boundary calculation and upgrade the runtime
The declared affected versions are Wire releases below 6.4.5 and 7.0.0-alpha01 through 7.0.0-alpha03. Upgrade the runtime dependency to the fixed release for its line: 6.4.5 or 7.0.0-alpha04. The recorded runtime comparisons cover JVM version 6.4.1 and version 6.4.5 through byte-array decoding. Other affected releases, Swift, and alternative reader entry points rely on advisory scope and source or patch comparisons rather than those runtime observations.
The upstream fixing commit 25ebcab moves validation ahead of boundary assignment. Its checkedLimit(length) helper checks the requested count against remainingInLimit(), the bytes remaining inside the current message, before adding it to pos. The excerpt below is the fixing diff.
The fix checks remaining bytes before addition
diff --git a/wire-runtime/src/commonMain/kotlin/com/squareup/wire/ByteArrayProtoReader32.kt b/wire-runtime/src/commonMain/kotlin/com/squareup/wire/ByteArrayProtoReader32.kt |
Excerpt from the recorded material.
The helper throws checked EOFException when the count is negative or exceeds the remaining bytes. remainingInLimit() also rejects a cursor already beyond the boundary. Only a count that fits reaches the addition, preventing the negative boundary seen above. This patch establishes the validation order; the following runtime comparison demonstrates its effect for the primary string input.
In that comparison, the same Java wrapper and message adapter run with Wire 6.4.5 and their Okio and Kotlin dependencies. The comparison selects wire-runtime-jvm-6.4.5.jar as the library Java loads, establishing the tested version despite the wrapper’s unchanged banner. The recorded request sends the same six-byte input and checks the service afterward.
Wire 6.4.5 rejects the same input and remains available
mkdir -p <PATH>/jars-fixed && cd <PATH> && cp jars/okio-jvm-3.17.0.jar jars/kotlin-stdlib-2.0.21.jar jars-fixed/ && cp wire-runtime-jvm-6.4.5.jar jars-fixed/ && nohup java -cp 'jars-fixed/*' WirePocServer.java 8077 > fixed.log 2>&1 & sleep 3; echo "--- trigger on 6.4.5 ---"; printf '\x0a\xff\xff\xff\xff\x07' | curl -s -m 5 -w '\nHTTP=%{http_code}\n' --data-binary @- http://<TARGET>:8077/decode; sleep 1; curl -s -o /dev/null -w 'alive-after GET=%{http_code}\n' -m 5 http://<TARGET>:8077/; tail -2 <PATH>/fixed.log |
Excerpt; placeholders replace lab-specific values without changing code logic or results.
The decoder rejects the input with checked EOFException; the wrapper returns HTTP 400, and the subsequent GET returns 200. This demonstrates rejection of the primary overflow input while the wrapper remains responsive. It does not establish runtime results for the full unknown-field and skipped-group variants on 6.4.5. The upstream regression test ProtoReader32Test.lengthDelimitedRejectsPositiveLengthOverflow includes all three full cases and expects IOException, but no regression-suite pass is recorded.
Recognizing the failure in an application
Investigate unchecked IllegalArgumentException escaping ProtoAdapter.decode() when the stack includes ByteArrayProtoReader32.readString or readUtf8 and an invalid, especially negative, end index. Related indicators include ArrayIndexOutOfBoundsException during unknown-field reading or group skipping. Correlate these failures with malformed protobuf input and request or process failure to assess availability impact.
Checked IOException or EOFException alone does not demonstrate the availability failure: ordinary truncation on 6.4.1 and the primary overflow input on 6.4.5 both follow that rejection path. The advisory identifies availability impact; it does not identify confidentiality, integrity, or code-execution impact.