This is an automated email from the ASF dual-hosted git repository.

git-hulk pushed a commit to branch unstable
in repository https://gitbox.apache.org/repos/asf/kvrocks.git


The following commit(s) were added to refs/heads/unstable by this push:
     new 634ed15df fix(rdb): reject over-large declared lengths when loading 
RDB payloads (#3550)
634ed15df is described below

commit 634ed15dfd9249cfb57a52541dddba05b377df0a
Author: hulk <[email protected]>
AuthorDate: Sat Jul 11 17:30:21 2026 +0800

    fix(rdb): reject over-large declared lengths when loading RDB payloads 
(#3550)
    
    The RDB load path sized buffers from an attacker-declared 64-bit length
    before checking the stream actually holds that many bytes. A write-level
    client could send a `RESTORE` payload declaring a huge length and make
    the server allocate it up front and crash (OOM) — via both the plain
    string path (`read_string.resize(len)`) and the LZF path (`out_buf(len,
    0)`).
    
    Add `RdbStream::EnsureRemainingBytes()` and validate every length that
    drives a read or allocation against the bytes still remaining in the
    stream, so a value can never be declared larger than the input that
    carries it. The LZF decompressed size may legitimately exceed the
    remaining stream, so it is additionally capped against
    `proto-max-bulk-len`.
    
    Add gocase regression tests for both the plain-string and LZF-encoded
    `RESTORE` payloads: the server now returns an error and stays up.
---
 src/common/rdb_stream.cc                  |  9 +++++
 src/common/rdb_stream.h                   | 21 +++++++++++
 src/storage/rdb/rdb.cc                    | 15 ++++++++
 tests/gocase/unit/restore/restore_test.go | 58 ++++++++++++++++++++++++++-----
 tests/gocase/util/crc64.go                | 32 +++++++++++++++++
 5 files changed, 127 insertions(+), 8 deletions(-)

diff --git a/src/common/rdb_stream.cc b/src/common/rdb_stream.cc
index 5a4fb9be3..6f7324629 100644
--- a/src/common/rdb_stream.cc
+++ b/src/common/rdb_stream.cc
@@ -20,6 +20,9 @@
 
 #include "rdb_stream.h"
 
+#include <filesystem>
+#include <system_error>
+
 #include "fmt/format.h"
 #include "vendor/crc64.h"
 
@@ -52,6 +55,12 @@ Status RdbFileStream::Open() {
     return {Status::NotOK, fmt::format("failed to open rdb file: '{}': {}", 
file_name_, strerror(errno))};
   }
 
+  std::error_code ec;
+  file_size_ = std::filesystem::file_size(file_name_, ec);
+  if (ec) {
+    return {Status::NotOK, fmt::format("failed to get the size of rdb file: 
'{}': {}", file_name_, ec.message())};
+  }
+
   return Status::OK();
 }
 
diff --git a/src/common/rdb_stream.h b/src/common/rdb_stream.h
index eb2bc8eca..a4270226a 100644
--- a/src/common/rdb_stream.h
+++ b/src/common/rdb_stream.h
@@ -35,6 +35,10 @@ class RdbStream {
   virtual Status Read(char *buf, size_t len) = 0;
   virtual Status Write(const char *buf, size_t len) = 0;
   virtual StatusOr<uint64_t> GetCheckSum() const = 0;
+  // Rejects a declared object length that exceeds the bytes still available in
+  // the stream, before that length is used to allocate memory: a value can 
never
+  // be larger than the input that carries it.
+  virtual Status EnsureRemainingBytes(uint64_t n) const = 0;
   StatusOr<uint8_t> ReadByte() {
     uint8_t value = 0;
     auto s = Read(reinterpret_cast<char *>(&value), 1);
@@ -55,6 +59,14 @@ class RdbStringStream : public RdbStream {
   Status Read(char *buf, size_t len) override;
   Status Write(const char *buf, size_t len) override;
   StatusOr<uint64_t> GetCheckSum() const override;
+  Status EnsureRemainingBytes(uint64_t n) const override {
+    uint64_t remaining = pos_ < input_.size() ? input_.size() - pos_ : 0;
+    if (n > remaining) {
+      return {Status::NotOK,
+              fmt::format("invalid RDB payload: required {} bytes exceeds the 
remaining {} bytes", n, remaining)};
+    }
+    return Status::OK();
+  }
   std::string &GetInput() { return input_; }
 
  private:
@@ -80,6 +92,14 @@ class RdbFileStream : public RdbStream {
     memrev64ifbe(&crc);
     return crc;
   }
+  Status EnsureRemainingBytes(uint64_t n) const override {
+    uint64_t remaining = file_size_ > total_read_bytes_ ? file_size_ - 
total_read_bytes_ : 0;
+    if (n > remaining) {
+      return {Status::NotOK,
+              fmt::format("invalid RDB payload: required {} bytes exceeds the 
remaining {} bytes", n, remaining)};
+    }
+    return Status::OK();
+  }
 
  private:
   std::ifstream ifs_;
@@ -87,4 +107,5 @@ class RdbFileStream : public RdbStream {
   uint64_t check_sum_;
   size_t total_read_bytes_;
   size_t max_read_chunk_size_;  // maximum single read chunk size
+  uint64_t file_size_ = 0;      // total size of the rdb file, set in Open()
 };
diff --git a/src/storage/rdb/rdb.cc b/src/storage/rdb/rdb.cc
index 5adf48f05..4d01bed3c 100644
--- a/src/storage/rdb/rdb.cc
+++ b/src/storage/rdb/rdb.cc
@@ -141,6 +141,15 @@ StatusOr<std::string> RDB::LoadStringObject() { return 
loadEncodedString(); }
 StatusOr<std::string> RDB::loadLzfString() {
   auto compression_len = GET_OR_RET(loadObjectLen(nullptr));
   auto len = GET_OR_RET(loadObjectLen(nullptr));
+  // The compressed bytes must actually be present in the stream.
+  GET_OR_RET(stream_->EnsureRemainingBytes(compression_len));
+  // The decompressed size can legitimately exceed the remaining stream (that's
+  // what compression does), so it can't be bounded by the stream; cap it 
against
+  // proto-max-bulk-len so a tiny payload can't declare a huge output 
allocation.
+  if (uint64_t max_len = storage_->GetConfig()->proto_max_bulk_len; len > 
max_len) {
+    return {Status::NotOK, fmt::format("invalid RDB payload: required {} bytes 
exceeds the proto-max-bulk-len limit {}",
+                                       len, max_len)};
+  }
   std::string out_buf(len, 0);
   std::vector<char> vec(compression_len);
   GET_OR_RET(stream_->Read(vec.data(), compression_len));
@@ -180,6 +189,7 @@ StatusOr<std::string> RDB::loadEncodedString() {
   if (len == 0) {
     return "";
   }
+  GET_OR_RET(stream_->EnsureRemainingBytes(len));
   std::string read_string;
   read_string.resize(len);
   GET_OR_RET(stream_->Read(read_string.data(), len));
@@ -192,6 +202,7 @@ StatusOr<std::vector<std::string>> 
RDB::LoadListWithQuickList(int type) {
   if (len == 0) {
     return list;
   }
+  GET_OR_RET(stream_->EnsureRemainingBytes(len));
 
   uint64_t container = QuickListNodeContainerPacked;
   for (size_t i = 0; i < len; i++) {
@@ -228,6 +239,7 @@ StatusOr<std::vector<std::string>> RDB::LoadListObject() {
   if (len == 0) {
     return list;
   }
+  GET_OR_RET(stream_->EnsureRemainingBytes(len));
   for (size_t i = 0; i < len; i++) {
     auto element = GET_OR_RET(loadEncodedString());
     list.push_back(std::move(element));
@@ -247,6 +259,7 @@ StatusOr<std::vector<std::string>> RDB::LoadSetObject() {
   if (len == 0) {
     return set;
   }
+  GET_OR_RET(stream_->EnsureRemainingBytes(len));
   for (size_t i = 0; i < len; i++) {
     auto element = GET_OR_RET(LoadStringObject());
     set.push_back(std::move(element));
@@ -272,6 +285,7 @@ StatusOr<std::map<std::string, std::string>> 
RDB::LoadHashObject() {
   if (len == 0) {
     return hash;
   }
+  GET_OR_RET(stream_->EnsureRemainingBytes(len));
 
   for (size_t i = 0; i < len; i++) {
     auto field = GET_OR_RET(LoadStringObject());
@@ -344,6 +358,7 @@ StatusOr<std::vector<MemberScore>> RDB::LoadZSetObject(int 
type) {
   if (len == 0) {
     return zset;
   }
+  GET_OR_RET(stream_->EnsureRemainingBytes(len));
 
   for (size_t i = 0; i < len; i++) {
     auto member = GET_OR_RET(LoadStringObject());
diff --git a/tests/gocase/unit/restore/restore_test.go 
b/tests/gocase/unit/restore/restore_test.go
index d92cc39a0..1c46e4a2c 100644
--- a/tests/gocase/unit/restore/restore_test.go
+++ b/tests/gocase/unit/restore/restore_test.go
@@ -22,7 +22,6 @@ package restore
 import (
        "context"
        "encoding/binary"
-       "hash/crc64"
        "testing"
        "time"
 
@@ -251,12 +250,6 @@ func TestRestore_Set(t *testing.T) {
 }
 
 func TestRestoreRejectsInvalidIntSetLength(t *testing.T) {
-       // Redis CRC64 reflected polynomial, used by the DUMP/RESTORE payload 
footer.
-       redisCRC64Table := crc64.MakeTable(0x95ac9329ac4bc9b5)
-       redisCRC64 := func(data []byte) uint64 {
-               return ^crc64.Update(^uint64(0), redisCRC64Table, data)
-       }
-
        srv := util.StartServer(t, map[string]string{})
        defer srv.Close()
 
@@ -271,7 +264,7 @@ func TestRestoreRejectsInvalidIntSetLength(t *testing.T) {
        body = binary.LittleEndian.AppendUint32(body, 0x20000000)
        // RDB version 11 followed by the Redis CRC64 checksum.
        body = binary.LittleEndian.AppendUint16(body, 11)
-       value := string(binary.LittleEndian.AppendUint64(body, 
redisCRC64(body)))
+       value := string(binary.LittleEndian.AppendUint64(body, 
util.RedisCRC64(body)))
 
        restoreCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
        defer cancel()
@@ -280,6 +273,55 @@ func TestRestoreRejectsInvalidIntSetLength(t *testing.T) {
        require.NoError(t, rdb.Ping(ctx).Err())
 }
 
+func TestRestoreRejectsOversizedLength(t *testing.T) {
+       srv := util.StartServer(t, map[string]string{})
+       defer srv.Close()
+
+       ctx := context.Background()
+       rdb := srv.NewClient()
+       defer func() { require.NoError(t, rdb.Close()) }()
+
+       // RDBTypeString declaring a 1 TiB length with no actual string bytes.
+       body := []byte{0x00, 0x81} // RDBTypeString, RDB64BitLen
+       body = binary.BigEndian.AppendUint64(body, 1<<40)
+       body = binary.LittleEndian.AppendUint16(body, 11) // RDB version
+       value := string(binary.LittleEndian.AppendUint64(body, 
util.RedisCRC64(body)))
+
+       restoreCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
+       defer cancel()
+       require.ErrorContains(t, rdb.Restore(restoreCtx, util.RandString(32, 
64, util.Alpha), 0, value).Err(),
+               "invalid RDB payload")
+       require.NoError(t, rdb.Ping(ctx).Err())
+}
+
+func TestRestoreRejectsOversizedLzfLength(t *testing.T) {
+       srv := util.StartServer(t, map[string]string{})
+       defer srv.Close()
+
+       ctx := context.Background()
+       rdb := srv.NewClient()
+       defer func() { require.NoError(t, rdb.Close()) }()
+
+       // LZF-encoded RDBTypeString: a 1-byte compressed body declaring a 1 TiB
+       // decompressed size, which the stream bound alone can't catch.
+       body := []byte{
+               0x00, // RDBTypeString
+               0xc3, // RDBEncVal | RDBEncLzf -> LZF-encoded string
+               0x01, // compressed length = 1
+               0x81, // RDB64BitLen marker for the decompressed length
+       }
+       body = binary.BigEndian.AppendUint64(body, 1<<40) // decompressed 
length = 1 TiB
+       body = append(body, 0x00)                         // 1 byte of 
compressed data
+       body = binary.LittleEndian.AppendUint16(body, 11) // RDB version
+       value := string(binary.LittleEndian.AppendUint64(body, 
util.RedisCRC64(body)))
+
+       restoreCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
+       defer cancel()
+       require.ErrorContains(t, rdb.Restore(restoreCtx, util.RandString(32, 
64, util.Alpha), 0, value).Err(),
+               "invalid RDB payload")
+       require.NoError(t, rdb.Ping(ctx).Err())
+}
+
 func TestRestoreWithTTL(t *testing.T) {
        srv := util.StartServer(t, map[string]string{})
        defer srv.Close()
diff --git a/tests/gocase/util/crc64.go b/tests/gocase/util/crc64.go
new file mode 100644
index 000000000..744915d9f
--- /dev/null
+++ b/tests/gocase/util/crc64.go
@@ -0,0 +1,32 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package util
+
+import "hash/crc64"
+
+// redisCRC64Table is the reflected CRC-64 (Jones) table Redis uses for the
+// DUMP/RESTORE payload footer.
+var redisCRC64Table = crc64.MakeTable(0x95ac9329ac4bc9b5)
+
+// RedisCRC64 computes the CRC-64 checksum that Redis appends to a DUMP/RESTORE
+// payload, matching the checksum kvrocks verifies when loading the payload.
+func RedisCRC64(data []byte) uint64 {
+       return ^crc64.Update(^uint64(0), redisCRC64Table, data)
+}

Reply via email to