[
https://issues.apache.org/jira/browse/KAFKA-20966?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18106745#comment-18106745
]
sepuri sai krishna commented on KAFKA-20966:
--------------------------------------------
Hi [~bbejeck], thanks for looking at this.
No, not from production. I found it by code inspection while comparing the
tiered read path against the other {{LogInputStream}} implementations, so
please weigh the priority accordingly. That said, I've since reproduced it end
to end through {{RemoteLogManager.read()}}, so it isn't only theoretical.
Details below.
h3. What the code does
{{RemoteLogInputStream.nextBatch()}} reads the size field off the segment and
sizes an allocation from it, checking only the lower bound:
{code:java}
int size = logHeaderBuffer.getInt(SIZE_OFFSET);
// V0 has the smallest overhead, stricter checking is done later
if (size < LegacyRecord.RECORD_OVERHEAD_V0)
throw new CorruptRecordException(...);
int bufferSize = LOG_OVERHEAD + size;
ByteBuffer buffer = ByteBuffer.allocate(bufferSize);
{code}
Its closest structural twin is
{{AbstractLegacyRecordBatch.DataLogInputStream}}, same interface, also reading
from a raw {{InputStream}}, also reading {{SIZE_OFFSET}}, also allocating from
it, and that one bounds the value first:
{code:java}
if (size > maxMessageSize)
throw new CorruptRecordException(String.format("Record size exceeds the
largest allowable message size (%d).", maxMessageSize));
ByteBuffer batchBuffer = ByteBuffer.allocate(size);
{code}
{{ByteBufferLogInputStream.nextBatchSize()}} bounds it the same way.
{{FileLogInputStream}} has no {{maxMessageSize}}, but doesn't need one, it
bounds against the actual file length ({{if (position > end - LOG_OVERHEAD -
size)}}) and is file-channel backed, so it never allocates from the field at
all. {{RemoteLogInputStream}} is the only one of the four that allocates from
that field unbounded.
h3. Reproduction
I wrote a test that drives the real production path: a real segment file with
two real records, with only the 4-byte size field at {{SIZE_OFFSET}}
overwritten, returned by a stubbed {{fetchLogSegment()}}. It deliberately does
*not* stub {{getRemoteLogInputStream()}}, so the real {{RemoteLogInputStream}}
reads the real bytes. {{max.message.bytes}} is the default 1048588 throughout.
Three cases:
*Control*, untouched segment, reads back fine:
{noformat}
CONTROL: read baseOffset=0 lastOffset=1 sizeInBytes=83
{noformat}
*Size field set to Integer.MAX_VALUE*, {{LOG_OVERHEAD + size}} overflows int,
so {{allocate()}} gets a negative capacity. This is deterministic and
heap-independent:
{noformat}
OVERFLOW: RemoteLogManager.read() threw java.lang.IllegalArgumentException:
capacity < 0: (-2147483637 < 0)
at
org.apache.kafka.common.record.internal.RemoteLogInputStream.nextBatch(RemoteLogInputStream.java:59)
at
org.apache.kafka.server.log.remote.storage.RemoteLogManager.findFirstBatch(RemoteLogManager.java:2109)
at
org.apache.kafka.server.log.remote.storage.RemoteLogManager.read(RemoteLogManager.java:1899)
{noformat}
*Size field set to 512 MiB*, the allocation succeeds inside the test heap, and
then the short read makes {{nextBatch()}} return null, which {{findFirstBatch}}
treats as end-of-stream:
{noformat}
SILENT: read() returned normally, records empty=true, after allocating
536870924 bytes; no CorruptRecordException was raised
{noformat}
I found that third case the more interesting one, and I hadn't appreciated it
when I filed this. A corrupted size field is indistinguishable from a clean
end-of-stream: the broker allocates half a gigabyte on the fetch path, throws
it away, and hands the consumer an empty fetch with no error and no corruption
signal at all. Separately, in a standalone harness with the allocation sized at
~2 GiB and {{-Xmx256m}}, the same call site produces {{OutOfMemoryError: Java
heap space}}, though obviously whether that happens depends on the heap.
In all three corrupted cases a {{size > maxMessageSize}} check would have
turned it into a {{CorruptRecordException}} naming the bad value.
h3. Scope
To be clear about the trigger, since I think it's what decides whether this is
worth fixing: the bytes come back from the configured {{RemoteStorageManager}},
so reaching this needs a corrupted or truncated segment, or a plugin returning
a stream at the wrong position. It isn't attacker-controlled input in any
normal sense, and I'm not claiming a security issue. The argument is just that
the tiered path trusts a pluggable third-party store more than the local path
trusts its own disk, which seems backwards, and the fix is small, thread the
existing {{maxMessageSize}} through from the log config, which both call sites
in {{RemoteLogManager}} already have to hand.
PR is up at https://github.com/apache/kafka/pull/23206. Happy to add the
{{RemoteLogManager}}-level test above to it if that's useful, and equally happy
to close this as Not A Problem if you think the extra parameter isn't worth
carrying.
> RemoteLogInputStream can attempt an unbounded memory allocation when reading
> a corrupted remote log segment
> -----------------------------------------------------------------------------------------------------------
>
> Key: KAFKA-20966
> URL: https://issues.apache.org/jira/browse/KAFKA-20966
> Project: Kafka
> Issue Type: Bug
> Components: Tiered-Storage
> Reporter: sepuri sai krishna
> Assignee: sepuri sai krishna
> Priority: Major
>
> RemoteLogInputStream.nextBatch()
> (clients/src/main/java/org/apache/kafka/common/record/internal/RemoteLogInputStream.java,
> lines 49-59) reads a 4-byte batch-size field directly off the InputStream
> returned by the pluggable RemoteStorageManager and uses it to size an
> allocation, with no check other than a lower bound:
> int size = logHeaderBuffer.getInt(SIZE_OFFSET);
> // V0 has the smallest overhead, stricter checking is done later
> if (size < LegacyRecord.RECORD_OVERHEAD_V0)
> throw new CorruptRecordException(...);
> int bufferSize = LOG_OVERHEAD + size;
> ByteBuffer buffer = ByteBuffer.allocate(bufferSize); // no upper bound
> on size
> There is no check that "size" doesn't exceed a sane maximum before
> allocating. "size" is a 4-byte signed int taken directly from the remote
> segment's bytes, so it can be as large as ~2GB.
> Its sibling class, ByteBufferLogInputStream (same package), reads the
> identical length-prefixed header format but validates the declared size
> against maxMessageSize before trusting it:
> if (recordSize > maxMessageSize)
> throw new CorruptRecordException(String.format(
> "Record size %d exceeds the largest allowable message size (%d).",
> recordSize, maxMessageSize));
> RemoteLogInputStream has no equivalent check, and is actually the more
> exposed of the two: ByteBufferLogInputStream only slices an
> already-in-memory, already-bounded ByteBuffer, whereas RemoteLogInputStream
> calls ByteBuffer.allocate() directly from the untrusted value, before it has
> even validated that the input stream contains that many bytes.
> Impact: a corrupted or bit-rotted remote log segment, or a misbehaving/buggy
> pluggable RemoteStorageManager implementation (S3/GCS/HDFS-backed, etc.), can
> cause the broker to attempt allocating up to ~2GB per batch read. This is
> reachable from RemoteLogManager.read() (consumer fetch falling through to
> tiered storage) and RemoteLogManager.lookupTimestamp() (offset-by-timestamp
> lookups against tiered segments) -- both real, hot server-side code paths,
> not test-only code.
> Proposed fix: thread a maxMessageSize bound into RemoteLogInputStream's
> constructor (mirroring ByteBufferLogInputStream's existing pattern) and throw
> CorruptRecordException if the declared size exceeds it, using
> UnifiedLog.config().maxMessageSize(), which is already available at both call
> sites in RemoteLogManager.
> I'm happy to submit a PR for this fix.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)