CVE-2026-11127
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
forcomponents/webapk/android/libs/client/src/org/chromium/components/webapk/lib/client/WebApkVerifySignature.java |
modified | |
ifcomponents/webapk/android/libs/client/src/org/chromium/components/webapk/lib/client/WebApkVerifySignature.java |
modified |
Files Changed
components/webapk/android/libs/client/src/org/chromium/components/webapk/lib/client/WebApkVerifySignature.javacomponents/webapk/android/libs/client/src/org/chromium/components/webapk/lib/client/WebApkVerifySignatureTest.java
Patch
From 3733bb9b463d701e71123a37538aedbb470c3079 Mon Sep 17 00:00:00 2001 From: Andrew Paseltiner <[email protected]> Date: Thu, 30 Apr 2026 09:34:22 -0700 Subject: [PATCH] WebAPK: Fix sign-extension bugs in ZIP parser This CL fixes sign-extension bugs in WebApkVerifySignature's read2() and read4() methods. These methods were incorrectly returning signed values, leading to incorrect offset calculations when the high bit was set. Key changes: - Updated read2() to return an unsigned 16-bit int (masked with 0xFFFF). - Split 4-byte reading into two methods: - read4Raw(): Returns a raw int for signatures and bit sequences. This is sufficient as standard ZIP signatures do not have the high bit set, and scanning (e.g. in findEOCDStart) remains robust. - read4InIntRange(): Returns a range-validated int for offsets and sizes. It throws IndexOutOfBoundsException if the value is negative (bit 31 is set), effectively enforcing the 2GB ByteBuffer limit. - Kept ZIP offsets and sizes as int (mCentralDirOffset, mEndOfCentralDirOffset, Block.mPosition, Block.mCompressedSize) since they are now validated upon reading. - Added regression tests for read2() and read4InIntRange(). Fixed: 501535295 Change-Id: I002f1d3c2fc18e55732fe14108496792f5e5b52b Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7801363 Commit-Queue: Andrew Paseltiner <[email protected]> Reviewed-by: Yaron Friedman <[email protected]> Cr-Commit-Position: refs/heads/main@{#1623279} --- diff --git a/components/webapk/android/libs/client/src/org/chromium/components/webapk/lib/client/WebApkVerifySignature.java b/components/webapk/android/libs/client/src/org/chromium/components/webapk/lib/client/WebApkVerifySignature.java index 52aa2e2..f5ea7eab1 100644 --- a/components/webapk/android/libs/client/src/org/chromium/components/webapk/lib/client/WebApkVerifySignature.java +++ b/components/webapk/android/libs/client/src/org/chromium/components/webapk/lib/client/WebApkVerifySignature.java @@ -9,6 +9,7 @@ import static java.nio.ByteOrder.LITTLE_ENDIAN; import androidx.annotation.IntDef; +import androidx.annotation.VisibleForTesting; import org.chromium.base.Log; import org.chromium.build.annotations.NullMarked; @@ -60,16 +61,16 @@ private static final String TAG = "WebApkVerifySignature"; /** End Of Central Directory Signature. */ - private static final long EOCD_SIG = 0x06054b50; + private static final int EOCD_SIG = 0x06054b50; /** Central Directory Signature. */ - private static final long CD_SIG = 0x02014b50; + private static final int CD_SIG = 0x02014b50; /** Local File Header Signature. */ - private static final long LFH_SIG = 0x04034b50; + private static final int LFH_SIG = 0x04034b50; /** Data descriptor Signature. */ - private static final long DATA_DESCRIPTOR_SIG = 0x08074b50; + private static final int DATA_DESCRIPTOR_SIG = 0x08074b50; /** Minimum end-of-central-directory size in bytes, including variable length file comment. */ private static final int MIN_EOCD_SIZE = 22; @@ -304,7 +305,7 @@ seek(start + 10); mRecordCount = read2(); // Number of Central Directory records seekDelta(4); // Size of central directory - mCentralDirOffset = read4(); // as bytes from start of file. + mCentralDirOffset = read4InIntRange(); // as bytes from start of file. int commentLength = read2(); mComment = readString(commentLength); if (mBuffer.position() < mBuffer.limit()) { @@ -324,7 +325,7 @@ mBlocks = new ArrayList<>(mRecordCount); seek(mCentralDirOffset); for (int i = 0; i < mRecordCount; i++) { - int signature = read4(); + int signature = read4Raw(); if (signature != CD_SIG) { Log.d(TAG, "Missing Central Directory Signature"); return Error.BAD_APK; @@ -332,13 +333,13 @@ // CreatorVersion(2), ReaderVersion(2), Flags(2), CompressionMethod(2) // ModifiedTime(2), ModifiedDate(2), CRC32(4) = 16 bytes seekDelta(16); - int compressedSize = read4(); + int compressedSize = read4InIntRange(); seekDelta(4); // uncompressed size int fileNameLength = read2(); int extraLen = read2(); int fileCommentLength = read2(); seekDelta(8); // DiskNumberStart(2), Internal Attrs(2), External Attrs(4) - int offset = read4(); + int offset = read4InIntRange(); String filename = readString(fileNameLength); seekDelta(extraLen + fileCommentLength); if (fileCommentLength > MAX_FILE_COMMENT_LENGTH) { @@ -363,7 +364,7 @@ } seek(block.mPosition); - int signature = read4(); + int signature = read4Raw(); if (signature != LFH_SIG) { Log.d(TAG, "LFH Signature missing"); return Error.BAD_APK; @@ -383,7 +384,7 @@ lastByte = block.mPosition + block.mHeaderSize + block.mCompressedSize; if ((flags & 0x8) != 0) { seek(lastByte); - if (read4() == DATA_DESCRIPTOR_SIG) { + if (read4Raw() == DATA_DESCRIPTOR_SIG) { // Data descriptor, style 1: sig(4), crc-32(4), compressed size(4), // uncompressed size(4) = 16 bytes lastByte += 16; @@ -422,7 +423,7 @@ int minSearchOffset = Math.max(0, offset - MAX_EOCD_SIZE); for (; offset >= minSearchOffset; offset--) { seek(offset); - if (read4() == EOCD_SIG) { + if (read4Raw() == EOCD_SIG) { // found! return offset; } @@ -453,8 +454,11 @@ * * @return short value read (as an int). */ - private int read2() { - return mBuffer.getShort(); + @VisibleForTesting + int read2() { + // Mask with 0xFFFF to treat the short as an unsigned 16-bit integer and avoid sign + // extension. + return mBuffer.getShort() & 0xFFFF; } /** @@ -462,10 +466,26 @@ * * @return value read. */ - private int read4() { + private int read4Raw() { return mBuffer.getInt(); } + /** + * Reads four bytes as an int. + * + * @return value read. + */ + @VisibleForTesting + int read4InIntRange() { + int val = read4Raw(); + if (val < 0) { + // Mask with 0xFFFFFFFFL to treat the int as an unsigned 32-bit integer for the error + // message. + throw new IndexOutOfBoundsException("32-bit value too large: " + (val & 0xFFFFFFFFL)); + } + return val; + } + /** Read {@link length} many bytes into a string. */ private String readString(int length) { if (length <= 0) { diff --git a/components/webapk/android/libs/client/src/org/chromium/components/webapk/lib/client/WebApkVerifySignatureTest.java b/components/webapk/android/libs/client/src/org/chromium/components/webapk/lib/client/WebApkVerifySignatureTest.java index dd38136..f9f715e 100644 --- a/components/webapk/android/libs/client/src/org/chromium/components/webapk/lib/client/WebApkVerifySignatureTest.java +++ b/components/webapk/android/libs/client/src/org/chromium/components/webapk/lib/client/WebApkVerifySignatureTest.java @@ -17,6 +17,8 @@ import org.chromium.testing.local.TestDir; import java.io.RandomAccessFile; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; import java.nio.MappedByteBuffer; import java.nio.channels.FileChannel; import java.nio.file.Files; @@ -43,6 +45,56 @@ } @Test + public void testRead2() { + byte[] data = { + (byte) 0x01, (byte) 0x00, // 1 + (byte) 0xFF, (byte) 0x7F, // 32767 + (byte) 0x00, (byte) 0x80, // 32768 + (byte) 0xFF, (byte) 0xFF // 65535 + }; + ByteBuffer buf = ByteBuffer.wrap(data); + buf.order(ByteOrder.LITTLE_ENDIAN); + WebApkVerifySignature v = new WebApkVerifySignature(buf); + + assertEquals(1, v.read2()); + assertEquals(32767, v.read2()); + assertEquals(32768, v.read2()); + assertEquals(65535, v.read2());
Original Bug Report
Potential WebAPK signature bypass via ZIP parser differential in WebApkVerifySignature
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team.
Overview: A sign-extension bug in Chrome’s WebApkVerifySignature ZIP parser causes 16-bit fields to be evaluated as negative values if the high bit is set. This creates a parser differential with Android’s libziparchive, potentially allowing an attacker to craft a WebAPK where Chrome hashes a legitimate payload while Android extracts a malicious one.
Affected files:
components/webapk/android/libs/client/src/org/chromium/components/webapk/lib/client/WebApkVerifySignature.java
Estimated timestamp from git blame: 2017-06-08
Root Cause
In components/webapk/android/libs/client/src/org/chromium/components/webapk/lib/client/WebApkVerifySignature.java, Chrome uses a custom ZIP parser to cryptographically hash WebAPK contents and verify their signatures. The parser uses a helper method, read2(), to read 16-bit fields from the ZIP structure:
private int read2() {
return mBuffer.getShort();
}
Java’s short primitive is a signed 16-bit integer. When read2() encounters a raw value of 0x8000 or greater, it is interpreted as a negative number. Because read2() returns an int, this negative short is sign-extended into a negative integer (e.g., 0xFFFF becomes -1). Standard ZIP 16-bit length fields are unsigned.
Impact and Exploitation Scenario
This flaw creates a severe parser differential between Chrome’s signature verification and Android’s OS-level APK extraction (which uses the C++ libziparchive and correctly treats these fields as unsigned). An attacker could exploit this discrepancy to bypass WebAPK signature validation.
Potential Attack Scenario: Note: This is a theoretical attack sequence based on static code analysis; a live proof-of-concept has not been executed.
- An attacker crafts a malicious APK acting as a WebAPK.
- In the Local File Header (LFH) for a critical file, the attacker sets the 16-bit
extraFieldLengthfield to0xFFFF. - When Chrome attempts to verify the WebAPK (
WebApkValidator.verifyCommentSignedWebApk), it parses the LFH. - Chrome reads
extraFieldLengthviaread2(), which returns-1. - Chrome calculates the data start offset using this negative value:
block.mHeaderSize = 30 + fileNameLength + (-1). - A standard unsigned parser (like Android’s
libziparchive) evaluates this as30 + fileNameLength + 65535. - This creates an exact
65536-byte discrepancy between where Chrome believes the file data begins and where Android believes it begins. - Chrome’s
calculateHash()seeks to its calculated (earlier) offset and hashes the subsequent data. If the attacker places a “legitimate” WebAPK payload at this offset, Chrome validates the signature successfully. - When Android installs the APK,
libziparchiveseeks to its calculated (later) offset and extracts the data. If the attacker places a “malicious” payload at this offset, Android installs the malicious code. - The resulting installed app gains WebAPK privileges for the spoofed origin (e.g., launching without a URL bar, native URL handling) despite containing unauthorized code.
Recommended Fix
Update the read2() and read4() helper methods in WebApkVerifySignature.java to explicitly mask the return values, ensuring they are evaluated as unsigned integers:
private int read2() {
return mBuffer.getShort() & 0xFFFF;
}
private long read4() {
return mBuffer.getInt() & 0xFFFFFFFFL;
}
(Note: If read4() is updated to return long, dependent variables and method signatures handling 32-bit ZIP fields must also be updated to long to prevent truncation.)
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.