Skip to content

CryptoAPIDecryptor: don't size the descriptor array from an untrusted count - #1347

Merged
pjfanning merged 1 commit into
apache:trunkfrom
pjfanning:cryptoapi-descriptor-count
Sep 22, 2026
Merged

pjfanning merged 1 commit into
apache:trunkfrom
pjfanning:cryptoapi-descriptor-count

Conversation

@pjfanning

@pjfanning pjfanning commented Sep 22, 2026

Copy link
Copy Markdown
Member

CryptoAPIDecryptor.getSummaryEntries reads the stream descriptor count as an unsigned 32-bit field and uses it directly as an array length, with no bound at all:

int encryptedStreamDescriptorCount = Math.toIntExact(leis.readUInt());
StreamDescriptorEntry[] entries = new StreamDescriptorEntry[encryptedStreamDescriptorCount];

A crafted encrypted stream can therefore ask for a billion-element StreamDescriptorEntry[] from a handful of input bytes, before any entry data is read.

What a real count looks like

The encrypted summary stream carries the property set streams (MS-OFFCRYPTO 2.3.5.4 RC4 CryptoAPI Encrypted Summary Stream), so a real file declares 2SummaryInformation and DocumentSummaryInformation. Instrumenting the sample in TestDocumentEncryption confirms it: 2 entries in 128 bytes.

Bound

Rather than pick a fixed cap — any number would be a guess, and a guess can reject a valid file — the bound is derived from the data. Every entry occupies at least 18 bytes (streamOffset 4 + streamSize 4 + block 2 + nameSize 1 + flags 1 + reserved2 4 + terminator 2), and the stream is already read fully into a byte[], so a declared count larger than the bytes following it is necessarily bogus:

final long encryptedStreamDescriptorCount = leis.readUInt();
final long remaining = summary.length - (streamDescriptorArrayOffset + 4L);
final long maxDescriptorCount = remaining / STREAM_DESCRIPTOR_MIN_SIZE;
if (encryptedStreamDescriptorCount > maxDescriptorCount) {
    throw new IOException("Declared stream descriptor count " + ... );
}

This cannot reject a well formed file however many streams it carries — the bytes have to be there either way.

The entries are then collected as they are parsed rather than pre-allocated, so memory stays proportional to the data actually present:

List<StreamDescriptorEntry> entries = new ArrayList<>();
for (long i = 0; i < encryptedStreamDescriptorCount; i++) { ... }

entries was only consumed by an enhanced-for loop, so a List drops in for the array.

Reading the encrypted stream moves into a small readEncryptedStream helper so the byte[] length is available for the bound. InputStream.available() is banned by forbiddenapis, and its message points at exactly this pattern — "use IOUtils.toByteArray to read the stream fully and then count the available bytes" — which is what the method already did.

Context

Found while sweeping Math.toIntExact call sites after #1345. It was the only genuinely unbounded allocation the sweep turned up; the separate HwmfBitmapDib cleanup is #1346.

Tests

:poi:test --tests 'org.apache.poi.poifs.crypt.*' and :poi-scratchpad:test --tests 'org.apache.poi.hslf.record.TestDocumentEncryption' pass (32 tests), and forbiddenApisMain / forbiddenApisTest are clean across poi, poi-scratchpad and poi-examples.

🤖 Generated with Claude Code

… count

getSummaryEntries reads the stream descriptor count as an unsigned 32-bit
field and used it directly as an array length, with no bound at all:

    int encryptedStreamDescriptorCount = Math.toIntExact(leis.readUInt());
    StreamDescriptorEntry[] entries =
            new StreamDescriptorEntry[encryptedStreamDescriptorCount];

A crafted encrypted stream can therefore ask for a billion-element array from
a handful of input bytes.

Two changes:

* Reject a count the input cannot back. The stream is already read fully into
  a byte[] and every entry occupies at least 18 bytes, so a declared count
  larger than the bytes following it is necessarily bogus. This is derived
  from the data rather than a fixed cap, so it cannot reject a well formed
  file however many streams it carries.
* Collect the entries as they are parsed instead of pre-allocating, so the
  memory used stays proportional to the data actually present.

entries was only consumed by an enhanced-for loop, so a List works in place of
the array. The read of the encrypted stream moves into a small helper so the
byte[] length is available for the bound - InputStream.available() is banned by
forbiddenapis, and its message points at exactly this pattern.

For reference, the encrypted summary stream carries the property set streams
(MS-OFFCRYPTO 2.3.5.4), so real files declare 2; the sample in
TestDocumentEncryption has 2 entries in 128 bytes.

Found while sweeping Math.toIntExact call sites after apache#1345.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pjfanning
pjfanning force-pushed the cryptoapi-descriptor-count branch from bbb0001 to 098a6ec Compare September 22, 2026 13:14
@pjfanning
pjfanning merged commit b1494b9 into apache:trunk Sep 22, 2026
4 checks passed
@pjfanning
pjfanning deleted the cryptoapi-descriptor-count branch September 22, 2026 14:06
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.

1 participant