CVE-2022-4969: Insufficient Information (NVD-CWE-noinfo)
value: A vulnerability, which was classified as critical, has been found in bwoodsend rockhopper up to 0.1.2. Affected by this issue is the function count_rows of the file rockhopper/src/ragged_array.c of the component Binary Parser. The manipulation of the argument raw leads to buffer overflow. Local access is required to approach this attack. Upgrading to version 0.2.0 is able to address this issue. The name of the patch is 1a15fad5e06ae693eb9b8908363d2c8ef455104e. It is recommended to upgrade the affected component. The identifier of this vulnerability is VDB-266312.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-05-27T17:15Z
Repository: https://github.com/bwoodsend/rockhopper
Language: Python
Files changed: rockhopper/src/ragged_array.c, setup.py, tests/test_io.py
Commit date: 2022-05-22 19:58:20 +0100
Commit message: Fix a potential security exploit in RaggedArray.loads(buffer, ldtype=np.uint64).

When parsing binary data with ldtype=np.uint64, if a length is sufficiently
close to 2^64 that it causes the parsing buffer pointer (starting from &buffer)
to overflow, then arbitrary memory owned by the current process and whose
address is < &buffer can be read. Should the overflow map the pointer to a
location not owned by the current process, a segfault will occur.

Overflow is now caught and raised as a ValueError() (like in any other case
where the parsing fails).

Diff: diff --git a/rockhopper/src/ragged_array.c b/rockhopper/src/ragged_array.c
index 0cdada8..801b0dc 100644
--- a/rockhopper/src/ragged_array.c
+++ b/rockhopper/src/ragged_array.c
@@ -57,12 +57,13 @@ int count_rows(void * raw, int raw_length, int length_power, int big_endian,
 
   IntRead read = choose_int_read(length_power, big_endian);
 
   int rows = 0;
 
+  void * start = raw;
   void * end = raw + raw_length;
-  while (raw <= end - (1 << length_power)) {
+  while (raw <= end - (1 << length_power) && raw >= start) {
     uint64_t length = read(raw);
     raw += (1 << length_power);
     raw += length * itemsize;
     rows ++;
   }
diff --git a/setup.py b/setup.py
index 667d8b5..a1a55ef 100644
--- a/setup.py
+++ b/setup.py
@@ -32,11 +32,11 @@
     install_requires=copy_requirements(),
     entry_points={"pyinstaller40": "hook-dirs=rockhopper:_PyInstaller_hook"},
     extras_require={
         "test": [
             'pytest>=3', 'pytest-order', 'coverage', 'pytest-cov',
-            'coverage-conditional-plugin'
+            'coverage-conditional-plugin', 'hypothesis'
         ]
     },
     license="MIT license",
     long_description=readme,
     keywords='rockhopper',
diff --git a/tests/test_io.py b/tests/test_io.py
index ea2ea10..03f56bc 100644
--- a/tests/test_io.py
+++ b/tests/test_io.py
@@ -5,10 +5,11 @@
 import pickle
 
 import numpy as np
 import pytest
 from cslug import ptr
+from hypothesis import given, strategies, settings, Verbosity, example
 
 from rockhopper import RaggedArray
 from rockhopper._ragged_array import _2_power, _big_endian
 
 pytestmark = pytest.mark.order(3)
@@ -59,10 +60,53 @@ def test_dump_load(dtype, byteorder):
     assert np.array_equal(self.ends, parsed.ends)
     assert np.array_equal(self.flat, parsed.flat)
     assert consumed == len(bin)
 
 
+int_types = [
+    np.uint8, np.uint16, np.uint32, np.uint64,
+    np.int8, np.int16, np.int32, np.int64,
+]
+blob = bytes(range(256)) + bytes(range(256))[::-1]
+
+
+@pytest.mark.parametrize("dtype", int_types)
+@pytest.mark.parametrize("ldtype", int_types)
+def test_loads_pointer_overflow_guard(dtype, ldtype):
+    """Test that the check for pointer overflowing caused by reading a huge row
+    length works."""
+    for i in range(-30, len(blob)):
+        try:
+            RaggedArray.loads(blob[i: i+30], dtype=dtype, ldtype=ldtype)
+        except ValueError:
+            pass
+
+
+@pytest.mark.parametrize("dtype", int_types)
+@pytest.mark.parametrize("ldtype", int_types)
+def test_fuzz_loads(dtype, ldtype):
+    """Scan for possible segfaults.
+
+    All invalid inputs must lead to a ValueError rather than a seg-fault or
+    RaggedArray.loads() could be tricked into reading arbitrary memory addresses
+    by a maliciously constructed invalid data file.
+
+    """
+    @given(strategies.binary())
+    @example(b'\xc0\\\\\xb0\x93\x91\xff\xffpEfe\x167\xee')
+    def fuzz(x):
+        print(x)
+        try:
+            self, _ = RaggedArray.loads(x, dtype=dtype, ldtype=ldtype)
+        except ValueError:
+            pass
+        else:
+            assert self.dumps(ldtype=ldtype).tobytes() == x
+
+    fuzz()
+
+
 def test_dump_byteorder():
     self = RaggedArray.from_nested([[0x0109, 0x0208, 0x0307]], dtype=np.uint16)
 
     bin = list(self.astype(self.dtype.newbyteorder(">")).dumps(ldtype=np.uint8))
     assert bin == [3, 1, 9, 2, 8, 3, 7]

Vulnerable code:   while (raw <= end - (1 << length_power)) {
            'coverage-conditional-plugin'
Fixed code:   void * start = raw;
  while (raw <= end - (1 << length_power) && raw >= start) {
            'coverage-conditional-plugin', 'hypothesis'
from hypothesis import given, strategies, settings, Verbosity, example
int_types = [
    np.uint8, np.uint16, np.uint32, np.uint64,
    np.int8, np.int16, np.int32, np.int64,
]
blob = bytes(range(256)) + bytes(range(256))[::-1]


@pytest.mark.parametrize("dtype", int_types)
@pytest.mark.parametrize("ldtype", int_types)
def test_loads_pointer_overflow_guard(dtype, ldtype):
    """Test that the check for pointer overflowing caused by reading a huge row
    length works."""
    for i in range(-30, len(blob)):
        try:
            RaggedArray.loads(blob[i: i+30], dtype=dtype, ldtype=ldtype)
        except ValueError:
            pass


@pytest.mark.parametrize("dtype", int_types)
@pytest.mark.parametrize("ldtype", int_types)
def test_fuzz_loads(dtype, ldtype):
    """Scan for possible segfaults.

    All invalid inputs must lead to a ValueError rather than a seg-fault or
    RaggedArray.loads() could be tricked into reading arbitrary memory addresses
    by a maliciously constructed invalid data file.

    """
    @given(strategies.binary())
    @example(b'\xc0\\\\\xb0\x93\x91\xff\xffpEfe\x167\xee')
    def fuzz(x):
        print(x)
        try:
            self, _ = RaggedArray.loads(x, dtype=dtype, ldtype=ldtype)
        except ValueError:
            pass
        else:
            assert self.dumps(ldtype=ldtype).tobytes() == x

    fuzz()